refactor(ts): convert TrackerRow + TrackerBucket to TSX (B6) — tracker/ dir done

The two largest tracker components. TrackerRow (726 ln): typed threshold/
optimistic-actual as branded Dollars, amount_suggestion/autopay_stats now real
@/types (AmountSuggestion/AutopayStats) with null-safe `stats?.` access,
querySelectorAll<HTMLElement> for keyboard nav, Date.now()-getTime() arithmetic,
drag-handler casts for the motion.tr TableRow. TrackerBucket: typed
dragPropsFor/moveControlsFor returns (DragProps/MoveControls), bulkPay result
cast, asDollars at every total's fmt boundary.

client/components/tracker/ is now 100% .tsx (18 components). typecheck 0, lint 0
errors (40 warns), build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 19:42:26 -05:00
parent f4f1dadaab
commit c2097efc49
3 changed files with 123 additions and 59 deletions

View File

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState, type ReactNode } from 'react';
import { LayoutGroup } from 'framer-motion'; import { LayoutGroup } from 'framer-motion';
import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react'; import { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import { import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell, Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table'; } from '@/components/ui/table';
@ -17,9 +18,19 @@ import {
} from '@/lib/trackerUtils'; } from '@/lib/trackerUtils';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { TrackerRow as Row } from '@/components/tracker/TrackerRow'; import { TrackerRow as Row } from '@/components/tracker/TrackerRow';
import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow'; import { MobileTrackerRow, type DragProps, type MoveControls } from '@/components/tracker/MobileTrackerRow';
import type { Payment, TrackerRow } from '@/types';
function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }) { interface SortableHeadProps {
sortKey: string;
activeSortKey?: string;
sortDir?: string;
onSort?: (key: string) => void;
children?: ReactNode;
className?: string;
}
function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }: SortableHeadProps) {
const active = activeSortKey === sortKey; const active = activeSortKey === sortKey;
const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown; const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown;
return ( return (
@ -49,6 +60,24 @@ function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, class
); );
} }
interface TrackerBucketProps {
label: string;
rows: TrackerRow[];
year: number;
month: number;
refresh: () => void;
onEditBill?: (row: TrackerRow) => void;
loading?: boolean;
onReorderRows?: (rows: TrackerRow[]) => void;
reorderEnabled?: boolean;
movingBillId?: number | null;
sortKey?: string;
sortDir?: string;
onSort?: (key: string) => void;
driftedIds?: Set<number>;
visibleColumns?: string[];
}
export function TrackerBucket({ export function TrackerBucket({
label, label,
rows, rows,
@ -63,13 +92,13 @@ export function TrackerBucket({
sortKey = TRACKER_SORT_DEFAULT, sortKey = TRACKER_SORT_DEFAULT,
sortDir = TRACKER_SORT_ASC, sortDir = TRACKER_SORT_ASC,
onSort, onSort,
driftedIds = new Set(), driftedIds = new Set<number>(),
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
}) { }: TrackerBucketProps) {
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); const showColumn = (key: string) => visibleColumnSet.has(key);
const tableColSpan = 1 + visibleColumns.length; const tableColSpan = 1 + visibleColumns.length;
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter(r => !r.is_skipped); const activeRows = rows.filter(r => !r.is_skipped);
@ -107,13 +136,13 @@ export function TrackerBucket({
async function handlePayAllDue() { async function handlePayAllDue() {
setPayingAll(true); setPayingAll(true);
try { try {
const result = await api.bulkPay(payAllItems.map(({ name, ...item }) => item)); const result = await api.bulkPay(payAllItems.map(({ name, ...item }) => item)) as { created?: Payment[] };
const created = result.created || []; const created = result.created || [];
setPayAllOpen(false); setPayAllOpen(false);
if (created.length === 0) { if (created.length === 0) {
toast.info('Those bills were already paid.'); toast.info('Those bills were already paid.');
} else { } else {
toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'}${fmt(payAllTotal)}`, { toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'}${fmt(asDollars(payAllTotal))}`, {
action: { action: {
label: 'Undo', label: 'Undo',
onClick: async () => { onClick: async () => {
@ -122,7 +151,7 @@ export function TrackerBucket({
toast.success('Payments removed'); toast.success('Payments removed');
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to undo payments.'); toast.error(errMessage(err, 'Failed to undo payments.'));
} }
}, },
}, },
@ -130,18 +159,18 @@ export function TrackerBucket({
} }
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to pay bills.'); toast.error(errMessage(err, 'Failed to pay bills.'));
} finally { } finally {
setPayingAll(false); setPayingAll(false);
} }
} }
function reorderByIndex(fromIndex, toIndex) { function reorderByIndex(fromIndex: number, toIndex: number) {
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return; if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
onReorderRows?.(moveInArray(rows, fromIndex, toIndex)); onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
} }
function dragPropsFor(row, index) { function dragPropsFor(row: TrackerRow, index: number): DragProps {
if (!reorderEnabled) return { draggable: false }; if (!reorderEnabled) return { draggable: false };
return { return {
draggable: true, draggable: true,
@ -175,7 +204,7 @@ export function TrackerBucket({
}; };
} }
function moveControlsFor(row, index) { function moveControlsFor(row: TrackerRow, index: number): MoveControls {
return { return {
enabled: !!reorderEnabled, enabled: !!reorderEnabled,
moving: movingBillId === row.id, moving: movingBillId === row.id,
@ -220,16 +249,16 @@ export function TrackerBucket({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}> <span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)} {fmt(asDollars(totalPaidTowardDue))}
</span> </span>
<span className="text-muted-foreground/50 mx-1">/</span> <span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)} {fmt(asDollars(totalThreshold))}
{totalOverpaid > 0 && ( {totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span> <span className="ml-1 text-emerald-500">+{fmt(asDollars(totalOverpaid))}</span>
)} )}
</span> </span>
{!allPaid && totalRemaining > 0 && ( {!allPaid && totalRemaining > 0 && (
<span className="text-[11px] text-muted-foreground/70">{fmt(totalRemaining)} left</span> <span className="text-[11px] text-muted-foreground/70">{fmt(asDollars(totalRemaining))} left</span>
)} )}
{allPaid && ( {allPaid && (
<span className="text-[11px] text-emerald-500">Done</span> <span className="text-[11px] text-emerald-500">Done</span>
@ -259,13 +288,13 @@ export function TrackerBucket({
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle> <AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this This records a payment for {payAllItems.length} unpaid bill{payAllItems.length === 1 ? '' : 's'} in this
bucket, totalling {fmt(payAllTotal)}. You can undo it right after. bucket, totalling {fmt(asDollars(payAllTotal))}. You can undo it right after.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}> <AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`} {payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>

View File

@ -1,9 +1,10 @@
import React, { useState, useRef, useTransition, useEffect } from 'react'; import { useState, useRef, useTransition, useEffect } from 'react';
import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { ArrowDown, ArrowUp, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
import { asDollars, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@ -23,25 +24,44 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell'; import { NotesCell } from '@/components/tracker/NotesCell';
import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions';
import type { Payment, TrackerRow as TrackerRowData } from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }) { // Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
const amountRef = useRef(null); // onDrag* gesture types on the motion.tr-backed TableRow.
const [editPayment, setEditPayment] = useState(null); type TableRowDrag = React.ComponentProps<typeof TableRow>['onDragStart'];
interface TrackerRowProps {
row: TrackerRowData;
year: number;
month: number;
refresh: () => void;
index: number;
onEditBill?: (row: TrackerRowData) => void;
moveControls?: MoveControls;
dragProps?: DragProps;
isDrifted?: boolean;
visibleColumns?: string[];
}
export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) {
const amountRef = useRef<HTMLInputElement>(null);
const [editPayment, setEditPayment] = useState<Payment | null>(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [showMbs, setShowMbs] = useState(false); const [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const [optimisticActual, setOptimisticActual] = useState(undefined); const [optimisticActual, setOptimisticActual] = useState<Dollars | undefined>(undefined);
// Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls
// back on error. Cleared when real data arrives (see effect below). // back on error. Cleared when real data arrives (see effect below).
const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [optimisticPaid, setOptimisticPaid] = useState<boolean | undefined>(undefined);
const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [showUpdateNudge, setShowUpdateNudge] = useState(false);
const [nudgeAmount, setNudgeAmount] = useState(null); const [nudgeAmount, setNudgeAmount] = useState<Dollars | null>(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const quickPay = useQuickPay(); const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month); const togglePaid = useTogglePaid(year, month);
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); const showColumn = (key: string) => visibleColumnSet.has(key);
const [editingExpected, setEditingExpected] = useState(false); const [editingExpected, setEditingExpected] = useState(false);
const [expectedDraft, setExpectedDraft] = useState(''); const [expectedDraft, setExpectedDraft] = useState('');
@ -61,6 +81,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; const serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold;
const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid; const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid;
const summary = paymentSummary(row, threshold); const summary = paymentSummary(row, threshold);
const bankPending = row.bank_pending_count ?? 0;
// Effective status to show: // Effective status to show:
// skipped > optimistic flip > paid (threshold-based) > backend status // skipped > optimistic flip > paid (threshold-based) > backend status
@ -82,9 +103,9 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
function handleQuickPay() { function handleQuickPay() {
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value ?? '');
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
quickPay.run(row, val, defaultPaymentDate); quickPay.run(row, asDollars(val), defaultPaymentDate);
} }
function performTogglePaid() { function performTogglePaid() {
@ -99,7 +120,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
performTogglePaid(); performTogglePaid();
} }
function handleRowKeyDown(e) { function handleRowKeyDown(e: React.KeyboardEvent<HTMLTableRowElement>) {
// Only act on the row element itself, not on interactive children // Only act on the row element itself, not on interactive children
if (e.target !== e.currentTarget) return; if (e.target !== e.currentTarget) return;
@ -107,7 +128,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
case 'ArrowDown': case 'ArrowDown':
case 'j': { case 'j': {
e.preventDefault(); e.preventDefault();
const all = [...document.querySelectorAll('[data-tracker-row]')]; const all = [...document.querySelectorAll<HTMLElement>('[data-tracker-row]')];
const next = all[all.indexOf(e.currentTarget) + 1]; const next = all[all.indexOf(e.currentTarget) + 1];
next?.focus(); next?.focus();
break; break;
@ -115,7 +136,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
case 'ArrowUp': case 'ArrowUp':
case 'k': { case 'k': {
e.preventDefault(); e.preventDefault();
const all = [...document.querySelectorAll('[data-tracker-row]')]; const all = [...document.querySelectorAll<HTMLElement>('[data-tracker-row]')];
const prev = all[all.indexOf(e.currentTarget) - 1]; const prev = all[all.indexOf(e.currentTarget) - 1];
prev?.focus(); prev?.focus();
break; break;
@ -156,7 +177,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
setOptimisticActual(undefined); setOptimisticActual(undefined);
toast.error(err.message || 'Failed to update amount'); toast.error(errMessage(err, 'Failed to update amount'));
} }
} }
@ -169,12 +190,12 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
toast.success(`Default updated to ${fmt(amount)}`); toast.success(`Default updated to ${fmt(amount)}`);
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update default'); toast.error(errMessage(err, 'Failed to update default'));
} }
}); });
} }
async function handleApplySuggestion(amount) { async function handleApplySuggestion(amount: Dollars) {
setOptimisticActual(amount); setOptimisticActual(amount);
try { try {
await api.saveBillMonthlyState(row.id, { await api.saveBillMonthlyState(row.id, {
@ -186,7 +207,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
setOptimisticActual(undefined); setOptimisticActual(undefined);
toast.error(err.message || 'Failed to apply suggestion'); toast.error(errMessage(err, 'Failed to apply suggestion'));
} }
} }
@ -198,7 +219,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
if (val === current) return; if (val === current) return;
if (effectiveActual != null) { if (effectiveActual != null) {
setOptimisticActual(val); setOptimisticActual(asDollars(val));
try { try {
await api.saveBillMonthlyState(row.id, { await api.saveBillMonthlyState(row.id, {
year, month, year, month,
@ -209,14 +230,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
setOptimisticActual(undefined); setOptimisticActual(undefined);
toast.error(err.message || 'Failed to update amount'); toast.error(errMessage(err, 'Failed to update amount'));
} }
} else { } else {
try { try {
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val }); await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update expected amount'); toast.error(errMessage(err, 'Failed to update expected amount'));
} }
} }
} }
@ -230,18 +251,18 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount }); await api.updateBill(row.id, { name: row.name, due_day: day, expected_amount: row.expected_amount });
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update due date'); toast.error(errMessage(err, 'Failed to update due date'));
} }
} }
async function handleConfirmSuggestion() { async function handleConfirmSuggestion() {
setSuggestionLoading(true); setSuggestionLoading(true);
try { try {
const result = await api.confirmAutopaySuggestion(row.id, { year, month }); const result = await api.confirmAutopaySuggestion(row.id, { year, month }) as { created?: boolean };
toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded'); toast.success(result.created ? 'Autopay payment confirmed' : 'Autopay already recorded');
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion'); toast.error(errMessage(err, 'Failed to confirm autopay suggestion'));
} finally { } finally {
setSuggestionLoading(false); setSuggestionLoading(false);
} }
@ -254,7 +275,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
toast.success('Autopay suggestion dismissed'); toast.success('Autopay suggestion dismissed');
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion'); toast.error(errMessage(err, 'Failed to dismiss autopay suggestion'));
} finally { } finally {
setSuggestionLoading(false); setSuggestionLoading(false);
} }
@ -271,10 +292,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`} aria-label={`${row.name}, ${effectiveStatus}, ${row.due_day ? `due ${row.due_day}` : ''}`}
onKeyDown={handleRowKeyDown} onKeyDown={handleRowKeyDown}
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart} onDragStart={dragProps?.onDragStart as TableRowDrag}
onDragEnter={dragProps?.onDragEnter} onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver} onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd} onDragEnd={dragProps?.onDragEnd as TableRowDrag}
onDrop={dragProps?.onDrop} onDrop={dragProps?.onDrop}
className={cn( className={cn(
'group border-border/65 transition-colors duration-150', 'group border-border/65 transition-colors duration-150',
@ -344,10 +365,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
{row.autopay_enabled && (() => { {row.autopay_enabled && (() => {
const stats = row.autopay_stats; const stats = row.autopay_stats;
const hasFailed = stats && stats.failures > 0; const hasFailed = !!(stats && stats.failures > 0);
const verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null; const verifiedAt = row.autopay_verified_at ? new Date(row.autopay_verified_at) : null;
const daysSinceVerify = verifiedAt const daysSinceVerify = verifiedAt
? Math.floor((Date.now() - verifiedAt) / 86400000) ? Math.floor((Date.now() - verifiedAt.getTime()) / 86400000)
: null; : null;
const needsVerify = daysSinceVerify === null || daysSinceVerify > 90; const needsVerify = daysSinceVerify === null || daysSinceVerify > 90;
const badgeCls = hasFailed const badgeCls = hasFailed
@ -369,10 +390,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
) : ( ) : (
<p className="font-semibold">Autopay enabled</p> <p className="font-semibold">Autopay enabled</p>
)} )}
{hasFailed && stats.last_failure_date && ( {hasFailed && stats?.last_failure_date && (
<p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p> <p className="text-xs opacity-80">Last failed: {stats.last_failure_date}</p>
)} )}
{hasFailed && stats.last_failure_notes && ( {hasFailed && stats?.last_failure_notes && (
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p> <p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
)} )}
{needsVerify ? ( {needsVerify ? (
@ -523,7 +544,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && ( Math.abs(row.amount_suggestion.suggestion - row.expected_amount) / row.expected_amount > 0.05 && (
<button <button
type="button" type="button"
onClick={() => handleApplySuggestion(row.amount_suggestion.suggestion)} onClick={() => handleApplySuggestion(row.amount_suggestion!.suggestion)}
className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground" className="text-[10px] text-muted-foreground/50 transition-colors hover:text-muted-foreground"
title={`Based on last ${row.amount_suggestion.months_used} months`} title={`Based on last ${row.amount_suggestion.months_used} months`}
> >
@ -574,10 +595,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
{showColumn('status') && ( {showColumn('status') && (
<TableCell className="w-[9%] py-2.5"> <TableCell className="w-[9%] py-2.5">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
{row.bank_pending_count > 0 ? ( {bankPending > 0 ? (
<span <span
className="inline-flex items-center rounded-full border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400" className="inline-flex items-center rounded-full border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400"
title={`Bank has ${row.bank_pending_count} pending charge${row.bank_pending_count > 1 ? 's' : ''} matching this bill`} title={`Bank has ${bankPending} pending charge${bankPending > 1 ? 's' : ''} matching this bill`}
> >
Pending Pending
</span> </span>

View File

@ -35,6 +35,20 @@ export interface AutopaySuggestion {
method: string; method: string;
} }
// A suggested expected-amount for a bill, from its recent payment history.
export interface AmountSuggestion {
suggestion: Dollars;
months_used: number;
}
// Autopay reliability rollup shown in the tracker-row tooltip.
export interface AutopayStats {
total: number;
failures: number;
last_failure_date?: string | null;
last_failure_notes?: string | null;
}
// A payment record as serialized by the server (money in dollars). serializePayment // A payment record as serialized by the server (money in dollars). serializePayment
// spreads the full DB row, so extra columns pass through as `unknown`. // spreads the full DB row, so extra columns pass through as `unknown`.
export interface Payment { export interface Payment {
@ -169,7 +183,7 @@ export interface TrackerRow {
inactive_reason: string | null; inactive_reason: string | null;
inactivated_at: string | null; inactivated_at: string | null;
sparkline: number[] | null; sparkline: number[] | null;
autopay_stats: unknown; autopay_stats: AutopayStats | null;
payments: Payment[]; payments: Payment[];
// Augmented by getTracker: // Augmented by getTracker:
actual_amount: Dollars | null; actual_amount: Dollars | null;
@ -177,7 +191,7 @@ export interface TrackerRow {
snoozed_until: string | null; snoozed_until: string | null;
monthly_notes?: string | null; monthly_notes?: string | null;
autopay_suggestion?: AutopaySuggestion | null; autopay_suggestion?: AutopaySuggestion | null;
amount_suggestion?: unknown; amount_suggestion?: AmountSuggestion | null;
previous_month_paid: Dollars; previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled): // Bank-mode augmentation (present only when bank_tracking.enabled):
pending_cleared?: boolean; pending_cleared?: boolean;