refactor(ts): convert pure lib utilities to TypeScript (TS7)
Five self-contained leaf modules, no behavior change: - reorder.ts — generic moveInArray<T>, 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 <noreply@anthropic.com>
This commit is contained in:
parent
7c6be66794
commit
3c51464bec
|
|
@ -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<T>`), `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, string> = 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';
|
||||
}
|
||||
|
|
@ -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<T>(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } {
|
||||
const list = Array.isArray(upcoming) ? upcoming : [];
|
||||
return {
|
||||
visible: list.slice(0, maxVisible),
|
||||
|
|
@ -1,19 +1,27 @@
|
|||
export function moveInArray(items, fromIndex, toIndex) {
|
||||
interface Identified {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export function moveInArray<T>(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<Identified> | null | undefined): Record<string, number> {
|
||||
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
|
||||
}
|
||||
|
||||
export function movedItemId(before, after) {
|
||||
export function movedItemId(
|
||||
before: ReadonlyArray<Identified> | null | undefined,
|
||||
after: ReadonlyArray<Identified> | null | undefined,
|
||||
): string | number | null {
|
||||
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
|
||||
return moved?.id || after?.[0]?.id || null;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue