import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } 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' || status === 'missed') return 'border-destructive/30 bg-destructive/15 text-destructive';
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 available = 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
Available money, extra income, assigned bills, and what remains.
0 ? 'text-teal-600 dark:text-teal-300' : ''} />
= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
/>
1st available
{fmt(starting.first_amount)}
15th available
{fmt(starting.fifteenth_amount)}
Monthly income
{fmt(summaryData?.income?.amount)}
);
}
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 (
);
})}
);
}
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) : '—'}
) : (
Choose a first target
Add debt balances and minimum payments to see the next payoff milestone here.
)}
);
}
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
return (
);
}
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.
{monthLabel}
Today
{loading && (
Loading calendar...
)}
{!loading && error && (
{error}
)}
{!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.
)}
>
)}
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)}
) : (
No day selected.
)}
);
}