214 lines
8.4 KiB
JavaScript
214 lines
8.4 KiB
JavaScript
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>
|
||
);
|
||
}
|