From a0a8579a081c41c9153b5069fe6d7784e0063512 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:18:18 -0500 Subject: [PATCH 01/69] =?UTF-8?q?refactor(tracker):=20shared=20useTogglePa?= =?UTF-8?q?id=20mutation=20hook=20=E2=80=94=20finish=20Tracker=20mutations?= =?UTF-8?q?=20(F1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle paid/unpaid was duplicated across the desktop and mobile rows (optimistic flip + Undo/paid toasts + refresh). Extracted into a shared useTogglePaid() React Query mutation hook: the caller keeps the instant local optimistic flip, the hook rolls it back on error and invalidates the tracker/badge caches on settle. isPending replaces the desktop row's local loading state (badge spinner + un-pay confirm dialog). With useQuickPay (P3), both core Tracker write actions are now idiomatic useMutation hooks living in one place. Co-Authored-By: Claude Opus 4.8 --- .../components/tracker/MobileTrackerRow.jsx | 37 ++------------ client/components/tracker/TrackerRow.jsx | 49 +++---------------- client/hooks/usePaymentActions.js | 48 ++++++++++++++++++ 3 files changed, 60 insertions(+), 74 deletions(-) diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index a4b24d4..3799874 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -12,7 +12,7 @@ import { } from '@/components/ui/alert-dialog'; import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; -import { useQuickPay } from '@/hooks/usePaymentActions'; +import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; @@ -47,6 +47,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const quickPay = useQuickPay(); + const togglePaid = useTogglePaid(year, month); // Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared // when fresh server data arrives (effect below). const [optimisticPaid, setOptimisticPaid] = useState(undefined); @@ -82,38 +83,8 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, quickPay.run(row, val, defaultPaymentDate); } - async function performTogglePaid() { - const wasPaid = isPaid; - setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles - try { - const result = await api.togglePaid(row.id, { - amount: wasPaid ? undefined : threshold, - year: year, - month: month, - }); - if (wasPaid && result.paymentId) { - toast.success('Payment moved to recovery', { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.restorePayment(result.paymentId); - toast.success('Payment restored'); - refresh(); - } catch (err) { - toast.error(err.message || 'Failed to restore payment'); - } - }, - }, - }); - } else if (!wasPaid) { - toast.success(`${row.name} — ${fmt(threshold)} paid`); - } - refresh(); - } catch (err) { - setOptimisticPaid(undefined); // roll back the instant flip - toast.error(err.message || 'Failed to toggle payment status'); - } + function performTogglePaid() { + togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid }); } function handleTogglePaid() { diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 51fc0c8..fed866c 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -16,7 +16,7 @@ import { import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; -import { useQuickPay } from '@/hooks/usePaymentActions'; +import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { StatusBadge } from '@/components/tracker/StatusBadge'; import { PaymentProgress } from '@/components/tracker/PaymentProgress'; @@ -30,7 +30,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); - const [loading, setLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [optimisticActual, setOptimisticActual] = useState(undefined); // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls @@ -40,6 +39,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); const quickPay = useQuickPay(); + const togglePaid = useTogglePaid(year, month); const visibleColumnSet = new Set(visibleColumns); const showColumn = key => visibleColumnSet.has(key); @@ -87,41 +87,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC quickPay.run(row, val, defaultPaymentDate); } - async function performTogglePaid() { - const wasPaid = isPaid; - setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles - setLoading?.(true); - try { - const result = await api.togglePaid(row.id, { - amount: wasPaid ? undefined : threshold, - year: year, - month: month, - }); - if (wasPaid && result.paymentId) { - toast.success('Payment moved to recovery', { - action: { - label: 'Undo', - onClick: async () => { - try { - await api.restorePayment(result.paymentId); - toast.success('Payment restored'); - refresh?.(); - } catch (err) { - toast.error(err.message || 'Failed to restore payment'); - } - }, - }, - }); - } else if (!wasPaid) { - toast.success(`${row.name} — ${fmt(threshold)} paid`); - } - refresh?.(); - } catch (err) { - setOptimisticPaid(undefined); // roll back the instant flip - toast.error(err.message || 'Failed to toggle payment status'); - } finally { - setLoading?.(false); - } + function performTogglePaid() { + togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid }); } function handleTogglePaid() { @@ -629,7 +596,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC if (effectiveStatus === 'skipped') return; handleTogglePaid(); }} - loading={loading} + loading={togglePaid.isPending} /> )} @@ -743,13 +710,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC - Cancel + Cancel - {loading ? 'Removing...' : 'Remove Payment'} + {togglePaid.isPending ? 'Removing...' : 'Remove Payment'} diff --git a/client/hooks/usePaymentActions.js b/client/hooks/usePaymentActions.js index fa8f617..575ff29 100644 --- a/client/hooks/usePaymentActions.js +++ b/client/hooks/usePaymentActions.js @@ -40,3 +40,51 @@ export function useQuickPay() { return { run, isPending: mutation.isPending }; } + +// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip +// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls +// it back on error, invalidates the tracker/badge caches on settle, and shows the +// right toast (an Undo when un-paying, a specific "paid" message otherwise). +// Shared by the desktop and mobile tracker rows. +export function useTogglePaid(year, month) { + const invalidate = useInvalidateTrackerData(); + const mutation = useMutation({ + mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }), + onSettled: () => invalidate(), + }); + + const run = (row, { wasPaid, threshold, setOptimisticPaid }) => { + setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles + mutation.mutate( + { rowId: row.id, amount: wasPaid ? undefined : threshold }, + { + onSuccess: (result) => { + if (wasPaid && result.paymentId) { + toast.success('Payment moved to recovery', { + action: { + label: 'Undo', + onClick: async () => { + try { + await api.restorePayment(result.paymentId); + toast.success('Payment restored'); + invalidate(); + } catch (err) { + toast.error(err.message || 'Failed to restore payment'); + } + }, + }, + }); + } else if (!wasPaid) { + toast.success(`${row.name} — ${fmt(threshold)} paid`); + } + }, + onError: (err) => { + setOptimisticPaid(undefined); // roll back the instant flip + toast.error(err.message || 'Failed to toggle payment status'); + }, + }, + ); + }; + + return { run, isPending: mutation.isPending }; +} -- 2.40.1 From 47c031f1e46761172a07276c8d803b7274eb4ae6 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:20:26 -0500 Subject: [PATCH 02/69] build(react): enable React Compiler (React 19 auto-memoization) (F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added babel-plugin-react-compiler to the Vite React plugin. The compiler auto-memoizes components/hooks at build time, so manual useMemo/useCallback become largely unnecessary going forward (existing ones are left in place — harmless, and the compiler adds the rest). Safe here because the codebase is rules-of-hooks clean (enforced by eslint-plugin-react-hooks). Validated: production build succeeds and the e2e probe passes 17/17 with the compiler on — every authed page renders axe-clean and Tracker/Summary/Analytics reconciliation holds. (Build time ~2.2s -> ~6.2s from the compiler pass; the runtime win is automatic memoization.) The compiler ESLint rule needs eslint-plugin-react-hooks v6 — a future upgrade. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 11 +++++++++++ package.json | 1 + vite.config.mjs | 9 ++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index d3fd29d..5a2ce63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^9.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^5.2.0", @@ -6488,6 +6489,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", diff --git a/package.json b/package.json index f8dc7c5..f410c6b 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "@testing-library/react": "^16.3.2", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "babel-plugin-react-compiler": "^1.0.0", "concurrently": "^9.1.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/vite.config.mjs b/vite.config.mjs index 0625f39..b72ea64 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -12,7 +12,14 @@ const apiPort = process.env.API_PORT || process.env.PORT || 3000; export default defineConfig({ plugins: [ - react(), + // React Compiler (React 19): auto-memoizes components/hooks at build time, so + // manual useMemo/useCallback become largely unnecessary. Safe here because the + // codebase is rules-of-hooks clean (enforced by eslint-plugin-react-hooks). + react({ + babel: { + plugins: [['babel-plugin-react-compiler', {}]], + }, + }), VitePWA({ registerType: 'autoUpdate', includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'], -- 2.40.1 From 907a407399fc8f4222193cd818c5db6cce9cfdd0 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:33:26 -0500 Subject: [PATCH 03/69] =?UTF-8?q?build(ts):=20TypeScript=20foundation=20?= =?UTF-8?q?=E2=80=94=20tsconfig=20+=20typecheck=20in=20CI=20(T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopting TypeScript incrementally (the move off plain JS). Upgraded jsconfig -> tsconfig with strict + noUncheckedIndexedAccess, but allowJs + checkJs:false so every existing .js/.jsx keeps resolving and running untouched — only .ts/.tsx get type-checked. Added 'npm run typecheck' (tsc --noEmit) and wired it into 'npm run ci'. Installed typescript 6 + @types/react/react-dom 19. Client-scoped (Vite resolves the '@/' paths); the CommonJS server is a separate TS story. Co-Authored-By: Claude Opus 4.8 --- jsconfig.json | 15 --------------- package-lock.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 +++++- tsconfig.json | 27 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 16 deletions(-) delete mode 100644 jsconfig.json create mode 100644 tsconfig.json diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 17ea4fb..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "ignoreDeprecations": "6.0", - "paths": { - "@/*": ["./client/*"] - }, - "jsx": "react-jsx", - "module": "ESNext", - "moduleResolution": "bundler", - "resolveJsonModule": true - }, - "include": ["client/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/package-lock.json b/package-lock.json index 5a2ce63..f41a4c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,8 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.50.1", "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "babel-plugin-react-compiler": "^1.0.0", @@ -65,6 +67,7 @@ "jsdom": "^29.1.1", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", + "typescript": "^6.0.3", "vite": "^5.4.10", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" @@ -6026,6 +6029,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -7198,6 +7221,13 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -12969,6 +12999,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index f410c6b..0f39f49 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "vite build", "check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check", "lint": "eslint client", + "typecheck": "tsc --noEmit -p tsconfig.json", "check": "npm run check:server && npm run build", "test": "node --test tests/*.test.js", "test:client": "vitest run", @@ -19,7 +20,7 @@ "test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots", "test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe", "smoke:prod": "bash scripts/prod-smoke.sh", - "ci": "npm run lint && npm run check:server && npm run test:all && npm run build", + "ci": "npm run lint && npm run typecheck && npm run check:server && npm run test:all && npm run build", "start": "node server.js" }, "dependencies": { @@ -68,6 +69,8 @@ "@eslint/js": "^9.39.4", "@playwright/test": "^1.50.1", "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "babel-plugin-react-compiler": "^1.0.0", @@ -79,6 +82,7 @@ "jsdom": "^29.1.1", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", + "typescript": "^6.0.3", "vite": "^5.4.10", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1a95308 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "paths": { "@/*": ["./client/*"] }, + "resolveJsonModule": true, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + + // Gradual adoption: existing .js/.jsx still resolve and run untouched + // (checkJs off = not type-checked), while new/converted .ts/.tsx are checked + // under full strict mode. Convert file-by-file; nothing breaks in between. + "allowJs": true, + "checkJs": false, + "strict": true, + "noUncheckedIndexedAccess": true, + + "types": ["vite/client"] + }, + "include": ["client/**/*"], + "exclude": ["node_modules", "dist", "client/**/*.test.*"] +} -- 2.40.1 From 255036afc2f49ba33aee7906d1447f14a6103fc2 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:36:24 -0500 Subject: [PATCH 04/69] =?UTF-8?q?feat(ts):=20branded=20Cents/Dollars=20mon?= =?UTF-8?q?ey=20types=20=E2=80=94=20first=20TS=20conversion=20(T2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converted client/lib/money.js -> money.ts with branded types: type Cents = number & { __unit: 'cents' } type Dollars = number & { __unit: 'dollars' } plus asCents/asDollars/centsToDollars/dollarsToCents. The formatters now require the correct branded unit (a bare number won't do), so a typed caller physically cannot format cents as dollars (the 100×-too-big bug) or vice-versa. Existing .jsx callers are unaffected (checkJs off) — gradual adoption. money.type-test.ts is a compile-time guard (never imported/bundled): its @ts-expect-error lines assert each unit mixup is a real error, so typecheck fails loudly if the branding ever regresses. Verified: removing a guard makes tsc error 'Argument of type 1234 is not assignable to DollarsInput'. typecheck + build (with React Compiler) + 48 client tests all green. Co-Authored-By: Claude Opus 4.8 --- client/lib/money.js | 77 ------------------------ client/lib/money.ts | 108 ++++++++++++++++++++++++++++++++++ client/lib/money.type-test.ts | 22 +++++++ 3 files changed, 130 insertions(+), 77 deletions(-) delete mode 100644 client/lib/money.js create mode 100644 client/lib/money.ts create mode 100644 client/lib/money.type-test.ts diff --git a/client/lib/money.js b/client/lib/money.js deleted file mode 100644 index 7f824e3..0000000 --- a/client/lib/money.js +++ /dev/null @@ -1,77 +0,0 @@ -// Currency formatting for the client, mirroring the server's utils/money.js. -// -// The API sends money in two units: bill / summary values are serialized as -// DOLLARS (the server calls fromCents before responding), while raw bank -// transaction amounts arrive as integer CENTS. So there are two entry points — -// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's -// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only, -// and this matches the server). Inputs are coerced defensively so null, '', -// undefined, or NaN never render as "$NaN". - -const DASH = '—'; - -function toNumber(value) { - const n = Number(value); - if (!Number.isFinite(n)) return 0; - return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00" -} - -function isBlank(value) { - return value === null || value === undefined || value === ''; -} - -/** - * Format a DOLLAR amount → "$1,234.56". - * @param {number|string|null} dollars - * @param {{ whole?: boolean, dash?: boolean }} [opts] - * whole — drop the cents ("$1,235"); dash — render blank input as "—" not "$0.00". - */ -export function formatUSD(dollars, { whole = false, dash = false } = {}) { - if (dash && isBlank(dollars)) return DASH; - return toNumber(dollars).toLocaleString('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: whole ? 0 : 2, - maximumFractionDigits: whole ? 0 : 2, - }); -} - -/** Whole-dollar convenience → "$1,235". */ -export function formatUSDWhole(dollars, opts = {}) { - return formatUSD(dollars, { ...opts, whole: true }); -} - -/** - * Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34". - * @param {number|string|null} cents - * @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts] - * signed — prefix "+"/"-" (income vs expense); dash — blank input → "—"; - * currency — ISO code (defaults USD). - */ -export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) { - if (dash && isBlank(cents)) return DASH; - const c = toNumber(cents); - const body = (Math.abs(c) / 100).toLocaleString('en-US', { - style: 'currency', - currency: currency || 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - if (signed) return (c < 0 ? '-' : '+') + body; - return (c < 0 ? '-' : '') + body; -} - -/** - * Shared form validator for a non-negative money field (dollars). Blank is - * allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns - * '' when valid, or an error string labelled for the field. Zero is allowed — - * these are non-negative, not strictly-positive, amounts. - * @param {number|string|null} val - * @param {string} [label] — field name for the message (e.g. "Amount", "Balance") - */ -export function validateNonNegativeMoney(val, label = 'Amount') { - if (val === '' || val === null || val === undefined) return ''; - const num = parseFloat(val); - if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; - return ''; -} diff --git a/client/lib/money.ts b/client/lib/money.ts new file mode 100644 index 0000000..14344b3 --- /dev/null +++ b/client/lib/money.ts @@ -0,0 +1,108 @@ +// Currency formatting for the client, mirroring the server's utils/money.js. +// +// The API sends money in two units: bill / summary values are serialized as +// DOLLARS (the server calls fromCents before responding), while raw bank +// transaction amounts arrive as integer CENTS. So there are two entry points — +// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's +// formatUSD / formatCentsUSD split. USD / en-US throughout. Inputs are coerced +// defensively so null, '', undefined, or NaN never render as "$NaN". +// +// The two units are *branded* below: a Dollars value can't be passed where Cents +// is expected (or vice-versa) without an explicit conversion, so the cents↔dollars +// mixups that have caused real money bugs (e.g. displaying cents as dollars → +// 100× wrong) become compile errors in typed code. + +/** Integer cents, e.g. a raw bank transaction amount (1234 = $12.34). */ +export type Cents = number & { readonly __unit: 'cents' }; +/** Dollars, e.g. an API-serialized bill amount (12.34 = $12.34). */ +export type Dollars = number & { readonly __unit: 'dollars' }; + +/** Brand a raw number as cents (the sanctioned way to enter the typed world). */ +export const asCents = (n: number): Cents => n as Cents; +/** Brand a raw number as dollars. */ +export const asDollars = (n: number): Dollars => n as Dollars; +/** Convert cents → dollars (the only way to cross the unit boundary). */ +export const centsToDollars = (c: Cents): Dollars => (c / 100) as Dollars; +/** Convert dollars → cents, rounding to the nearest cent. */ +export const dollarsToCents = (d: Dollars): Cents => Math.round(d * 100) as Cents; + +// A money value as it may still arrive untyped from a form field or legacy code: +// the branded unit, or a numeric string, or blank. Note bare `number` is NOT +// included — that's deliberate, so typed callers must brand (asDollars/asCents) +// and can't accidentally hand cents to a dollars formatter. +type DollarsInput = Dollars | string | null | undefined; +type CentsInput = Cents | string | null | undefined; + +const DASH = '—'; + +function toNumber(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n)) return 0; + return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00" +} + +function isBlank(value: unknown): value is null | undefined | '' { + return value === null || value === undefined || value === ''; +} + +/** + * Format a DOLLAR amount → "$1,234.56". + * `whole` drops the cents ("$1,235"); `dash` renders blank input as "—". + */ +export function formatUSD( + dollars: DollarsInput, + { whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {}, +): string { + if (dash && isBlank(dollars)) return DASH; + return toNumber(dollars).toLocaleString('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: whole ? 0 : 2, + maximumFractionDigits: whole ? 0 : 2, + }); +} + +/** Whole-dollar convenience → "$1,235". */ +export function formatUSDWhole( + dollars: DollarsInput, + opts: { dash?: boolean } = {}, +): string { + return formatUSD(dollars, { ...opts, whole: true }); +} + +/** + * Format an integer-CENTS amount (e.g. a bank transaction) → "$12.34". + * `signed` prefixes "+"/"-" (income vs expense); `dash` renders blank → "—"; + * `currency` is an ISO code (defaults USD). + */ +export function formatCentsUSD( + cents: CentsInput, + { signed = false, dash = false, currency = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {}, +): string { + if (dash && isBlank(cents)) return DASH; + const c = toNumber(cents); + const body = (Math.abs(c) / 100).toLocaleString('en-US', { + style: 'currency', + currency: currency || 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + if (signed) return (c < 0 ? '-' : '+') + body; + return (c < 0 ? '-' : '') + body; +} + +/** + * Shared form validator for a non-negative money field (dollars). Blank is + * allowed (returns ''); otherwise the value must parse to a number ≥ 0. Returns + * '' when valid, or an error string labelled for the field. Zero is allowed — + * these are non-negative, not strictly-positive, amounts. + */ +export function validateNonNegativeMoney( + val: string | number | null | undefined, + label = 'Amount', +): string { + if (val === '' || val === null || val === undefined) return ''; + const num = parseFloat(String(val)); + if (isNaN(num) || num < 0) return `${label} must be a non-negative number`; + return ''; +} diff --git a/client/lib/money.type-test.ts b/client/lib/money.type-test.ts new file mode 100644 index 0000000..943b919 --- /dev/null +++ b/client/lib/money.type-test.ts @@ -0,0 +1,22 @@ +// Compile-time guard for the branded money types. Not imported anywhere (so it +// never ships in a bundle); `npm run typecheck` type-checks it via the tsconfig +// include, and each `@ts-expect-error` asserts the following line IS a genuine +// type error. If the cents/dollars branding ever regresses, one of these lines +// stops erroring and typecheck fails loudly ("unused @ts-expect-error"). +import { formatUSD, formatCentsUSD, asCents, asDollars, centsToDollars } from './money'; + +const cents = asCents(1234); // $12.34 as integer cents +const dollars = asDollars(12.34); // $12.34 as dollars + +// ✅ Correct unit → compiles fine. +formatUSD(dollars); +formatCentsUSD(cents); +formatUSD(centsToDollars(cents)); // cross the boundary explicitly + +// ❌ Unit mixups → compile errors (the class of bug we keep fixing). +// @ts-expect-error cents can't be formatted as dollars (would render 100× too big) +formatUSD(cents); +// @ts-expect-error dollars can't be formatted as cents +formatCentsUSD(dollars); +// @ts-expect-error a raw number must be branded (asDollars/asCents) first +formatUSD(1234); -- 2.40.1 From 7d9bf12bdc409224f78552c07743b74d1b7846e0 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:37:21 -0500 Subject: [PATCH 05/69] docs(history): TypeScript foundation + branded money types (T1-T3) Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index bb1c363..1315175 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,11 @@ # Bill Tracker — Changelog ## v0.41.0 +### 🔷 TypeScript foundation + branded money types + +- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. +- **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. + ### 🧪 Money-service test coverage - **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2) -- 2.40.1 From b221e02d85c341524aef5eed0ca69ca2955e31f2 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:42:46 -0500 Subject: [PATCH 06/69] refactor(ts): convert client/lib/utils to TypeScript (TS4) The highest-traffic leaf module (cn, fmt, date/byte/uptime formatters, categoryColor) is now strict .ts. fmt inherits formatUSD's branded-dollars input via Parameters; noUncheckedIndexedAccess handled (destructuring defaults, in-range assertion). .jsx callers unaffected (Vite resolves the .ts). typecheck + build + 48 client tests green. Co-Authored-By: Claude Opus 4.8 --- client/lib/{utils.js => utils.ts} | 34 +++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) rename client/lib/{utils.js => utils.ts} (73%) diff --git a/client/lib/utils.js b/client/lib/utils.ts similarity index 73% rename from client/lib/utils.js rename to client/lib/utils.ts index e0ea3a2..73ec192 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.ts @@ -1,36 +1,37 @@ -import { clsx } from 'clsx'; +import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { formatUSD } from './money'; -export function cn(...inputs) { +export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many // existing call sites; the implementation lives in ./money (formatUSD) so all -// currency formatting has a single source of truth. -export function fmt(amount) { +// currency formatting has a single source of truth. Inherits formatUSD's typed +// (branded-dollars) input. +export function fmt(amount: Parameters[0]): string { return formatUSD(amount); } -export function fmtDate(dateStr) { +export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; - const [y, m, d] = dateStr.split('-'); + const [y = '', m = '', d = ''] = dateStr.split('-'); return `${parseInt(m)}/${parseInt(d)}/${y}`; } -export function localDateString(date = new Date()) { +export function localDateString(date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } -export function todayStr() { +export function todayStr(): string { return localDateString(); } -export function fmtUptime(seconds) { +export function fmtUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); @@ -41,14 +42,21 @@ export function fmtUptime(seconds) { return `${s}s`; } -export function fmtBytes(bytes) { +export function fmtBytes(bytes: number | null | undefined): string { if (!bytes) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(2)} MB`; } -const CATEGORY_COLOR_TONES = [ +interface CategoryTone { + border: string; + bg: string; + text: string; + bar: string; +} + +const CATEGORY_COLOR_TONES: CategoryTone[] = [ { border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500' }, { border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500' }, { border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' }, @@ -61,12 +69,12 @@ const CATEGORY_COLOR_TONES = [ // Deterministic color tone for a category (or merchant) name, used for // badges and avatars so the same name always renders the same color. -export function categoryColor(name) { +export function categoryColor(name: string | null | undefined): CategoryTone { const key = String(name || 'Uncategorized'); let hash = 0; for (let i = 0; i < key.length; i++) { hash = (hash * 31 + key.charCodeAt(i)) | 0; } const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length; - return CATEGORY_COLOR_TONES[index]; + return CATEGORY_COLOR_TONES[index]!; // index is always in range } -- 2.40.1 From 62a99fcaa42208ec1e59e1aa2f174ef9ccfb24e2 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:44:35 -0500 Subject: [PATCH 07/69] build(lint): extend ESLint to .ts/.tsx via typescript-eslint As files convert to TypeScript they must keep the react-hooks enforcement. Added a .ts/.tsx config block using the typescript-eslint parser with the same rules-of-hooks/exhaustive-deps/react-refresh rules; swapped core no-undef + no-unused-vars (type-blind) for TS-aware equivalents. Verified 'npm run lint' traverses .ts and flags issues there; 0 errors on the existing .ts files. Co-Authored-By: Claude Opus 4.8 --- eslint.config.mjs | 30 +++++ package-lock.json | 294 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 325 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 31b59bc..d423349 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,7 @@ import js from '@eslint/js'; import globals from 'globals'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; // Flat config scoped to the React client. The point is enforcement of the two // rules that catch real React bugs — rules-of-hooks (conditional hooks) and @@ -33,4 +34,33 @@ export default [ 'no-empty': ['warn', { allowEmptyCatch: true }], }, }, + { + // TypeScript files: same react-hooks/react-refresh enforcement, via the + // TS parser. TS itself handles undefined identifiers + unused vars, so the + // core rules that don't understand types are swapped for their TS-aware + // equivalents. (Not the full typescript-eslint recommended set — this keeps + // the signal focused on the same correctness rules as the JS config.) + files: ['client/**/*.{ts,tsx}'], + languageOptions: { + parser: tseslint.parser, + ecmaVersion: 2023, + sourceType: 'module', + globals: { ...globals.browser }, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + '@typescript-eslint': tseslint.plugin, + }, + rules: { + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + 'no-undef': 'off', // TypeScript checks this + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }], + 'no-empty': ['warn', { allowEmptyCatch: true }], + }, + }, ]; diff --git a/package-lock.json b/package-lock.json index f41a4c3..601a3df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", "vite": "^5.4.10", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" @@ -6063,6 +6064,262 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -12840,6 +13097,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -13013,6 +13283,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 0f39f49..2de1cf5 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^6.0.3", + "typescript-eslint": "^8.62.1", "vite": "^5.4.10", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.8" -- 2.40.1 From 7bb68db44270417e5aaa22be4dcf722622f8ffd9 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:47:12 -0500 Subject: [PATCH 08/69] refactor(ts): convert usePaymentActions to TypeScript (TS5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared quick-pay / toggle-paid mutation hooks are now typed: PayableRow for the row refs, typed mutation payloads, and Dollars for the money amounts (so the branded type flows through — a caller must supply branded dollars). Strict catch clauses narrowed via an errMessage(unknown) helper. typecheck + lint (react-hooks now enforced on this .ts) + build + 48 tests green. Co-Authored-By: Claude Opus 4.8 --- ...PaymentActions.js => usePaymentActions.ts} | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) rename client/hooks/{usePaymentActions.js => usePaymentActions.ts} (67%) diff --git a/client/hooks/usePaymentActions.js b/client/hooks/usePaymentActions.ts similarity index 67% rename from client/hooks/usePaymentActions.js rename to client/hooks/usePaymentActions.ts index 575ff29..94fabe4 100644 --- a/client/hooks/usePaymentActions.js +++ b/client/hooks/usePaymentActions.ts @@ -2,8 +2,20 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { fmt } from '@/lib/utils'; +import type { Dollars } from '@/lib/money'; import { useInvalidateTrackerData } from '@/hooks/useQueries'; +/** The bits of a tracker row these payment actions need. */ +interface PayableRow { + id: number; + name: string; +} + +/** Narrow an unknown error (strict catch clauses) to a message string. */ +function errMessage(err: unknown, fallback: string): string { + return err instanceof Error && err.message ? err.message : fallback; +} + // Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a // reversible Undo toast. A React Query mutation: `isPending` comes for free and // the tracker/badge caches are invalidated on settle. Shared by the desktop and @@ -11,11 +23,11 @@ import { useInvalidateTrackerData } from '@/hooks/useQueries'; export function useQuickPay() { const invalidate = useInvalidateTrackerData(); const mutation = useMutation({ - mutationFn: (payload) => api.quickPay(payload), + mutationFn: (payload: { bill_id: number; amount: number; paid_date: string }) => api.quickPay(payload), onSettled: () => invalidate(), }); - const run = (row, val, paidDate) => mutation.mutate( + const run = (row: PayableRow, val: Dollars, paidDate: string) => mutation.mutate( { bill_id: row.id, amount: val, paid_date: paidDate }, { onSuccess: (payment) => { @@ -28,32 +40,39 @@ export function useQuickPay() { toast.success('Payment removed'); invalidate(); } catch (err) { - toast.error(err.message || 'Failed to undo payment.'); + toast.error(errMessage(err, 'Failed to undo payment.')); } }, }, }); }, - onError: (err) => toast.error(err.message || 'Failed to add payment.'), + onError: (err) => toast.error(errMessage(err, 'Failed to add payment.')), }, ); return { run, isPending: mutation.isPending }; } +interface TogglePaidOptions { + wasPaid: boolean; + threshold: Dollars; + setOptimisticPaid: (value: boolean | undefined) => void; +} + // Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip // (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls // it back on error, invalidates the tracker/badge caches on settle, and shows the // right toast (an Undo when un-paying, a specific "paid" message otherwise). // Shared by the desktop and mobile tracker rows. -export function useTogglePaid(year, month) { +export function useTogglePaid(year: number, month: number) { const invalidate = useInvalidateTrackerData(); const mutation = useMutation({ - mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }), + mutationFn: ({ rowId, amount }: { rowId: number; amount: number | undefined }) => + api.togglePaid(rowId, { amount, year, month }), onSettled: () => invalidate(), }); - const run = (row, { wasPaid, threshold, setOptimisticPaid }) => { + const run = (row: PayableRow, { wasPaid, threshold, setOptimisticPaid }: TogglePaidOptions) => { setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles mutation.mutate( { rowId: row.id, amount: wasPaid ? undefined : threshold }, @@ -69,7 +88,7 @@ export function useTogglePaid(year, month) { toast.success('Payment restored'); invalidate(); } catch (err) { - toast.error(err.message || 'Failed to restore payment'); + toast.error(errMessage(err, 'Failed to restore payment')); } }, }, @@ -80,7 +99,7 @@ export function useTogglePaid(year, month) { }, onError: (err) => { setOptimisticPaid(undefined); // roll back the instant flip - toast.error(err.message || 'Failed to toggle payment status'); + toast.error(errMessage(err, 'Failed to toggle payment status')); }, }, ); -- 2.40.1 From 7c6be667940116933b13091c111b3c256479901f Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:53:45 -0500 Subject: [PATCH 09/69] refactor(ts): convert client/lib/trackerUtils to TypeScript (TS6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tracker row/status/sort helpers are now typed. Introduces a TrackerRow domain interface (the fields these helpers read) and a TrackerStatus union (paid|autodraft|upcoming|due_soon|late|missed|skipped). Row money fields stay plain `number` for now — branding them Dollars is a later increment, done when the tracker API response itself is typed so the brand flows in from the source. Strict-mode handling: STATUS_SORT_ORDER is Record (no undefined on lookup); moveInArray guards the noUncheckedIndexedAccess `T | undefined` from splice; the sort comparator relies on aliased-condition narrowing to reach string|number in each branch. typecheck 0 errors, lint 0 errors (47 warnings unchanged), build green, 48 client tests pass. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + .../lib/{trackerUtils.js => trackerUtils.ts} | 72 ++++++++++++++----- 2 files changed, 56 insertions(+), 17 deletions(-) rename client/lib/{trackerUtils.js => trackerUtils.ts} (76%) diff --git a/HISTORY.md b/HISTORY.md index 1315175..1b10591 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,6 +5,7 @@ - **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. - **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. +- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), and `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed). ### 🧪 Money-service test coverage diff --git a/client/lib/trackerUtils.js b/client/lib/trackerUtils.ts similarity index 76% rename from client/lib/trackerUtils.js rename to client/lib/trackerUtils.ts index a76cf48..0772b7a 100644 --- a/client/lib/trackerUtils.js +++ b/client/lib/trackerUtils.ts @@ -12,6 +12,8 @@ export const TRACKER_SORT_DEFAULT = 'manual'; export const TRACKER_SORT_ASC = 'asc'; export const TRACKER_SORT_DESC = 'desc'; +export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC; + export const TRACKER_SORT_OPTIONS = [ { key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC }, { key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC }, @@ -51,7 +53,40 @@ export const STATUS_META = { skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; -export function paymentDateForTrackerMonth(year, month, dueDay) { +// A tracker month's effective status for a bill. +export type TrackerStatus = + | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; + +// The subset of a tracker row these helpers read. Rows come from the (untyped) +// tracker API; money fields are dollars (the server serializes cents→dollars). +// Branding them as `Dollars` is a later increment (when the API is typed) — for +// now the computed money fields are plain `number`. Fields the aggregation +// always populates are required; the rest are optional/nullable. +export interface TrackerRow { + name?: string | null; + status: TrackerStatus; + is_skipped?: boolean; + expected_amount: number; + actual_amount?: number | null; + total_paid: number; + paid_toward_due?: number | null; + overpaid_amount?: number | null; + previous_month_paid?: number | null; + autopay_suggestion?: unknown; + category_name?: string | null; + current_balance?: number | null; + minimum_payment?: number | null; + due_date?: string | null; + due_day?: number | null; + last_paid_date?: string | null; + payments?: unknown[]; +} + +export function paymentDateForTrackerMonth( + year: number, + month: number, + dueDay: number | string | null | undefined, +): string { const now = new Date(); if (year === now.getFullYear() && month === now.getMonth() + 1) { return todayStr(); @@ -65,7 +100,7 @@ export function paymentDateForTrackerMonth(year, month, dueDay) { return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } -export function amountSearchText(...values) { +export function amountSearchText(...values: unknown[]): string { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { @@ -75,11 +110,11 @@ export function amountSearchText(...values) { .join(' '); } -export function rowThreshold(row) { +export function rowThreshold(row: TrackerRow): number { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } -export function rowEffectiveStatus(row) { +export function rowEffectiveStatus(row: TrackerRow): TrackerStatus { if (row.is_skipped) return 'skipped'; const threshold = rowThreshold(row); const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; @@ -89,24 +124,24 @@ export function rowEffectiveStatus(row) { // A bill's month is "settled" when its status is paid or autodraft. Mirrors the // server's statusService.isPaidStatus — the single low-level check the various // row/badge/calendar components share. -export function isPaidStatus(status) { +export function isPaidStatus(status: string): boolean { return status === 'paid' || status === 'autodraft'; } -export function rowIsPaid(row) { +export function rowIsPaid(row: TrackerRow): boolean { const status = rowEffectiveStatus(row); if (row.autopay_suggestion && status === 'autodraft') return false; return isPaidStatus(status); } -export function rowIsDebt(row) { +export function rowIsDebt(row: TrackerRow): boolean { const category = String(row.category_name || '').toLowerCase(); return Number(row.current_balance) > 0 || row.minimum_payment != null || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } -const STATUS_SORT_ORDER = { +const STATUS_SORT_ORDER: Record = { missed: 0, late: 1, due_soon: 2, @@ -116,18 +151,20 @@ const STATUS_SORT_ORDER = { skipped: 6, }; -function parseDateSortValue(value) { +function parseDateSortValue(value: string | null | undefined): number | null { if (!value) return null; const parsed = Date.parse(`${value}T00:00:00`); return Number.isFinite(parsed) ? parsed : null; } -function numericSortValue(value) { +function numericSortValue(value: unknown): number { const number = Number(value); return Number.isFinite(number) ? number : 0; } -function trackerSortValue(row, key) { +type SortValue = string | number | null | undefined; + +function trackerSortValue(row: TrackerRow, key: string): SortValue { switch (key) { case 'name': return String(row.name || '').toLowerCase(); @@ -150,7 +187,7 @@ function trackerSortValue(row, key) { } } -function compareSortValues(a, b, dir) { +function compareSortValues(a: SortValue, b: SortValue, dir: string): number { const aMissing = a === null || a === undefined || a === ''; const bMissing = b === null || b === undefined || b === ''; if (aMissing && bMissing) return 0; @@ -166,15 +203,15 @@ function compareSortValues(a, b, dir) { return dir === TRACKER_SORT_DESC ? -result : result; } -export function normalizeTrackerSortKey(key) { +export function normalizeTrackerSortKey(key: string): string { return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT; } -export function normalizeTrackerSortDir(dir) { +export function normalizeTrackerSortDir(dir: string): TrackerSortDir { return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC; } -export function sortTrackerRows(rows, sortKey, sortDir) { +export function sortTrackerRows(rows: TrackerRow[], sortKey: string, sortDir: string): TrackerRow[] { const key = normalizeTrackerSortKey(sortKey); if (key === TRACKER_SORT_DEFAULT) return rows; @@ -200,14 +237,15 @@ export function sortTrackerRows(rows, sortKey, sortDir) { .map(item => item.row); } -export function moveInArray(items, fromIndex, toIndex) { +export function moveInArray(items: T[], fromIndex: number, toIndex: number): T[] { const next = [...items]; const [moved] = next.splice(fromIndex, 1); + if (moved === undefined) return next; next.splice(toIndex, 0, moved); return next; } -export function paymentSummary(row, threshold) { +export function paymentSummary(row: TrackerRow, threshold: number) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) -- 2.40.1 From 3c51464bec6a7958f700817aaa2a18f9d2a902f4 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:59:16 -0500 Subject: [PATCH 10/69] refactor(ts): convert pure lib utilities to TypeScript (TS7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five self-contained leaf modules, no behavior change: - reorder.ts — generic moveInArray, reorderPayload, movedItemId - billingSchedule.ts — a Schedule union + typed normalize/label helpers - billDrafts.ts — SourceBill/Template/Category shapes for makeBillDraft - trackerTableColumns.ts — typed column parse/normalize - cashflowUtils.ts — typed SVG step-path timeline geometry (Safe-to-Spend) noUncheckedIndexedAccess handled via in-range `!` (guarded lengths) and destructuring defaults. Their .test.js suites import the .ts transparently via Vite/vitest and still pass. typecheck 0, lint 0 errors (47 warns), build green, 48 client tests pass. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 2 +- client/lib/billDrafts.js | 42 ---------- client/lib/billDrafts.ts | 82 +++++++++++++++++++ ...{billingSchedule.js => billingSchedule.ts} | 23 ++++-- .../{cashflowUtils.js => cashflowUtils.ts} | 61 ++++++++++---- client/lib/{reorder.js => reorder.ts} | 14 +++- ...TableColumns.js => trackerTableColumns.ts} | 8 +- 7 files changed, 160 insertions(+), 72 deletions(-) delete mode 100644 client/lib/billDrafts.js create mode 100644 client/lib/billDrafts.ts rename client/lib/{billingSchedule.js => billingSchedule.ts} (60%) rename client/lib/{cashflowUtils.js => cashflowUtils.ts} (55%) rename client/lib/{reorder.js => reorder.ts} (50%) rename client/lib/{trackerTableColumns.js => trackerTableColumns.ts} (80%) diff --git a/HISTORY.md b/HISTORY.md index 1b10591..e0203a7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,7 +5,7 @@ - **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. - **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. -- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), and `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed). +- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). ### 🧪 Money-service test coverage diff --git a/client/lib/billDrafts.js b/client/lib/billDrafts.js deleted file mode 100644 index b34e1d3..0000000 --- a/client/lib/billDrafts.js +++ /dev/null @@ -1,42 +0,0 @@ -import { billingCycleForSchedule, scheduleValue } from './billingSchedule'; - -function categoryForTemplate(template, categories = []) { - const keywords = template?.categoryKeywords || []; - const match = categories.find(category => { - const name = String(category.name || '').toLowerCase(); - return keywords.some(keyword => name.includes(keyword)); - }); - return match?.id ?? null; -} - -function categoryIdOrFallback(categoryId, template, categories = []) { - if (categoryId && categories.some(category => String(category.id) === String(categoryId))) { - return categoryId; - } - return template ? categoryForTemplate(template, categories) : null; -} - -export function makeBillDraft(source, { copy = false, template = null, categories = [] } = {}) { - const data = source || {}; - return { - ...data, - id: undefined, - source_bill_id: copy && data.id != null ? data.id : undefined, - active: 1, - name: copy - ? `${data.name || 'Bill'} (Copy)` - : (data.name || template?.name || ''), - category_id: categoryIdOrFallback(data.category_id, template, categories), - due_day: data.due_day || 1, - expected_amount: data.expected_amount ?? 0, - billing_cycle: billingCycleForSchedule(scheduleValue(data)), - cycle_type: scheduleValue(data), - cycle_day: String(data.cycle_day || '1'), - autopay_enabled: !!data.autopay_enabled, - autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'), - auto_mark_paid: !!data.auto_mark_paid, - has_2fa: !!data.has_2fa, - snowball_include: !!data.snowball_include, - snowball_exempt: !!data.snowball_exempt, - }; -} diff --git a/client/lib/billDrafts.ts b/client/lib/billDrafts.ts new file mode 100644 index 0000000..fe26304 --- /dev/null +++ b/client/lib/billDrafts.ts @@ -0,0 +1,82 @@ +import { billingCycleForSchedule, scheduleValue } from './billingSchedule'; + +interface Category { + id: string | number; + name?: string | null; +} + +interface Template { + name?: string | null; + categoryKeywords?: string[]; +} + +// The source bill a draft is seeded from. The specific fields the draft reads +// are declared; the index signature carries every other field through the spread. +interface SourceBill { + id?: string | number | null; + name?: string | null; + category_id?: string | number | null; + due_day?: number | null; + expected_amount?: number | null; + cycle_type?: string | null; + billing_cycle?: string | null; + cycle_day?: string | number | null; + autopay_enabled?: unknown; + autodraft_status?: string | null; + auto_mark_paid?: unknown; + has_2fa?: unknown; + snowball_include?: unknown; + snowball_exempt?: unknown; + [key: string]: unknown; +} + +interface MakeBillDraftOptions { + copy?: boolean; + template?: Template | null; + categories?: Category[]; +} + +function categoryForTemplate(template: Template | null | undefined, categories: Category[] = []): string | number | null { + const keywords = template?.categoryKeywords || []; + const match = categories.find(category => { + const name = String(category.name || '').toLowerCase(); + return keywords.some(keyword => name.includes(keyword)); + }); + return match?.id ?? null; +} + +function categoryIdOrFallback( + categoryId: string | number | null | undefined, + template: Template | null, + categories: Category[] = [], +): string | number | null { + if (categoryId && categories.some(category => String(category.id) === String(categoryId))) { + return categoryId; + } + return template ? categoryForTemplate(template, categories) : null; +} + +export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}) { + const data: SourceBill = source || {}; + return { + ...data, + id: undefined, + source_bill_id: copy && data.id != null ? data.id : undefined, + active: 1, + name: copy + ? `${data.name || 'Bill'} (Copy)` + : (data.name || template?.name || ''), + category_id: categoryIdOrFallback(data.category_id, template, categories), + due_day: data.due_day || 1, + expected_amount: data.expected_amount ?? 0, + billing_cycle: billingCycleForSchedule(scheduleValue(data)), + cycle_type: scheduleValue(data), + cycle_day: String(data.cycle_day || '1'), + autopay_enabled: !!data.autopay_enabled, + autodraft_status: data.autodraft_status || (data.autopay_enabled ? 'assumed_paid' : 'none'), + auto_mark_paid: !!data.auto_mark_paid, + has_2fa: !!data.has_2fa, + snowball_include: !!data.snowball_include, + snowball_exempt: !!data.snowball_exempt, + }; +} diff --git a/client/lib/billingSchedule.js b/client/lib/billingSchedule.ts similarity index 60% rename from client/lib/billingSchedule.js rename to client/lib/billingSchedule.ts index ddfd052..7696897 100644 --- a/client/lib/billingSchedule.js +++ b/client/lib/billingSchedule.ts @@ -1,4 +1,6 @@ -export const BILLING_SCHEDULE_OPTIONS = [ +export type Schedule = 'monthly' | 'weekly' | 'biweekly' | 'quarterly' | 'annual'; + +export const BILLING_SCHEDULE_OPTIONS: [Schedule, string][] = [ ['monthly', 'Monthly'], ['weekly', 'Weekly'], ['biweekly', 'Biweekly'], @@ -6,22 +8,27 @@ export const BILLING_SCHEDULE_OPTIONS = [ ['annual', 'Annual'], ]; -const LABELS = Object.fromEntries(BILLING_SCHEDULE_OPTIONS); +const LABELS: Record = Object.fromEntries(BILLING_SCHEDULE_OPTIONS); -export function scheduleFromBillingCycle(billingCycle) { +export interface ScheduleBill { + cycle_type?: string | null; + billing_cycle?: string | null; +} + +export function scheduleFromBillingCycle(billingCycle: string | null | undefined): Schedule { const value = String(billingCycle || '').toLowerCase(); if (value === 'quarterly') return 'quarterly'; if (value === 'annually' || value === 'annual') return 'annual'; return 'monthly'; } -export function normalizeSchedule(value, fallback = 'monthly') { +export function normalizeSchedule(value: string | null | undefined, fallback = 'monthly'): string { if (!value) return fallback; const normalized = String(value).toLowerCase(); return LABELS[normalized] ? normalized : fallback; } -export function scheduleValue(bill = {}) { +export function scheduleValue(bill: ScheduleBill = {}): string { const cycleType = normalizeSchedule(bill.cycle_type, ''); const billingCycle = String(bill.billing_cycle || '').toLowerCase(); @@ -32,14 +39,14 @@ export function scheduleValue(bill = {}) { return cycleType || scheduleFromBillingCycle(billingCycle); } -export function scheduleLabel(valueOrBill) { +export function scheduleLabel(valueOrBill: string | ScheduleBill | null | undefined): string { const value = valueOrBill && typeof valueOrBill === 'object' ? scheduleValue(valueOrBill) : normalizeSchedule(valueOrBill); return LABELS[value] || 'Monthly'; } -export function billingCycleForSchedule(schedule) { +export function billingCycleForSchedule(schedule: string | null | undefined): string { const value = normalizeSchedule(schedule); if (value === 'quarterly') return 'quarterly'; if (value === 'annual') return 'annually'; @@ -47,7 +54,7 @@ export function billingCycleForSchedule(schedule) { return 'monthly'; } -export function defaultCycleDayForSchedule(schedule) { +export function defaultCycleDayForSchedule(schedule: string | null | undefined): string { const value = normalizeSchedule(schedule); return value === 'weekly' || value === 'biweekly' ? 'monday' : '1'; } diff --git a/client/lib/cashflowUtils.js b/client/lib/cashflowUtils.ts similarity index 55% rename from client/lib/cashflowUtils.js rename to client/lib/cashflowUtils.ts index 1b51a96..4a99a78 100644 --- a/client/lib/cashflowUtils.js +++ b/client/lib/cashflowUtils.ts @@ -1,5 +1,30 @@ // Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable. +export interface TimelineEntry { + date: string; + balance: number | string | null; + bills?: unknown[]; + payday?: unknown; +} + +export interface GeometryPoint { + x: number; + y: number; + date: string; + balance: number; + bills: unknown[]; + isDrop: boolean; + isPayday: boolean; + isLast: boolean; +} + +export interface TimelineGeometry { + line: string; + area: string; + points: GeometryPoint[]; + zeroY: number; +} + /** * Convert the server's cashflow timeline into SVG step-path geometry. * Returns { line, area, points, zeroY } or null when there is nothing to draw. @@ -9,24 +34,32 @@ * - Y maps balances; the domain always includes 0 so the zero line is honest. * - Step shape: balance holds flat until a bill's day, then drops. */ -export function buildTimelineGeometry(timeline, width, height, pad = 4) { +export function buildTimelineGeometry( + timeline: TimelineEntry[] | null | undefined, + width: number, + height: number, + pad = 4, +): TimelineGeometry | null { if (!Array.isArray(timeline) || timeline.length < 2) return null; - const t0 = Date.parse(timeline[0].date); - const t1 = Date.parse(timeline[timeline.length - 1].date); + // length >= 2 (guarded), so first/last exist — the `!` satisfies noUncheckedIndexedAccess. + const first = timeline[0]!; + const last = timeline[timeline.length - 1]!; + const t0 = Date.parse(first.date); + const t1 = Date.parse(last.date); const span = Math.max(t1 - t0, 1); const balances = timeline.map(t => Number(t.balance) || 0); - const start = Number(timeline[0].balance) || 0; + const start = Number(first.balance) || 0; // Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)] const lo = Math.min(0, ...balances); const hi = Math.max(start, ...balances, 1); const range = Math.max(hi - lo, 1); - const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); - const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); + const x = (date: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); + const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); - const points = timeline.map((t, i) => ({ + const points: GeometryPoint[] = timeline.map((t, i) => ({ x: x(t.date), y: y(Number(t.balance) || 0), date: t.date, @@ -38,19 +71,19 @@ export function buildTimelineGeometry(timeline, width, height, pad = 4) { })); // Step path: horizontal to each next x, then vertical drop. - let line = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + let line = `M ${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`; for (let i = 1; i < points.length; i++) { - line += ` H ${points[i].x.toFixed(2)} V ${points[i].y.toFixed(2)}`; + line += ` H ${points[i]!.x.toFixed(2)} V ${points[i]!.y.toFixed(2)}`; } const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0); - const area = `${line} V ${baseY.toFixed(2)} H ${points[0].x.toFixed(2)} Z`; + const area = `${line} V ${baseY.toFixed(2)} H ${points[0]!.x.toFixed(2)} Z`; return { line, area, points, zeroY: y(0) }; } /** "5 days" / "tomorrow" / "today" */ -export function daysUntilLabel(days) { +export function daysUntilLabel(days: number | string | null | undefined): string { const n = Number(days); if (!Number.isFinite(n) || n <= 0) return 'today'; if (n === 1) return 'tomorrow'; @@ -58,15 +91,15 @@ export function daysUntilLabel(days) { } /** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */ -export function shortDate(dateStr) { +export function shortDate(dateStr: string | null | undefined): string { if (!dateStr || typeof dateStr !== 'string') return ''; - const [, m, d] = dateStr.split('-'); + const [, m = '', d = ''] = dateStr.split('-'); const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`; } /** Split upcoming bills into the visible few plus an overflow count. */ -export function splitUpcoming(upcoming, maxVisible = 4) { +export function splitUpcoming(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } { const list = Array.isArray(upcoming) ? upcoming : []; return { visible: list.slice(0, maxVisible), diff --git a/client/lib/reorder.js b/client/lib/reorder.ts similarity index 50% rename from client/lib/reorder.js rename to client/lib/reorder.ts index 65034d6..622a17e 100644 --- a/client/lib/reorder.js +++ b/client/lib/reorder.ts @@ -1,19 +1,27 @@ -export function moveInArray(items, fromIndex, toIndex) { +interface Identified { + id: string | number; +} + +export function moveInArray(items: T[], fromIndex: number, toIndex: number): T[] { if (!Array.isArray(items)) return []; if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) { return items; } const next = [...items]; const [moved] = next.splice(fromIndex, 1); + if (moved === undefined) return next; // in-range by the guard above; satisfies noUncheckedIndexedAccess next.splice(toIndex, 0, moved); return next; } -export function reorderPayload(items) { +export function reorderPayload(items: ReadonlyArray | null | undefined): Record { return Object.fromEntries((items || []).map((item, index) => [item.id, index])); } -export function movedItemId(before, after) { +export function movedItemId( + before: ReadonlyArray | null | undefined, + after: ReadonlyArray | null | undefined, +): string | number | null { const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id); return moved?.id || after?.[0]?.id || null; } diff --git a/client/lib/trackerTableColumns.js b/client/lib/trackerTableColumns.ts similarity index 80% rename from client/lib/trackerTableColumns.js rename to client/lib/trackerTableColumns.ts index 7ea29dd..6ebc08a 100644 --- a/client/lib/trackerTableColumns.js +++ b/client/lib/trackerTableColumns.ts @@ -12,19 +12,19 @@ export const TRACKER_TABLE_COLUMNS = [ export const TRACKER_TABLE_COLUMN_KEYS = TRACKER_TABLE_COLUMNS.map(column => column.key); export const DEFAULT_TRACKER_TABLE_COLUMNS = [...TRACKER_TABLE_COLUMN_KEYS]; -export function parseTrackerTableColumns(value) { +export function parseTrackerTableColumns(value: unknown): string[] { if (Array.isArray(value)) return normalizeTrackerTableColumns(value); if (!value) return DEFAULT_TRACKER_TABLE_COLUMNS; try { - const parsed = JSON.parse(value); + const parsed = JSON.parse(value as string); return normalizeTrackerTableColumns(parsed); } catch { return DEFAULT_TRACKER_TABLE_COLUMNS; } } -export function normalizeTrackerTableColumns(columns) { +export function normalizeTrackerTableColumns(columns: unknown): string[] { const valid = new Set(TRACKER_TABLE_COLUMN_KEYS); return Array.isArray(columns) ? columns.filter(column => valid.has(column)) @@ -32,6 +32,6 @@ export function normalizeTrackerTableColumns(columns) { : DEFAULT_TRACKER_TABLE_COLUMNS; } -export function trackerTableColumnsToSetting(columns) { +export function trackerTableColumnsToSetting(columns: unknown): string { return JSON.stringify(normalizeTrackerTableColumns(columns)); } -- 2.40.1 From 8c30a7ab09d345fb87b31385c80749d9ff0c6d31 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:01:30 -0500 Subject: [PATCH 11/69] refactor(ts): convert small hooks + version to TypeScript (TS8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAutoSave.ts — generic useAutoSave; AutoSaveStatus union; timer ref typed ReturnType; pending ref T|null narrows on != null. - useSearchPanelPreference.ts — typed [boolean, setter] tuple return. - version.ts — ReleaseNotes/ReleaseHighlight shapes; the Vite-injected __APP_VERSION__ global declared inline (declare const). No behavior change. useAutoSave.test.jsx imports the .ts transparently and passes. typecheck 0, lint 0 errors (47 warns), build green, 48 client tests. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 2 +- .../hooks/{useAutoSave.js => useAutoSave.ts} | 24 +++++++++++++------ ...ference.js => useSearchPanelPreference.ts} | 6 ++--- client/lib/{version.js => version.ts} | 18 ++++++++++++-- 4 files changed, 37 insertions(+), 13 deletions(-) rename client/hooks/{useAutoSave.js => useAutoSave.ts} (70%) rename client/hooks/{useSearchPanelPreference.js => useSearchPanelPreference.ts} (79%) rename client/lib/{version.js => version.ts} (86%) diff --git a/HISTORY.md b/HISTORY.md index e0203a7..b3e14c2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -5,7 +5,7 @@ - **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. - **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. -- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). +- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline). ### 🧪 Money-service test coverage diff --git a/client/hooks/useAutoSave.js b/client/hooks/useAutoSave.ts similarity index 70% rename from client/hooks/useAutoSave.js rename to client/hooks/useAutoSave.ts index 8e944b8..dc4b2d1 100644 --- a/client/hooks/useAutoSave.js +++ b/client/hooks/useAutoSave.ts @@ -1,5 +1,15 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +type SaveFn = (payload: T) => unknown; + +export interface UseAutoSave { + status: AutoSaveStatus; + schedule: (payload: T, delay?: number) => void; + flush: () => void; +} + /** * Debounced auto-save with a status the UI can render. * @@ -10,14 +20,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; * * Pending changes are flushed on unmount so navigating away never loses edits. */ -export function useAutoSave(saveFn, defaultDelay = 400) { - const [status, setStatus] = useState('idle'); - const timer = useRef(null); - const pending = useRef(null); - const saveRef = useRef(saveFn); +export function useAutoSave(saveFn: SaveFn, defaultDelay = 400): UseAutoSave { + const [status, setStatus] = useState('idle'); + const timer = useRef | undefined>(undefined); + const pending = useRef(null); + const saveRef = useRef>(saveFn); saveRef.current = saveFn; - const run = useCallback(async (payload) => { + const run = useCallback(async (payload: T) => { pending.current = null; setStatus('saving'); try { @@ -28,7 +38,7 @@ export function useAutoSave(saveFn, defaultDelay = 400) { } }, []); - const schedule = useCallback((payload, delay = defaultDelay) => { + const schedule = useCallback((payload: T, delay = defaultDelay) => { pending.current = payload; clearTimeout(timer.current); timer.current = setTimeout(() => run(payload), delay); diff --git a/client/hooks/useSearchPanelPreference.js b/client/hooks/useSearchPanelPreference.ts similarity index 79% rename from client/hooks/useSearchPanelPreference.js rename to client/hooks/useSearchPanelPreference.ts index 6438841..dbbc0fd 100644 --- a/client/hooks/useSearchPanelPreference.js +++ b/client/hooks/useSearchPanelPreference.ts @@ -3,11 +3,11 @@ import { api } from '@/api'; const SETTING_KEY = 'search_bars_collapsed'; -function settingToBool(value) { +function settingToBool(value: unknown): boolean { return value === true || value === 'true' || value === '1' || value === 1; } -export function useSearchPanelPreference() { +export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] { const [collapsed, setCollapsed] = useState(false); useEffect(() => { @@ -23,7 +23,7 @@ export function useSearchPanelPreference() { return () => { mounted = false; }; }, []); - const saveCollapsed = useCallback((nextCollapsed) => { + const saveCollapsed = useCallback((nextCollapsed: boolean) => { setCollapsed(nextCollapsed); api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {}); }, []); diff --git a/client/lib/version.js b/client/lib/version.ts similarity index 86% rename from client/lib/version.js rename to client/lib/version.ts index 182f6b2..720e454 100644 --- a/client/lib/version.js +++ b/client/lib/version.ts @@ -1,10 +1,24 @@ // __APP_VERSION__ is injected by Vite at build time from package.json. // Do not hardcode a version string here — update package.json instead. -/* global __APP_VERSION__ */ +declare const __APP_VERSION__: string; + export const APP_VERSION = __APP_VERSION__; export const APP_NAME = 'BillTracker'; -export const RELEASE_NOTES = { +export interface ReleaseHighlight { + icon: string; + title: string; + desc: string; +} + +export interface ReleaseNotes { + version: string; + date: string; + highlights: ReleaseHighlight[]; + image: { src: string; alt: string }; +} + +export const RELEASE_NOTES: ReleaseNotes = { version: APP_VERSION, date: '2026-05-31', highlights: [ -- 2.40.1 From 9b805e60b2c64aae6a418d179deab74434e0692e Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:09:04 -0500 Subject: [PATCH 12/69] refactor(ts): convert the API client to TypeScript (TS9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client/api.js -> api.ts. Types the fetch infrastructure: generic _fetch and get/post/put/patch/del helpers, an ApiError interface (status/code/ details/data), a QueryParams type for the query-string builder, and File-typed upload helpers. Endpoint response shapes are typed incrementally — most methods default to Promise; the ones consumed by typed .ts files get real shapes (quickPay -> PaymentRecord, togglePaid -> TogglePaidResult, settings -> settings map). Typing togglePaid's result surfaced a latent narrowing bug in usePaymentActions: the un-pay Undo closure read result.paymentId (number|undefined) inside a deferred async callback where TS re-widens the if-narrowed value — fixed by capturing the id in a const before the closure. The 16 files importing '@/api.js' with an explicit extension were normalized to '@/api' so Vite/TS resolve the .ts. Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests, and 17/17 e2e probe (every page renders, all API paths respond) — the central fetch-module rename is runtime-safe. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + client/api.js | 499 ----------------- client/api.ts | 527 ++++++++++++++++++ .../components/tracker/DriftInsightPanel.jsx | 2 +- client/components/tracker/EditableCell.jsx | 2 +- .../tracker/LowerThisMonthButton.jsx | 2 +- .../components/tracker/MobileTrackerRow.jsx | 2 +- .../components/tracker/MonthlyStateDialog.jsx | 2 +- client/components/tracker/NotesCell.jsx | 2 +- .../tracker/OverdueCommandCenter.jsx | 2 +- .../tracker/PaymentLedgerDialog.jsx | 2 +- client/components/tracker/PaymentModal.jsx | 2 +- .../tracker/StartingAmountsEditDialog.jsx | 2 +- client/components/tracker/TrackerBucket.jsx | 2 +- client/components/tracker/TrackerRow.jsx | 2 +- client/hooks/usePaymentActions.ts | 5 +- client/pages/CategoriesPage.jsx | 2 +- client/pages/SummaryPage.jsx | 2 +- client/pages/TrackerPage.jsx | 2 +- 19 files changed, 546 insertions(+), 516 deletions(-) delete mode 100644 client/api.js create mode 100644 client/api.ts diff --git a/HISTORY.md b/HISTORY.md index b3e14c2..aaade2b 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,7 @@ - **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig` → `tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19. - **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. - **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline). +- **[Client] Typed the API client (`client/api.js` → `api.ts`) — the fetch layer's infrastructure** — the central request layer is now TypeScript: a generic `_fetch` and `get`/`post`/`put`/`patch`/`del` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally — most methods resolve to `Promise` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay` → `PaymentRecord` (`id`), `togglePaid` → `TogglePaidResult` (`paymentId?`), `settings` → a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value — fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe. ### 🧪 Money-service test coverage diff --git a/client/api.js b/client/api.js deleted file mode 100644 index ef4322f..0000000 --- a/client/api.js +++ /dev/null @@ -1,499 +0,0 @@ -// Fetch CSRF token from the server once and cache in memory. -// The cookie is httpOnly so document.cookie cannot access it directly. -let _csrfFetch = null; -async function getCsrfToken() { - if (!_csrfFetch) { - _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) - .then(r => r.json()) - .then(d => d.token || '') - .catch(() => { - _csrfFetch = null; // don't cache a failed fetch - return ''; - }); - } - return _csrfFetch; -} - -const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; - -// Parse a response body without assuming it is JSON. Returns null when the -// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). -async function parseJsonSafe(res) { - if (res.status === 204) return null; - const text = await res.text(); - if (!text) return null; - try { return JSON.parse(text); } catch { return null; } -} - -async function _fetch(method, path, body, _retried = false) { - const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' }; - // Add CSRF token header for state-changing methods - if (MUTATING_METHODS.includes(method)) { - const csrfToken = await getCsrfToken(); - if (csrfToken) { - opts.headers['x-csrf-token'] = csrfToken; - } - } - if (body !== undefined) opts.body = JSON.stringify(body); - const res = await fetch('/api' + path, opts); - const data = await parseJsonSafe(res); - if (!res.ok) { - // Stale CSRF token (cookie rotated/expired since first fetch): refresh the - // cached token and retry the request once instead of forcing a page reload. - if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) { - _csrfFetch = null; - return _fetch(method, path, body, true); - } - const err = new Error(data?.message || data?.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data || {}; - err.details = data?.details || []; - err.code = data?.code; - throw err; - } - return data ?? {}; -} - -function queryString(params = {}) { - const qs = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); - }); - const value = qs.toString(); - return value ? `?${value}` : ''; -} - -const get = (path, params) => _fetch('GET', path + (params ? queryString(params) : '')); -const post = (path, body) => _fetch('POST', path, body); -const put = (path, body) => _fetch('PUT', path, body); -const patch = (path, body) => _fetch('PATCH', path, body); -const del = (path) => _fetch('DELETE', path); - -function filenameFromDisposition(value) { - if (!value) return null; - const match = value.match(/filename="?([^"]+)"?/i); - return match ? match[1] : null; -} - -export const api = { - // Auth - me: () => get('/auth/me'), - authMode: () => get('/auth/mode'), - login: (data) => post('/auth/login', data), - logout: () => post('/auth/logout'), - restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), - changePassword: (data) => post('/auth/change-password', data), - acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), - acknowledgeVersion: () => post('/auth/acknowledge-version'), - loginHistory: () => get('/auth/login-history'), - // Spending - spendingSummary: (p) => get('/spending/summary', p), - spendingTransactions:(p) => get('/spending/transactions', p), - categorizeTransaction: (id, d) => patch(`/spending/transactions/${id}/category`, d), - spendingBudgets: (p) => get('/spending/budgets', p), - setSpendingBudget: (d) => put('/spending/budgets', d), - copySpendingBudgets: (d) => post('/spending/budgets/copy', d), - spendingIncome: (p) => get('/spending/income', p), - spendingCategoryRules: () => get('/spending/category-rules'), - addSpendingRule: (d) => post('/spending/category-rules', d), - deleteSpendingRule: (id) => del(`/spending/category-rules/${id}`), - - totpStatus: () => get('/auth/totp/status'), - totpSetup: () => get('/auth/totp/setup'), - totpEnable: (data) => post('/auth/totp/enable', data), - totpDisable: (data) => post('/auth/totp/disable', data), - totpChallenge: (data) => post('/auth/totp/challenge', data), - - webauthnStatus: () => get('/auth/webauthn/status'), - webauthnSetup: () => get('/auth/webauthn/setup'), - webauthnEnable: (data) => post('/auth/webauthn/enable', data), - webauthnDisable: (data) => post('/auth/webauthn/disable', data), - webauthnCredentials: () => get('/auth/webauthn/credentials'), - webauthnDeleteCred: (id, data) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), - webauthnChallenge: (data) => post('/auth/webauthn/challenge', data), - - // Admin - hasUsers: () => get('/admin/has-users'), - adminUsers: () => get('/admin/users'), - createUser: (data) => post('/admin/users', data), - resetPassword: (id, data) => put(`/admin/users/${id}/password`, data), - updateUserRole: (id, data) => put(`/admin/users/${id}/role`, data), - updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data), - deleteUser: (id) => del(`/admin/users/${id}`), - authModeConfig: () => get('/admin/auth-mode'), - setAuthMode: (data) => put('/admin/auth-mode', data), - testOidcConfig: (data) => post('/admin/auth-mode/oidc-test', data), - adminBackups: () => get('/admin/backups'), - createAdminBackup: () => post('/admin/backups'), - deleteAdminBackup: (id) => del(`/admin/backups/${encodeURIComponent(id)}`), - restoreAdminBackup: (id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`), - adminBackupSettings: () => get('/admin/backups/settings'), - saveAdminBackupSettings: (data) => put('/admin/backups/settings', data), - runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'), - adminCleanup: () => get('/admin/cleanup'), - saveAdminCleanup: (data) => put('/admin/cleanup', data), - runAdminCleanup: () => post('/admin/cleanup/run'), - seedDemoData: () => post('/user/seed-demo-data'), - clearDemoData: () => post('/user/clear-demo-data'), - seededStatus: () => get('/user/seeded-status'), - eraseMyData: (confirm) => post('/user/erase-data', { confirm }), - downloadAdminBackup: async (id) => { - const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { - credentials: 'include', - }); - if (!res.ok) { - let data = {}; - try { data = await res.json(); } catch {} - throw new Error(data.error || `HTTP ${res.status}`); - } - return { - blob: await res.blob(), - filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || id, - }; - }, - importAdminBackup: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/admin/backups/import', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - - // Notifications (admin) - notifAdmin: () => get('/notifications/admin'), - saveNotifAdmin: (data) => put('/notifications/admin', data), - testEmail: (data) => post('/notifications/test', data), - testPushNotification: () => post('/notifications/test-push', {}), - notifMe: () => get('/notifications/me'), - saveNotifMe: (data) => put('/notifications/me', data), - - // Profile - profile: () => get('/profile'), - updateProfile: (data) => _fetch('PATCH', '/profile', data), - profileSettings: () => get('/profile/settings'), - updateProfileSettings: (data) => _fetch('PATCH', '/profile/settings', data), - changeProfilePassword: (data) => post('/profile/change-password', data), - profileExports: () => get('/profile/exports'), - profileImportHistory: () => get('/profile/import-history'), - - // Tracker - tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), - upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), - overdueCount: () => get('/tracker/overdue-count'), - snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data), - - // Calendar - calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), - calendarFeed: () => get('/calendar/feed'), - createCalendarFeed: () => post('/calendar/feed', {}), - regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), - revokeCalendarFeed: () => del('/calendar/feed'), - calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), - - // Summary - summary: (y, m) => get(`/summary?year=${y}&month=${m}`), - saveSummaryIncome: (data) => put('/summary/income', data), - getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`), - updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data), - - // Bills - bills: (params = {}) => get(`/bills${queryString(params)}`), - allBills: (params = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), - deletedBills: () => get('/bills/deleted'), - billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), - bill: (id) => get(`/bills/${id}`), - createBill: (data) => post('/bills', data), - updateBill: (id, data) => put(`/bills/${id}`, data), - reorderBills: (order) => put('/bills/reorder', order), - archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }), - updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), - billAmortization: (id, opts = {}) => { - const params = new URLSearchParams(); - if (opts.payment) params.set('payment', String(opts.payment)); - if (opts.max_months) params.set('max_months', String(opts.max_months)); - const qs = params.toString(); - return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`); - }, - updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), - deleteBill: (id) => del(`/bills/${id}`), - restoreBill: (id) => post(`/bills/${id}/restore`), - duplicateBill: (id, data) => post(`/bills/${id}/duplicate`, data), - verifyAutopay: (id) => post(`/bills/${id}/verify-autopay`, {}), - togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), - billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), - billTransactions: (id) => get(`/bills/${id}/transactions`), - syncBillSimplefinPayments: (id) => post(`/bills/${id}/sync-simplefin-payments`), - allBillMerchantRules: () => get('/bills/merchant-rules'), - billMerchantRules: (id) => get(`/bills/${id}/merchant-rules`), - previewMerchantRule: (id, merchant) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), - addMerchantRule: (id, merchant) => post(`/bills/${id}/merchant-rules`, { merchant }), - deleteMerchantRule: (id, ruleId) => del(`/bills/${id}/merchant-rules/${ruleId}`), - merchantRuleCandidates: (id) => get(`/bills/${id}/merchant-rules/candidates`), - toggleRuleAutoAttribute: (id, ruleId, on) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }), - importHistoricalPayments: (id, ids) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }), - billMonthlyState: (id, y, m) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), - saveBillMonthlyState: (id, data) => put(`/bills/${id}/monthly-state`, data), - billHistoryRanges: (id) => get(`/bills/${id}/history-ranges`), - createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data), - updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data), - deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`), - driftReport: () => get('/bills/drift-report'), - snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}), - billTemplates: () => get('/bills/templates'), - saveBillTemplate: (data) => post('/bills/templates', data), - deleteBillTemplate: (id) => del(`/bills/templates/${id}`), - - // Subscriptions - subscriptions: () => get('/subscriptions'), - confirmTransactionMatch: (transactionId, billId) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), - matchRecommendationToBill: (transactionIds, billId, merchant, catalogId, confidence) => post('/subscriptions/recommendations/match-bill', { - transaction_ids: transactionIds, - bill_id: billId, - merchant, - catalog_id: catalogId, - confidence, - }), - subscriptionRecommendations: () => get('/subscriptions/recommendations'), - subscriptionTransactionMatches: (params = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), - updateSubscription: (id, data) => _fetch('PATCH', `/subscriptions/${id}`, data), - createSubscriptionFromRecommendation: (data) => post('/subscriptions/recommendations/create', data), - declineRecommendation: (recommendation) => post('/subscriptions/recommendations/decline', { - decline_key: recommendation?.decline_key || recommendation, - catalog_id: recommendation?.catalog_match?.id, - merchant: recommendation?.merchant, - confidence: recommendation?.confidence, - }), - subscriptionCatalog: () => get('/subscriptions/catalog'), - updateSubscriptionCatalogLink:(id, catalogId) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }), - addCatalogDescriptor: (catalogId, d) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }), - deleteCatalogDescriptor: (id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`), - - // Payments - quickPay: (data) => post('/payments/quick', data), - confirmAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), - dismissAutopaySuggestion: (billId, data) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), - bulkPay: (items) => post('/payments/bulk', items), - createPayment: (data) => post('/payments', data), - updatePayment: (id, data) => put(`/payments/${id}`, data), - deletePayment: (id) => del(`/payments/${id}`), - restorePayment: (id) => post(`/payments/${id}/restore`), - recentAutoMatched: () => get('/payments/recent-auto'), - undoAutoMatch: (id) => post(`/payments/${id}/undo-auto`), - - // Snowball - snowball: () => get('/snowball'), - snowballSettings: () => get('/snowball/settings'), - saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), - saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), - snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`), - snowballPlans: () => get('/snowball/plans'), - snowballActivePlan: () => get('/snowball/plans/active'), - startSnowballPlan: (data) => post('/snowball/plans', data), - updateSnowballPlan: (id, d) => _fetch('PATCH', `/snowball/plans/${id}`, d), - pauseSnowballPlan: (id) => post(`/snowball/plans/${id}/pause`, {}), - resumeSnowballPlan: (id) => post(`/snowball/plans/${id}/resume`, {}), - completeSnowballPlan: (id) => post(`/snowball/plans/${id}/complete`, {}), - abandonSnowballPlan: (id) => post(`/snowball/plans/${id}/abandon`, {}), - - // Categories - categories: () => get('/categories'), - createCategory: (data) => post('/categories', data), - reorderCategories: (order) => put('/categories/reorder', order), - updateCategory: (id, data) => put(`/categories/${id}`, data), - toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }), - deleteCategory: (id) => del(`/categories/${id}`), - restoreCategory: (id) => post(`/categories/${id}/restore`), - - // Category groups - categoryGroups: () => get('/categories/groups'), - createCategoryGroup: (data) => post('/categories/groups', data), - updateCategoryGroup: (id, data) => put(`/categories/groups/${id}`, data), - deleteCategoryGroup: (id) => del(`/categories/groups/${id}`), - - // Settings - settings: () => get('/settings'), - saveSettings: (data) => put('/settings', data), - - // Analytics - analyticsSummary: (params = {}) => { - const qs = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); - }); - const query = qs.toString(); - return get(`/analytics/summary${query ? `?${query}` : ''}`); - }, - - // Status - status: () => get('/status'), - - // Version (public) - about: () => get('/about'), - privacy: () => get('/privacy'), - aboutAdmin: () => get('/about-admin'), - roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), - updateStatus: () => get('/version/update-status'), - checkForUpdates: () => post('/about-admin/check-updates'), - getUpdateCheckSetting: () => get('/about-admin/update-check-setting'), - setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }), - devLog: () => get('/about-admin/dev-log'), - version: () => get('/version'), - releaseHistory: () => get('/version/history'), - - // Export (returns a URL to navigate to, not a fetch) - exportUrl: (year, fmt) => `/api/export?year=${year}&format=${fmt||'csv'}`, - - // Spreadsheet Import - previewSpreadsheetImport: async (file, options = {}) => { - const params = new URLSearchParams(); - if (options.parseAllSheets) params.set('parse_all_sheets', 'true'); - if (options.defaultYear) params.set('year', String(options.defaultYear)); - if (options.defaultMonth) params.set('month', String(options.defaultMonth)); - const qs = params.toString(); - const csrfToken = await getCsrfToken(); - const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/octet-stream', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data), - previewCsvTransactionImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/csv/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'text/csv', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - commitCsvTransactionImport: (data) => post('/import/csv/commit', data), - previewOfxTransactionImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/ofx/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/x-ofx', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - commitOfxTransactionImport: (data) => post('/import/ofx/commit', data), - importHistory: () => get('/import/history'), - - // Transactions - transactions: (params = {}) => get(`/transactions${queryString(params)}`), - bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`), - createManualTransaction: (data) => post('/transactions/manual', data), - updateTransaction: (id, data) => put(`/transactions/${id}`, data), - deleteTransaction: (id) => del(`/transactions/${id}`), - matchTransaction: (id, billId) => post(`/transactions/${id}/match`, { billId }), - unmatchTransaction: (id) => post(`/transactions/${id}/unmatch`), - unmatchTransactionBulk: (matches) => post('/transactions/unmatch-bulk', { matches }), - ignoreTransaction: (id) => post(`/transactions/${id}/ignore`), - unignoreTransaction: (id) => post(`/transactions/${id}/unignore`), - transactionMerchantMatch: (id) => get(`/transactions/${id}/merchant-match`), - applyTransactionMerchantMatch: (id) => post(`/transactions/${id}/apply-merchant-match`), - autoCategorizeTransactions: (opts = {}) => post('/transactions/auto-categorize', opts), - - // Match suggestions - matchSuggestions: (params = {}) => get(`/matches/suggestions${queryString(params)}`), - rejectMatchSuggestion: (id) => post(`/matches/${encodeURIComponent(id)}/reject`), - - // Data sources & SimpleFIN bank sync - dataSources: (params = {}) => get(`/data-sources${queryString(params)}`), - simplefinStatus: () => get('/data-sources/simplefin/status'), - connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }), - syncDataSource: (id) => post(`/data-sources/${id}/sync`), - backfillDataSource: (id) => post(`/data-sources/${id}/backfill`), - deleteDataSource: (id) => del(`/data-sources/${id}`), - dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), - setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), - allFinancialAccounts: () => get('/data-sources/accounts/all'), - syncAllSources: () => post('/data-sources/sync-all', {}), - attributePaymentToMonth: (id, paid_date) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), - - // Admin — bank sync feature flag - bankSyncConfig: () => get('/admin/bank-sync-config'), - setBankSyncConfig: (data) => put('/admin/bank-sync-config', data), - - // User SQLite import - previewUserDbImport: async (file) => { - const csrfToken = await getCsrfToken(); - const res = await fetch('/api/import/user-db/preview', { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/octet-stream', - 'x-csrf-token': csrfToken, - ...(file.name ? { 'X-Filename': file.name } : {}), - }, - body: file, - }); - const data = await res.json(); - if (!res.ok) { - const err = new Error(data.message || data.error || `HTTP ${res.status}`); - err.status = res.status; - err.data = data; - err.details = data.details || []; - err.code = data.code; - throw err; - } - return data; - }, - applyUserDbImport: (data) => post('/import/user-db/apply', data), -}; diff --git a/client/api.ts b/client/api.ts new file mode 100644 index 0000000..53b6c44 --- /dev/null +++ b/client/api.ts @@ -0,0 +1,527 @@ +// Fetch CSRF token from the server once and cache in memory. +// The cookie is httpOnly so document.cookie cannot access it directly. +let _csrfFetch: Promise | null = null; +async function getCsrfToken(): Promise { + if (!_csrfFetch) { + _csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' }) + .then(r => r.json()) + .then(d => d.token || '') + .catch(() => { + _csrfFetch = null; // don't cache a failed fetch + return ''; + }); + } + return _csrfFetch; +} + +// Common parameter/body shapes for the client. Endpoint responses are typed +// incrementally — most methods currently resolve to `unknown` (callers narrow), +// with the money-critical/consumed ones given real shapes below. +type Id = number | string; +type Body = unknown; +type QueryParams = Record; + +/** Error thrown by the API client, carrying the server's structured fields. */ +export interface ApiError extends Error { + status?: number; + data?: unknown; + details?: unknown[]; + code?: string; +} + +/** Minimal shape of a payment record returned by the pay endpoints. */ +export interface PaymentRecord { + id: number; + [key: string]: unknown; +} + +/** Result of toggling a tracker row paid/unpaid. */ +export interface TogglePaidResult { + paymentId?: number; + [key: string]: unknown; +} + +const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; + +// Parse a response body without assuming it is JSON. Returns null when the +// body is empty (204) or not valid JSON (e.g. an HTML error page from a proxy). +async function parseJsonSafe(res: Response): Promise { + if (res.status === 204) return null; + const text = await res.text(); + if (!text) return null; + try { return JSON.parse(text); } catch { return null; } +} + +async function _fetch(method: string, path: string, body?: unknown, _retried = false): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + const opts: RequestInit = { method, headers, credentials: 'include' }; + // Add CSRF token header for state-changing methods + if (MUTATING_METHODS.includes(method)) { + const csrfToken = await getCsrfToken(); + if (csrfToken) { + headers['x-csrf-token'] = csrfToken; + } + } + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch('/api' + path, opts); + const data = await parseJsonSafe(res); + if (!res.ok) { + // Stale CSRF token (cookie rotated/expired since first fetch): refresh the + // cached token and retry the request once instead of forcing a page reload. + if (!_retried && res.status === 403 && data?.code === 'CSRF_INVALID' && MUTATING_METHODS.includes(method)) { + _csrfFetch = null; + return _fetch(method, path, body, true); + } + const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data || {}; + err.details = data?.details || []; + err.code = data?.code; + throw err; + } + return (data ?? {}) as T; +} + +function queryString(params: QueryParams = {}): string { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); + }); + const value = qs.toString(); + return value ? `?${value}` : ''; +} + +const get = (path: string, params?: QueryParams): Promise => _fetch('GET', path + (params ? queryString(params) : '')); +const post = (path: string, body?: unknown): Promise => _fetch('POST', path, body); +const put = (path: string, body?: unknown): Promise => _fetch('PUT', path, body); +const patch = (path: string, body?: unknown): Promise => _fetch('PATCH', path, body); +const del = (path: string): Promise => _fetch('DELETE', path); + +function filenameFromDisposition(value: string | null): string | null { + if (!value) return null; + const match = value.match(/filename="?([^"]+)"?/i); + return match ? (match[1] ?? null) : null; +} + +export const api = { + // Auth + me: () => get('/auth/me'), + authMode: () => get('/auth/mode'), + login: (data: Body) => post('/auth/login', data), + logout: () => post('/auth/logout'), + restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), + changePassword: (data: Body) => post('/auth/change-password', data), + acknowledgePrivacy: () => post('/auth/acknowledge-privacy'), + acknowledgeVersion: () => post('/auth/acknowledge-version'), + loginHistory: () => get('/auth/login-history'), + // Spending + spendingSummary: (p?: QueryParams) => get('/spending/summary', p), + spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p), + categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d), + spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p), + setSpendingBudget: (d: Body) => put('/spending/budgets', d), + copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d), + spendingIncome: (p?: QueryParams) => get('/spending/income', p), + spendingCategoryRules: () => get('/spending/category-rules'), + addSpendingRule: (d: Body) => post('/spending/category-rules', d), + deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`), + + totpStatus: () => get('/auth/totp/status'), + totpSetup: () => get('/auth/totp/setup'), + totpEnable: (data: Body) => post('/auth/totp/enable', data), + totpDisable: (data: Body) => post('/auth/totp/disable', data), + totpChallenge: (data: Body) => post('/auth/totp/challenge', data), + + webauthnStatus: () => get('/auth/webauthn/status'), + webauthnSetup: () => get('/auth/webauthn/setup'), + webauthnEnable: (data: Body) => post('/auth/webauthn/enable', data), + webauthnDisable: (data: Body) => post('/auth/webauthn/disable', data), + webauthnCredentials: () => get('/auth/webauthn/credentials'), + webauthnDeleteCred: (id: Id, data: Body) => _fetch('DELETE', `/auth/webauthn/credentials/${encodeURIComponent(id)}`, data), + webauthnChallenge: (data: Body) => post('/auth/webauthn/challenge', data), + + // Admin + hasUsers: () => get('/admin/has-users'), + adminUsers: () => get('/admin/users'), + createUser: (data: Body) => post('/admin/users', data), + resetPassword: (id: Id, data: Body) => put(`/admin/users/${id}/password`, data), + updateUserRole: (id: Id, data: Body) => put(`/admin/users/${id}/role`, data), + updateUserActive: (id: Id, data: Body) => put(`/admin/users/${id}/active`, data), + deleteUser: (id: Id) => del(`/admin/users/${id}`), + authModeConfig: () => get('/admin/auth-mode'), + setAuthMode: (data: Body) => put('/admin/auth-mode', data), + testOidcConfig: (data: Body) => post('/admin/auth-mode/oidc-test', data), + adminBackups: () => get('/admin/backups'), + createAdminBackup: () => post('/admin/backups'), + deleteAdminBackup: (id: Id) => del(`/admin/backups/${encodeURIComponent(id)}`), + restoreAdminBackup: (id: Id) => post(`/admin/backups/${encodeURIComponent(id)}/restore`), + adminBackupSettings: () => get('/admin/backups/settings'), + saveAdminBackupSettings: (data: Body) => put('/admin/backups/settings', data), + runScheduledBackupNow: () => post('/admin/backups/run-scheduled-now'), + adminCleanup: () => get('/admin/cleanup'), + saveAdminCleanup: (data: Body) => put('/admin/cleanup', data), + runAdminCleanup: () => post('/admin/cleanup/run'), + seedDemoData: () => post('/user/seed-demo-data'), + clearDemoData: () => post('/user/clear-demo-data'), + seededStatus: () => get('/user/seeded-status'), + eraseMyData: (confirm: unknown) => post('/user/erase-data', { confirm }), + downloadAdminBackup: async (id: Id) => { + const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { + credentials: 'include', + }); + if (!res.ok) { + let data: any = {}; + try { data = await res.json(); } catch {} + throw new Error(data.error || `HTTP ${res.status}`); + } + return { + blob: await res.blob(), + filename: filenameFromDisposition(res.headers.get('Content-Disposition')) || String(id), + }; + }, + importAdminBackup: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/admin/backups/import', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + + // Notifications (admin) + notifAdmin: () => get('/notifications/admin'), + saveNotifAdmin: (data: Body) => put('/notifications/admin', data), + testEmail: (data: Body) => post('/notifications/test', data), + testPushNotification: () => post('/notifications/test-push', {}), + notifMe: () => get('/notifications/me'), + saveNotifMe: (data: Body) => put('/notifications/me', data), + + // Profile + profile: () => get('/profile'), + updateProfile: (data: Body) => _fetch('PATCH', '/profile', data), + profileSettings: () => get('/profile/settings'), + updateProfileSettings: (data: Body) => _fetch('PATCH', '/profile/settings', data), + changeProfilePassword: (data: Body) => post('/profile/change-password', data), + profileExports: () => get('/profile/exports'), + profileImportHistory: () => get('/profile/import-history'), + + // Tracker + tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), + upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), + overdueCount: () => get('/tracker/overdue-count'), + snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), + + // Calendar + calendar: (y: number, m: number) => get(`/calendar?year=${y}&month=${m}`), + calendarFeed: () => get('/calendar/feed'), + createCalendarFeed: () => post('/calendar/feed', {}), + regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}), + revokeCalendarFeed: () => del('/calendar/feed'), + calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }), + + // Summary + summary: (y: number, m: number) => get(`/summary?year=${y}&month=${m}`), + saveSummaryIncome: (data: Body) => put('/summary/income', data), + getMonthlyStartingAmounts: (y: number, m: number) => get(`/monthly-starting-amounts?year=${y}&month=${m}`), + updateMonthlyStartingAmounts: (data: Body) => put('/monthly-starting-amounts', data), + + // Bills + bills: (params: QueryParams = {}) => get(`/bills${queryString(params)}`), + allBills: (params: QueryParams = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), + billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), + bill: (id: Id) => get(`/bills/${id}`), + createBill: (data: Body) => post('/bills', data), + updateBill: (id: Id, data: Body) => put(`/bills/${id}`, data), + reorderBills: (order: Body) => put('/bills/reorder', order), + archiveBill: (id: Id, archived = true) => put(`/bills/${id}/archived`, { archived }), + updateBillBalance: (id: Id, bal: unknown) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), + billAmortization: (id: Id, opts: { payment?: number | string; max_months?: number | string } = {}) => { + const params = new URLSearchParams(); + if (opts.payment) params.set('payment', String(opts.payment)); + if (opts.max_months) params.set('max_months', String(opts.max_months)); + const qs = params.toString(); + return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`); + }, + updateBillSnowball: (id: Id, data: Body) => _fetch('PATCH', `/bills/${id}/snowball`, data), + deleteBill: (id: Id) => del(`/bills/${id}`), + restoreBill: (id: Id) => post(`/bills/${id}/restore`), + duplicateBill: (id: Id, data: Body) => post(`/bills/${id}/duplicate`, data), + verifyAutopay: (id: Id) => post(`/bills/${id}/verify-autopay`, {}), + togglePaid: (id: Id, data: Body) => post(`/bills/${id}/toggle-paid`, data), + billPayments: (id: Id, p?: number, l?: number) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), + billTransactions: (id: Id) => get(`/bills/${id}/transactions`), + syncBillSimplefinPayments: (id: Id) => post(`/bills/${id}/sync-simplefin-payments`), + allBillMerchantRules: () => get('/bills/merchant-rules'), + billMerchantRules: (id: Id) => get(`/bills/${id}/merchant-rules`), + previewMerchantRule: (id: Id, merchant: string) => get(`/bills/${id}/merchant-rules/preview?merchant=${encodeURIComponent(merchant)}`), + addMerchantRule: (id: Id, merchant: string) => post(`/bills/${id}/merchant-rules`, { merchant }), + deleteMerchantRule: (id: Id, ruleId: Id) => del(`/bills/${id}/merchant-rules/${ruleId}`), + merchantRuleCandidates: (id: Id) => get(`/bills/${id}/merchant-rules/candidates`), + toggleRuleAutoAttribute: (id: Id, ruleId: Id, on: unknown) => _fetch('PATCH', `/bills/${id}/merchant-rules/${ruleId}/auto-attribute`, { enabled: on }), + importHistoricalPayments: (id: Id, ids: unknown) => post(`/bills/${id}/merchant-rules/import-historical`, { transaction_ids: ids }), + billMonthlyState: (id: Id, y: number, m: number) => get(`/bills/${id}/monthly-state?year=${y}&month=${m}`), + saveBillMonthlyState: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), + billHistoryRanges: (id: Id) => get(`/bills/${id}/history-ranges`), + createBillHistoryRange: (id: Id, data: Body) => post(`/bills/${id}/history-ranges`, data), + updateBillHistoryRange: (id: Id, rangeId: Id, data: Body) => put(`/bills/${id}/history-ranges/${rangeId}`, data), + deleteBillHistoryRange: (id: Id, rangeId: Id) => del(`/bills/${id}/history-ranges/${rangeId}`), + driftReport: () => get('/bills/drift-report'), + snoozeBillDrift: (id: Id) => post(`/bills/${id}/snooze-drift`, {}), + billTemplates: () => get('/bills/templates'), + saveBillTemplate: (data: Body) => post('/bills/templates', data), + deleteBillTemplate: (id: Id) => del(`/bills/templates/${id}`), + + // Subscriptions + subscriptions: () => get('/subscriptions'), + confirmTransactionMatch: (transactionId: Id, billId: Id) => post('/matches/confirm', { transaction_id: transactionId, bill_id: billId }), + matchRecommendationToBill: (transactionIds: unknown, billId: Id, merchant: unknown, catalogId: unknown, confidence: unknown) => post('/subscriptions/recommendations/match-bill', { + transaction_ids: transactionIds, + bill_id: billId, + merchant, + catalog_id: catalogId, + confidence, + }), + subscriptionRecommendations: () => get('/subscriptions/recommendations'), + subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), + updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data), + createSubscriptionFromRecommendation: (data: Body) => post('/subscriptions/recommendations/create', data), + declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', { + decline_key: recommendation?.decline_key || recommendation, + catalog_id: recommendation?.catalog_match?.id, + merchant: recommendation?.merchant, + confidence: recommendation?.confidence, + }), + subscriptionCatalog: () => get('/subscriptions/catalog'), + updateSubscriptionCatalogLink:(id: Id, catalogId: Id) => _fetch('PUT', `/subscriptions/${id}/catalog-link`, { catalog_id: catalogId }), + addCatalogDescriptor: (catalogId: Id, d: unknown) => post(`/subscriptions/catalog/${catalogId}/descriptors`, { descriptor: d }), + deleteCatalogDescriptor: (id: Id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`), + + // Payments + quickPay: (data: Body) => post('/payments/quick', data), + confirmAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), + dismissAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), + bulkPay: (items: Body) => post('/payments/bulk', items), + createPayment: (data: Body) => post('/payments', data), + updatePayment: (id: Id, data: Body) => put(`/payments/${id}`, data), + deletePayment: (id: Id) => del(`/payments/${id}`), + restorePayment: (id: Id) => post(`/payments/${id}/restore`), + recentAutoMatched: () => get('/payments/recent-auto'), + undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`), + + // Snowball + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data), + saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items), + snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`), + snowballPlans: () => get('/snowball/plans'), + snowballActivePlan: () => get('/snowball/plans/active'), + startSnowballPlan: (data: Body) => post('/snowball/plans', data), + updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d), + pauseSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/pause`, {}), + resumeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/resume`, {}), + completeSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/complete`, {}), + abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}), + + // Categories + categories: () => get('/categories'), + createCategory: (data: Body) => post('/categories', data), + reorderCategories: (order: Body) => put('/categories/reorder', order), + updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data), + toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }), + deleteCategory: (id: Id) => del(`/categories/${id}`), + restoreCategory: (id: Id) => post(`/categories/${id}/restore`), + + // Category groups + categoryGroups: () => get('/categories/groups'), + createCategoryGroup: (data: Body) => post('/categories/groups', data), + updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data), + deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`), + + // Settings + settings: () => get>('/settings'), + saveSettings: (data: Body) => put('/settings', data), + + // Analytics + analyticsSummary: (params: QueryParams = {}) => { + const qs = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') qs.set(key, String(value)); + }); + const query = qs.toString(); + return get(`/analytics/summary${query ? `?${query}` : ''}`); + }, + + // Status + status: () => get('/status'), + + // Version (public) + about: () => get('/about'), + privacy: () => get('/privacy'), + aboutAdmin: () => get('/about-admin'), + roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`), + updateStatus: () => get('/version/update-status'), + checkForUpdates: () => post('/about-admin/check-updates'), + getUpdateCheckSetting: () => get('/about-admin/update-check-setting'), + setUpdateCheckSetting: (enabled: unknown) => put('/about-admin/update-check-setting', { enabled }), + devLog: () => get('/about-admin/dev-log'), + version: () => get('/version'), + releaseHistory: () => get('/version/history'), + + // Export (returns a URL to navigate to, not a fetch) + exportUrl: (year: Id, fmt?: string): string => `/api/export?year=${year}&format=${fmt||'csv'}`, + + // Spreadsheet Import + previewSpreadsheetImport: async (file: File, options: { parseAllSheets?: boolean; defaultYear?: number | string; defaultMonth?: number | string } = {}) => { + const params = new URLSearchParams(); + if (options.parseAllSheets) params.set('parse_all_sheets', 'true'); + if (options.defaultYear) params.set('year', String(options.defaultYear)); + if (options.defaultMonth) params.set('month', String(options.defaultMonth)); + const qs = params.toString(); + const csrfToken = await getCsrfToken(); + const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + applySpreadsheetImport: (data: Body) => post('/import/spreadsheet/apply', data), + previewCsvTransactionImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/csv/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'text/csv', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitCsvTransactionImport: (data: Body) => post('/import/csv/commit', data), + previewOfxTransactionImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/ofx/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/x-ofx', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + commitOfxTransactionImport: (data: Body) => post('/import/ofx/commit', data), + importHistory: () => get('/import/history'), + + // Transactions + transactions: (params: QueryParams = {}) => get(`/transactions${queryString(params)}`), + bankTransactionsLedger: (params: QueryParams = {}) => get(`/transactions/bank-ledger${queryString(params)}`), + createManualTransaction: (data: Body) => post('/transactions/manual', data), + updateTransaction: (id: Id, data: Body) => put(`/transactions/${id}`, data), + deleteTransaction: (id: Id) => del(`/transactions/${id}`), + matchTransaction: (id: Id, billId: Id) => post(`/transactions/${id}/match`, { billId }), + unmatchTransaction: (id: Id) => post(`/transactions/${id}/unmatch`), + unmatchTransactionBulk: (matches: Body) => post('/transactions/unmatch-bulk', { matches }), + ignoreTransaction: (id: Id) => post(`/transactions/${id}/ignore`), + unignoreTransaction: (id: Id) => post(`/transactions/${id}/unignore`), + transactionMerchantMatch: (id: Id) => get(`/transactions/${id}/merchant-match`), + applyTransactionMerchantMatch: (id: Id) => post(`/transactions/${id}/apply-merchant-match`), + autoCategorizeTransactions: (opts: Body = {}) => post('/transactions/auto-categorize', opts), + + // Match suggestions + matchSuggestions: (params: QueryParams = {}) => get(`/matches/suggestions${queryString(params)}`), + rejectMatchSuggestion: (id: Id) => post(`/matches/${encodeURIComponent(id)}/reject`), + + // Data sources & SimpleFIN bank sync + dataSources: (params: QueryParams = {}) => get(`/data-sources${queryString(params)}`), + simplefinStatus: () => get('/data-sources/simplefin/status'), + connectSimplefin: (setupToken: unknown) => post('/data-sources/simplefin/connect', { setupToken }), + syncDataSource: (id: Id) => post(`/data-sources/${id}/sync`), + backfillDataSource: (id: Id) => post(`/data-sources/${id}/backfill`), + deleteDataSource: (id: Id) => del(`/data-sources/${id}`), + dataSourceAccounts: (sourceId: Id) => get(`/data-sources/${sourceId}/accounts`), + setAccountMonitored: (sourceId: Id, accountId: Id, monitored: unknown) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), + allFinancialAccounts: () => get('/data-sources/accounts/all'), + syncAllSources: () => post('/data-sources/sync-all', {}), + attributePaymentToMonth: (id: Id, paid_date: unknown) => _fetch('PATCH', `/payments/${id}/attribute-to-month`, { paid_date }), + + // Admin — bank sync feature flag + bankSyncConfig: () => get('/admin/bank-sync-config'), + setBankSyncConfig: (data: Body) => put('/admin/bank-sync-config', data), + + // User SQLite import + previewUserDbImport: async (file: File) => { + const csrfToken = await getCsrfToken(); + const res = await fetch('/api/import/user-db/preview', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/octet-stream', + 'x-csrf-token': csrfToken, + ...(file.name ? { 'X-Filename': file.name } : {}), + }, + body: file, + }); + const data = await res.json(); + if (!res.ok) { + const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError; + err.status = res.status; + err.data = data; + err.details = data.details || []; + err.code = data.code; + throw err; + } + return data; + }, + applyUserDbImport: (data: Body) => post('/import/user-db/apply', data), +}; diff --git a/client/components/tracker/DriftInsightPanel.jsx b/client/components/tracker/DriftInsightPanel.jsx index a80875c..1d84bac 100644 --- a/client/components/tracker/DriftInsightPanel.jsx +++ b/client/components/tracker/DriftInsightPanel.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; diff --git a/client/components/tracker/EditableCell.jsx b/client/components/tracker/EditableCell.jsx index e3474b7..29954e7 100644 --- a/client/components/tracker/EditableCell.jsx +++ b/client/components/tracker/EditableCell.jsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { Input } from '@/components/ui/input'; diff --git a/client/components/tracker/LowerThisMonthButton.jsx b/client/components/tracker/LowerThisMonthButton.jsx index e97bdbf..e6e47d4 100644 --- a/client/components/tracker/LowerThisMonthButton.jsx +++ b/client/components/tracker/LowerThisMonthButton.jsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils'; diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index 3799874..6f142c4 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react'; import { motion } from 'framer-motion'; import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/client/components/tracker/MonthlyStateDialog.jsx b/client/components/tracker/MonthlyStateDialog.jsx index 4d8d9c4..9b0314e 100644 --- a/client/components/tracker/MonthlyStateDialog.jsx +++ b/client/components/tracker/MonthlyStateDialog.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { fmt } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/client/components/tracker/NotesCell.jsx b/client/components/tracker/NotesCell.jsx index da7611a..77d3f05 100644 --- a/client/components/tracker/NotesCell.jsx +++ b/client/components/tracker/NotesCell.jsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn } from '@/lib/utils'; // Monthly notes stored in monthly_bill_state — per-month, not per-bill. diff --git a/client/components/tracker/OverdueCommandCenter.jsx b/client/components/tracker/OverdueCommandCenter.jsx index 2cbf141..d4d111d 100644 --- a/client/components/tracker/OverdueCommandCenter.jsx +++ b/client/components/tracker/OverdueCommandCenter.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt, localDateString } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; diff --git a/client/components/tracker/PaymentLedgerDialog.jsx b/client/components/tracker/PaymentLedgerDialog.jsx index 794ba2b..a660356 100644 --- a/client/components/tracker/PaymentLedgerDialog.jsx +++ b/client/components/tracker/PaymentLedgerDialog.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Plus } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { fmt, fmtDate } from '@/lib/utils'; import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils'; import { Button } from '@/components/ui/button'; diff --git a/client/components/tracker/PaymentModal.jsx b/client/components/tracker/PaymentModal.jsx index ed50311..25e7134 100644 --- a/client/components/tracker/PaymentModal.jsx +++ b/client/components/tracker/PaymentModal.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; diff --git a/client/components/tracker/StartingAmountsEditDialog.jsx b/client/components/tracker/StartingAmountsEditDialog.jsx index e56c352..9716e69 100644 --- a/client/components/tracker/StartingAmountsEditDialog.jsx +++ b/client/components/tracker/StartingAmountsEditDialog.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { fmt } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx index 0f39560..f1add05 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.jsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { LayoutGroup } from 'framer-motion'; import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt } from '@/lib/utils'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index fed866c..afd9df5 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useTransition, useEffect } from 'react'; import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { cn, fmt, fmtDate } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/client/hooks/usePaymentActions.ts b/client/hooks/usePaymentActions.ts index 94fabe4..ca479af 100644 --- a/client/hooks/usePaymentActions.ts +++ b/client/hooks/usePaymentActions.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { fmt } from '@/lib/utils'; import type { Dollars } from '@/lib/money'; import { useInvalidateTrackerData } from '@/hooks/useQueries'; @@ -79,12 +79,13 @@ export function useTogglePaid(year: number, month: number) { { onSuccess: (result) => { if (wasPaid && result.paymentId) { + const paymentId = result.paymentId; // capture the narrowed id for the deferred Undo closure toast.success('Payment moved to recovery', { action: { label: 'Undo', onClick: async () => { try { - await api.restorePayment(result.paymentId); + await api.restorePayment(paymentId); toast.success('Payment restored'); invalidate(); } catch (err) { diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index 9ef761b..e5e2c35 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart, } from 'lucide-react'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InputDialog } from '@/components/ui/input-dialog'; diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx index d1d5e37..dc64025 100644 --- a/client/pages/SummaryPage.jsx +++ b/client/pages/SummaryPage.jsx @@ -17,7 +17,7 @@ import { RotateCcw, Save, } from 'lucide-react'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index f6a17f0..09a3b39 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react'; import { toast } from 'sonner'; -import { api } from '@/api.js'; +import { api } from '@/api'; import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import BillModal from '@/components/BillModal'; -- 2.40.1 From d02d3c9c72440689a98871c919d55a816d329fdf Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:13:05 -0500 Subject: [PATCH 13/69] refactor(ts): convert React Query hooks to TypeScript (TS10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hooks/useQueries.js -> .ts. Typed params and QueryParams-typed query builders (QueryParams now exported from api.ts). Query result data stays unknown until endpoint response shapes are typed. TypeScript caught dead config: three hooks passed `cacheTime` to useQuery, which React Query v5 renamed to `gcTime` and silently ignores — so those caches were garbage-collected at the 5-min default instead of the intended 30 min / 2 hr. Renamed to gcTime so the intended retention takes effect (memory-retention only; no fetch or correctness change). Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests, 17/17 e2e probe (every page renders). Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + client/api.ts | 2 +- client/hooks/{useQueries.js => useQueries.ts} | 49 +++++++++++++------ 3 files changed, 35 insertions(+), 17 deletions(-) rename client/hooks/{useQueries.js => useQueries.ts} (83%) diff --git a/HISTORY.md b/HISTORY.md index aaade2b..b8e2e83 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7,6 +7,7 @@ - **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js` → `money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green. - **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline). - **[Client] Typed the API client (`client/api.js` → `api.ts`) — the fetch layer's infrastructure** — the central request layer is now TypeScript: a generic `_fetch` and `get`/`post`/`put`/`patch`/`del` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally — most methods resolve to `Promise` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay` → `PaymentRecord` (`id`), `togglePaid` → `TogglePaidResult` (`paymentId?`), `settings` → a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value — fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe. +- **[Client] Typed the React Query hooks (`hooks/useQueries.js` → `.ts`) — and it caught dead config** — the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` — an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only — no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe. ### 🧪 Money-service test coverage diff --git a/client/api.ts b/client/api.ts index 53b6c44..0acf133 100644 --- a/client/api.ts +++ b/client/api.ts @@ -19,7 +19,7 @@ async function getCsrfToken(): Promise { // with the money-critical/consumed ones given real shapes below. type Id = number | string; type Body = unknown; -type QueryParams = Record; +export type QueryParams = Record; /** Error thrown by the API client, carrying the server's structured fields. */ export interface ApiError extends Error { diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.ts similarity index 83% rename from client/hooks/useQueries.js rename to client/hooks/useQueries.ts index 64313e9..31c713f 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.ts @@ -1,14 +1,14 @@ import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useCallback } from 'react'; -import { api } from '@/api'; +import { api, type QueryParams } from '@/api'; // Custom hook for fetching tracker data -export function useTracker(year, month) { +export function useTracker(year: number, month: number) { return useQuery({ queryKey: ['tracker', year, month], queryFn: () => api.tracker(year, month), staleTime: 1000 * 60 * 5, // 5 minutes - cacheTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 30, // 30 minutes }); } @@ -18,7 +18,7 @@ export function useBills() { queryKey: ['bills'], queryFn: () => api.allBills(), staleTime: 1000 * 60 * 5, // 5 minutes - cacheTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 30, // 30 minutes }); } @@ -28,7 +28,7 @@ export function useCategories() { queryKey: ['categories'], queryFn: () => api.categories(), staleTime: 1000 * 60 * 60, // 1 hour - cacheTime: 1000 * 60 * 60 * 2, // 2 hours + gcTime: 1000 * 60 * 60 * 2, // 2 hours }); } @@ -72,7 +72,7 @@ export function useInvalidateTrackerData() { // to it is instant. No-op if it's already cached and fresh. export function usePrefetchTracker() { const queryClient = useQueryClient(); - return useCallback((year, month) => { + return useCallback((year: number, month: number) => { queryClient.prefetchQuery({ queryKey: ['tracker', year, month], queryFn: () => api.tracker(year, month), @@ -86,7 +86,7 @@ export function usePrefetchTracker() { // dedup, cancellation, and out-of-order responses — no manual sequence guards. // keepPreviousData keeps the last result visible while a new month/filter loads. -export function useAnalyticsSummary(params) { +export function useAnalyticsSummary(params?: QueryParams) { return useQuery({ queryKey: ['analytics-summary', params], queryFn: () => api.analyticsSummary(params), @@ -111,7 +111,7 @@ export function useDeletedBills() { }); } -export function useSummary(year, month) { +export function useSummary(year: number, month: number) { return useQuery({ queryKey: ['summary', year, month], queryFn: () => api.summary(year, month), @@ -123,7 +123,17 @@ export function useSummary(year, month) { }); } -export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) { +interface BankLedgerArgs { + accountId: string; + flow: string; + page: number; + query: string; + sortBy: string; + sortDir: string; + pageSize: number; +} + +export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }: BankLedgerArgs) { return useQuery({ queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir], queryFn: () => api.bankTransactionsLedger({ @@ -140,7 +150,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p }); } -export function useSpendingSummary(year, month) { +export function useSpendingSummary(year: number, month: number) { return useQuery({ queryKey: ['spending-summary', year, month], queryFn: () => api.spendingSummary({ year, month }), @@ -149,11 +159,18 @@ export function useSpendingSummary(year, month) { }); } -export function useSpendingTransactions({ year, month, activeCat, page }) { +interface SpendingTransactionsArgs { + year: number; + month: number; + activeCat?: number | string | null; + page: number; +} + +export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) { return useQuery({ queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page], queryFn: () => { - const params = { year, month, page, limit: 50 }; + const params: QueryParams = { year, month, page, limit: 50 }; if (activeCat === null) params.category_id = 'null'; else if (activeCat !== undefined) params.category_id = activeCat; return api.spendingTransactions(params); @@ -167,8 +184,8 @@ export function useSpendingCategories() { return useQuery({ queryKey: ['spending-categories'], queryFn: async () => { - const d = await api.categories(); - return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled); + const d = await api.categories() as any; + return (d.categories || d || []).filter((c: any) => !c.deleted_at && c.spending_enabled); }, staleTime: 1000 * 60 * 5, }); @@ -201,7 +218,7 @@ export function useSnowballActivePlan() { export function useSnowballPlans() { return useQuery({ queryKey: ['snowball-plans'], - queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []), + queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []), staleTime: 1000 * 60 * 2, }); } @@ -217,7 +234,7 @@ export function useSubscriptions() { export function useSubscriptionRecommendations() { return useQuery({ queryKey: ['subscription-recommendations'], - queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []), + queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []), staleTime: 1000 * 60 * 5, }); } -- 2.40.1 From 3c7a55c3a183b2f7ebf65251b60513500d15257a Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:21:45 -0500 Subject: [PATCH 14/69] feat(ts): type the Tracker API response with branded Dollars (A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New client/types.ts domain-types module. Types the full /tracker response — TrackerResponse { summary, bank_tracking, cashflow, rows } — with every money field branded `Dollars` (the server serializes cents→dollars, verified against statusService.buildTrackerRow + trackerService.getTracker + buildBankTracking + buildSafeToSpend). api.tracker() now returns Promise. TrackerRow + TrackerStatus move to @/types (canonical) and are re-exported from @/lib/trackerUtils for compat; trackerUtils' row money fields upgrade from plain `number` to branded `Dollars` (rowThreshold -> Dollars, paymentSummary re-brands its computed amounts via asDollars so callers can fmt() them directly). This is the first endpoint where the money brand flows end-to-end: fetch layer -> row helpers, and (in phase B) into the components that format them. Nested payloads no typed consumer reads yet (summary.trend, autopay_suggestion/ _stats, sparkline) are left `unknown`, to refine when consumed. asDollars is an identity at runtime, so behavior is unchanged: typecheck 0, lint 0 errors, build green, 48 client tests pass. Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 4 +- client/lib/trackerUtils.ts | 51 ++++------- client/types.ts | 172 +++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 client/types.ts diff --git a/client/api.ts b/client/api.ts index 0acf133..607b948 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,3 +1,5 @@ +import type { TrackerResponse } from '@/types'; + // Fetch CSRF token from the server once and cache in memory. // The cookie is httpOnly so document.cookie cannot access it directly. let _csrfFetch: Promise | null = null; @@ -217,7 +219,7 @@ export const api = { profileImportHistory: () => get('/profile/import-history'), // Tracker - tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), + tracker: (y: number, m: number, params: QueryParams = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), overdueCount: () => get('/tracker/overdue-count'), snoozeOverdue: (id: Id, data: Body) => put(`/bills/${id}/monthly-state`, data), diff --git a/client/lib/trackerUtils.ts b/client/lib/trackerUtils.ts index 0772b7a..34be542 100644 --- a/client/lib/trackerUtils.ts +++ b/client/lib/trackerUtils.ts @@ -1,4 +1,10 @@ import { todayStr } from '@/lib/utils'; +import { asDollars, type Dollars } from '@/lib/money'; +import type { TrackerRow, TrackerStatus } from '@/types'; + +// Re-export the shared domain types so existing `@/lib/trackerUtils` importers +// keep resolving them (the canonical definitions live in `@/types`). +export type { TrackerRow, TrackerStatus } from '@/types'; export const MONTHS = [ 'January','February','March','April','May','June', @@ -53,35 +59,6 @@ export const STATUS_META = { skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' }, }; -// A tracker month's effective status for a bill. -export type TrackerStatus = - | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; - -// The subset of a tracker row these helpers read. Rows come from the (untyped) -// tracker API; money fields are dollars (the server serializes cents→dollars). -// Branding them as `Dollars` is a later increment (when the API is typed) — for -// now the computed money fields are plain `number`. Fields the aggregation -// always populates are required; the rest are optional/nullable. -export interface TrackerRow { - name?: string | null; - status: TrackerStatus; - is_skipped?: boolean; - expected_amount: number; - actual_amount?: number | null; - total_paid: number; - paid_toward_due?: number | null; - overpaid_amount?: number | null; - previous_month_paid?: number | null; - autopay_suggestion?: unknown; - category_name?: string | null; - current_balance?: number | null; - minimum_payment?: number | null; - due_date?: string | null; - due_day?: number | null; - last_paid_date?: string | null; - payments?: unknown[]; -} - export function paymentDateForTrackerMonth( year: number, month: number, @@ -110,7 +87,7 @@ export function amountSearchText(...values: unknown[]): string { .join(' '); } -export function rowThreshold(row: TrackerRow): number { +export function rowThreshold(row: TrackerRow): Dollars { return row.actual_amount != null ? row.actual_amount : row.expected_amount; } @@ -245,7 +222,7 @@ export function moveInArray(items: T[], fromIndex: number, toIndex: number): return next; } -export function paymentSummary(row: TrackerRow, threshold: number) { +export function paymentSummary(row: TrackerRow, threshold: Dollars) { const target = Number(threshold) || 0; const paid = Number(row.total_paid) || 0; const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) @@ -256,12 +233,14 @@ export function paymentSummary(row: TrackerRow, threshold: number) { : Math.max(paid - target, 0); const remaining = Math.max(target - paidTowardDue, 0); const percent = target > 0 ? Math.min(100, Math.round((paidTowardDue / target) * 100)) : 0; + // Re-brand the computed amounts as dollars (arithmetic on branded numbers + // yields a plain number) so callers can pass them straight to fmt/formatUSD. return { - target, - paid, - paidTowardDue, - overpaid, - remaining, + target: asDollars(target), + paid: asDollars(paid), + paidTowardDue: asDollars(paidTowardDue), + overpaid: asDollars(overpaid), + remaining: asDollars(remaining), percent, count: Array.isArray(row.payments) ? row.payments.length : 0, partial: paid > 0 && remaining > 0, diff --git a/client/types.ts b/client/types.ts new file mode 100644 index 0000000..bb8ef50 --- /dev/null +++ b/client/types.ts @@ -0,0 +1,172 @@ +// Domain types for the API responses. Money fields are branded `Dollars` — the +// server stores integer cents and serializes dollars over the wire (via +// utils/money.fromCents), so every amount a component receives is dollars. Typing +// them `Dollars` lets `fmt`/`formatUSD` accept them while rejecting raw cents +// (e.g. bank-transaction `*_cents` fields), catching the cents-as-dollars bug +// class at compile time. +// +// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads +// that no typed consumer reads yet (trend series, autopay suggestion/stats, +// sparkline) are left `unknown` and refined when a consumer needs them. +import type { Dollars } from '@/lib/money'; + +// A bill's month status. Mirrors the server's statusService. +export type TrackerStatus = + | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped'; + +// A payment record as serialized by the server (money in dollars). serializePayment +// spreads the full DB row, so extra columns pass through as `unknown`. +export interface Payment { + id: number; + bill_id?: number; + amount: Dollars; + paid_date: string; + payment_source?: string | null; + notes?: string | null; + balance_delta?: Dollars | null; + interest_delta?: Dollars | null; + created_at?: string; + deleted_at?: string | null; + [key: string]: unknown; +} + +// One tracker row: a bill's state for a given month. Built by +// statusService.buildTrackerRow and augmented by trackerService.getTracker. +export interface TrackerRow { + id: number; + name: string; + category_id: number | null; + category_name: string | null; + due_date: string; + due_day: number | null; + bucket: string; + expected_amount: Dollars; + notes: string | null; + total_paid: Dollars; + paid_toward_due: Dollars; + overpaid_amount: Dollars; + balance: Dollars; + has_payment: boolean; + is_settled: boolean; + last_paid_date: string | null; + last_payment_amount: Dollars | null; + status: TrackerStatus; + autopay_enabled: boolean; + autodraft_status: string | null; + auto_mark_paid: boolean; + billing_cycle: string | null; + cycle_type: string | null; + cycle_day: string | number | null; + current_balance: Dollars | null; + minimum_payment: Dollars | null; + interest_rate: number | null; + is_subscription: boolean; + has_2fa: boolean; + has_merchant_rule: boolean; + has_linked_transactions: boolean; + website: string | null; + autopay_verified_at: string | null; + inactive_reason: string | null; + inactivated_at: string | null; + sparkline: unknown; + autopay_stats: unknown; + payments: Payment[]; + // Augmented by getTracker: + actual_amount: Dollars | null; + is_skipped: boolean; + snoozed_until: string | null; + autopay_suggestion?: unknown; + previous_month_paid: Dollars; + // Bank-mode augmentation (present only when bank_tracking.enabled): + pending_cleared?: boolean; + bank_pending_count?: number; +} + +export interface TrackerSummary { + total_expected: Dollars; + total_starting: Dollars; + has_starting_amounts: boolean; + total_paid: Dollars; + paid_toward_due: Dollars; + remaining: Dollars; + total_remaining: Dollars; + remaining_period: string; + remaining_label: string; + remaining_hint: string; + overdue: Dollars; + count_paid: number; + count_upcoming: number; + count_late: number; + count_autodraft: number; + previous_month_total: Dollars; + trend: unknown; +} + +// Bank tracking is off (`{ enabled: false }`) or on with the account snapshot. +export type BankTracking = + | { enabled: false } + | { + enabled: true; + account_id: number; + account_name: string; + org_name: string; + balance: Dollars; + pending_payments: Dollars; + pending_days: number; + effective_balance: Dollars; + unpaid_this_month: Dollars; + remaining: Dollars; + last_updated: string | null; + }; + +export interface SafeToSpendUpcoming { + id: number; + name: string; + due_date: string; + amount: Dollars; + status: TrackerStatus; +} + +export interface CashflowTimelinePoint { + date: string; + balance: number; // running dollar balance; cashflowUtils treats it loosely + bills: unknown[]; + payday?: boolean; +} + +export interface TrackerCashflow { + next_payday: string | null; + days_until_payday: number; + available: Dollars; + safe_to_spend: Dollars; + still_due_total: Dollars; + still_due_count: number; + upcoming: SafeToSpendUpcoming[]; + timeline: CashflowTimelinePoint[]; + has_data: boolean; + uses_bank_balance: boolean; + period: string; + period_end_label: string; + period_starting: Dollars; + period_bills_total: Dollars; + period_paid: Dollars; + period_paid_count: number; + period_total_count: number; + period_projected: Dollars; + month_starting: Dollars; + month_bills_total: Dollars; + month_paid: Dollars; + month_paid_count: number; + month_total_count: number; + month_projected: Dollars; +} + +export interface TrackerResponse { + year: number; + month: number; + today: string; + summary: TrackerSummary; + bank_tracking: BankTracking; + cashflow: TrackerCashflow; + rows: TrackerRow[]; +} -- 2.40.1 From 337ad95a3e1e4044a0ef6d4ef7efcceffd982525 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:24:35 -0500 Subject: [PATCH 15/69] feat(ts): type Bill + Payment API responses with branded Dollars (A2-A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Bill and reuses Payment domain types (money → Dollars, mirroring serializeBill/serializePayment which spread the DB row and convert the money columns). Wired: bills/allBills/deletedBills → Bill[], bill/createBill/updateBill → Bill; quickPay/createPayment/updatePayment/restorePayment → Payment. Replaced api.ts's minimal PaymentRecord with the richer @/types Payment (usePaymentActions still resolves payment.id). typecheck 0, build green, 48 client tests. Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 28 +++++++++++----------------- client/types.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/client/api.ts b/client/api.ts index 607b948..fbd82de 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,4 +1,4 @@ -import type { TrackerResponse } from '@/types'; +import type { Bill, Payment, TrackerResponse } from '@/types'; // Fetch CSRF token from the server once and cache in memory. // The cookie is httpOnly so document.cookie cannot access it directly. @@ -31,12 +31,6 @@ export interface ApiError extends Error { code?: string; } -/** Minimal shape of a payment record returned by the pay endpoints. */ -export interface PaymentRecord { - id: number; - [key: string]: unknown; -} - /** Result of toggling a tracker row paid/unpaid. */ export interface TogglePaidResult { paymentId?: number; @@ -239,13 +233,13 @@ export const api = { updateMonthlyStartingAmounts: (data: Body) => put('/monthly-starting-amounts', data), // Bills - bills: (params: QueryParams = {}) => get(`/bills${queryString(params)}`), - allBills: (params: QueryParams = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), - deletedBills: () => get('/bills/deleted'), + bills: (params: QueryParams = {}) => get(`/bills${queryString(params)}`), + allBills: (params: QueryParams = {}) => get(`/bills${queryString({ inactive: true, ...params })}`), + deletedBills: () => get('/bills/deleted'), billAudit: (includeInactive = false) => get(`/bills/audit${includeInactive ? '?inactive=true' : ''}`), - bill: (id: Id) => get(`/bills/${id}`), - createBill: (data: Body) => post('/bills', data), - updateBill: (id: Id, data: Body) => put(`/bills/${id}`, data), + bill: (id: Id) => get(`/bills/${id}`), + createBill: (data: Body) => post('/bills', data), + updateBill: (id: Id, data: Body) => put(`/bills/${id}`, data), reorderBills: (order: Body) => put('/bills/reorder', order), archiveBill: (id: Id, archived = true) => put(`/bills/${id}/archived`, { archived }), updateBillBalance: (id: Id, bal: unknown) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), @@ -311,14 +305,14 @@ export const api = { deleteCatalogDescriptor: (id: Id) => _fetch('DELETE', `/subscriptions/catalog/descriptors/${id}`), // Payments - quickPay: (data: Body) => post('/payments/quick', data), + quickPay: (data: Body) => post('/payments/quick', data), confirmAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/confirm`, data), dismissAutopaySuggestion: (billId: Id, data: Body) => post(`/payments/autopay-suggestions/${billId}/dismiss`, data), bulkPay: (items: Body) => post('/payments/bulk', items), - createPayment: (data: Body) => post('/payments', data), - updatePayment: (id: Id, data: Body) => put(`/payments/${id}`, data), + createPayment: (data: Body) => post('/payments', data), + updatePayment: (id: Id, data: Body) => put(`/payments/${id}`, data), deletePayment: (id: Id) => del(`/payments/${id}`), - restorePayment: (id: Id) => post(`/payments/${id}/restore`), + restorePayment: (id: Id) => post(`/payments/${id}/restore`), recentAutoMatched: () => get('/payments/recent-auto'), undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`), diff --git a/client/types.ts b/client/types.ts index bb8ef50..8a035e4 100644 --- a/client/types.ts +++ b/client/types.ts @@ -30,6 +30,50 @@ export interface Payment { [key: string]: unknown; } +// A bill as serialized by the server (billsService.serializeBill spreads the DB +// row and converts the three money columns to dollars). SQLite booleans come back +// as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass +// through as `unknown` via the index signature. +export interface Bill { + id: number; + user_id?: number; + name: string; + category_id: number | null; + category_name?: string | null; + due_day: number | null; + override_due_date: string | null; + bucket: string; + expected_amount: Dollars; + interest_rate: number | null; + billing_cycle: string | null; + autopay_enabled: number; + autodraft_status: string | null; + auto_mark_paid: number; + website: string | null; + username: string | null; + account_info: string | null; + has_2fa: number; + notes: string | null; + history_visibility: string | null; + active: number; + cycle_type: string | null; + cycle_day: string | number | null; + current_balance: Dollars | null; + minimum_payment: Dollars | null; + snowball_order: number | null; + snowball_include: number; + snowball_exempt: number; + is_subscription: number; + subscription_type: string | null; + reminder_days_before: number | null; + subscription_source: string | null; + subscription_detected_at: string | null; + created_at?: string; + updated_at?: string; + deleted_at?: string | null; + [key: string]: unknown; +} + // One tracker row: a bill's state for a given month. Built by // statusService.buildTrackerRow and augmented by trackerService.getTracker. export interface TrackerRow { -- 2.40.1 From 4af738f947332e8b839763acffbd6ee2c3129675 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:27:08 -0500 Subject: [PATCH 16/69] feat(ts): type the shared Category API response (A4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Category (+ CategoryBillSummary) to @/types and wires categories/ createCategory/updateCategory. Category has no money field of its own (budgets live on the spending endpoints); the nested per-bill total_paid is a raw SQL sum, so it's deliberately left unbranded. With categories now typed, useSpending Categories drops its `any` cast and dead d.categories branch. Category, Bill, Payment, and the Tracker envelope are the cross-cutting types used by many components — worth defining upfront. The remaining single-consumer page responses (summary, analytics, spending, snowball, subscriptions, bank ledger) are typed alongside their page conversions in phase B, where the exact fields consumed are visible (more accurate than a speculative interface). typecheck 0, build green, 48 client tests. Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 8 ++++---- client/hooks/useQueries.ts | 4 ++-- client/types.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/client/api.ts b/client/api.ts index fbd82de..d35a734 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,4 +1,4 @@ -import type { Bill, Payment, TrackerResponse } from '@/types'; +import type { Bill, Category, Payment, TrackerResponse } from '@/types'; // Fetch CSRF token from the server once and cache in memory. // The cookie is httpOnly so document.cookie cannot access it directly. @@ -332,10 +332,10 @@ export const api = { abandonSnowballPlan: (id: Id) => post(`/snowball/plans/${id}/abandon`, {}), // Categories - categories: () => get('/categories'), - createCategory: (data: Body) => post('/categories', data), + categories: () => get('/categories'), + createCategory: (data: Body) => post('/categories', data), reorderCategories: (order: Body) => put('/categories/reorder', order), - updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data), + updateCategory: (id: Id, data: Body) => put(`/categories/${id}`, data), toggleCategorySpending: (id: Id, val: unknown) => patch(`/categories/${id}/spending`, { spending_enabled: val }), deleteCategory: (id: Id) => del(`/categories/${id}`), restoreCategory: (id: Id) => post(`/categories/${id}/restore`), diff --git a/client/hooks/useQueries.ts b/client/hooks/useQueries.ts index 31c713f..ee71991 100644 --- a/client/hooks/useQueries.ts +++ b/client/hooks/useQueries.ts @@ -184,8 +184,8 @@ export function useSpendingCategories() { return useQuery({ queryKey: ['spending-categories'], queryFn: async () => { - const d = await api.categories() as any; - return (d.categories || d || []).filter((c: any) => !c.deleted_at && c.spending_enabled); + const cats = await api.categories(); + return cats.filter(c => !c.deleted_at && c.spending_enabled); }, staleTime: 1000 * 60 * 5, }); diff --git a/client/types.ts b/client/types.ts index 8a035e4..cf8a0df 100644 --- a/client/types.ts +++ b/client/types.ts @@ -30,6 +30,39 @@ export interface Payment { [key: string]: unknown; } +// A category's per-bill rollup (categories list endpoint). `total_paid` here is a +// raw SQL sum (not run through fromCents), so it is deliberately NOT branded. +export interface CategoryBillSummary { + id: number; + name: string; + active: boolean; + payment_count: number; + total_paid: number; + last_paid_date: string | null; + [key: string]: unknown; +} + +// A spending/bill category. Has no money field of its own (budgets live on the +// spending endpoints). The list endpoint adds bill rollups; create/update return +// the bare row — so the rollup fields are optional. +export interface Category { + id: number; + user_id?: number; + name: string; + sort_order?: number; + spending_enabled: boolean; + group_id: number | null; + created_at?: string; + updated_at?: string; + bill_count?: number; + active_bill_count?: number; + inactive_bill_count?: number; + payment_count?: number; + bill_names?: string[]; + bills?: CategoryBillSummary[]; + [key: string]: unknown; +} + // A bill as serialized by the server (billsService.serializeBill spreads the DB // row and converts the three money columns to dollars). SQLite booleans come back // as 0/1 integers. Extra joined columns (sparkline, has_merchant_rule, …) pass -- 2.40.1 From dc675fbecd845a9fe45d998d3a3481c52cee59fd Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:31:35 -0500 Subject: [PATCH 17/69] refactor(ts): convert tracker leaf components to TSX (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First .tsx conversions — the tracker leaf components, where the branded Dollars now flows into the UI: StatusBadge (status: TrackerStatus), FilterChip, PaymentProgress (paymentSummary's Dollars → fmt type-checks), SummaryCards (SummaryCard value: Dollars, CardDef/CardType/TrendInfo typed). Props interfaces added; no behavior change. Confirms the .tsx pattern: a typed parent importing untyped .jsx children is fine (children accept any props — no forced cascade). typecheck 0, lint 0 errors, build green, 48 client tests, 17/17 e2e probe (home page renders). Co-Authored-By: Claude Opus 4.8 --- .../{FilterChip.jsx => FilterChip.tsx} | 9 ++++- ...aymentProgress.jsx => PaymentProgress.tsx} | 13 ++++++- .../{StatusBadge.jsx => StatusBadge.tsx} | 10 ++++- .../{SummaryCards.jsx => SummaryCards.tsx} | 38 ++++++++++++++++--- 4 files changed, 61 insertions(+), 9 deletions(-) rename client/components/tracker/{FilterChip.jsx => FilterChip.tsx} (66%) rename client/components/tracker/{PaymentProgress.jsx => PaymentProgress.tsx} (87%) rename client/components/tracker/{StatusBadge.jsx => StatusBadge.tsx} (87%) rename client/components/tracker/{SummaryCards.jsx => SummaryCards.tsx} (84%) diff --git a/client/components/tracker/FilterChip.jsx b/client/components/tracker/FilterChip.tsx similarity index 66% rename from client/components/tracker/FilterChip.jsx rename to client/components/tracker/FilterChip.tsx index 747568f..d689a91 100644 --- a/client/components/tracker/FilterChip.jsx +++ b/client/components/tracker/FilterChip.tsx @@ -1,6 +1,13 @@ +import type { ReactNode } from 'react'; import { cn } from '@/lib/utils'; -export function FilterChip({ active, children, onClick }) { +interface FilterChipProps { + active?: boolean; + children?: ReactNode; + onClick?: () => void; +} + +export function FilterChip({ active, children, onClick }: FilterChipProps) { return (