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 { ArrowDown, ArrowUp, CheckCircle2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt } from '@/lib/utils';
import { cn, fmt, errMessage } from '@/lib/utils';
import { asDollars } from '@/lib/money';
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
@ -17,9 +18,19 @@ import {
} from '@/lib/trackerUtils';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
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 Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown;
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({
label,
rows,
@ -63,13 +92,13 @@ export function TrackerBucket({
sortKey = TRACKER_SORT_DEFAULT,
sortDir = TRACKER_SORT_ASC,
onSort,
driftedIds = new Set(),
driftedIds = new Set<number>(),
visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS,
}) {
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
}: TrackerBucketProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key);
const showColumn = (key: string) => visibleColumnSet.has(key);
const tableColSpan = 1 + visibleColumns.length;
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
const activeRows = rows.filter(r => !r.is_skipped);
@ -107,13 +136,13 @@ export function TrackerBucket({
async function handlePayAllDue() {
setPayingAll(true);
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 || [];
setPayAllOpen(false);
if (created.length === 0) {
toast.info('Those bills were already paid.');
} 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: {
label: 'Undo',
onClick: async () => {
@ -122,7 +151,7 @@ export function TrackerBucket({
toast.success('Payments removed');
refresh?.();
} 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?.();
} catch (err) {
toast.error(err.message || 'Failed to pay bills.');
toast.error(errMessage(err, 'Failed to pay bills.'));
} finally {
setPayingAll(false);
}
}
function reorderByIndex(fromIndex, toIndex) {
function reorderByIndex(fromIndex: number, toIndex: number) {
if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return;
onReorderRows?.(moveInArray(rows, fromIndex, toIndex));
}
function dragPropsFor(row, index) {
function dragPropsFor(row: TrackerRow, index: number): DragProps {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
@ -175,7 +204,7 @@ export function TrackerBucket({
};
}
function moveControlsFor(row, index) {
function moveControlsFor(row: TrackerRow, index: number): MoveControls {
return {
enabled: !!reorderEnabled,
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">
<span className="whitespace-nowrap">
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
{fmt(totalPaidTowardDue)}
{fmt(asDollars(totalPaidTowardDue))}
</span>
<span className="text-muted-foreground/50 mx-1">/</span>
{fmt(totalThreshold)}
{fmt(asDollars(totalThreshold))}
{totalOverpaid > 0 && (
<span className="ml-1 text-emerald-500">+{fmt(totalOverpaid)}</span>
<span className="ml-1 text-emerald-500">+{fmt(asDollars(totalOverpaid))}</span>
)}
</span>
{!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 && (
<span className="text-[11px] text-emerald-500">Done</span>
@ -259,13 +288,13 @@ export function TrackerBucket({
<AlertDialogTitle>Pay all due in {label}?</AlertDialogTitle>
<AlertDialogDescription>
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>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={payingAll}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={(e) => { e.preventDefault(); handlePayAllDue(); }} disabled={payingAll}>
{payingAll ? 'Paying…' : `Pay ${fmt(payAllTotal)}`}
{payingAll ? 'Paying…' : `Pay ${fmt(asDollars(payAllTotal))}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@ -1,9 +1,10 @@
import React, { useState, useRef, useTransition, useEffect } from 'react';
import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
import { useState, useRef, useTransition, useEffect } from 'react';
import { ArrowDown, ArrowUp, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
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 { Input } from '@/components/ui/input';
import {
@ -23,25 +24,44 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { NotesCell } from '@/components/tracker/NotesCell';
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 }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
// Cast type for the HTML5 drag handlers, which conflict with framer-motion's own
// onDrag* gesture types on the motion.tr-backed TableRow.
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 [showMbs, setShowMbs] = useState(false);
const [confirmUnpay, setConfirmUnpay] = 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
// 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 [nudgeAmount, setNudgeAmount] = useState(null);
const [nudgeAmount, setNudgeAmount] = useState<Dollars | null>(null);
const [, startTransition] = useTransition();
const quickPay = useQuickPay();
const togglePaid = useTogglePaid(year, month);
const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key);
const showColumn = (key: string) => visibleColumnSet.has(key);
const [editingExpected, setEditingExpected] = useState(false);
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 isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid;
const summary = paymentSummary(row, threshold);
const bankPending = row.bank_pending_count ?? 0;
// Effective status to show:
// 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] || '');
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; }
quickPay.run(row, val, defaultPaymentDate);
quickPay.run(row, asDollars(val), defaultPaymentDate);
}
function performTogglePaid() {
@ -99,7 +120,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
performTogglePaid();
}
function handleRowKeyDown(e) {
function handleRowKeyDown(e: React.KeyboardEvent<HTMLTableRowElement>) {
// Only act on the row element itself, not on interactive children
if (e.target !== e.currentTarget) return;
@ -107,7 +128,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
case 'ArrowDown':
case 'j': {
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];
next?.focus();
break;
@ -115,7 +136,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
case 'ArrowUp':
case 'k': {
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];
prev?.focus();
break;
@ -156,7 +177,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.();
} catch (err) {
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)}`);
refresh?.();
} 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);
try {
await api.saveBillMonthlyState(row.id, {
@ -186,7 +207,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.();
} catch (err) {
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 (effectiveActual != null) {
setOptimisticActual(val);
setOptimisticActual(asDollars(val));
try {
await api.saveBillMonthlyState(row.id, {
year, month,
@ -209,14 +230,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
refresh?.();
} catch (err) {
setOptimisticActual(undefined);
toast.error(err.message || 'Failed to update amount');
toast.error(errMessage(err, 'Failed to update amount'));
}
} else {
try {
await api.updateBill(row.id, { name: row.name, due_day: row.due_day, expected_amount: val });
refresh?.();
} 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 });
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to update due date');
toast.error(errMessage(err, 'Failed to update due date'));
}
}
async function handleConfirmSuggestion() {
setSuggestionLoading(true);
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');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to confirm autopay suggestion');
toast.error(errMessage(err, 'Failed to confirm autopay suggestion'));
} finally {
setSuggestionLoading(false);
}
@ -254,7 +275,7 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
toast.success('Autopay suggestion dismissed');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to dismiss autopay suggestion');
toast.error(errMessage(err, 'Failed to dismiss autopay suggestion'));
} finally {
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}` : ''}`}
onKeyDown={handleRowKeyDown}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragStart={dragProps?.onDragStart as TableRowDrag}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDragEnd={dragProps?.onDragEnd as TableRowDrag}
onDrop={dragProps?.onDrop}
className={cn(
'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}>
{row.autopay_enabled && (() => {
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 daysSinceVerify = verifiedAt
? Math.floor((Date.now() - verifiedAt) / 86400000)
? Math.floor((Date.now() - verifiedAt.getTime()) / 86400000)
: null;
const needsVerify = daysSinceVerify === null || daysSinceVerify > 90;
const badgeCls = hasFailed
@ -369,10 +390,10 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
) : (
<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>
)}
{hasFailed && stats.last_failure_notes && (
{hasFailed && stats?.last_failure_notes && (
<p className="text-xs opacity-70 italic">{stats.last_failure_notes}</p>
)}
{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 && (
<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"
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') && (
<TableCell className="w-[9%] py-2.5">
<div className="flex flex-col items-center gap-1">
{row.bank_pending_count > 0 ? (
{bankPending > 0 ? (
<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"
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
</span>

View File

@ -35,6 +35,20 @@ export interface AutopaySuggestion {
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
// spreads the full DB row, so extra columns pass through as `unknown`.
export interface Payment {
@ -169,7 +183,7 @@ export interface TrackerRow {
inactive_reason: string | null;
inactivated_at: string | null;
sparkline: number[] | null;
autopay_stats: unknown;
autopay_stats: AutopayStats | null;
payments: Payment[];
// Augmented by getTracker:
actual_amount: Dollars | null;
@ -177,7 +191,7 @@ export interface TrackerRow {
snoozed_until: string | null;
monthly_notes?: string | null;
autopay_suggestion?: AutopaySuggestion | null;
amount_suggestion?: unknown;
amount_suggestion?: AmountSuggestion | null;
previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled):
pending_cleared?: boolean;