refactor(ts): convert client/lib/trackerUtils to TypeScript (TS6)

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<TrackerStatus,number> (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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 21:53:45 -05:00
parent 7bb68db442
commit 7c6be66794
2 changed files with 56 additions and 17 deletions

View File

@ -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] 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] 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 ### 🧪 Money-service test coverage

View File

@ -12,6 +12,8 @@ export const TRACKER_SORT_DEFAULT = 'manual';
export const TRACKER_SORT_ASC = 'asc'; export const TRACKER_SORT_ASC = 'asc';
export const TRACKER_SORT_DESC = 'desc'; export const TRACKER_SORT_DESC = 'desc';
export type TrackerSortDir = typeof TRACKER_SORT_ASC | typeof TRACKER_SORT_DESC;
export const TRACKER_SORT_OPTIONS = [ export const TRACKER_SORT_OPTIONS = [
{ key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC }, { key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC },
{ key: 'name', label: 'Bill name', 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' }, 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(); const now = new Date();
if (year === now.getFullYear() && month === now.getMonth() + 1) { if (year === now.getFullYear() && month === now.getMonth() + 1) {
return todayStr(); return todayStr();
@ -65,7 +100,7 @@ export function paymentDateForTrackerMonth(year, month, dueDay) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
} }
export function amountSearchText(...values) { export function amountSearchText(...values: unknown[]): string {
return values return values
.filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value)))
.flatMap(value => { .flatMap(value => {
@ -75,11 +110,11 @@ export function amountSearchText(...values) {
.join(' '); .join(' ');
} }
export function rowThreshold(row) { export function rowThreshold(row: TrackerRow): number {
return row.actual_amount != null ? row.actual_amount : row.expected_amount; 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'; if (row.is_skipped) return 'skipped';
const threshold = rowThreshold(row); const threshold = rowThreshold(row);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; 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 // 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 // server's statusService.isPaidStatus — the single low-level check the various
// row/badge/calendar components share. // row/badge/calendar components share.
export function isPaidStatus(status) { export function isPaidStatus(status: string): boolean {
return status === 'paid' || status === 'autodraft'; return status === 'paid' || status === 'autodraft';
} }
export function rowIsPaid(row) { export function rowIsPaid(row: TrackerRow): boolean {
const status = rowEffectiveStatus(row); const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false; if (row.autopay_suggestion && status === 'autodraft') return false;
return isPaidStatus(status); return isPaidStatus(status);
} }
export function rowIsDebt(row) { export function rowIsDebt(row: TrackerRow): boolean {
const category = String(row.category_name || '').toLowerCase(); const category = String(row.category_name || '').toLowerCase();
return Number(row.current_balance) > 0 return Number(row.current_balance) > 0
|| row.minimum_payment != null || row.minimum_payment != null
|| ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token));
} }
const STATUS_SORT_ORDER = { const STATUS_SORT_ORDER: Record<TrackerStatus, number> = {
missed: 0, missed: 0,
late: 1, late: 1,
due_soon: 2, due_soon: 2,
@ -116,18 +151,20 @@ const STATUS_SORT_ORDER = {
skipped: 6, skipped: 6,
}; };
function parseDateSortValue(value) { function parseDateSortValue(value: string | null | undefined): number | null {
if (!value) return null; if (!value) return null;
const parsed = Date.parse(`${value}T00:00:00`); const parsed = Date.parse(`${value}T00:00:00`);
return Number.isFinite(parsed) ? parsed : null; return Number.isFinite(parsed) ? parsed : null;
} }
function numericSortValue(value) { function numericSortValue(value: unknown): number {
const number = Number(value); const number = Number(value);
return Number.isFinite(number) ? number : 0; 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) { switch (key) {
case 'name': case 'name':
return String(row.name || '').toLowerCase(); 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 aMissing = a === null || a === undefined || a === '';
const bMissing = b === null || b === undefined || b === ''; const bMissing = b === null || b === undefined || b === '';
if (aMissing && bMissing) return 0; if (aMissing && bMissing) return 0;
@ -166,15 +203,15 @@ function compareSortValues(a, b, dir) {
return dir === TRACKER_SORT_DESC ? -result : result; 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; 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; 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); const key = normalizeTrackerSortKey(sortKey);
if (key === TRACKER_SORT_DEFAULT) return rows; if (key === TRACKER_SORT_DEFAULT) return rows;
@ -200,14 +237,15 @@ export function sortTrackerRows(rows, sortKey, sortDir) {
.map(item => item.row); .map(item => item.row);
} }
export function moveInArray(items, fromIndex, toIndex) { export function moveInArray<T>(items: T[], fromIndex: number, toIndex: number): T[] {
const next = [...items]; const next = [...items];
const [moved] = next.splice(fromIndex, 1); const [moved] = next.splice(fromIndex, 1);
if (moved === undefined) return next;
next.splice(toIndex, 0, moved); next.splice(toIndex, 0, moved);
return next; return next;
} }
export function paymentSummary(row, threshold) { export function paymentSummary(row: TrackerRow, threshold: number) {
const target = Number(threshold) || 0; const target = Number(threshold) || 0;
const paid = Number(row.total_paid) || 0; const paid = Number(row.total_paid) || 0;
const paidTowardDue = Number.isFinite(Number(row.paid_toward_due)) const paidTowardDue = Number.isFinite(Number(row.paid_toward_due))