refactor(ts): TrackerPage → TypeScript (the home page)

The tracker: URL-driven month/filters/sort, React Query data (TrackerResponse),
bank-projection banner, summary cards, safe-to-spend, overdue center, drift
insights, drag-reorder buckets, and all row/modal orchestration. TrackerSummary.trend
is typed unknown in @/types so it's cast at the TrendCard boundary.
This commit is contained in:
null 2026-07-04 23:06:46 -05:00
parent dbf3c42d62
commit d559f4b00b
1 changed files with 135 additions and 74 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
@ -8,7 +8,8 @@ import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts';
import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule';
import { cn, fmt, localDateString } from '@/lib/utils';
import { cn, localDateString } from '@/lib/utils';
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import SearchFilterPanel from '@/components/SearchFilterPanel';
@ -41,26 +42,77 @@ import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards';
import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog';
import { TrackerBucket as Bucket } from '@/components/tracker/TrackerBucket';
import IncomeBreakdownModal from '@/components/IncomeBreakdownModal';
import type { TrackerRow, TrackerSummary, BankTracking, DriftBill, Bill, Category } from '@/types';
function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
function parseUtc(str) {
interface LateAttr {
payment_id: number;
bill_name?: string;
amount?: number;
original_date: string;
suggested_date: string;
}
interface SyncResult {
auto_matched?: number;
transactions_new?: number;
matched_bills?: string[];
late_attributions?: LateAttr[];
}
interface BankSyncStatus {
enabled?: boolean;
has_connections?: boolean;
has_merchant_rules?: boolean;
}
interface EditBillData {
bill: Bill | null;
initialBill?: Partial<Bill>;
categories: Category[];
}
interface TrackerFilters {
category: string;
cycle: string;
autopay: boolean;
firstBucket: boolean;
fifteenthBucket: boolean;
unpaid: boolean;
overdue: boolean;
debt: boolean;
}
type BoolFilterKey = 'autopay' | 'firstBucket' | 'fifteenthBucket' | 'unpaid' | 'overdue' | 'debt';
type Patch = Record<string, string | number | boolean | null | undefined>;
function parseUtc(str: string | null | undefined): Date | null {
if (!str) return null;
// SQLite datetime('now') returns "2026-06-07 23:40:15" — no T, no Z. Treat as UTC.
const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z';
return new Date(normalized);
}
function fmtBalanceAge(isoStr) {
if (!isoStr) return null;
return parseUtc(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
function fmtBalanceAge(isoStr: string | null | undefined): string | null {
const d = parseUtc(isoStr);
if (!d) return null;
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function settingEnabled(value, fallback = true) {
function settingEnabled(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }: {
bankTracking?: BankTracking;
onSnooze: () => void;
onIgnore: () => void;
busy?: boolean;
}) {
if (!bankTracking?.enabled) return null;
const isPositive = Number(bankTracking.remaining ?? 0) >= 0;
@ -123,7 +175,11 @@ function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
);
}
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }) {
function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }: {
visibleColumns: string[];
onColumnToggle?: (key: string, checked: boolean) => void;
saving?: boolean;
}) {
const visibleSet = new Set(visibleColumns);
return (
@ -162,9 +218,15 @@ function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }) {
}
// ── Main page ──────────────────────────────────────────────────────────────
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }: {
attr?: LateAttr | null;
remaining: number;
busy?: boolean;
onAccept: (attr: LateAttr) => void;
onDismiss: () => void;
}) {
if (!attr) return null;
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const fmtDate = (d: string) => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
const priorMonth = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
return (
<Dialog open onOpenChange={onDismiss}>
@ -207,7 +269,7 @@ export default function TrackerPage() {
const search = searchParams.get('q') || '';
// Stable identity so the memo that filters rows on `filters` doesn't recompute
// every render (a new object literal each render would defeat it).
const filters = useMemo(() => ({
const filters = useMemo<TrackerFilters>(() => ({
category: searchParams.get('fc') || FILTER_ALL,
cycle: searchParams.get('cy') || FILTER_ALL,
autopay: searchParams.get('ap') === '1',
@ -224,7 +286,7 @@ export default function TrackerPage() {
: TRACKER_SORT_ASC;
// replace: true keeps history clean for rapid navigation (e.g. search keystrokes)
const updateParams = useCallback((patch) => {
const updateParams = useCallback((patch: Patch) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(patch).forEach(([k, v]) => {
@ -236,19 +298,19 @@ export default function TrackerPage() {
}, [setSearchParams]);
// Edit Bill modal: { bill, categories } when open, null when closed
const [bankSyncStatus, setBankSyncStatus] = useState(null);
const [bankSyncStatus, setBankSyncStatus] = useState<BankSyncStatus | null>(null);
const [bankSyncing, setBankSyncing] = useState(false);
const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved
const [lateAttributions, setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState<number | null>(null); // payment_id being resolved
const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true');
const [editBillData, setEditBillData] = useState(null);
const [editBillData, setEditBillData] = useState<EditBillData | null>(null);
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
const [incomeModalOpen, setIncomeModalOpen] = useState(false);
const [orderedRows, setOrderedRows] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
const [movingBillId, setMovingBillId] = useState<number | null>(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [trackerSettings, setTrackerSettings] = useState({
const [trackerSettings, setTrackerSettings] = useState<Record<string, string>>({
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
@ -260,11 +322,12 @@ export default function TrackerPage() {
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
const [commandCenterPayRow, setCommandCenterPayRow] = useState<TrackerRow | null>(null);
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month);
const { data: driftData } = useDriftReport();
const { data: driftDataRaw } = useDriftReport();
const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined;
// Live cross-query refresh: a bill mutation invalidates the tracker AND the
// overdue badge / drift report / bills list so the whole shell updates, not
// just the one tracker query. Use this everywhere a row/payment mutation lands.
@ -279,20 +342,20 @@ export default function TrackerPage() {
// Load SimpleFIN status once to decide whether to show the sync button
useEffect(() => {
api.simplefinStatus()
.then(setBankSyncStatus)
.then(s => setBankSyncStatus(s as BankSyncStatus))
.catch(() => setBankSyncStatus(null));
}, []);
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record<string, string>) })))
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync
useEffect(() => {
function handler(e) {
const attrs = e.detail?.attributions;
function handler(e: Event) {
const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).detail?.attributions;
if (Array.isArray(attrs) && attrs.length > 0) {
setLateAttributions(prev => [...prev, ...attrs]);
}
@ -301,7 +364,7 @@ export default function TrackerPage() {
return () => window.removeEventListener('tracker:late-attributions', handler);
}, []);
function adjacentMonth(delta) {
function adjacentMonth(delta: number) {
let nm = month + delta;
let ny = year;
if (nm > 12) { ny += 1; nm = 1; }
@ -309,18 +372,18 @@ export default function TrackerPage() {
return { year: ny, month: nm };
}
function navigate(delta) {
function navigate(delta: number) {
updateParams(adjacentMonth(delta));
}
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
const prefetchTracker = usePrefetchTracker();
function prefetchAdjacent(delta) {
function prefetchAdjacent(delta: number) {
const { year: ny, month: nm } = adjacentMonth(delta);
prefetchTracker(ny, nm);
}
function bankSyncMessage(result) {
function bankSyncMessage(result: SyncResult): string {
const matched = result.auto_matched ?? 0;
const newTx = result.transactions_new ?? 0;
const billNames = result.matched_bills ?? [];
@ -340,13 +403,13 @@ export default function TrackerPage() {
const promise = api.syncAllSources();
toast.promise(promise, {
loading: 'Syncing bank…',
success: bankSyncMessage,
error: (err) => err.message || 'Bank sync failed',
success: (result: unknown) => bankSyncMessage(result as SyncResult),
error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed',
});
try {
const result = await promise;
const result = await promise as SyncResult;
// Surface late-attribution prompts (payments that just crossed a month boundary)
if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions);
if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions ?? []);
invalidateData();
} catch {
// toast.promise already surfaced the error
@ -368,24 +431,24 @@ export default function TrackerPage() {
bankSyncStatus?.has_connections &&
bankSyncStatus?.has_merchant_rules;
async function handleOpenEditBill(row) {
async function handleOpenEditBill(row: TrackerRow) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id),
api.categories(),
api.bill(row.id) as Promise<Bill>,
api.categories() as Promise<Category[]>,
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error(err.message);
toast.error((err as { message?: string })?.message || 'Failed to open bill');
}
}
async function handleOpenAddBill() {
try {
const categories = await api.categories();
const categories = await api.categories() as Category[];
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error(err.message || 'Failed to open bill editor');
toast.error((err as { message?: string })?.message || 'Failed to open bill editor');
}
}
@ -395,7 +458,7 @@ export default function TrackerPage() {
}
const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]);
const summary = data?.summary || {};
const summary = (data?.summary ?? {}) as TrackerSummary;
const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow;
const today = localDateString();
@ -414,17 +477,17 @@ export default function TrackerPage() {
[trackerSettings.tracker_table_columns]
);
async function saveTrackerSettings(patch, successMessage) {
async function saveTrackerSettings(patch: Record<string, string>, successMessage?: string) {
setSavingTrackerSetting(true);
setTrackerSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch);
const next = await api.saveSettings(patch) as Record<string, string>;
setTrackerSettings(prev => ({ ...prev, ...next }));
if (successMessage) toast.success(successMessage);
} catch (err) {
toast.error(err.message || 'Failed to update tracker setting');
toast.error((err as { message?: string })?.message || 'Failed to update tracker setting');
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record<string, string>) })))
.catch(() => {});
} finally {
setSavingTrackerSetting(false);
@ -446,7 +509,7 @@ export default function TrackerPage() {
}, 'Bank projection banner hidden. You can turn it back on in Settings.');
}
function handleTableColumnToggle(columnKey, checked) {
function handleTableColumnToggle(columnKey: string, checked: boolean) {
const nextColumns = checked
? [...visibleTableColumns, columnKey]
: visibleTableColumns.filter(key => key !== columnKey);
@ -463,18 +526,15 @@ export default function TrackerPage() {
});
}
function showSearchSortPanel() {
saveTrackerSettings({ tracker_show_search_sort: 'true' });
}
const toggleFilter = (key) => {
const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
const toggleFilter = (key: BoolFilterKey) => {
const paramMap: Record<BoolFilterKey, string> = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' };
updateParams({ [paramMap[key]]: !filters[key] });
};
const setFilterValue = (key, value) => {
const paramMap = { category: 'fc', cycle: 'cy' };
const setFilterValue = (key: 'category' | 'cycle', value: string) => {
const paramMap: Record<'category' | 'cycle', string> = { category: 'fc', cycle: 'cy' };
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
};
const setSort = (key) => {
const setSort = (key: string) => {
const normalizedKey = normalizeTrackerSortKey(key);
if (normalizedKey === TRACKER_SORT_DEFAULT) {
updateParams({ sort: null, dir: null });
@ -489,7 +549,7 @@ export default function TrackerPage() {
if (!hasSort) return;
updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC });
};
const handleSortHeader = (key) => {
const handleSortHeader = (key: string) => {
const normalizedKey = normalizeTrackerSortKey(key);
if (normalizedKey === TRACKER_SORT_DEFAULT) return;
if (sortKey === normalizedKey) {
@ -513,12 +573,12 @@ export default function TrackerPage() {
|| filters.debt
|| hasSort
);
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : null;
const searchSortLabel = hasSort ? `sorted by ${TRACKER_SORT_LABELS[sortKey]} ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : undefined;
const resetFilters = () => {
updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null });
};
const categoryOptions = useMemo(() => {
const map = new Map();
const map = new Map<string, string>();
rows.forEach(row => {
if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name);
});
@ -557,7 +617,7 @@ export default function TrackerPage() {
}, [filters, rows, search]);
// When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface
// at the top of each bucket. Bucket split runs after so each bucket is sorted independently.
const URGENCY_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
const URGENCY_ORDER: Record<string, number> = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };
const sortedRows = hasSort
? sortTrackerRows(filteredRows, sortKey, sortDir)
: pinUpcoming
@ -575,7 +635,7 @@ export default function TrackerPage() {
const second = sortedRows.filter(r => r.bucket === '15th');
const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming;
async function persistTrackerOrder(nextRows, movedBillId) {
async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) {
const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index]));
setOrderedRows(nextRows);
setMovingBillId(movedBillId);
@ -585,21 +645,22 @@ export default function TrackerPage() {
invalidateData();
} catch (err) {
setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order');
toast.error((err as { message?: string })?.message || 'Failed to save bill order');
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket, orderedBucketRows) {
function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) {
const sourceRows = rows;
const nextRows = [...sourceRows];
const replacement = [...orderedBucketRows];
for (let i = 0; i < nextRows.length; i += 1) {
if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift();
const cur = nextRows[i];
if (cur && cur.bucket === bucket) nextRows[i] = replacement.shift()!;
}
const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id);
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null);
}
return (
@ -804,7 +865,7 @@ export default function TrackerPage() {
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
<Skeleton variant="card" className="h-32" />
{summary.trend && <Skeleton variant="card" className="h-32" />}
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
</div>
) : showSummaryCards ? (
<div className="grid grid-cols-2 gap-3 lg:flex">
@ -866,7 +927,7 @@ export default function TrackerPage() {
<SummaryCard
type="paid"
value={summary.total_paid}
hint={summary.previous_month_total > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
hint={Number(summary.previous_month_total ?? 0) > 0 ? `Last month: ${fmt(summary.previous_month_total)}` : undefined}
/>
{/* In bank mode the hero card already surfaces the projected remaining,
so only show the Remaining card when there's no bank hero. */}
@ -878,12 +939,12 @@ export default function TrackerPage() {
/>
)}
<SummaryCard type="overdue" value={summary.overdue} />
{summary.trend && <TrendCard trend={summary.trend} />}
{summary.trend ? <TrendCard trend={summary.trend as Parameters<typeof TrendCard>[0]['trend']} /> : null}
</div>
) : null}
{/* Compact month-progress line — quick "how far through the month am I?" */}
{showSummaryCards && !loading && !isError && summary?.total_expected > 0 && (() => {
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
return (
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
@ -928,7 +989,7 @@ export default function TrackerPage() {
{/* ── Drift / Price-Change Insights ── */}
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel
driftBills={driftData.bills}
driftBills={driftData?.bills || []}
refresh={invalidateData}
/>
)}
@ -954,7 +1015,7 @@ export default function TrackerPage() {
)}
{/* ── Empty state ── */}
{!isError && rows.length === 0 && data !== null && (
{!isError && rows.length === 0 && data != null && (
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-border bg-muted/20">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center mb-3">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
@ -1017,7 +1078,7 @@ export default function TrackerPage() {
onSave={() => invalidateData()}
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }) as Partial<Bill>,
categories: editBillData.categories,
})}
/>
@ -1053,12 +1114,12 @@ export default function TrackerPage() {
setAttrBusy(attr.payment_id);
try {
await api.attributePaymentToMonth(attr.payment_id, attr.suggested_date);
const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`);
const monthName = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${monthName}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
invalidateData();
} catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again');
toast.error((err as { message?: string })?.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry
} finally {
setAttrBusy(null);
@ -1074,7 +1135,7 @@ export default function TrackerPage() {
row={commandCenterPayRow}
year={year}
month={month}
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
threshold={(commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount) as Dollars}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}