refactor(ts): CalendarPage → TypeScript (money map + grid + cashflow + snowball glance)

This commit is contained in:
null 2026-07-04 22:58:34 -05:00
parent 82bd13036a
commit dbf3c42d62
1 changed files with 198 additions and 51 deletions

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Banknote,
@ -15,7 +15,8 @@ import {
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { cn, fmtDate, todayStr, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import { isPaidStatus } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -23,6 +24,123 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
interface StatusSummary {
paid_count: number;
due_count: number;
skipped_count: number;
missed_count: number;
total_due?: number;
total_paid?: number;
}
interface CalDayBill {
bill_id: number;
name: string;
category_name?: string | null;
status?: string;
effective_amount?: number;
paid_amount?: number;
due_date?: string;
}
interface CalDayPayment {
payment_id: number;
bill_name?: string;
method?: string;
amount?: number;
}
interface CalDay {
date: string;
day: number;
status_summary: StatusSummary;
payments: CalDayPayment[];
bills_due: CalDayBill[];
}
interface SummaryInfo {
bill_count?: number;
paid_count?: number;
skipped_count?: number;
missed_count?: number;
paid_total?: number;
expected_total?: number;
remaining_total?: number;
paid_percent?: number;
expense_total?: number;
expense_count?: number;
result?: number;
[key: string]: unknown;
}
interface Cashflow {
has_data?: boolean;
period?: string;
period_projected?: number;
month_projected?: number;
uses_bank_balance?: boolean;
period_end_label?: string;
period_starting?: number;
period_bills_total?: number;
period_paid?: number;
period_paid_count?: number;
period_total_count?: number;
month_starting?: number;
month_bills_total?: number;
month_paid?: number;
month_paid_count?: number;
month_total_count?: number;
}
interface CalendarData {
year: number;
month: number;
days: CalDay[];
summary?: SummaryInfo;
cashflow?: Cashflow;
}
interface BankTracking {
enabled?: boolean;
effective_balance?: number;
account_name?: string;
balance?: number;
pending_payments?: number;
pending_days?: number;
unpaid_this_month?: number;
remaining?: number;
last_updated?: string;
}
interface SummaryDataShape {
starting_amounts?: {
first_amount?: number;
fifteenth_amount?: number;
other_amount?: number;
combined_amount?: number;
};
summary?: SummaryInfo;
bank_tracking?: BankTracking;
income?: { amount?: number };
}
interface SnowballDebt { name?: string; months?: number; payoff_display?: string }
interface SnowballProjection {
snowball?: {
debts?: SnowballDebt[];
months_to_freedom?: number;
payoff_display?: string;
total_interest_paid?: number;
};
comparison?: { months_saved?: number; interest_saved?: number };
}
interface MoneyMarker { label: string; amount: number }
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
@ -34,26 +152,26 @@ function currentMonth() {
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function shiftMonth(year, month, delta) {
function shiftMonth(year: number, month: number, delta: number) {
const next = new Date(year, month - 1 + delta, 1);
return { year: next.getFullYear(), month: next.getMonth() + 1 };
}
function displayStatus(status) {
function displayStatus(status?: string): string {
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 (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
function statusTone(status?: string): string {
if (isPaidStatus(status || '')) 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 }) {
function LegendItem({ className, label }: { className?: string; label: ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-foreground/75">
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
@ -62,7 +180,13 @@ function LegendItem({ className, label }) {
);
}
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }: {
icon: ComponentType<{ className?: string }>;
label: ReactNode;
value: number | null | undefined;
hint?: ReactNode;
valueClassName?: string;
}) {
return (
<div className="min-w-0 rounded-lg border border-border/70 bg-background/70 p-3">
<div className="flex items-center gap-2">
@ -77,7 +201,7 @@ function MoneyMetric({ icon: Icon, label, value, hint, valueClassName }) {
);
}
function MoneyMap({ summaryData, loading }) {
function MoneyMap({ summaryData, loading }: { summaryData: SummaryDataShape | null; loading: boolean }) {
if (loading) {
return (
<Card>
@ -94,7 +218,7 @@ function MoneyMap({ summaryData, loading }) {
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 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);
@ -108,7 +232,7 @@ function MoneyMap({ summaryData, loading }) {
<CardTitle className="text-base">Monthly Money Map</CardTitle>
<CardDescription>
{bankMode
? `Live bank balance · ${bt.account_name}`
? `Live bank balance · ${bt?.account_name}`
: 'Available money, extra income, assigned bills, and what remains.'}
</CardDescription>
</div>
@ -126,15 +250,15 @@ function MoneyMap({ summaryData, loading }) {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{bankMode ? (
<>
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt.balance || 0)} hint={`as of last sync`} />
<MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt.pending_payments || 0)} hint={`paid, not yet cleared (${bt.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" />
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
<MoneyMetric icon={Banknote} label="Bank Balance" value={Number(bt?.balance || 0)} hint={`as of last sync`} />
<MoneyMetric icon={PiggyBank} label="Pending" value={Number(bt?.pending_payments || 0)} hint={`paid, not yet cleared (${bt?.pending_days}d window)`} valueClassName="text-amber-600 dark:text-amber-400" />
<MoneyMetric icon={CalendarDays} label="Unpaid Bills" value={Number(bt?.unpaid_this_month || 0)} hint={`${summary.expense_count || 0} active bills`} />
<MoneyMetric
icon={CircleDollarSign}
label="After Bills"
value={Number(bt.remaining || 0)}
value={Number(bt?.remaining || 0)}
hint="effective balance unpaid"
valueClassName={Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
valueClassName={Number(bt?.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive'}
/>
</>
) : (
@ -173,7 +297,7 @@ function MoneyMap({ summaryData, loading }) {
{bankMode && (
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3',
Number(bt.remaining || 0) >= 0
Number(bt?.remaining || 0) >= 0
? 'border-emerald-500/25 bg-emerald-500/5'
: 'border-destructive/25 bg-destructive/5',
)}>
@ -182,21 +306,21 @@ function MoneyMap({ summaryData, loading }) {
Projected Month-End Balance
</p>
<p className="text-[11px] text-muted-foreground/70">
{fmt(bt.balance || 0)} bank
{Number(bt.pending_payments || 0) > 0 && ` ${fmt(bt.pending_payments)} pending`}
{` ${fmt(bt.unpaid_this_month || 0)} remaining bills`}
{fmt(bt?.balance || 0)} bank
{Number(bt?.pending_payments || 0) > 0 && ` ${fmt(bt?.pending_payments)} pending`}
{` ${fmt(bt?.unpaid_this_month || 0)} remaining bills`}
</p>
</div>
<p className={cn(
'tracker-number text-2xl font-bold tabular-nums shrink-0',
Number(bt.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
Number(bt?.remaining || 0) >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive',
)}>
{Number(bt.remaining || 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bt.remaining || 0)))}
{Number(bt?.remaining || 0) < 0 ? '' : ''}{fmt(Math.abs(Number(bt?.remaining || 0)))}
</p>
</div>
)}
{bankMode && bt.last_updated && (
{bankMode && bt?.last_updated && (
<p className="text-xs text-muted-foreground">
Balance last updated: {new Date(bt.last_updated).toLocaleString()}
</p>
@ -206,7 +330,7 @@ function MoneyMap({ summaryData, loading }) {
);
}
function SummaryProgress({ summary }) {
function SummaryProgress({ summary }: { summary?: SummaryInfo }) {
const percent = Number(summary?.paid_percent || 0);
return (
@ -252,7 +376,7 @@ function SummaryProgress({ summary }) {
);
}
function DayIndicators({ day, moneyMarker }) {
function DayIndicators({ day, moneyMarker }: { day: CalDay; moneyMarker: MoneyMarker | null }) {
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;
@ -271,11 +395,16 @@ function DayIndicators({ day, moneyMarker }) {
);
}
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }: {
data: CalendarData;
selectedDate?: string;
onSelectDay: (day: CalDay) => void;
moneyMarkers?: Record<string, MoneyMarker>;
}) {
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 cells: ({ type: 'blank'; key: string } | { type: 'day'; key: string; day: CalDay })[] = [
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank' as const, key: `blank-${index}` })),
...data.days.map(day => ({ type: 'day' as const, key: day.date, day })),
];
const today = todayStr();
@ -363,11 +492,24 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) {
);
}
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) {
function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }: {
label: ReactNode;
projected: number;
starting?: number;
billsTotal?: number;
paid?: number;
paidCount?: number;
totalCount?: number;
period?: string;
year: number;
month: number;
}) {
const navigate = useNavigate();
const billsTotalN = Number(billsTotal || 0);
const paidN = Number(paid || 0);
const isNegative = projected < 0;
const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0;
const unpaidCount = totalCount - paidCount;
const amountPct = billsTotalN > 0 ? Math.min(100, Math.round((paidN / billsTotalN) * 100)) : 0;
const unpaidCount = Number(totalCount || 0) - Number(paidCount || 0);
const bucketParam = period === '1st' ? 'b1=1' : 'b2=1';
function goToUnpaid() {
@ -393,7 +535,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
<div className="space-y-1.5">
{/* Amount-based progress bar */}
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{fmt(paid)} of {fmt(billsTotal)} paid</span>
<span>{fmt(paidN)} of {fmt(billsTotalN)} paid</span>
{unpaidCount > 0 && (
<button
type="button"
@ -404,7 +546,7 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
{unpaidCount} unpaid
</button>
)}
{unpaidCount === 0 && totalCount > 0 && (
{unpaidCount === 0 && Number(totalCount || 0) > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">All paid </span>
)}
</div>
@ -418,14 +560,14 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou
/>
</div>
<p className="text-[10px] text-muted-foreground">
{fmt(starting)} starting · {fmt(billsTotal)} due
{fmt(starting)} starting · {fmt(billsTotalN)} due
</p>
</div>
</div>
);
}
function CashFlowCard({ cashflow, year, month }) {
function CashFlowCard({ cashflow, year, month }: { cashflow?: Cashflow; year: number; month: number }) {
if (!cashflow?.has_data) return null;
const periodProjected = Number(cashflow.period_projected ?? 0);
@ -503,7 +645,7 @@ function CashFlowCard({ cashflow, year, month }) {
);
}
function DebtPayoffGlance({ projection }) {
function DebtPayoffGlance({ projection }: { projection: SnowballProjection | null }) {
const snowball = projection?.snowball;
const comparison = projection?.comparison;
const targetDebt = snowball?.debts?.[0] || null;
@ -626,7 +768,12 @@ function DebtPayoffGlance({ projection }) {
);
}
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
function DayDetailDialog({ day, open, onOpenChange, moneyMarker }: {
day: CalDay | null;
open: boolean;
onOpenChange: (open: boolean) => void;
moneyMarker: MoneyMarker | null;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
@ -730,7 +877,7 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) {
);
}
function CalendarSubscribeDialog({ open, onOpenChange }) {
function CalendarSubscribeDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
@ -758,12 +905,12 @@ 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 [data, setData] = useState<CalendarData | null>(null);
const [summaryData, setSummaryData] = useState<SummaryDataShape | null>(null);
const [snowballProjection, setSnowballProjection] = useState<SnowballProjection | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedDay, setSelectedDay] = useState(null);
const [selectedDay, setSelectedDay] = useState<CalDay | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [calendarFeedOpen, setCalendarFeedOpen] = useState(false);
@ -781,14 +928,14 @@ export default function CalendarPage() {
throw calendarResult.reason;
}
const result = calendarResult.value;
const result = calendarResult.value as CalendarData;
setData(result);
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value : null);
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value : null);
setSummaryData(summaryResult.status === 'fulfilled' ? summaryResult.value as SummaryDataShape : null);
setSnowballProjection(snowballResult.status === 'fulfilled' ? snowballResult.value as SnowballProjection : 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.');
setError(errMessage(err, 'Calendar data could not be loaded.'));
toast.error(errMessage(err, 'Calendar data could not be loaded.'));
} finally {
setLoading(false);
}
@ -798,11 +945,11 @@ export default function CalendarPage() {
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 moneyMarkers = useMemo<Record<string, MoneyMarker>>(() => {
const starting = summaryData?.starting_amounts;
if (!starting) return {};
const markers = {};
const markers: Record<string, MoneyMarker> = {};
const firstAmount = Number(starting.first_amount || 0);
const fifteenthAmount = Number(starting.fifteenth_amount || 0);
@ -823,7 +970,7 @@ export default function CalendarPage() {
}, [month, summaryData, year]);
const selectedMoneyMarker = selectedDay ? moneyMarkers[selectedDay.date] || null : null;
function navigate(delta) {
function navigate(delta: number) {
const next = shiftMonth(year, month, delta);
setYear(next.year);
setMonth(next.month);