BillTracker/client/components/tracker/CashFlowCard.jsx

214 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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