From dc49eb9633f3962866d307187d700cf03d6097da Mon Sep 17 00:00:00 2001 From: null Date: Fri, 12 Jun 2026 01:32:28 -0500 Subject: [PATCH] feat(cashflow): safe-to-spend projection with timeline, vitest setup, package upgrades --- .forgejo/workflows/ci.yml | 35 + client/components/tracker/CashFlowCard.jsx | 213 ++++ client/lib/cashflowUtils.js | 75 ++ client/lib/cashflowUtils.test.js | 76 ++ client/lib/trackerUtils.test.js | 108 ++ client/lib/utils.test.js | 34 + client/pages/TrackerPage.jsx | 11 + package-lock.json | 1136 +++++++++++++++++++- package.json | 6 +- services/trackerService.js | 89 ++ tests/safeToSpend.test.js | 104 ++ vite.config.mjs | 7 + 12 files changed, 1883 insertions(+), 11 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 client/components/tracker/CashFlowCard.jsx create mode 100644 client/lib/cashflowUtils.js create mode 100644 client/lib/cashflowUtils.test.js create mode 100644 client/lib/trackerUtils.test.js create mode 100644 client/lib/utils.test.js create mode 100644 tests/safeToSpend.test.js diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..b77bef3 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,35 @@ +# CI — syntax check, server tests (node:test), client tests (Vitest), Vite build. +# Runs on every push and pull request so no change lands unverified. +# +# Forgejo Actions reads .forgejo/workflows/. The container image matches the +# Dockerfile runtime (node:22) so better-sqlite3 prebuilds resolve identically. + +name: CI + +on: + push: + pull_request: + +jobs: + ci: + runs-on: docker + container: + image: node:22-bookworm + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Syntax check (server) + run: npm run check:server + + - name: Server tests (node:test) + run: npm run test + + - name: Client tests (Vitest) + run: npm run test:client + + - name: Build (Vite) + run: npm run build diff --git a/client/components/tracker/CashFlowCard.jsx b/client/components/tracker/CashFlowCard.jsx new file mode 100644 index 0000000..889bdff --- /dev/null +++ b/client/components/tracker/CashFlowCard.jsx @@ -0,0 +1,213 @@ +import { useId } from 'react'; +import { motion } from 'framer-motion'; +import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react'; +import { cn, fmt, fmtDate } from '@/lib/utils'; +import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils'; + +const CHART_W = 400; +const CHART_H = 96; + +function TimelineChart({ timeline, positive }) { + const gradId = useId(); + const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H); + if (!geo) return null; + + const tone = positive ? '#10b981' : '#f43f5e'; // emerald-500 / rose-500 + + return ( + + + + + + + + + {/* zero line — only meaningful when the projection dips negative */} + {geo.points.some(p => p.balance < 0) && ( + + )} + + + + + {/* bill-day markers */} + {geo.points.filter(p => p.isDrop).map(p => ( + + + {`${fmtDate(p.date)} — ${p.bills.map(b => `${b.name} ${fmt(b.amount)}`).join(', ')} → ${fmt(p.balance)} left`} + + + ))} + + {/* payday marker */} + {geo.points.filter(p => p.isPayday).map(p => ( + + + + {`Payday ${fmtDate(p.date)} — ${fmt(p.balance)} left`} + + + ))} + + ); +} + +function UpcomingList({ upcoming }) { + const { visible, overflow } = splitUpcoming(upcoming, 4); + + if (visible.length === 0) { + return ( +
+ + All bills covered + +

Nothing else is due before payday.

+
+ ); + } + + return ( + + ); +} + +/** + * Safe-to-Spend hero card: what's left for the current 1st/15th period after + * everything still due before the next payday is covered. Sits directly under + * the summary cards and reads from the tracker payload's `cashflow` block. + */ +export default function CashFlowCard({ cashflow, onSetStartingAmounts }) { + if (!cashflow) return null; + + // No starting amounts and no bank sync — invite setup instead of guessing. + if (!cashflow.has_data) { + return ( + +
+
+ +
+

See your safe-to-spend

+

+ Add what's in your account for the 1st and 15th, and BillTracker projects what's left after bills. +

+
+
+ +
+
+ ); + } + + const safe = Number(cashflow.safe_to_spend ?? 0); + const positive = safe >= 0; + + return ( + +
+ +
+ {/* ── Number ── */} +
+
+ +

Safe to spend

+
+

+ {positive ? '' : '−'}{fmt(Math.abs(safe))} +

+

+ + until {shortDate(cashflow.next_payday)} · {daysUntilLabel(cashflow.days_until_payday)} +

+
+ + {/* ── Projection ── */} +
+ +

+ {fmt(cashflow.available)} on hand → {cashflow.still_due_count === 0 + ? 'no bills left before payday' + : `${cashflow.still_due_count} bill${cashflow.still_due_count === 1 ? '' : 's'} (${fmt(cashflow.still_due_total)}) before payday`} +

+
+ + {/* ── Upcoming ── */} +
+

+ Due before payday +

+ +
+
+ + ); +} diff --git a/client/lib/cashflowUtils.js b/client/lib/cashflowUtils.js new file mode 100644 index 0000000..1b51a96 --- /dev/null +++ b/client/lib/cashflowUtils.js @@ -0,0 +1,75 @@ +// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable. + +/** + * Convert the server's cashflow timeline into SVG step-path geometry. + * Returns { line, area, points, zeroY } or null when there is nothing to draw. + * + * - X spreads entries across actual day positions (not even spacing), so a + * bill due tomorrow sits visually close to today. + * - Y maps balances; the domain always includes 0 so the zero line is honest. + * - Step shape: balance holds flat until a bill's day, then drops. + */ +export function buildTimelineGeometry(timeline, width, height, pad = 4) { + if (!Array.isArray(timeline) || timeline.length < 2) return null; + + const t0 = Date.parse(timeline[0].date); + const t1 = Date.parse(timeline[timeline.length - 1].date); + const span = Math.max(t1 - t0, 1); + + const balances = timeline.map(t => Number(t.balance) || 0); + const start = Number(timeline[0].balance) || 0; + // Domain: [min(0, lowest), max(starting, highest, smallest positive head-room)] + const lo = Math.min(0, ...balances); + const hi = Math.max(start, ...balances, 1); + const range = Math.max(hi - lo, 1); + + const x = (date) => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); + const y = (bal) => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); + + const points = timeline.map((t, i) => ({ + x: x(t.date), + y: y(Number(t.balance) || 0), + date: t.date, + balance: Number(t.balance) || 0, + bills: t.bills || [], + isDrop: (t.bills || []).length > 0, + isPayday: !!t.payday, + isLast: i === timeline.length - 1, + })); + + // Step path: horizontal to each next x, then vertical drop. + let line = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`; + for (let i = 1; i < points.length; i++) { + line += ` H ${points[i].x.toFixed(2)} V ${points[i].y.toFixed(2)}`; + } + + const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0); + const area = `${line} V ${baseY.toFixed(2)} H ${points[0].x.toFixed(2)} Z`; + + return { line, area, points, zeroY: y(0) }; +} + +/** "5 days" / "tomorrow" / "today" */ +export function daysUntilLabel(days) { + const n = Number(days); + if (!Number.isFinite(n) || n <= 0) return 'today'; + if (n === 1) return 'tomorrow'; + return `${n} days`; +} + +/** "Jul 1" from "2026-07-01" — no Date parsing, no timezone traps. */ +export function shortDate(dateStr) { + if (!dateStr || typeof dateStr !== 'string') return ''; + const [, m, d] = dateStr.split('-'); + const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${MONTHS[parseInt(m, 10) - 1]} ${parseInt(d, 10)}`; +} + +/** Split upcoming bills into the visible few plus an overflow count. */ +export function splitUpcoming(upcoming, maxVisible = 4) { + const list = Array.isArray(upcoming) ? upcoming : []; + return { + visible: list.slice(0, maxVisible), + overflow: Math.max(0, list.length - maxVisible), + }; +} diff --git a/client/lib/cashflowUtils.test.js b/client/lib/cashflowUtils.test.js new file mode 100644 index 0000000..1030945 --- /dev/null +++ b/client/lib/cashflowUtils.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from './cashflowUtils'; + +const TIMELINE = [ + { date: '2026-06-10', balance: 500, bills: [] }, + { date: '2026-06-12', balance: 300, bills: [{ id: 1, name: 'Rent', amount: 200 }] }, + { date: '2026-06-15', balance: 300, bills: [], payday: true }, +]; + +describe('buildTimelineGeometry', () => { + it('returns null for empty or single-point timelines', () => { + expect(buildTimelineGeometry([], 400, 96)).toBeNull(); + expect(buildTimelineGeometry([TIMELINE[0]], 400, 96)).toBeNull(); + expect(buildTimelineGeometry(null, 400, 96)).toBeNull(); + }); + + it('positions points by actual day spacing, not even spacing', () => { + const geo = buildTimelineGeometry(TIMELINE, 400, 96, 0); + // 2026-06-12 is 2/5ths of the way from 06-10 to 06-15 + expect(geo.points[1].x).toBeCloseTo(400 * (2 / 5), 5); + expect(geo.points[0].x).toBe(0); + expect(geo.points[2].x).toBe(400); + }); + + it('maps balances down the Y axis and includes zero in the domain', () => { + const geo = buildTimelineGeometry(TIMELINE, 400, 100, 0); + // domain is [0, 500]: start sits at top, zero at bottom + expect(geo.points[0].y).toBe(0); + expect(geo.zeroY).toBe(100); + expect(geo.points[1].y).toBeCloseTo(100 * (1 - 300 / 500), 5); + }); + + it('handles a projection that goes negative', () => { + const geo = buildTimelineGeometry([ + { date: '2026-06-10', balance: 100, bills: [] }, + { date: '2026-06-12', balance: -50, bills: [{ id: 1, name: 'Big', amount: 150 }] }, + { date: '2026-06-15', balance: -50, bills: [], payday: true }, + ], 400, 100, 0); + // zero line sits inside the chart, not on an edge + expect(geo.zeroY).toBeGreaterThan(0); + expect(geo.zeroY).toBeLessThan(100); + // negative balance plots below the zero line + expect(geo.points[1].y).toBeGreaterThan(geo.zeroY); + }); + + it('builds a step path (H then V segments) and a closed area', () => { + const geo = buildTimelineGeometry(TIMELINE, 400, 96, 0); + expect(geo.line).toMatch(/^M [\d.]+ [\d.]+( H [\d.]+ V [\d.-]+)+$/); + expect(geo.area.endsWith('Z')).toBe(true); + expect(geo.points[1].isDrop).toBe(true); + expect(geo.points[2].isPayday).toBe(true); + }); +}); + +describe('daysUntilLabel / shortDate / splitUpcoming', () => { + it('labels day counts naturally', () => { + expect(daysUntilLabel(0)).toBe('today'); + expect(daysUntilLabel(1)).toBe('tomorrow'); + expect(daysUntilLabel(5)).toBe('5 days'); + expect(daysUntilLabel(undefined)).toBe('today'); + }); + + it('formats dates without Date() timezone traps', () => { + expect(shortDate('2026-07-01')).toBe('Jul 1'); + expect(shortDate('2026-12-15')).toBe('Dec 15'); + expect(shortDate('')).toBe(''); + expect(shortDate(null)).toBe(''); + }); + + it('splits upcoming into visible + overflow', () => { + const six = Array.from({ length: 6 }, (_, i) => ({ id: i })); + expect(splitUpcoming(six, 4)).toEqual({ visible: six.slice(0, 4), overflow: 2 }); + expect(splitUpcoming([], 4)).toEqual({ visible: [], overflow: 0 }); + expect(splitUpcoming(null, 4)).toEqual({ visible: [], overflow: 0 }); + }); +}); diff --git a/client/lib/trackerUtils.test.js b/client/lib/trackerUtils.test.js new file mode 100644 index 0000000..b8b654c --- /dev/null +++ b/client/lib/trackerUtils.test.js @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { + rowThreshold, rowEffectiveStatus, rowIsPaid, rowIsDebt, + sortTrackerRows, moveInArray, paymentSummary, amountSearchText, + normalizeTrackerSortKey, normalizeTrackerSortDir, + TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, +} from './trackerUtils'; + +const row = (overrides = {}) => ({ + id: 1, name: 'Bill', due_date: '2026-06-12', due_day: 12, bucket: '1st', + status: 'upcoming', expected_amount: 100, actual_amount: null, + total_paid: 0, is_skipped: false, ...overrides, +}); + +describe('rowThreshold / rowEffectiveStatus / rowIsPaid', () => { + it('uses the monthly override over the bill default', () => { + expect(rowThreshold(row())).toBe(100); + expect(rowThreshold(row({ actual_amount: 80 }))).toBe(80); + expect(rowThreshold(row({ actual_amount: 0 }))).toBe(0); // 0 is a real override + }); + + it('promotes to paid when payments meet the threshold', () => { + expect(rowEffectiveStatus(row({ total_paid: 100, status: 'upcoming' }))).toBe('paid'); + expect(rowEffectiveStatus(row({ total_paid: 99.99, status: 'upcoming' }))).toBe('upcoming'); + // the override lowers the bar + expect(rowEffectiveStatus(row({ total_paid: 80, actual_amount: 80, status: 'late' }))).toBe('paid'); + }); + + it('skipped always wins; autodraft with a pending suggestion is not "paid"', () => { + expect(rowEffectiveStatus(row({ is_skipped: true, total_paid: 500 }))).toBe('skipped'); + expect(rowIsPaid(row({ status: 'autodraft' }))).toBe(true); + expect(rowIsPaid(row({ status: 'autodraft', autopay_suggestion: { id: 9 } }))).toBe(false); + }); + + it('detects debt rows by balance, minimum payment, or category', () => { + expect(rowIsDebt(row({ current_balance: 1200 }))).toBe(true); + expect(rowIsDebt(row({ minimum_payment: 35 }))).toBe(true); + expect(rowIsDebt(row({ category_name: 'Credit Cards' }))).toBe(true); + expect(rowIsDebt(row({ category_name: 'Utilities' }))).toBe(false); + }); +}); + +describe('sortTrackerRows', () => { + const rows = [ + row({ id: 1, name: 'Zeta', due_date: '2026-06-20', expected_amount: 50, status: 'paid' }), + row({ id: 2, name: 'Alpha', due_date: '2026-06-05', expected_amount: 200, status: 'missed' }), + row({ id: 3, name: 'Mid', due_date: '2026-06-12', expected_amount: 100, status: 'upcoming' }), + ]; + + it('manual (default) sort leaves order untouched', () => { + expect(sortTrackerRows(rows, TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC)).toEqual(rows); + }); + + it('sorts by name, both directions', () => { + expect(sortTrackerRows(rows, 'name', TRACKER_SORT_ASC).map(r => r.name)).toEqual(['Alpha', 'Mid', 'Zeta']); + expect(sortTrackerRows(rows, 'name', TRACKER_SORT_DESC).map(r => r.name)).toEqual(['Zeta', 'Mid', 'Alpha']); + }); + + it('does not mutate the input array', () => { + const copy = [...rows]; + sortTrackerRows(rows, 'name', TRACKER_SORT_ASC); + expect(rows).toEqual(copy); + }); + + it('normalizes unknown keys/dirs to safe defaults', () => { + expect(normalizeTrackerSortKey('nope')).toBe(TRACKER_SORT_DEFAULT); + expect(normalizeTrackerSortDir('sideways')).toBe(TRACKER_SORT_ASC); + }); +}); + +describe('paymentSummary (payment form math)', () => { + it('computes partial payment state', () => { + const s = paymentSummary(row({ total_paid: 40 }), 100); + expect(s).toMatchObject({ target: 100, paid: 40, paidTowardDue: 40, overpaid: 0, remaining: 60, percent: 40, partial: true }); + }); + + it('caps percent at 100 and tracks overpayment', () => { + const s = paymentSummary(row({ total_paid: 130 }), 100); + expect(s.percent).toBe(100); + expect(s.overpaid).toBe(30); + expect(s.remaining).toBe(0); + expect(s.partial).toBe(false); + }); + + it('handles a zero target without dividing by zero', () => { + const s = paymentSummary(row({ total_paid: 10 }), 0); + expect(s.percent).toBe(0); + expect(s.remaining).toBe(0); + }); + + it('prefers server-computed paid_toward_due/overpaid_amount when present', () => { + const s = paymentSummary(row({ total_paid: 130, paid_toward_due: 100, overpaid_amount: 30 }), 100); + expect(s.paidTowardDue).toBe(100); + expect(s.overpaid).toBe(30); + }); +}); + +describe('amountSearchText / moveInArray', () => { + it('indexes amounts in plain, fixed, and $ forms', () => { + expect(amountSearchText(12.5)).toBe('12.5 12.50 $12.50'); + expect(amountSearchText(null, undefined, 'abc')).toBe(''); + }); + + it('moves items preserving the rest of the order', () => { + expect(moveInArray(['a', 'b', 'c', 'd'], 0, 2)).toEqual(['b', 'c', 'a', 'd']); + expect(moveInArray(['a', 'b'], 1, 0)).toEqual(['b', 'a']); + }); +}); diff --git a/client/lib/utils.test.js b/client/lib/utils.test.js new file mode 100644 index 0000000..1c65422 --- /dev/null +++ b/client/lib/utils.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { fmt, fmtDate, localDateString, todayStr } from './utils'; + +describe('fmt (money display)', () => { + it('formats with thousands separators and two decimals', () => { + expect(fmt(0)).toBe('$0.00'); + expect(fmt(1234.5)).toBe('$1,234.50'); + expect(fmt(1234567.89)).toBe('$1,234,567.89'); + }); + + it('treats null/undefined as zero', () => { + expect(fmt(null)).toBe('$0.00'); + expect(fmt(undefined)).toBe('$0.00'); + }); +}); + +describe('local dates', () => { + it('fmtDate renders M/D/YYYY from ISO', () => { + expect(fmtDate('2026-06-05')).toBe('6/5/2026'); + expect(fmtDate(null)).toBe('—'); + }); + + it('localDateString uses local calendar parts (no UTC drift)', () => { + // 23:30 local on Jan 31 must stay Jan 31 regardless of timezone + const lateNight = new Date(2026, 0, 31, 23, 30, 0); + expect(localDateString(lateNight)).toBe('2026-01-31'); + const earlyMorning = new Date(2026, 5, 1, 0, 5, 0); + expect(localDateString(earlyMorning)).toBe('2026-06-01'); + }); + + it('todayStr matches localDateString for now', () => { + expect(todayStr()).toBe(localDateString(new Date())); + }); +}); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index c40b177..6daa11e 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -25,6 +25,7 @@ import { } from '@/components/ui/dropdown-menu'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; +import CashFlowCard from '@/components/tracker/CashFlowCard'; import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter'; import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; import { @@ -380,6 +381,8 @@ export default function TrackerPage() { const bankTracking = data?.bank_tracking; const cashflow = data?.cashflow; const today = localDateString(); + // Safe-to-spend projects from "now", so it only makes sense on the current month. + const isCurrentMonth = today.startsWith(`${year}-${String(month).padStart(2, '0')}`); const bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || ''; const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort); const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards); @@ -844,6 +847,14 @@ export default function TrackerPage() {
) : null} + {/* ── Safe to Spend ── */} + {!isError && !loading && isCurrentMonth && cashflow && ( + setEditStartingOpen(true)} + /> + )} + {/* ── Overdue Command Center ── */} {!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && ( =6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2173,6 +2208,25 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -2276,6 +2330,16 @@ "@otplib/core": "13.4.1" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@peculiar/asn1-android": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", @@ -4296,6 +4360,281 @@ "node": ">=14.0.0" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -4859,6 +5198,13 @@ "node": ">=20.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.100.9", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", @@ -4955,6 +5301,17 @@ "node": ">=12" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -5000,6 +5357,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -5009,6 +5377,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5105,6 +5480,92 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5277,6 +5738,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -5752,6 +6223,16 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6515,6 +6996,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6686,6 +7174,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -8210,6 +8708,279 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9464,6 +10235,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oidc-token-hash": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", @@ -9694,6 +10479,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9750,9 +10542,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -9769,7 +10561,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10601,6 +11393,47 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -11009,6 +11842,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11153,6 +11993,13 @@ "node": ">=0.8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -11162,6 +12009,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -11583,10 +12437,27 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -11628,6 +12499,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12245,6 +13126,224 @@ } } }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -12375,6 +13474,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", diff --git a/package.json b/package.json index 1f6c994..31d9151 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check", "check": "npm run check:server && npm run build", "test": "node --test tests/*.test.js", + "test:client": "vitest run", + "test:all": "npm run test && npm run test:client", + "ci": "npm run check:server && npm run test:all && npm run build", "start": "node server.js" }, "dependencies": { @@ -64,7 +67,8 @@ "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "vite": "^5.4.10", - "vite-plugin-pwa": "^1.3.0" + "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.8" }, "directories": { "doc": "docs" diff --git a/services/trackerService.js b/services/trackerService.js index 7f852b7..c422765 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -292,6 +292,78 @@ function rowPaidTowardDue(row) { return Math.min(Number(row.total_paid) || 0, rowDueAmount(row)); } +function rowOutstanding(row) { + return Math.max(Number(row.balance) || 0, 0); +} + +/** + * Safe-to-spend projection for the current 1st/15th pay period. + * + * Pure function (no DB) so it is unit-testable: takes serialized tracker rows + * (dollar-denominated, post buildTrackerRow) plus the cash available for the + * period, and answers: "after every bill still due before the next payday is + * covered — including overdue carry-over — what is left to spend?" + * + * The next payday is the next bucket boundary (the 15th, or the 1st of the + * following month), matching the app's semi-monthly bucket model. + */ +function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfMonth }) { + const nextPayday = dayOfMonth < 15 + ? `${year}-${String(month).padStart(2, '0')}-15` + : (month === 12 ? `${year + 1}-01-01` : `${year}-${String(month + 1).padStart(2, '0')}-01`); + + // Both strings are YYYY-MM-DD → Date.parse treats them as UTC midnight, + // so the difference is an exact whole number of days. + const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000)); + + const stillDueRows = activeRows + .filter(r => !['paid', 'autodraft'].includes(r.status)) + .filter(r => rowOutstanding(r) > 0) + .filter(r => r.due_date < nextPayday) + .sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name))); + + const stillDueTotal = sumMoney(stillDueRows, rowOutstanding); + const safeToSpend = roundMoney(available - stillDueTotal); + + // Daily projection from today to payday: balance steps down as bills hit. + // Overdue bills land on "today" — they need to be paid now, not in the past. + const byDate = new Map(); + for (const r of stillDueRows) { + const date = r.due_date > todayStr ? r.due_date : todayStr; + if (!byDate.has(date)) byDate.set(date, []); + byDate.get(date).push({ id: r.id, name: r.name, amount: rowOutstanding(r) }); + } + + const timeline = []; + let running = roundMoney(available); + if (!byDate.has(todayStr)) timeline.push({ date: todayStr, balance: running, bills: [] }); + for (const date of [...byDate.keys()].sort()) { + const bills = byDate.get(date); + running = roundMoney(running - sumMoney(bills, b => b.amount)); + timeline.push({ date, balance: running, bills }); + } + if (timeline.length === 0 || timeline[timeline.length - 1].date !== nextPayday) { + timeline.push({ date: nextPayday, balance: running, bills: [], payday: true }); + } + + return { + next_payday: nextPayday, + days_until_payday: daysUntilPayday, + available: roundMoney(available), + safe_to_spend: safeToSpend, + still_due_total: stillDueTotal, + still_due_count: stillDueRows.length, + upcoming: stillDueRows.slice(0, 8).map(r => ({ + id: r.id, + name: r.name, + due_date: r.due_date, + amount: rowOutstanding(r), + status: r.status, + })), + timeline, + }; +} + function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) return null; @@ -528,7 +600,23 @@ function getTracker(userId, query = {}, now = new Date()) { const monthTotalCount = activeRows.length; const monthProjected = roundMoney(totalStarting - monthBillsTotal); + // Safe to spend: cash on hand for this period after already-made payments + // (bank mode: effective balance already nets them out), minus everything + // still due before the next payday. + const availableNow = bankTracking.enabled + ? bankTracking.effective_balance + : roundMoney(periodStartingAmount - periodPaidTowardDue); + const safeToSpend = buildSafeToSpend({ + activeRows, + available: availableNow, + todayStr, + year, + month, + dayOfMonth, + }); + const cashflow = { + ...safeToSpend, has_data: hasStartingAmounts, uses_bank_balance: bankTracking.enabled, period: activeRemainingPeriod, @@ -704,4 +792,5 @@ module.exports = { getUpcomingBills, validateTrackerMonth, getOverdueCount, + buildSafeToSpend, }; diff --git a/tests/safeToSpend.test.js b/tests/safeToSpend.test.js new file mode 100644 index 0000000..e40fc1f --- /dev/null +++ b/tests/safeToSpend.test.js @@ -0,0 +1,104 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { buildSafeToSpend } = require('../services/trackerService'); + +function row(overrides = {}) { + return { + id: 1, + name: 'Bill', + due_date: '2026-06-12', + status: 'upcoming', + balance: 50, + ...overrides, + }; +} + +test('safe to spend subtracts bills still due before the next payday', () => { + const result = buildSafeToSpend({ + activeRows: [ + row({ id: 1, name: 'Rent', due_date: '2026-06-12', balance: 800 }), + row({ id: 2, name: 'Power', due_date: '2026-06-14', balance: 120.5 }), + row({ id: 3, name: 'Paid one', due_date: '2026-06-13', status: 'paid', balance: 0 }), + row({ id: 4, name: 'After payday', due_date: '2026-06-20', balance: 60 }), + ], + available: 1500, + todayStr: '2026-06-10', + year: 2026, + month: 6, + dayOfMonth: 10, + }); + + assert.equal(result.next_payday, '2026-06-15'); + assert.equal(result.days_until_payday, 5); + assert.equal(result.still_due_count, 2); + assert.equal(result.still_due_total, 920.5); + assert.equal(result.safe_to_spend, 579.5); + assert.deepEqual(result.upcoming.map(u => u.name), ['Rent', 'Power']); +}); + +test('second half of the month rolls payday to the 1st, December wraps the year', () => { + const june = buildSafeToSpend({ + activeRows: [], available: 100, todayStr: '2026-06-20', year: 2026, month: 6, dayOfMonth: 20, + }); + assert.equal(june.next_payday, '2026-07-01'); + + const dec = buildSafeToSpend({ + activeRows: [], available: 100, todayStr: '2026-12-20', year: 2026, month: 12, dayOfMonth: 20, + }); + assert.equal(dec.next_payday, '2027-01-01'); + assert.equal(dec.days_until_payday, 12); +}); + +test('overdue bills count against safe-to-spend and land on today in the timeline', () => { + const result = buildSafeToSpend({ + activeRows: [ + row({ id: 1, name: 'Late card', due_date: '2026-06-03', status: 'late', balance: 200 }), + row({ id: 2, name: 'Internet', due_date: '2026-06-13', balance: 80 }), + ], + available: 500, + todayStr: '2026-06-10', + year: 2026, + month: 6, + dayOfMonth: 10, + }); + + assert.equal(result.safe_to_spend, 220); + // Overdue bill is charged on today's entry, not in the past + const todayEntry = result.timeline.find(t => t.date === '2026-06-10'); + assert.equal(todayEntry.balance, 300); + assert.deepEqual(todayEntry.bills.map(b => b.name), ['Late card']); + // Timeline ends on payday + assert.equal(result.timeline[result.timeline.length - 1].date, '2026-06-15'); + assert.equal(result.timeline[result.timeline.length - 1].balance, 220); +}); + +test('cent-exact math: no float drift across many bills', () => { + const bills = Array.from({ length: 30 }, (_, i) => + row({ id: i, name: `B${i}`, due_date: '2026-06-12', balance: 0.1 })); + const result = buildSafeToSpend({ + activeRows: bills, available: 10, todayStr: '2026-06-10', year: 2026, month: 6, dayOfMonth: 10, + }); + assert.equal(result.still_due_total, 3); + assert.equal(result.safe_to_spend, 7); +}); + +test('negative safe-to-spend is reported, not clamped', () => { + const result = buildSafeToSpend({ + activeRows: [row({ balance: 300 })], + available: 100, todayStr: '2026-06-10', year: 2026, month: 6, dayOfMonth: 10, + }); + assert.equal(result.safe_to_spend, -200); +}); + +test('skipped/zero-balance rows and empty data produce a flat timeline', () => { + const result = buildSafeToSpend({ + activeRows: [row({ status: 'paid', balance: 0 })], + available: 250, todayStr: '2026-06-10', year: 2026, month: 6, dayOfMonth: 10, + }); + assert.equal(result.still_due_count, 0); + assert.equal(result.safe_to_spend, 250); + assert.equal(result.timeline[0].date, '2026-06-10'); + assert.equal(result.timeline[0].balance, 250); + assert.ok(result.timeline[result.timeline.length - 1].payday); +}); diff --git a/vite.config.mjs b/vite.config.mjs index 22958d6..0157625 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -64,4 +64,11 @@ export default defineConfig({ outDir: 'dist', emptyOutDir: true, }, + // Vitest — client-side unit tests (pure logic in client/lib). + // Server tests stay on node:test (`npm run test`); client tests run with + // `npm run test:client`; `npm run test:all` runs both. + test: { + environment: 'node', + include: ['client/**/*.test.js'], + }, });