2026-06-12 01:32:28 -05:00
|
|
|
// Pure helpers for the Safe-to-Spend card. No DOM, no React — unit-testable.
|
|
|
|
|
|
2026-07-03 21:59:16 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 01:32:28 -05:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-07-03 21:59:16 -05:00
|
|
|
export function buildTimelineGeometry(
|
|
|
|
|
timeline: TimelineEntry[] | null | undefined,
|
|
|
|
|
width: number,
|
|
|
|
|
height: number,
|
|
|
|
|
pad = 4,
|
|
|
|
|
): TimelineGeometry | null {
|
2026-06-12 01:32:28 -05:00
|
|
|
if (!Array.isArray(timeline) || timeline.length < 2) return null;
|
|
|
|
|
|
2026-07-03 21:59:16 -05:00
|
|
|
// 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);
|
2026-06-12 01:32:28 -05:00
|
|
|
const span = Math.max(t1 - t0, 1);
|
|
|
|
|
|
|
|
|
|
const balances = timeline.map(t => Number(t.balance) || 0);
|
2026-07-03 21:59:16 -05:00
|
|
|
const start = Number(first.balance) || 0;
|
2026-06-12 01:32:28 -05:00
|
|
|
// 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);
|
|
|
|
|
|
2026-07-03 21:59:16 -05:00
|
|
|
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);
|
2026-06-12 01:32:28 -05:00
|
|
|
|
2026-07-03 21:59:16 -05:00
|
|
|
const points: GeometryPoint[] = timeline.map((t, i) => ({
|
2026-06-12 01:32:28 -05:00
|
|
|
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.
|
2026-07-03 21:59:16 -05:00
|
|
|
let line = `M ${points[0]!.x.toFixed(2)} ${points[0]!.y.toFixed(2)}`;
|
2026-06-12 01:32:28 -05:00
|
|
|
for (let i = 1; i < points.length; i++) {
|
2026-07-03 21:59:16 -05:00
|
|
|
line += ` H ${points[i]!.x.toFixed(2)} V ${points[i]!.y.toFixed(2)}`;
|
2026-06-12 01:32:28 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const baseY = y(Math.min(0, lo) === lo && lo < 0 ? lo : 0);
|
2026-07-03 21:59:16 -05:00
|
|
|
const area = `${line} V ${baseY.toFixed(2)} H ${points[0]!.x.toFixed(2)} Z`;
|
2026-06-12 01:32:28 -05:00
|
|
|
|
|
|
|
|
return { line, area, points, zeroY: y(0) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** "5 days" / "tomorrow" / "today" */
|
2026-07-03 21:59:16 -05:00
|
|
|
export function daysUntilLabel(days: number | string | null | undefined): string {
|
2026-06-12 01:32:28 -05:00
|
|
|
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. */
|
2026-07-03 21:59:16 -05:00
|
|
|
export function shortDate(dateStr: string | null | undefined): string {
|
2026-06-12 01:32:28 -05:00
|
|
|
if (!dateStr || typeof dateStr !== 'string') return '';
|
2026-07-03 21:59:16 -05:00
|
|
|
const [, m = '', d = ''] = dateStr.split('-');
|
2026-06-12 01:32:28 -05:00
|
|
|
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. */
|
2026-07-03 21:59:16 -05:00
|
|
|
export function splitUpcoming<T>(upcoming: T[] | null | undefined, maxVisible = 4): { visible: T[]; overflow: number } {
|
2026-06-12 01:32:28 -05:00
|
|
|
const list = Array.isArray(upcoming) ? upcoming : [];
|
|
|
|
|
return {
|
|
|
|
|
visible: list.slice(0, maxVisible),
|
|
|
|
|
overflow: Math.max(0, list.length - maxVisible),
|
|
|
|
|
};
|
|
|
|
|
}
|