import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Banknote,
CalendarDays,
ChevronLeft,
ChevronRight,
CircleDollarSign,
PiggyBank,
RefreshCw,
Target,
TrendingDown,
Trophy,
WalletCards,
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function currentMonth() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function shiftMonth(year, month, delta) {
const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 1 };
}
function displayStatus(status) {
if (status === 'due_soon') return 'Due';
if (status === 'late') return 'Late';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
}
function statusTone(status) {
if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';
return 'border-primary/30 bg-primary/15 text-primary';
}
function LegendItem({ className, label }) {
return (
{label}
);
}
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
return (
{fmt(value)}
{hint &&
{hint}
}
);
}
function MoneyMap({ summaryData, loading }) {
if (loading) {
return (
{Array.from({ length: 4 }).map((_, index) => (
))}
);
}
const starting = summaryData?.starting_amounts || {};
const summary = summaryData?.summary || {};
const bt = summaryData?.bank_tracking;
const bankMode = bt?.enabled === true;
const available = bankMode ? Number(bt.effective_balance || 0) : Number(starting.combined_amount || 0);
const assigned = Number(summary.expense_total || 0);
const paid = Number(summary.paid_total || 0);
const remaining = Number(summary.result || 0);
const extraIncome = Number(starting.other_amount || 0);
return (
Monthly Money Map
{bankMode
? `Live bank balance · ${bt.account_name}`
: 'Available money, extra income, assigned bills, and what remains.'}
{!bankMode && (
Edit money plan
)}
{bankMode ? (
<>
= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
/>
>
) : (
<>
0 ? 'text-teal-600 dark:text-teal-300' : ''} />
= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
/>
>
)}
{!bankMode && (
1st available
{fmt(starting.first_amount)}
15th available
{fmt(starting.fifteenth_amount)}
Monthly income
{fmt(summaryData?.income?.amount)}
)}
{bankMode && (
= 0
? 'border-emerald-500/25 bg-emerald-500/5'
: 'border-destructive/25 bg-destructive/5',
)}>
Projected Month-End Balance
{fmt(bt.balance || 0)} bank
{Number(bt.pending_payments || 0) > 0 && ` − ${fmt(bt.pending_payments)} pending`}
{` − ${fmt(bt.unpaid_this_month || 0)} remaining bills`}
= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
)}>
{Number(bt.remaining || 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))}
)}
{bankMode && bt.last_updated && (
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
)}
);
}
function SummaryProgress({ summary }) {
const percent = Number(summary?.paid_percent || 0);
return (
Total Expenses Paid
Monthly progress across active, unskipped bills.
{fmt(summary?.paid_total)}
/
{fmt(summary?.expected_total)}
{fmt(summary?.remaining_total)} remaining
{summary?.bill_count || 0} active bills
{summary?.paid_count || 0} paid
{!!summary?.skipped_count && {summary.skipped_count} skipped }
{!!summary?.missed_count && {summary.missed_count} late or missed }
);
}
function DayIndicators({ day, moneyMarker }) {
const summary = day.status_summary;
const hasPaid = summary.paid_count > 0;
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
const hasSkipped = summary.skipped_count > 0;
const hasMissed = summary.missed_count > 0;
const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0;
return (
{moneyMarker && }
{hasPaid && }
{(hasDue || paymentOnly) && }
{hasSkipped && }
{hasMissed && }
);
}
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
const cells = [
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
...data.days.map(day => ({ type: 'day', key: day.date, day })),
];
const today = todayStr();
return (
{WEEKDAYS.map(day => (
{day}
))}
{cells.map(cell => {
if (cell.type === 'blank') {
return
;
}
const day = cell.day;
const isToday = day.date === today;
const isSelected = day.date === selectedDate;
const summary = day.status_summary;
const moneyMarker = moneyMarkers?.[day.date] || null;
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0 || !!moneyMarker;
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
const hasMissed = summary.missed_count > 0;
return (
onSelectDay(day)}
className={cn(
'flex min-h-16 flex-col border-b border-r border-border/60 bg-card/70 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
hasActivity && 'bg-primary/[0.06] hover:bg-accent/70',
isPaidDay && 'bg-emerald-500/[0.10]',
hasMissed && 'border-rose-400/40 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.12]',
isSelected && 'ring-2 ring-primary ring-inset bg-primary/[0.09]',
)}
aria-label={`View ${fmtDate(day.date)}`}
>
{day.day}
{summary.due_count > 0 && (
{summary.due_count}
)}
{moneyMarker && (
+{fmt(moneyMarker.amount)}
)}
{day.bills_due.slice(0, 2).map(bill => (
{bill.name}
))}
{day.bills_due.length > 2 && (
+{day.bills_due.length - 2} more
)}
);
})}
);
}
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) {
const navigate = useNavigate();
const isNegative = projected < 0;
const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0;
const unpaidCount = totalCount - paidCount;
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
function goToUnpaid() {
navigate(`/?un=1&${bucketParam}&year=${year}&month=${month}`);
}
return (
{label}
{isNegative ? '−' : ''}{fmt(Math.abs(projected))}
{/* Amount-based progress bar */}
{fmt(paid)} of {fmt(billsTotal)} paid
{unpaidCount > 0 && (
{unpaidCount} unpaid →
)}
{unpaidCount === 0 && totalCount > 0 && (
All paid ✓
)}
{fmt(starting)} starting · {fmt(billsTotal)} due
);
}
function CashFlowCard({ cashflow, year, month }) {
if (!cashflow?.has_data) return null;
const periodProjected = Number(cashflow.period_projected ?? 0);
const monthProjected = Number(cashflow.month_projected ?? 0);
// In the second half of the month the period end = month end — one panel suffices.
// Only show the month panel separately in the first half where they differ.
const showMonthPanel = cashflow.period === '1st';
const anyNegative = periodProjected < 0 || (showMonthPanel && monthProjected < 0);
const shortfallAmount = showMonthPanel
? Math.min(periodProjected, monthProjected)
: periodProjected;
return (
Cash Flow Projection
{cashflow.uses_bank_balance && (
Live balance
)}
What you'll have after all bills clear — not just what's been paid so far.
{/* Negative balance alert */}
{anyNegative && (
⚠
You're projected to be{' '}
{fmt(Math.abs(shortfallAmount))} short
{' '}by{' '}{cashflow.period_end_label}.
Review unpaid bills or adjust your starting amounts.
)}
);
}
function DebtPayoffGlance({ projection }) {
const snowball = projection?.snowball;
const comparison = projection?.comparison;
const targetDebt = snowball?.debts?.[0] || null;
const targetMonths = Number(targetDebt?.months || 0);
const monthsSaved = comparison?.months_saved;
return (
Current payoff focus, with the final debt-free date close by.
{snowball?.months_to_freedom ? (
{targetDebt && (
Target debt
{targetDebt.name}
Clears {targetDebt.payoff_display || 'on the current plan'}
Target runway
{targetMonths ? `${targetMonths} mo` : '—'}
Debt-free
{snowball.payoff_display}
)}
{monthsSaved !== undefined ? `${monthsSaved} mo` : '—'}
{fmt(snowball.total_interest_paid)}
{!targetDebt && (
Projection ready
Open Snowball to review the active payoff order.
)}
Full plan
{snowball.months_to_freedom} mo
Interest saved
{comparison ? fmt(comparison.interest_saved) : '—'}
Open Snowball
) : (
Choose a first target
Add debt balances and minimum payments to see the next payoff milestone here.
Set up Snowball
)}
);
}
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
return (
{day ? fmtDate(day.date) : 'Day details'}
Bills due and payments recorded for this date.
{day && (
{moneyMarker && (
Available Money
+{fmt(moneyMarker.amount)}
{moneyMarker.label}
)}
Bills Due
{day.bills_due.length === 0 ? (
No bills due this day.
) : (
{day.bills_due.map(bill => (
{bill.name}
{bill.category_name || 'Uncategorized'}
{displayStatus(bill.status)}
Expected
{fmt(bill.effective_amount)}
Paid
{fmt(bill.paid_amount)}
Due
{fmtDate(bill.due_date)}
))}
)}
Payments
{day.payments.length === 0 ? (
No payments were recorded on this day.
) : (
{day.payments.map(payment => (
{payment.bill_name}
{payment.method || 'Payment'}
{fmt(payment.amount)}
))}
)}
Open Tracker
Manage Bills
)}
);
}
export default function CalendarPage() {
const initial = currentMonth();
const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month);
const [data, setData] = useState(null);
const [summaryData, setSummaryData] = useState(null);
const [snowballProjection, setSnowballProjection] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedDay, setSelectedDay] = useState(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [calendarResult, summaryResult, snowballResult] = await Promise.allSettled([
api.calendar(year, month),
api.summary(year, month),
api.snowballProjection(),
]);
if (calendarResult.status === 'rejected') {
throw calendarResult.reason;
}
const result = calendarResult.value;
setData(result);
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null);
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null);
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
} catch (err) {
setError(err.message || 'Calendar data could not be loaded.');
toast.error(err.message || 'Calendar data could not be loaded.');
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => { load(); }, [load]);
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
const moneyMarkers = useMemo(() => {
const starting = summaryData?.starting_amounts;
if (!starting) return {};
const markers = {};
const firstAmount = Number(starting.first_amount || 0);
const fifteenthAmount = Number(starting.fifteenth_amount || 0);
if (firstAmount > 0) {
markers[`${year}-${String(month).padStart(2, '0')}-01`] = {
label: '1st available',
amount: firstAmount,
};
}
if (fifteenthAmount > 0) {
markers[`${year}-${String(month).padStart(2, '0')}-15`] = {
label: '15th available',
amount: fifteenthAmount,
};
}
return markers;
}, [month, summaryData, year]);
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
function navigate(delta) {
const next = shiftMonth(year, month, delta);
setYear(next.year);
setMonth(next.month);
setSelectedDay(null);
setDetailOpen(false);
}
function goToday() {
const next = currentMonth();
setYear(next.year);
setMonth(next.month);
setSelectedDay(null);
setDetailOpen(false);
}
return (
Monthly Calendar
Calendar
View bills, payments, and monthly progress by date.
navigate(-1)} aria-label="Previous month">
{monthLabel}
navigate(1)} aria-label="Next month">
Today
Add All
Today
{loading && (
Loading calendar...
)}
{!loading && error && (
{error}
Try again
)}
{!loading && !error && data && (
<>
{
setSelectedDay(day);
setDetailOpen(true);
}}
/>
{!hasAnyBills && (
No bills on this calendar yet.
Add a bill to start seeing due dates and payment progress.
Add bill
)}
>
)}
Selected Day
Tap a date to inspect bills and payments.
{selectedDay ? (
{fmtDate(selectedDay.date)}
Due
{fmt(selectedDay.status_summary.total_due)}
Paid
{fmt(selectedDay.status_summary.total_paid)}
setDetailOpen(true)}>
View day details
) : (
No day selected.
)}
);
}