From 3c51464bec6a7958f700817aaa2a18f9d2a902f4 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 21:59:16 -0500 Subject: [PATCH] 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)); }