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, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
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
)}
);
}
function CalendarSubscribeDialog({ open, onOpenChange }) {
return (
Subscribe to Bill Calendar
Create a private calendar feed URL, preview what will appear, then copy it into your calendar app. Bill Tracker does not add anything to Apple, Google, Android, or Outlook until you subscribe with that URL.
Best for keeping calendars updated
A subscription stays linked to Bill Tracker, so future due-date or amount changes can appear when your calendar app refreshes the feed. Calendar apps control their own refresh timing.
);
}
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 [calendarFeedOpen, setCalendarFeedOpen] = 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
setCalendarFeedOpen(true)}>
Subscribe Calendar
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.
)}
);
}