From 7c6be667940116933b13091c111b3c256479901f Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:53:45 -0500 Subject: [PATCH] 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))