Compare commits

..

7 Commits

Author SHA1 Message Date
null 62a99fcaa4 build(lint): extend ESLint to .ts/.tsx via typescript-eslint
As files convert to TypeScript they must keep the react-hooks enforcement.
Added a .ts/.tsx config block using the typescript-eslint parser with the same
rules-of-hooks/exhaustive-deps/react-refresh rules; swapped core no-undef +
no-unused-vars (type-blind) for TS-aware equivalents. Verified 'npm run lint'
traverses .ts and flags issues there; 0 errors on the existing .ts files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:44:35 -05:00
null b221e02d85 refactor(ts): convert client/lib/utils to TypeScript (TS4)
The highest-traffic leaf module (cn, fmt, date/byte/uptime formatters,
categoryColor) is now strict .ts. fmt inherits formatUSD's branded-dollars input
via Parameters<typeof formatUSD>; noUncheckedIndexedAccess handled (destructuring
defaults, in-range assertion). .jsx callers unaffected (Vite resolves the .ts).
typecheck + build + 48 client tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:42:46 -05:00
null 7d9bf12bdc docs(history): TypeScript foundation + branded money types (T1-T3)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:37:21 -05:00
null 255036afc2 feat(ts): branded Cents/Dollars money types — first TS conversion (T2)
Converted client/lib/money.js -> money.ts with branded types:
  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 do), so a typed caller physically
cannot format cents as dollars (the 100×-too-big bug) or vice-versa. Existing
.jsx callers are unaffected (checkJs off) — gradual adoption.

money.type-test.ts is a compile-time guard (never imported/bundled): its
@ts-expect-error lines assert each unit mixup is a real 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'.

typecheck + build (with React Compiler) + 48 client tests all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:36:24 -05:00
null 907a407399 build(ts): TypeScript foundation — tsconfig + typecheck in CI (T1)
Adopting TypeScript incrementally (the move off plain JS). Upgraded jsconfig ->
tsconfig with strict + noUncheckedIndexedAccess, but allowJs + checkJs:false so
every existing .js/.jsx keeps resolving and running untouched — only .ts/.tsx
get type-checked. Added 'npm run typecheck' (tsc --noEmit) and wired it into
'npm run ci'. Installed typescript 6 + @types/react/react-dom 19. Client-scoped
(Vite resolves the '@/' paths); the CommonJS server is a separate TS story.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:33:26 -05:00
null 47c031f1e4 build(react): enable React Compiler (React 19 auto-memoization) (F2)
Added babel-plugin-react-compiler to the Vite React plugin. The compiler
auto-memoizes components/hooks at build time, so manual useMemo/useCallback
become largely unnecessary going forward (existing ones are left in place —
harmless, and the compiler adds the rest). Safe here because the codebase is
rules-of-hooks clean (enforced by eslint-plugin-react-hooks).

Validated: production build succeeds and the e2e probe passes 17/17 with the
compiler on — every authed page renders axe-clean and Tracker/Summary/Analytics
reconciliation holds. (Build time ~2.2s -> ~6.2s from the compiler pass; the
runtime win is automatic memoization.) The compiler ESLint rule needs
eslint-plugin-react-hooks v6 — a future upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:20:26 -05:00
null a0a8579a08 refactor(tracker): shared useTogglePaid mutation hook — finish Tracker mutations (F1)
Toggle paid/unpaid was duplicated across the desktop and mobile rows (optimistic
flip + Undo/paid toasts + refresh). Extracted into a shared useTogglePaid()
React Query mutation hook: the caller keeps the instant local optimistic flip,
the hook rolls it back on error and invalidates the tracker/badge caches on
settle. isPending replaces the desktop row's local loading state (badge spinner
+ un-pay confirm dialog). With useQuickPay (P3), both core Tracker write actions
are now idiomatic useMutation hooks living in one place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:18:18 -05:00
14 changed files with 637 additions and 181 deletions

View File

@ -1,6 +1,11 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.41.0 ## v0.41.0
### 🔷 TypeScript foundation + branded money types
- **[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.
### 🧪 Money-service test coverage ### 🧪 Money-service test coverage
- **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2) - **[Tests] Covered two untested money-critical services** — the manual-vs-bank payment source-of-truth (`paymentAccountingService`) and the analytics month-window math (`analyticsService`) had no dedicated tests. Added `tests/paymentAccountingService.test.js` (bank-backed predicate, the accounting-active SQL fragment, and the core invariant: a bank payment overrides a provisional manual payment so it isn't double-counted, restores the balance, and the manual payment counts again with its balance re-applied when the override is removed) and `tests/analyticsService.test.js` (month key/label/addMonths/monthEndDate/buildMonths with year-boundary + leap-year edges, and `validateSummaryQuery` defaults/range errors). (Tracker X2)

View File

@ -12,7 +12,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { useQuickPay } from '@/hooks/usePaymentActions'; import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
@ -47,6 +47,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const quickPay = useQuickPay(); const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month);
// Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared // Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared
// when fresh server data arrives (effect below). // when fresh server data arrives (effect below).
const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [optimisticPaid, setOptimisticPaid] = useState(undefined);
@ -82,38 +83,8 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
quickPay.run(row, val, defaultPaymentDate); quickPay.run(row, val, defaultPaymentDate);
} }
async function performTogglePaid() { function performTogglePaid() {
const wasPaid = isPaid; togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
try {
const result = await api.togglePaid(row.id, {
amount: wasPaid ? undefined : threshold,
year: year,
month: month,
});
if (wasPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
refresh();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else if (!wasPaid) {
toast.success(`${row.name}${fmt(threshold)} paid`);
}
refresh();
} catch (err) {
setOptimisticPaid(undefined); // roll back the instant flip
toast.error(err.message || 'Failed to toggle payment status');
}
} }
function handleTogglePaid() { function handleTogglePaid() {

View File

@ -16,7 +16,7 @@ import {
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { useQuickPay } from '@/hooks/usePaymentActions'; import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
@ -30,7 +30,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [loading, setLoading] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const [optimisticActual, setOptimisticActual] = useState(undefined); const [optimisticActual, setOptimisticActual] = useState(undefined);
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls
@ -40,6 +39,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const [nudgeAmount, setNudgeAmount] = useState(null); const [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const quickPay = useQuickPay(); const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month);
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); const showColumn = key => visibleColumnSet.has(key);
@ -87,41 +87,8 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
quickPay.run(row, val, defaultPaymentDate); quickPay.run(row, val, defaultPaymentDate);
} }
async function performTogglePaid() { function performTogglePaid() {
const wasPaid = isPaid; togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
setLoading?.(true);
try {
const result = await api.togglePaid(row.id, {
amount: wasPaid ? undefined : threshold,
year: year,
month: month,
});
if (wasPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else if (!wasPaid) {
toast.success(`${row.name}${fmt(threshold)} paid`);
}
refresh?.();
} catch (err) {
setOptimisticPaid(undefined); // roll back the instant flip
toast.error(err.message || 'Failed to toggle payment status');
} finally {
setLoading?.(false);
}
} }
function handleTogglePaid() { function handleTogglePaid() {
@ -629,7 +596,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
if (effectiveStatus === 'skipped') return; if (effectiveStatus === 'skipped') return;
handleTogglePaid(); handleTogglePaid();
}} }}
loading={loading} loading={togglePaid.isPending}
/> />
)} )}
</div> </div>
@ -743,13 +710,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={togglePaid.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={loading} disabled={togglePaid.isPending}
onClick={performTogglePaid} onClick={performTogglePaid}
> >
{loading ? 'Removing...' : 'Remove Payment'} {togglePaid.isPending ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -40,3 +40,51 @@ export function useQuickPay() {
return { run, isPending: mutation.isPending }; return { run, isPending: mutation.isPending };
} }
// Toggle a tracker row paid/unpaid. The caller owns the instant optimistic flip
// (its local `setOptimisticPaid`) so the row updates immediately; this hook rolls
// it back on error, invalidates the tracker/badge caches on settle, and shows the
// right toast (an Undo when un-paying, a specific "paid" message otherwise).
// Shared by the desktop and mobile tracker rows.
export function useTogglePaid(year, month) {
const invalidate = useInvalidateTrackerData();
const mutation = useMutation({
mutationFn: ({ rowId, amount }) => api.togglePaid(rowId, { amount, year, month }),
onSettled: () => invalidate(),
});
const run = (row, { wasPaid, threshold, setOptimisticPaid }) => {
setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles
mutation.mutate(
{ rowId: row.id, amount: wasPaid ? undefined : threshold },
{
onSuccess: (result) => {
if (wasPaid && result.paymentId) {
toast.success('Payment moved to recovery', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(result.paymentId);
toast.success('Payment restored');
invalidate();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
}
},
},
});
} else if (!wasPaid) {
toast.success(`${row.name}${fmt(threshold)} paid`);
}
},
onError: (err) => {
setOptimisticPaid(undefined); // roll back the instant flip
toast.error(err.message || 'Failed to toggle payment status');
},
},
);
};
return { run, isPending: mutation.isPending };
}

View File

@ -1,77 +0,0 @@
// Currency formatting for the client, mirroring the server's utils/money.js.
//
// The API sends money in two units: bill / summary values are serialized as
// DOLLARS (the server calls fromCents before responding), while raw bank
// transaction amounts arrive as integer CENTS. So there are two entry points —
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
// formatUSD / formatCentsUSD split. USD / en-US throughout (the app is USD-only,
// and this matches the server). Inputs are coerced defensively so null, '',
// undefined, or NaN never render as "$NaN".
const DASH = '—';
function toNumber(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
}
function isBlank(value) {
return value === null || value === undefined || value === '';
}
/**
* Format a DOLLAR amount "$1,234.56".
* @param {number|string|null} dollars
* @param {{ whole?: boolean, dash?: boolean }} [opts]
* whole drop the cents ("$1,235"); dash render blank input as "—" not "$0.00".
*/
export function formatUSD(dollars, { whole = false, dash = false } = {}) {
if (dash && isBlank(dollars)) return DASH;
return toNumber(dollars).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: whole ? 0 : 2,
maximumFractionDigits: whole ? 0 : 2,
});
}
/** Whole-dollar convenience → "$1,235". */
export function formatUSDWhole(dollars, opts = {}) {
return formatUSD(dollars, { ...opts, whole: true });
}
/**
* Format an integer-CENTS amount (e.g. a bank transaction) "$12.34".
* @param {number|string|null} cents
* @param {{ signed?: boolean, dash?: boolean, currency?: string }} [opts]
* signed prefix "+"/"-" (income vs expense); dash blank input "—";
* currency ISO code (defaults USD).
*/
export function formatCentsUSD(cents, { signed = false, dash = false, currency = 'USD' } = {}) {
if (dash && isBlank(cents)) return DASH;
const c = toNumber(cents);
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
style: 'currency',
currency: currency || 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
if (signed) return (c < 0 ? '-' : '+') + body;
return (c < 0 ? '-' : '') + body;
}
/**
* Shared form validator for a non-negative money field (dollars). Blank is
* allowed (returns ''); otherwise the value must parse to a number 0. Returns
* '' when valid, or an error string labelled for the field. Zero is allowed
* these are non-negative, not strictly-positive, amounts.
* @param {number|string|null} val
* @param {string} [label] field name for the message (e.g. "Amount", "Balance")
*/
export function validateNonNegativeMoney(val, label = 'Amount') {
if (val === '' || val === null || val === undefined) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
return '';
}

108
client/lib/money.ts Normal file
View File

@ -0,0 +1,108 @@
// Currency formatting for the client, mirroring the server's utils/money.js.
//
// The API sends money in two units: bill / summary values are serialized as
// DOLLARS (the server calls fromCents before responding), while raw bank
// transaction amounts arrive as integer CENTS. So there are two entry points —
// formatUSD(dollars) and formatCentsUSD(cents) — matching the server's
// formatUSD / formatCentsUSD split. USD / en-US throughout. Inputs are coerced
// defensively so null, '', undefined, or NaN never render as "$NaN".
//
// The two units are *branded* below: a Dollars value can't be passed where Cents
// is expected (or vice-versa) without an explicit conversion, so the cents↔dollars
// mixups that have caused real money bugs (e.g. displaying cents as dollars →
// 100× wrong) become compile errors in typed code.
/** Integer cents, e.g. a raw bank transaction amount (1234 = $12.34). */
export type Cents = number & { readonly __unit: 'cents' };
/** Dollars, e.g. an API-serialized bill amount (12.34 = $12.34). */
export type Dollars = number & { readonly __unit: 'dollars' };
/** Brand a raw number as cents (the sanctioned way to enter the typed world). */
export const asCents = (n: number): Cents => n as Cents;
/** Brand a raw number as dollars. */
export const asDollars = (n: number): Dollars => n as Dollars;
/** Convert cents → dollars (the only way to cross the unit boundary). */
export const centsToDollars = (c: Cents): Dollars => (c / 100) as Dollars;
/** Convert dollars → cents, rounding to the nearest cent. */
export const dollarsToCents = (d: Dollars): Cents => Math.round(d * 100) as Cents;
// A money value as it may still arrive untyped from a form field or legacy code:
// the branded unit, or a numeric string, or blank. Note bare `number` is NOT
// included — that's deliberate, so typed callers must brand (asDollars/asCents)
// and can't accidentally hand cents to a dollars formatter.
type DollarsInput = Dollars | string | null | undefined;
type CentsInput = Cents | string | null | undefined;
const DASH = '—';
function toNumber(value: unknown): number {
const n = Number(value);
if (!Number.isFinite(n)) return 0;
return n === 0 ? 0 : n; // normalize -0 → +0 so it never renders as "-$0.00"
}
function isBlank(value: unknown): value is null | undefined | '' {
return value === null || value === undefined || value === '';
}
/**
* Format a DOLLAR amount "$1,234.56".
* `whole` drops the cents ("$1,235"); `dash` renders blank input as "—".
*/
export function formatUSD(
dollars: DollarsInput,
{ whole = false, dash = false }: { whole?: boolean; dash?: boolean } = {},
): string {
if (dash && isBlank(dollars)) return DASH;
return toNumber(dollars).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: whole ? 0 : 2,
maximumFractionDigits: whole ? 0 : 2,
});
}
/** Whole-dollar convenience → "$1,235". */
export function formatUSDWhole(
dollars: DollarsInput,
opts: { dash?: boolean } = {},
): string {
return formatUSD(dollars, { ...opts, whole: true });
}
/**
* Format an integer-CENTS amount (e.g. a bank transaction) "$12.34".
* `signed` prefixes "+"/"-" (income vs expense); `dash` renders blank "—";
* `currency` is an ISO code (defaults USD).
*/
export function formatCentsUSD(
cents: CentsInput,
{ signed = false, dash = false, currency = 'USD' }: { signed?: boolean; dash?: boolean; currency?: string } = {},
): string {
if (dash && isBlank(cents)) return DASH;
const c = toNumber(cents);
const body = (Math.abs(c) / 100).toLocaleString('en-US', {
style: 'currency',
currency: currency || 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
if (signed) return (c < 0 ? '-' : '+') + body;
return (c < 0 ? '-' : '') + body;
}
/**
* Shared form validator for a non-negative money field (dollars). Blank is
* allowed (returns ''); otherwise the value must parse to a number 0. Returns
* '' when valid, or an error string labelled for the field. Zero is allowed
* these are non-negative, not strictly-positive, amounts.
*/
export function validateNonNegativeMoney(
val: string | number | null | undefined,
label = 'Amount',
): string {
if (val === '' || val === null || val === undefined) return '';
const num = parseFloat(String(val));
if (isNaN(num) || num < 0) return `${label} must be a non-negative number`;
return '';
}

View File

@ -0,0 +1,22 @@
// Compile-time guard for the branded money types. Not imported anywhere (so it
// never ships in a bundle); `npm run typecheck` type-checks it via the tsconfig
// include, and each `@ts-expect-error` asserts the following line IS a genuine
// type error. If the cents/dollars branding ever regresses, one of these lines
// stops erroring and typecheck fails loudly ("unused @ts-expect-error").
import { formatUSD, formatCentsUSD, asCents, asDollars, centsToDollars } from './money';
const cents = asCents(1234); // $12.34 as integer cents
const dollars = asDollars(12.34); // $12.34 as dollars
// ✅ Correct unit → compiles fine.
formatUSD(dollars);
formatCentsUSD(cents);
formatUSD(centsToDollars(cents)); // cross the boundary explicitly
// ❌ Unit mixups → compile errors (the class of bug we keep fixing).
// @ts-expect-error cents can't be formatted as dollars (would render 100× too big)
formatUSD(cents);
// @ts-expect-error dollars can't be formatted as cents
formatCentsUSD(dollars);
// @ts-expect-error a raw number must be branded (asDollars/asCents) first
formatUSD(1234);

View File

@ -1,36 +1,37 @@
import { clsx } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { formatUSD } from './money'; import { formatUSD } from './money';
export function cn(...inputs) { export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many // Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many
// existing call sites; the implementation lives in ./money (formatUSD) so all // existing call sites; the implementation lives in ./money (formatUSD) so all
// currency formatting has a single source of truth. // currency formatting has a single source of truth. Inherits formatUSD's typed
export function fmt(amount) { // (branded-dollars) input.
export function fmt(amount: Parameters<typeof formatUSD>[0]): string {
return formatUSD(amount); return formatUSD(amount);
} }
export function fmtDate(dateStr) { export function fmtDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'; if (!dateStr) return '—';
const [y, m, d] = dateStr.split('-'); const [y = '', m = '', d = ''] = dateStr.split('-');
return `${parseInt(m)}/${parseInt(d)}/${y}`; return `${parseInt(m)}/${parseInt(d)}/${y}`;
} }
export function localDateString(date = new Date()) { export function localDateString(date: Date = new Date()): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
export function todayStr() { export function todayStr(): string {
return localDateString(); return localDateString();
} }
export function fmtUptime(seconds) { export function fmtUptime(seconds: number): string {
const d = Math.floor(seconds / 86400); const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600); const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
@ -41,14 +42,21 @@ export function fmtUptime(seconds) {
return `${s}s`; return `${s}s`;
} }
export function fmtBytes(bytes) { export function fmtBytes(bytes: number | null | undefined): string {
if (!bytes) return '0 B'; if (!bytes) return '0 B';
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`; return `${(bytes / 1048576).toFixed(2)} MB`;
} }
const CATEGORY_COLOR_TONES = [ interface CategoryTone {
border: string;
bg: string;
text: string;
bar: string;
}
const CATEGORY_COLOR_TONES: CategoryTone[] = [
{ border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500' }, { border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500' },
{ border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500' }, { border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500' },
{ border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' }, { border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' },
@ -61,12 +69,12 @@ const CATEGORY_COLOR_TONES = [
// Deterministic color tone for a category (or merchant) name, used for // Deterministic color tone for a category (or merchant) name, used for
// badges and avatars so the same name always renders the same color. // badges and avatars so the same name always renders the same color.
export function categoryColor(name) { export function categoryColor(name: string | null | undefined): CategoryTone {
const key = String(name || 'Uncategorized'); const key = String(name || 'Uncategorized');
let hash = 0; let hash = 0;
for (let i = 0; i < key.length; i++) { for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) | 0; hash = (hash * 31 + key.charCodeAt(i)) | 0;
} }
const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length; const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length;
return CATEGORY_COLOR_TONES[index]; return CATEGORY_COLOR_TONES[index]!; // index is always in range
} }

View File

@ -2,6 +2,7 @@ import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh'; import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
// Flat config scoped to the React client. The point is enforcement of the two // Flat config scoped to the React client. The point is enforcement of the two
// rules that catch real React bugs — rules-of-hooks (conditional hooks) and // rules that catch real React bugs — rules-of-hooks (conditional hooks) and
@ -33,4 +34,33 @@ export default [
'no-empty': ['warn', { allowEmptyCatch: true }], 'no-empty': ['warn', { allowEmptyCatch: true }],
}, },
}, },
{
// TypeScript files: same react-hooks/react-refresh enforcement, via the
// TS parser. TS itself handles undefined identifiers + unused vars, so the
// core rules that don't understand types are swapped for their TS-aware
// equivalents. (Not the full typescript-eslint recommended set — this keeps
// the signal focused on the same correctness rules as the JS config.)
files: ['client/**/*.{ts,tsx}'],
languageOptions: {
parser: tseslint.parser,
ecmaVersion: 2023,
sourceType: 'module',
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'@typescript-eslint': tseslint.plugin,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'no-undef': 'off', // TypeScript checks this
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }],
'no-empty': ['warn', { allowEmptyCatch: true }],
},
},
]; ];

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["./client/*"]
},
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true
},
"include": ["client/**/*"],
"exclude": ["node_modules", "dist"]
}

349
package-lock.json generated
View File

@ -54,8 +54,11 @@
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
@ -64,6 +67,8 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"
@ -6025,6 +6030,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -6039,6 +6064,262 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
"integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/type-utils": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz",
"integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz",
"integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.62.1",
"@typescript-eslint/types": "^8.62.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz",
"integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz",
"integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz",
"integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz",
"integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz",
"integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.62.1",
"@typescript-eslint/tsconfig-utils": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/visitor-keys": "8.62.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz",
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.1",
"@typescript-eslint/types": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz",
"integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.62.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -6488,6 +6769,16 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -7187,6 +7478,13 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/data-urls": { "node_modules/data-urls": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@ -12799,6 +13097,19 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": { "node_modules/ts-interface-checker": {
"version": "0.1.13", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@ -12958,6 +13269,44 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/typescript-eslint": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz",
"integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.62.1",
"@typescript-eslint/parser": "8.62.1",
"@typescript-eslint/typescript-estree": "8.62.1",
"@typescript-eslint/utils": "8.62.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@ -10,6 +10,7 @@
"build": "vite build", "build": "vite build",
"check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check", "check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check",
"lint": "eslint client", "lint": "eslint client",
"typecheck": "tsc --noEmit -p tsconfig.json",
"check": "npm run check:server && npm run build", "check": "npm run check:server && npm run build",
"test": "node --test tests/*.test.js", "test": "node --test tests/*.test.js",
"test:client": "vitest run", "test:client": "vitest run",
@ -19,7 +20,7 @@
"test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots", "test:e2e:update": "node e2e/setup/prepare-db.js && playwright test --project=chromium-desktop --project=chromium-mobile --update-snapshots",
"test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe", "test:e2e:probe": "node e2e/setup/prepare-db.js && playwright test --project=probe",
"smoke:prod": "bash scripts/prod-smoke.sh", "smoke:prod": "bash scripts/prod-smoke.sh",
"ci": "npm run lint && npm run check:server && npm run test:all && npm run build", "ci": "npm run lint && npm run typecheck && npm run check:server && npm run test:all && npm run build",
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
@ -68,8 +69,11 @@
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
@ -78,6 +82,8 @@
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"paths": { "@/*": ["./client/*"] },
"resolveJsonModule": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
// Gradual adoption: existing .js/.jsx still resolve and run untouched
// (checkJs off = not type-checked), while new/converted .ts/.tsx are checked
// under full strict mode. Convert file-by-file; nothing breaks in between.
"allowJs": true,
"checkJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"types": ["vite/client"]
},
"include": ["client/**/*"],
"exclude": ["node_modules", "dist", "client/**/*.test.*"]
}

View File

@ -12,7 +12,14 @@ const apiPort = process.env.API_PORT || process.env.PORT || 3000;
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), // React Compiler (React 19): auto-memoizes components/hooks at build time, so
// manual useMemo/useCallback become largely unnecessary. Safe here because the
// codebase is rules-of-hooks clean (enforced by eslint-plugin-react-hooks).
react({
babel: {
plugins: [['babel-plugin-react-compiler', {}]],
},
}),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'], includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'],