refactor(ts): convert tracker dialogs + cards to TSX (B3)

Six tracker components consuming the branded domain types:
AutopaySuggestionActions, CashFlowCard (typed TrackerCashflow + TimelineChart/
UpcomingList), MonthlyStateDialog, PaymentModal, PaymentLedgerDialog,
StartingAmountsEditDialog. New @/types: AutopaySuggestion, TimelineBill; Payment
gains method/autopay_failure. Branded money paid off again — fmt(Math.abs(safe))
and the starting-amounts arithmetic correctly required asDollars wraps at the
format boundary. typecheck 0, lint 0 errors (41 warns), build green, 48 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:29:42 -05:00
parent 2cac46fda2
commit c1ec0a4f2e
7 changed files with 115 additions and 39 deletions

View File

@ -1,8 +1,17 @@
import { Clock, X, CheckCircle2, Loader2 } from 'lucide-react';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import type { TrackerRow } from '@/types';
export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }) {
interface AutopaySuggestionActionsProps {
row: TrackerRow;
loading?: boolean;
onConfirm?: () => void;
onDismiss?: () => void;
compact?: boolean;
}
export function AutopaySuggestionActions({ row, loading, onConfirm, onDismiss, compact = false }: AutopaySuggestionActionsProps) {
const suggestion = row.autopay_suggestion;
if (!suggestion) return null;

View File

@ -2,12 +2,14 @@ import { useId } from 'react';
import { motion } from 'framer-motion';
import { Wallet, CalendarClock, Sparkles, ArrowRight } from 'lucide-react';
import { cn, fmt, fmtDate } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { buildTimelineGeometry, daysUntilLabel, shortDate, splitUpcoming } from '@/lib/cashflowUtils';
import type { TrackerCashflow, CashflowTimelinePoint, SafeToSpendUpcoming, TimelineBill } from '@/types';
const CHART_W = 400;
const CHART_H = 96;
function TimelineChart({ timeline, positive }) {
function TimelineChart({ timeline, positive }: { timeline: CashflowTimelinePoint[]; positive: boolean }) {
const gradId = useId();
const geo = buildTimelineGeometry(timeline, CHART_W, CHART_H);
if (!geo) return null;
@ -52,7 +54,7 @@ function TimelineChart({ timeline, positive }) {
{geo.points.filter(p => p.isDrop).map(p => (
<circle key={p.date} cx={p.x} cy={p.y} r="3" fill={tone} stroke="var(--background, #18181b)" strokeWidth="1.5">
<title>
{`${fmtDate(p.date)}${p.bills.map(b => `${b.name} ${fmt(b.amount)}`).join(', ')}${fmt(p.balance)} left`}
{`${fmtDate(p.date)}${(p.bills as TimelineBill[]).map(b => `${b.name} ${fmt(b.amount)}`).join(', ')}${fmt(asDollars(p.balance))} left`}
</title>
</circle>
))}
@ -62,7 +64,7 @@ function TimelineChart({ timeline, positive }) {
<g key="payday">
<line x1={p.x} x2={p.x} y1="4" y2={CHART_H - 4} stroke={tone} strokeOpacity="0.35" strokeWidth="1" strokeDasharray="2 3" vectorEffect="non-scaling-stroke" />
<circle cx={p.x} cy={p.y} r="3.5" fill="none" stroke={tone} strokeWidth="2">
<title>{`Payday ${fmtDate(p.date)}${fmt(p.balance)} left`}</title>
<title>{`Payday ${fmtDate(p.date)}${fmt(asDollars(p.balance))} left`}</title>
</circle>
</g>
))}
@ -70,7 +72,7 @@ function TimelineChart({ timeline, positive }) {
);
}
function UpcomingList({ upcoming }) {
function UpcomingList({ upcoming }: { upcoming: SafeToSpendUpcoming[] }) {
const { visible, overflow } = splitUpcoming(upcoming, 4);
if (visible.length === 0) {
@ -114,7 +116,7 @@ function UpcomingList({ upcoming }) {
* everything still due before the next payday is covered. Sits directly under
* the summary cards and reads from the tracker payload's `cashflow` block.
*/
export default function CashFlowCard({ cashflow, onSetStartingAmounts }) {
export default function CashFlowCard({ cashflow, onSetStartingAmounts }: { cashflow?: TrackerCashflow | null; onSetStartingAmounts?: () => void }) {
if (!cashflow) return null;
// No starting amounts and no bank sync — invite setup instead of guessing.
@ -182,7 +184,7 @@ export default function CashFlowCard({ cashflow, onSetStartingAmounts }) {
)}
title={`${fmt(cashflow.available)} available ${fmt(cashflow.still_due_total)} still due before payday`}
>
{positive ? '' : ''}{fmt(Math.abs(safe))}
{positive ? '' : ''}{fmt(asDollars(Math.abs(safe)))}
</p>
<p className="mt-2 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<CalendarClock className="h-3.5 w-3.5" />

View File

@ -1,20 +1,30 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { fmt } from '@/lib/utils';
import { fmt, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import type { TrackerRow } from '@/types';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
interface MonthlyStateDialogProps {
row: TrackerRow;
year: number;
month: number;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: () => void;
}
function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) {
const [actualAmount, setActualAmount] = useState('');
const [notes, setNotes] = useState('');
const [isSkipped, setIsSkipped] = useState(false);
@ -29,7 +39,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
}
}, [open, row]);
async function handleSave(e) {
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const amt = actualAmount.trim() ? parseFloat(actualAmount) : null;
if (amt !== null && (isNaN(amt) || amt < 0)) {
@ -49,7 +59,7 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
onSaved();
onOpenChange(false);
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err));
} finally {
setSaving(false);
}

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { fmt, fmtDate } from '@/lib/utils';
import { fmt, fmtDate, errMessage } from '@/lib/utils';
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -16,18 +16,30 @@ import {
import PaymentModal from '@/components/tracker/PaymentModal';
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
import type { Dollars } from '@/lib/money';
import type { Payment, TrackerRow } from '@/types';
export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
interface PaymentLedgerDialogProps {
row: TrackerRow;
year: number;
month: number;
threshold: Dollars;
defaultPaymentDate: string;
onClose: () => void;
onSaved?: () => void;
}
export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }: PaymentLedgerDialogProps) {
const summary = paymentSummary(row, threshold);
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
const [date, setDate] = useState(defaultPaymentDate);
const [method, setMethod] = useState(METHOD_NONE);
const [notes, setNotes] = useState('');
const [busy, setBusy] = useState(false);
const [editPayment, setEditPayment] = useState(null);
const [editPayment, setEditPayment] = useState<Payment | null>(null);
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
async function handleAdd(e) {
async function handleAdd(e: React.FormEvent) {
e.preventDefault();
const parsedAmount = parseFloat(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
@ -52,7 +64,7 @@ export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymen
onSaved?.();
onClose?.();
} catch (err) {
toast.error(err.message || 'Failed to add payment');
toast.error(errMessage(err, 'Failed to add payment'));
} finally {
setBusy(false);
}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -14,10 +15,18 @@ import {
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import type { Payment } from '@/types';
const METHOD_NONE = 'none';
function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
interface PaymentModalProps {
payment: Payment;
autopayEnabled?: boolean;
onClose: () => void;
onSave: () => void;
}
function PaymentModal({ payment, autopayEnabled, onClose, onSave }: PaymentModalProps) {
const [amount, setAmount] = useState(String(payment.amount));
const [date, setDate] = useState(payment.paid_date);
// Use METHOD_NONE sentinel — empty string value crashes Radix Select
@ -29,7 +38,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
const showAutopayFailure = autopayEnabled || method === 'autopay';
async function handleSave(e) {
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
try {
@ -43,7 +52,7 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
toast.success('Payment saved');
onSave(); onClose();
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err));
} finally { setBusy(false); }
}
@ -60,14 +69,14 @@ function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(err.message || 'Failed to restore payment');
toast.error(errMessage(err, 'Failed to restore payment'));
}
},
},
});
onSave(); onClose();
} catch (err) {
toast.error(err.message);
toast.error(errMessage(err));
} finally { setBusy(false); }
}

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { fmt } from '@/lib/utils';
import { fmt, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -14,14 +15,31 @@ const MONTHS = [
'July','August','September','October','November','December',
];
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
interface StartingAmountsPreview {
first_amount?: number;
fifteenth_amount?: number;
other_amount?: number;
paid_total?: number;
paid_from_first?: number;
paid_from_fifteenth?: number;
}
interface StartingAmountsEditDialogProps {
open: boolean;
onClose: () => void;
year: number;
month: number;
onSave: () => void;
}
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }: StartingAmountsEditDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [firstAmount, setFirstAmount] = useState('0');
const [fifteenthAmount, setFifteenthAmount] = useState('0');
const [otherAmount, setOtherAmount] = useState('0');
const [preview, setPreview] = useState(null);
const [preview, setPreview] = useState<StartingAmountsPreview | null>(null);
const monthName = `${MONTHS[month - 1]} ${year}`;
const localFirst = Number(firstAmount) || 0;
@ -40,7 +58,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
setLoading(true);
setError('');
try {
const result = await api.getMonthlyStartingAmounts(year, month);
const result = await api.getMonthlyStartingAmounts(year, month) as StartingAmountsPreview;
if (!alive) return;
setPreview(result);
setFirstAmount(String(result.first_amount ?? 0));
@ -48,7 +66,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
setOtherAmount(String(result.other_amount ?? 0));
} catch (err) {
if (!alive) return;
setError(err.message || 'Monthly starting amounts could not be loaded.');
setError(errMessage(err, 'Monthly starting amounts could not be loaded.'));
} finally {
if (alive) setLoading(false);
}
@ -57,7 +75,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
return () => { alive = false; };
}, [open, year, month]);
async function handleSave(e) {
async function handleSave(e: React.FormEvent) {
e.preventDefault();
const first = Number(firstAmount);
const fifteenth = Number(fifteenthAmount);
@ -80,7 +98,7 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
toast.success('Monthly starting amounts saved.');
onSave();
} catch (err) {
setError(err.message || 'Monthly starting amounts could not be saved.');
setError(errMessage(err, 'Monthly starting amounts could not be saved.'));
} finally {
setSaving(false);
}
@ -153,29 +171,29 @@ function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(asDollars(totalStarting))}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(asDollars(paidSoFar))}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(asDollars(totalRemaining))}</p>
</div>
</div>
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">1st remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
<span className="font-mono font-semibold sm:block">{fmt(asDollars(firstRemaining))}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">15th remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
<span className="font-mono font-semibold sm:block">{fmt(asDollars(fifteenthRemaining))}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">Other</span>
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
<span className="font-mono font-semibold sm:block">{fmt(asDollars(localOther))}</span>
</div>
</div>
</div>

View File

@ -14,6 +14,14 @@ import type { Dollars } from '@/lib/money';
export type TrackerStatus =
| 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';
// A suggested autopay payment (trackerService.applyAutopaySuggestions).
export interface AutopaySuggestion {
bill_id: number;
amount: Dollars;
paid_date: string;
method: string;
}
// A payment record as serialized by the server (money in dollars). serializePayment
// spreads the full DB row, so extra columns pass through as `unknown`.
export interface Payment {
@ -21,8 +29,10 @@ export interface Payment {
bill_id?: number;
amount: Dollars;
paid_date: string;
method?: string | null;
payment_source?: string | null;
notes?: string | null;
autopay_failure?: number | null;
balance_delta?: Dollars | null;
interest_delta?: Dollars | null;
created_at?: string;
@ -153,7 +163,7 @@ export interface TrackerRow {
is_skipped: boolean;
snoozed_until: string | null;
monthly_notes?: string | null;
autopay_suggestion?: unknown;
autopay_suggestion?: AutopaySuggestion | null;
amount_suggestion?: unknown;
previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled):
@ -206,10 +216,16 @@ export interface SafeToSpendUpcoming {
status: TrackerStatus;
}
// A bill landing on a given cashflow-timeline day.
export interface TimelineBill {
name: string;
amount: Dollars;
}
export interface CashflowTimelinePoint {
date: string;
balance: number; // running dollar balance; cashflowUtils treats it loosely
bills: unknown[];
bills: TimelineBill[];
payday?: boolean;
}