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