From 6a9b8b3cb87b8ff3a5edc39674b0e783bd5b6f34 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 20:39:30 -0500 Subject: [PATCH] refactor(ts): convert transactions/ + snowball/ dirs to TSX (B14) transactions/: CategoryPicker (Category[], Node-cast for outside-click), MatchBillDialog (BankTransaction/Bill, advisory_filter cast). snowball/: PayoffChart (TrackPoint chart geometry, asDollars for formatUSD), PlanHistory Panel + PlanStatusBanner (SnowballPlan/ActivePlan types with Dollars-branded debt balances; asDollars at fmt boundaries, ?? guards for optional-money comparisons). typecheck 0, lint 0 errors, build green. Co-Authored-By: Claude Opus 4.8 --- client/components/snowball/PayoffChart.tsx | 163 ++++++++++++++++++ ...nHistoryPanel.jsx => PlanHistoryPanel.tsx} | 71 ++++++-- ...nStatusBanner.jsx => PlanStatusBanner.tsx} | 91 +++++++--- ...{CategoryPicker.jsx => CategoryPicker.tsx} | 16 +- ...atchBillDialog.jsx => MatchBillDialog.tsx} | 26 ++- 5 files changed, 315 insertions(+), 52 deletions(-) create mode 100644 client/components/snowball/PayoffChart.tsx rename client/components/snowball/{PlanHistoryPanel.jsx => PlanHistoryPanel.tsx} (81%) rename client/components/snowball/{PlanStatusBanner.jsx => PlanStatusBanner.tsx} (83%) rename client/components/transactions/{CategoryPicker.jsx => CategoryPicker.tsx} (81%) rename client/components/transactions/{MatchBillDialog.jsx => MatchBillDialog.tsx} (90%) diff --git a/client/components/snowball/PayoffChart.tsx b/client/components/snowball/PayoffChart.tsx new file mode 100644 index 0000000..1612acf --- /dev/null +++ b/client/components/snowball/PayoffChart.tsx @@ -0,0 +1,163 @@ +import { formatUSD, asDollars } from '@/lib/money'; + +const W = 720; +const H = 300; +const PAD = { left: 68, right: 24, top: 20, bottom: 56 }; +const CW = W - PAD.left - PAD.right; +const CH = H - PAD.top - PAD.bottom; + +interface TrackPoint { + month: number; + balance: number; +} + +interface ChartPoint { + x: number; + y: number; + month: number; + balance: number; +} + +function money(v: number | string | null | undefined): string { + const n = Number(v) || 0; + if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`; + return `$${n.toFixed(0)}`; +} + +function fullMoney(v: number): string { + return formatUSD(asDollars(v)); +} + +function buildPoints(track: TrackPoint[], startBalance: number, maxMonths: number): ChartPoint[] { + const all = [{ month: 0, balance: startBalance }, ...track]; + return all.map(({ month, balance }) => ({ + x: PAD.left + (month / maxMonths) * CW, + y: PAD.top + CH - (balance / startBalance) * CH, + month, + balance, + })); +} + +function toLine(pts: { x: number; y: number }[]): string { + return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); +} + +interface PayoffChartProps { + minTrack?: TrackPoint[]; + currentTrack?: TrackPoint[]; + simTrack?: TrackPoint[]; + startBalance?: number; +} + +export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }: PayoffChartProps) { + const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12); + const bal = Math.max(startBalance, 1); + + const minPts = buildPoints(minTrack, bal, maxMonths); + const currentPts = buildPoints(currentTrack, bal, maxMonths); + const simPts = buildPoints(simTrack, bal, maxMonths); + + const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24; + const xLabels: number[] = []; + for (let m = xStep; m <= maxMonths; m += xStep) { + xLabels.push(m); + } + + const yTicks = [0, 0.25, 0.5, 0.75, 1]; + + const showCurrent = currentTrack.length > 0 && + currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance); + + return ( +
+ + + {/* Grid + Y axis */} + {yTicks.map(tick => { + const y = PAD.top + CH - tick * CH; + return ( + + + + {money(bal * tick)} + + + ); + })} + + {/* X axis labels */} + {xLabels.map(m => { + const x = PAD.left + (m / maxMonths) * CW; + return ( + + {m}mo + + ); + })} + + {/* X axis baseline */} + + + {/* Min-only track (slate dashed) */} + {minPts.length > 1 && ( + + )} + + {/* Current snowball plan (indigo dashed) */} + {showCurrent && currentPts.length > 1 && ( + + )} + + {/* Simulation track (amber solid, prominent) */} + {simPts.length > 1 && ( + <> + + {/* Endpoint dot */} + {(() => { + const last = simPts[simPts.length - 1]!; + return ; + })()} + + )} + + {/* Tooltips at 6-month intervals on sim track */} + {simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => ( + + {`Month ${p.month}: ${fullMoney(p.balance)} remaining`} + + ))} + + {/* Legend */} + + + Min only + + {showCurrent && ( + <> + + Snowball plan + + )} + + + Simulation + + + +
+ ); +} diff --git a/client/components/snowball/PlanHistoryPanel.jsx b/client/components/snowball/PlanHistoryPanel.tsx similarity index 81% rename from client/components/snowball/PlanHistoryPanel.jsx rename to client/components/snowball/PlanHistoryPanel.tsx index b84d9b6..5e547d1 100644 --- a/client/components/snowball/PlanHistoryPanel.jsx +++ b/client/components/snowball/PlanHistoryPanel.tsx @@ -1,44 +1,81 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; import { ChevronDown, History } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { cn } from '@/lib/utils'; import { formatUSD, formatUSDWhole } from '@/lib/money'; +import type { Dollars } from '@/lib/money'; -function fmt(v) { +interface SnowballPlanDebt { + bill_id: number; + name: string; + starting_balance: Dollars; + projected_payoff_date?: string | null; + projected_total_interest?: Dollars | null; +} + +interface SnowballPlanSnapshot { + debts?: SnowballPlanDebt[]; + projected_months?: number; + projected_payoff_date?: string | null; + interest_saved?: Dollars; + minimum_only_months?: number; +} + +interface CurrentDebt { + bill_id: number; + current_balance?: Dollars | null; +} + +export interface SnowballPlan { + id: number; + name: string; + status: string; + method: string; + started_at?: string | null; + completed_at?: string | null; + paused_at?: string | null; + extra_payment?: Dollars; + notes?: string | null; + plan_snapshot?: SnowballPlanSnapshot; + current_debts?: CurrentDebt[]; +} + +function fmt(v: Dollars | null | undefined): string { return formatUSDWhole(v); } -function fmtFull(v) { +function fmtFull(v: Dollars | null | undefined): string { return formatUSD(v); } -function dateRange(plan) { +function dateRange(plan: SnowballPlan): string { const start = plan.started_at ? new Date(plan.started_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) : '—'; - const end = plan.completed_at || plan.paused_at - ? new Date(plan.completed_at || plan.paused_at).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) + const endDate = plan.completed_at || plan.paused_at; + const end = endDate + ? new Date(endDate).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) : plan.status === 'abandoned' ? 'abandoned' : 'present'; return `${start} – ${end}`; } -function StatusBadge({ status }) { +function StatusBadge({ status }: { status: string }) { const map = { active: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400', paused: 'bg-amber-500/12 text-amber-600 dark:text-amber-400', completed: 'bg-indigo-500/12 text-indigo-600 dark:text-indigo-400', abandoned: 'bg-rose-500/12 text-rose-600 dark:text-rose-400', }; - const labels = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' }; + const labels: Record = { active: 'Active', paused: 'Paused', completed: 'Completed', abandoned: 'Abandoned' }; return ( - + {labels[status] ?? status} ); } -function MethodBadge({ method }) { - const map = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' }; +function MethodBadge({ method }: { method: string }) { + const map: Record = { snowball: 'Snowball', avalanche: 'Avalanche', custom: 'Custom' }; return ( {map[method] ?? method} @@ -48,7 +85,7 @@ function MethodBadge({ method }) { // ─── Expanded plan detail ───────────────────────────────────────────────────── -function PlanDetail({ plan }) { +function PlanDetail({ plan }: { plan: SnowballPlan }) { const snapshot = plan.plan_snapshot ?? {}; const debts = snapshot.debts ?? []; @@ -62,7 +99,7 @@ function PlanDetail({ plan }) {

{snapshot.projected_payoff_date ?? '—'}

)} - {snapshot.interest_saved > 0 && ( + {(snapshot.interest_saved ?? 0) > 0 && (

Interest saved

{fmt(snapshot.interest_saved)}

@@ -74,7 +111,7 @@ function PlanDetail({ plan }) {

{snapshot.minimum_only_months}

)} - {plan.extra_payment > 0 && ( + {(plan.extra_payment ?? 0) > 0 && (

Extra/mo

{fmtFull(plan.extra_payment)}

@@ -137,7 +174,7 @@ function PlanDetail({ plan }) { // ─── Single plan row ────────────────────────────────────────────────────────── -function PlanRow({ plan }) { +function PlanRow({ plan }: { plan: SnowballPlan }) { const [expanded, setExpanded] = useState(false); const snapshot = plan.plan_snapshot ?? {}; const debtCount = (snapshot.debts ?? []).length; @@ -155,7 +192,7 @@ function PlanRow({ plan }) {

{dateRange(plan)} {debtCount > 0 && ` · ${debtCount} debt${debtCount !== 1 ? 's' : ''}`} - {snapshot.interest_saved > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`} + {(snapshot.interest_saved ?? 0) > 0 && ` · ${fmt(snapshot.interest_saved)} interest saved`}

@@ -172,7 +209,7 @@ function PlanRow({ plan }) { // ─── PlanHistoryPanel ───────────────────────────────────────────────────────── -export default function PlanHistoryPanel({ plans = [] }) { +export default function PlanHistoryPanel({ plans = [] }: { plans?: SnowballPlan[] }) { const [open, setOpen] = useState(false); const historical = plans.filter(p => !['active', 'paused'].includes(p.status)); diff --git a/client/components/snowball/PlanStatusBanner.jsx b/client/components/snowball/PlanStatusBanner.tsx similarity index 83% rename from client/components/snowball/PlanStatusBanner.jsx rename to client/components/snowball/PlanStatusBanner.tsx index e05de15..69b3998 100644 --- a/client/components/snowball/PlanStatusBanner.jsx +++ b/client/components/snowball/PlanStatusBanner.tsx @@ -1,5 +1,5 @@ -import React, { useMemo, useState } from 'react'; -import { CheckCircle2, ChevronDown, Circle, Clock, Pause, Play, TrendingUp, X, Zap } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { CheckCircle2, ChevronDown, Pause, Play, X, Zap } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -7,18 +7,48 @@ import { } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { formatUSDWhole } from '@/lib/money'; +import { formatUSDWhole, asDollars, type Dollars } from '@/lib/money'; -function fmt(v) { +interface CurrentDebt { + bill_id: number; + name: string; + starting_balance?: Dollars; + current_balance?: Dollars | null; + progress_pct?: number; + projected_payoff_month?: number; +} + +interface SnapshotDebt { + bill_id: number; + projected_payoff_date?: string | null; + projected_payoff_month?: number; +} + +interface ActivePlan { + name: string; + status: string; + started_at?: string | null; + months_elapsed?: number; + current_debts?: CurrentDebt[]; + plan_snapshot?: { debts?: SnapshotDebt[]; interest_saved?: Dollars }; +} + +interface ConfirmState { + title: string; + description: string; + onConfirm?: () => void; +} + +function fmt(v: Dollars | null | undefined): string { return formatUSDWhole(v); } -function dateLabel(iso) { +function dateLabel(iso: string | null | undefined): string { if (!iso) return '—'; return new Date(iso).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }); } -function months(n) { +function months(n: number | null | undefined): string { if (!n || n <= 0) return 'just started'; const y = Math.floor(n / 12); const m = n % 12; @@ -29,16 +59,23 @@ function months(n) { // ─── On-track indicator ─────────────────────────────────────────────────────── -function computeOnTrack(debt, monthsElapsed) { - if (!debt.projected_payoff_month || debt.current_balance === null) return null; - if (debt.starting_balance <= 0) return null; +interface OnTrackDebt { + projected_payoff_month?: number; + current_balance?: Dollars | null; + starting_balance?: Dollars; +} + +function computeOnTrack(debt: OnTrackDebt, monthsElapsed: number): string | null { + if (!debt.projected_payoff_month || debt.current_balance == null) return null; + const startBal = debt.starting_balance ?? asDollars(0); + if (startBal <= 0) return null; const remaining = debt.projected_payoff_month - monthsElapsed; if (remaining <= 0) return debt.current_balance <= 0 ? 'done' : 'behind'; const progressExpected = monthsElapsed / debt.projected_payoff_month; - const progressActual = debt.starting_balance > 0 - ? 1 - (debt.current_balance / debt.starting_balance) + const progressActual = startBal > 0 + ? 1 - (debt.current_balance / startBal) : 0; const diff = progressActual - progressExpected; @@ -47,7 +84,7 @@ function computeOnTrack(debt, monthsElapsed) { return 'on_track'; } -function OnTrackPill({ status }) { +function OnTrackPill({ status }: { status: string | null }) { if (!status) return null; const map = { ahead: { label: '↑ Ahead', cls: 'bg-teal-500/12 text-teal-600 dark:text-teal-400' }, @@ -55,7 +92,7 @@ function OnTrackPill({ status }) { behind: { label: '↓ Behind', cls: 'bg-amber-500/12 text-amber-600 dark:text-amber-400' }, done: { label: '✓ Paid off', cls: 'bg-emerald-500/12 text-emerald-600 dark:text-emerald-400' }, }; - const { label, cls } = map[status] ?? map.on_track; + const { label, cls } = map[status as keyof typeof map] ?? map.on_track; return ( {label} @@ -65,8 +102,8 @@ function OnTrackPill({ status }) { // ─── Per-debt progress row ──────────────────────────────────────────────────── -function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) { - const startBal = debt.starting_balance ?? 0; +function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }: { debt: CurrentDebt; snapshotDebt?: SnapshotDebt; monthsElapsed: number }) { + const startBal = debt.starting_balance ?? asDollars(0); const curBal = debt.current_balance ?? startBal; const pct = debt.progress_pct ?? 0; const trackStatus = computeOnTrack({ ...debt, ...snapshotDebt }, monthsElapsed); @@ -110,13 +147,22 @@ function DebtProgressRow({ debt, snapshotDebt, monthsElapsed }) { // ─── PlanStatusBanner ───────────────────────────────────────────────────────── -export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }) { +interface PlanStatusBannerProps { + plan?: ActivePlan | null; + onPause?: () => void; + onResume?: () => void; + onComplete?: () => void; + onAbandon?: () => void; + onNewPlan?: () => void; +} + +export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, onAbandon, onNewPlan }: PlanStatusBannerProps) { const [open, setOpen] = useState(true); - const [confirmDialog, setConfirmDialog] = useState(null); + const [confirmDialog, setConfirmDialog] = useState(null); const snapshot = plan?.plan_snapshot ?? {}; const snapshotMap = useMemo(() => { - const m = {}; + const m: Record = {}; (snapshot.debts ?? []).forEach(d => { m[d.bill_id] = d; }); return m; }, [snapshot.debts]); @@ -132,7 +178,7 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, const isActive = plan?.status === 'active'; const isPaused = plan?.status === 'paused'; - function confirm(action, title, description, onConfirm) { + function confirm(_action: string, title: string, description: string, onConfirm?: () => void) { setConfirmDialog({ title, description, onConfirm }); } @@ -143,7 +189,6 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
- {/* Header */} {/* Header row. The name/progress area and the chevron are the collapsible toggles; the action buttons are siblings (not nested inside a trigger button) so they don't trip axe nested-interactive (a11y QA-B14-02). */} @@ -243,9 +288,9 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete,
- {fmt(totalPaid)} paid of {fmt(totalStart)} + {fmt(asDollars(totalPaid))} paid of {fmt(asDollars(totalStart))} - {snapshot.interest_saved > 0 && ( + {(snapshot.interest_saved ?? 0) > 0 && ( {fmt(snapshot.interest_saved)} interest saved vs minimum @@ -268,7 +313,7 @@ export default function PlanStatusBanner({ plan, onPause, onResume, onComplete, setConfirmDialog(null)}>Cancel - { confirmDialog?.onConfirm(); setConfirmDialog(null); }}> + { confirmDialog?.onConfirm?.(); setConfirmDialog(null); }}> Confirm diff --git a/client/components/transactions/CategoryPicker.jsx b/client/components/transactions/CategoryPicker.tsx similarity index 81% rename from client/components/transactions/CategoryPicker.jsx rename to client/components/transactions/CategoryPicker.tsx index f0fe486..0c750a4 100644 --- a/client/components/transactions/CategoryPicker.jsx +++ b/client/components/transactions/CategoryPicker.tsx @@ -1,15 +1,23 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Tag } from 'lucide-react'; +import type { Category } from '@/types'; // ── Category picker dropdown ───────────────────────────────────────────────── -export function CategoryPicker({ categories, current, currentLabel, onSelect }) { +interface CategoryPickerProps { + categories: Category[]; + current?: number | string | null; + currentLabel?: string | null; + onSelect: (id: number | string | null, replace: boolean) => void; +} + +export function CategoryPicker({ categories, current, currentLabel, onSelect }: CategoryPickerProps) { const [open, setOpen] = useState(false); - const ref = useRef(null); + const ref = useRef(null); useEffect(() => { if (!open) return; - const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; + const close = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [open]); diff --git a/client/components/transactions/MatchBillDialog.jsx b/client/components/transactions/MatchBillDialog.tsx similarity index 90% rename from client/components/transactions/MatchBillDialog.jsx rename to client/components/transactions/MatchBillDialog.tsx index 4c78613..99af160 100644 --- a/client/components/transactions/MatchBillDialog.jsx +++ b/client/components/transactions/MatchBillDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { CheckCircle2, Loader2, Link2, Search, Plus, } from 'lucide-react'; @@ -10,20 +10,31 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import type { BankTransaction, Bill } from '@/types'; -export function transactionDate(tx) { +export function transactionDate(tx: BankTransaction | null | undefined): string { return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || '—'; } -export function transactionTitle(tx) { +export function transactionTitle(tx: BankTransaction | null | undefined): string { return tx?.payee || tx?.description || tx?.memo || 'Transaction'; } -export function formatTransactionAmount(amount, currency = 'USD') { +export function formatTransactionAmount(amount: Parameters[0], currency = 'USD') { return formatCentsUSD(amount, { signed: true, currency }); } -export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }) { +interface MatchBillDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + transaction: BankTransaction | null; + bills: Bill[]; + onConfirm: (billId: number | string) => void; + loading?: boolean; + onCreateBill?: (transaction: BankTransaction | null, query?: string) => void; +} + +export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConfirm, loading, onCreateBill }: MatchBillDialogProps) { const [query, setQuery] = useState(''); const [selectedBillId, setSelectedBillId] = useState(''); @@ -43,6 +54,7 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf }, [bills, query]); const selectedBill = bills.find(bill => String(bill.id) === String(selectedBillId)); + const af = transaction?.advisory_filter as { confidence?: string } | undefined; return ( @@ -67,7 +79,7 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf 'text-sm font-semibold tabular-nums', Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600', )}> - {formatTransactionAmount(transaction.amount, transaction.currency)} + {formatTransactionAmount(transaction.amount, transaction.currency ?? undefined)}

{transaction.description && transaction.description !== transactionTitle(transaction) && ( @@ -95,7 +107,6 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf

No bills found.

{onCreateBill && (() => { - const af = transaction?.advisory_filter; const label = query.trim() ? `Create "${query.trim()}" as a new bill` : 'Create a new bill'; @@ -158,7 +169,6 @@ export function MatchBillDialog({ open, onOpenChange, transaction, bills, onConf {onCreateBill && (() => { - const af = transaction?.advisory_filter; if (af?.confidence === 'high') { return (