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:
parent
7bb68db442
commit
7c6be66794
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
Loading…
Reference in New Issue