BillTracker/client/components/tracker/CashFlowCard.jsx

214 lines
8.4 KiB
React
Raw Normal View History

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