Compare commits
No commits in common. "62a99fcaa42208ec1e59e1aa2f174ef9ccfb24e2" and "c86acd7d75c2ae8878bdce346ca19743a6f6741f" have entirely different histories.
62a99fcaa4
...
c86acd7d75
|
|
@ -1,11 +1,6 @@
|
|||
# Bill Tracker — Changelog
|
||||
## 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
|
||||
|
||||
- **[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';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||
import { useQuickPay } from '@/hooks/usePaymentActions';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
|
||||
|
|
@ -47,7 +47,6 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
|||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const quickPay = useQuickPay();
|
||||
const togglePaid = useTogglePaid(year, month);
|
||||
// Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared
|
||||
// when fresh server data arrives (effect below).
|
||||
const [optimisticPaid, setOptimisticPaid] = useState(undefined);
|
||||
|
|
@ -83,8 +82,38 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
|||
quickPay.run(row, val, defaultPaymentDate);
|
||||
}
|
||||
|
||||
function performTogglePaid() {
|
||||
togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
|
||||
async function performTogglePaid() {
|
||||
const wasPaid = isPaid;
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
|
||||
import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions';
|
||||
import { useQuickPay } from '@/hooks/usePaymentActions';
|
||||
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
|
||||
import { StatusBadge } from '@/components/tracker/StatusBadge';
|
||||
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
||||
|
|
@ -30,6 +30,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
|
||||
const [showMbs, setShowMbs] = useState(false);
|
||||
const [confirmUnpay, setConfirmUnpay] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [suggestionLoading, setSuggestionLoading] = useState(false);
|
||||
const [optimisticActual, setOptimisticActual] = useState(undefined);
|
||||
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls
|
||||
|
|
@ -39,7 +40,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
const [nudgeAmount, setNudgeAmount] = useState(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const quickPay = useQuickPay();
|
||||
const togglePaid = useTogglePaid(year, month);
|
||||
const visibleColumnSet = new Set(visibleColumns);
|
||||
const showColumn = key => visibleColumnSet.has(key);
|
||||
|
||||
|
|
@ -87,8 +87,41 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
quickPay.run(row, val, defaultPaymentDate);
|
||||
}
|
||||
|
||||
function performTogglePaid() {
|
||||
togglePaid.run(row, { wasPaid: isPaid, threshold, setOptimisticPaid });
|
||||
async function performTogglePaid() {
|
||||
const wasPaid = isPaid;
|
||||
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() {
|
||||
|
|
@ -596,7 +629,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
if (effectiveStatus === 'skipped') return;
|
||||
handleTogglePaid();
|
||||
}}
|
||||
loading={togglePaid.isPending}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -710,13 +743,13 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
|
|||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={togglePaid.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={togglePaid.isPending}
|
||||
disabled={loading}
|
||||
onClick={performTogglePaid}
|
||||
>
|
||||
{togglePaid.isPending ? 'Removing...' : 'Remove Payment'}
|
||||
{loading ? 'Removing...' : 'Remove Payment'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -40,51 +40,3 @@ export function useQuickPay() {
|
|||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
// 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 '';
|
||||
}
|
||||
|
|
@ -1,108 +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. 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 '';
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// 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,37 +1,36 @@
|
|||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { formatUSD } from './money';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// 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
|
||||
// currency formatting has a single source of truth. Inherits formatUSD's typed
|
||||
// (branded-dollars) input.
|
||||
export function fmt(amount: Parameters<typeof formatUSD>[0]): string {
|
||||
// currency formatting has a single source of truth.
|
||||
export function fmt(amount) {
|
||||
return formatUSD(amount);
|
||||
}
|
||||
|
||||
export function fmtDate(dateStr: string | null | undefined): string {
|
||||
export function fmtDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
const [y = '', m = '', d = ''] = dateStr.split('-');
|
||||
const [y, m, d] = dateStr.split('-');
|
||||
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
||||
}
|
||||
|
||||
export function localDateString(date: Date = new Date()): string {
|
||||
export function localDateString(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function todayStr(): string {
|
||||
export function todayStr() {
|
||||
return localDateString();
|
||||
}
|
||||
|
||||
export function fmtUptime(seconds: number): string {
|
||||
export function fmtUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
|
|
@ -42,21 +41,14 @@ export function fmtUptime(seconds: number): string {
|
|||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function fmtBytes(bytes: number | null | undefined): string {
|
||||
export function fmtBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
interface CategoryTone {
|
||||
border: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
bar: string;
|
||||
}
|
||||
|
||||
const CATEGORY_COLOR_TONES: CategoryTone[] = [
|
||||
const CATEGORY_COLOR_TONES = [
|
||||
{ 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-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500' },
|
||||
|
|
@ -69,12 +61,12 @@ const CATEGORY_COLOR_TONES: CategoryTone[] = [
|
|||
|
||||
// Deterministic color tone for a category (or merchant) name, used for
|
||||
// badges and avatars so the same name always renders the same color.
|
||||
export function categoryColor(name: string | null | undefined): CategoryTone {
|
||||
export function categoryColor(name) {
|
||||
const key = String(name || 'Uncategorized');
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) | 0;
|
||||
}
|
||||
const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length;
|
||||
return CATEGORY_COLOR_TONES[index]!; // index is always in range
|
||||
return CATEGORY_COLOR_TONES[index];
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import js from '@eslint/js';
|
|||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
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
|
||||
// rules that catch real React bugs — rules-of-hooks (conditional hooks) and
|
||||
|
|
@ -34,33 +33,4 @@ export default [
|
|||
'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 }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@/*": ["./client/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["client/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -54,11 +54,8 @@
|
|||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
|
@ -67,8 +64,6 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.8"
|
||||
|
|
@ -6030,26 +6025,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
|
|
@ -6064,262 +6039,6 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
|
@ -6769,16 +6488,6 @@
|
|||
"@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": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
|
|
@ -7478,13 +7187,6 @@
|
|||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
|
|
@ -13097,19 +12799,6 @@
|
|||
"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": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
|
|
@ -13269,44 +12958,6 @@
|
|||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
"build": "vite build",
|
||||
"check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check",
|
||||
"lint": "eslint client",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check": "npm run check:server && npm run build",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"test:client": "vitest run",
|
||||
|
|
@ -20,7 +19,7 @@
|
|||
"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",
|
||||
"smoke:prod": "bash scripts/prod-smoke.sh",
|
||||
"ci": "npm run lint && npm run typecheck && npm run check:server && npm run test:all && npm run build",
|
||||
"ci": "npm run lint && npm run check:server && npm run test:all && npm run build",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -69,11 +68,8 @@
|
|||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
|
|
@ -82,8 +78,6 @@
|
|||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.8"
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"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,14 +12,7 @@ const apiPort = process.env.API_PORT || process.env.PORT || 3000;
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
// 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', {}]],
|
||||
},
|
||||
}),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['img/logo.png', 'img/pwa-192.png', 'img/pwa-512.png'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue