// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable. export interface TimelineEntry { date: string; balance: number | string | null; bills?: unknown[]; payday?: unknown; } export interface GeometryPoint { x: number; y: number; date: string; balance: number; bills: unknown[]; isDrop: boolean; isPayday: boolean; isLast: boolean; } export interface TimelineGeometry { line: string; area: string; points: GeometryPoint[]; zeroY: number; } /** * 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: TimelineEntry[] | null | undefined, width: number, height: number, pad = 4, ): TimelineGeometry | null { if (!Array.isArray(timeline) || timeline.length < 2) return null; // length >= 2 (guarded), so first/last exist — the `!` satisfies noUncheckedIndexedAccess. const first = timeline[0]!; const last = timeline[timeline.length - 1]!; const t0 = Date.parse(first.date); const t1 = Date.parse(last.date); const span = Math.max(t1 - t0, 1); const balances = timeline.map(t => Number(t.balance) || 0); const start = Number(first.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: string): number => pad + ((Date.parse(date) - t0) / span) * (width - pad * 2); const y = (bal: number): number => pad + (1 - ((bal - lo) / range)) * (height - pad * 2); const points: GeometryPoint[] = 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: number | string | null | undefined): string { 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: string | null | undefined): string { 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: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } { const list = Array.isArray(upcoming) ? upcoming : []; return { visible: list.slice(0, maxVisible), overflow: Math.max(0, list.length - maxVisible), }; }