feat(cashflow): safe-to-spend projection with timeline, vitest setup, package upgrades

This commit is contained in:
null 2026-06-12 01:32:28 -05:00
parent d0835b86ab
commit dc49eb9633
12 changed files with 1883 additions and 11 deletions

35
.forgejo/workflows/ci.yml Normal file
View File

@ -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

View File

@ -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 (
<svg
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
preserveAspectRatio="none"
className="h-24 w-full"
role="img"
aria-label="Projected balance until next payday"
>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={tone} stopOpacity="0.28" />
<stop offset="100%" stopColor={tone} stopOpacity="0.02" />
</linearGradient>
</defs>
{/* zero line — only meaningful when the projection dips negative */}
{geo.points.some(p => p.balance < 0) && (
<line
x1="0" x2={CHART_W} y1={geo.zeroY} y2={geo.zeroY}
stroke="#f43f5e" strokeOpacity="0.45" strokeDasharray="4 4" strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
)}
<path d={geo.area} fill={`url(#${gradId})`} />
<path
d={geo.line}
fill="none"
stroke={tone}
strokeWidth="2"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{/* bill-day markers */}
{geo.points.filter(p => p.isDrop).map(p => (
<circle key={p.date} cx={p.x} cy={p.y} r="3" fill={tone} stroke="var(--background, #18181b)" strokeWidth="1.5">
<title>
{`${fmtDate(p.date)}${p.bills.map(b => `${b.name} ${fmt(b.amount)}`).join(', ')}${fmt(p.balance)} left`}
</title>
</circle>
))}
{/* payday marker */}
{geo.points.filter(p => p.isPayday).map(p => (
<g key="payday">
<line x1={p.x} x2={p.x} y1="4" y2={CHART_H - 4} stroke={tone} strokeOpacity="0.35" strokeWidth="1" strokeDasharray="2 3" vectorEffect="non-scaling-stroke" />
<circle cx={p.x} cy={p.y} r="3.5" fill="none" stroke={tone} strokeWidth="2">
<title>{`Payday ${fmtDate(p.date)}${fmt(p.balance)} left`}</title>
</circle>
</g>
))}
</svg>
);
}
function UpcomingList({ upcoming }) {
const { visible, overflow } = splitUpcoming(upcoming, 4);
if (visible.length === 0) {
return (
<div className="flex h-full flex-col items-start justify-center gap-1.5">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-300">
<Sparkles className="h-4 w-4" /> All bills covered
</span>
<p className="text-[11px] text-muted-foreground">Nothing else is due before payday.</p>
</div>
);
}
return (
<ul className="space-y-1.5">
{visible.map(bill => (
<li key={bill.id} className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-foreground/90">
{bill.name}
<span className={cn(
'ml-1.5 text-[10px] uppercase tracking-wide',
bill.status === 'late' || bill.status === 'missed'
? 'font-semibold text-rose-500 dark:text-rose-300'
: 'text-muted-foreground',
)}>
{bill.status === 'late' || bill.status === 'missed' ? 'overdue' : shortDate(bill.due_date)}
</span>
</span>
<span className="shrink-0 font-mono text-foreground/80">{fmt(bill.amount)}</span>
</li>
))}
{overflow > 0 && (
<li className="text-[11px] text-muted-foreground">+{overflow} more before payday</li>
)}
</ul>
);
}
/**
* 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 (
<motion.section
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="relative overflow-hidden rounded-xl border border-dashed border-border/80 bg-card/60 px-5 py-4"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Wallet className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-semibold text-foreground">See your safe-to-spend</p>
<p className="text-[11px] text-muted-foreground">
Add what's in your account for the 1st and 15th, and BillTracker projects what's left after bills.
</p>
</div>
</div>
<button
onClick={onSetStartingAmounts}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent"
>
Set starting amounts <ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</motion.section>
);
}
const safe = Number(cashflow.safe_to_spend ?? 0);
const positive = safe >= 0;
return (
<motion.section
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn(
'relative overflow-hidden rounded-xl border border-border/80 bg-card/95 shadow-sm shadow-black/15',
positive
? 'shadow-[0_4px_24px_rgba(16,185,129,0.10)]'
: 'border-rose-400/35 shadow-[0_4px_24px_rgba(244,63,94,0.12)]',
)}
aria-label="Safe to spend"
>
<div className={cn(
'absolute top-0 left-0 right-0 h-[3px] bg-gradient-to-r',
positive ? 'from-emerald-500 via-teal-400 to-emerald-300' : 'from-rose-500 via-rose-400 to-orange-300',
)} />
<div className="grid gap-5 px-5 py-4 md:grid-cols-[200px,minmax(0,1fr),220px] md:items-center">
{/* ── Number ── */}
<div>
<div className="mb-2 flex items-center gap-2">
<Wallet className={cn('h-4 w-4', positive ? 'text-emerald-500 dark:text-emerald-300' : 'text-rose-500 dark:text-rose-300')} />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Safe to spend</p>
</div>
<p
className={cn(
'font-mono text-[2rem] font-bold leading-none tracking-tight',
positive ? 'text-emerald-600 dark:text-emerald-200' : 'text-rose-500 dark:text-rose-300',
)}
title={`${fmt(cashflow.available)} available ${fmt(cashflow.still_due_total)} still due before payday`}
>
{positive ? '' : ''}{fmt(Math.abs(safe))}
</p>
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />
until {shortDate(cashflow.next_payday)} · {daysUntilLabel(cashflow.days_until_payday)}
</p>
</div>
{/* ── Projection ── */}
<div className="min-w-0">
<TimelineChart timeline={cashflow.timeline} positive={positive} />
<p className="mt-1 text-center text-[10px] text-muted-foreground/70">
{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`}
</p>
</div>
{/* ── Upcoming ── */}
<div className="md:border-l md:border-border/60 md:pl-5">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Due before payday
</p>
<UpcomingList upcoming={cashflow.upcoming} />
</div>
</div>
</motion.section>
);
}

View File

@ -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),
};
}

View File

@ -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 });
});
});

View File

@ -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']);
});
});

34
client/lib/utils.test.js Normal file
View File

@ -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()));
});
});

View File

@ -25,6 +25,7 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import CashFlowCard from '@/components/tracker/CashFlowCard';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter'; import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
import { import {
@ -380,6 +381,8 @@ export default function TrackerPage() {
const bankTracking = data?.bank_tracking; const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow; const cashflow = data?.cashflow;
const today = localDateString(); 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 bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || '';
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort); const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards); const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
@ -844,6 +847,14 @@ export default function TrackerPage() {
</div> </div>
) : null} ) : null}
{/* ── Safe to Spend ── */}
{!isError && !loading && isCurrentMonth && cashflow && (
<CashFlowCard
cashflow={cashflow}
onSetStartingAmounts={() => setEditStartingOpen(true)}
/>
)}
{/* ── Overdue Command Center ── */} {/* ── Overdue Command Center ── */}
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && ( {!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter <OverdueCommandCenter

1136
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,9 @@
"check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check", "check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check",
"check": "npm run check:server && npm run build", "check": "npm run check:server && npm run build",
"test": "node --test tests/*.test.js", "test": "node --test tests/*.test.js",
"test:client": "vitest run",
"test:all": "npm run test && npm run test:client",
"ci": "npm run check:server && npm run test:all && npm run build",
"start": "node server.js" "start": "node server.js"
}, },
"dependencies": { "dependencies": {
@ -64,7 +67,8 @@
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8"
}, },
"directories": { "directories": {
"doc": "docs" "doc": "docs"

View File

@ -292,6 +292,78 @@ function rowPaidTowardDue(row) {
return Math.min(Number(row.total_paid) || 0, rowDueAmount(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) { function applyAutopaySuggestions(db, bill, payments, mbs, year, month, todayStr, dismissedSuggestions) {
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) return null; if (!dueDate) return null;
@ -528,7 +600,23 @@ function getTracker(userId, query = {}, now = new Date()) {
const monthTotalCount = activeRows.length; const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal); 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 = { const cashflow = {
...safeToSpend,
has_data: hasStartingAmounts, has_data: hasStartingAmounts,
uses_bank_balance: bankTracking.enabled, uses_bank_balance: bankTracking.enabled,
period: activeRemainingPeriod, period: activeRemainingPeriod,
@ -704,4 +792,5 @@ module.exports = {
getUpcomingBills, getUpcomingBills,
validateTrackerMonth, validateTrackerMonth,
getOverdueCount, getOverdueCount,
buildSafeToSpend,
}; };

104
tests/safeToSpend.test.js Normal file
View File

@ -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);
});

View File

@ -64,4 +64,11 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
emptyOutDir: true, 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'],
},
}); });