feat(cashflow): safe-to-spend projection with timeline, vitest setup, package upgrades
This commit is contained in:
parent
d0835b86ab
commit
dc49eb9633
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()));
|
||||
});
|
||||
});
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Safe to Spend ── */}
|
||||
{!isError && !loading && isCurrentMonth && cashflow && (
|
||||
<CashFlowCard
|
||||
cashflow={cashflow}
|
||||
onSetStartingAmounts={() => setEditStartingOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Overdue Command Center ── */}
|
||||
{!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && (
|
||||
<OverdueCommandCenter
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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'],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue