From dbf3c42d6295f947681d2e991934bb327ef7df47 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 22:58:34 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20CalendarPage=20=E2=86=92=20Type?= =?UTF-8?q?Script=20(money=20map=20+=20grid=20+=20cashflow=20+=20snowball?= =?UTF-8?q?=20glance)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{CalendarPage.jsx => CalendarPage.tsx} | 249 ++++++++++++++---- 1 file changed, 198 insertions(+), 51 deletions(-) rename client/pages/{CalendarPage.jsx => CalendarPage.tsx} (85%) diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.tsx similarity index 85% rename from client/pages/CalendarPage.jsx rename to client/pages/CalendarPage.tsx index 35a9c37..3ae947b 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.tsx @@ -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 ( @@ -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 (
@@ -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 ( @@ -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 }) { Monthly Money Map {bankMode - ? `Live bank balance · ${bt.account_name}` + ? `Live bank balance · ${bt?.account_name}` : 'Available money, extra income, assigned bills, and what remains.'}
@@ -126,15 +250,15 @@ function MoneyMap({ summaryData, loading }) {
{bankMode ? ( <> - - - + + + = 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 && (
= 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

- {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`}

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

)} - {bankMode && bt.last_updated && ( + {bankMode && bt?.last_updated && (

Balance last updated: {new Date(bt.last_updated).toLocaleString()}

@@ -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; +}) { 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
{/* Amount-based progress bar */}
- {fmt(paid)} of {fmt(billsTotal)} paid + {fmt(paidN)} of {fmt(billsTotalN)} paid {unpaidCount > 0 && (
@@ -418,14 +560,14 @@ function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCou />

- {fmt(starting)} starting · {fmt(billsTotal)} due + {fmt(starting)} starting · {fmt(billsTotalN)} due

); } -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 ( @@ -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 ( @@ -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(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 [selectedDay, setSelectedDay] = useState(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>(() => { const starting = summaryData?.starting_amounts; if (!starting) return {}; - const markers = {}; + const markers: Record = {}; 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);