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 (
+
+ {visible.map(bill => (
+
+
+ {bill.name}
+
+ {bill.status === 'late' || bill.status === 'missed' ? 'overdue' : shortDate(bill.due_date)}
+
+
+ {fmt(bill.amount)}
+
+ ))}
+ {overflow > 0 && (
+ +{overflow} more before payday
+ )}
+
+ );
+}
+
+/**
+ * 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.
+
+
+
+
+ Set starting amounts
+
+
+
+ );
+ }
+
+ const safe = Number(cashflow.safe_to_spend ?? 0);
+ const positive = safe >= 0;
+
+ return (
+
+
+
+
+ {/* ── Number ── */}
+
+
+
+ {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'],
+ },
});