Compare commits
7 Commits
c86acd7d75
...
62a99fcaa4
| Author | SHA1 | Date |
|---|---|---|
|
|
62a99fcaa4 | |
|
|
b221e02d85 | |
|
|
7d9bf12bdc | |
|
|
255036afc2 | |
|
|
907a407399 | |
|
|
47c031f1e4 | |
|
|
a0a8579a08 |
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 '';
|
|
||||||
}
|
|
||||||
|
|
@ -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 '';
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.*"]
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue