import { useState, useEffect, useMemo, useCallback, useDeferredValue, type ReactNode } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2, Download, Printer, FileSpreadsheet, CalendarDays, Keyboard } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker, useSettings, useSimplefinStatus, useSaveSettings } from '@/hooks/useQueries'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { cn, localDateString, parseUtc, settingEnabled } from '@/lib/utils'; import { downloadFile } from '@/lib/download'; import { formatUSD, asDollars, type Dollars } from '@/lib/money'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { EmptyState, PageHeader, StatusDot } from '@/components/ui/app-primitives'; import SearchFilterPanel from '@/components/SearchFilterPanel'; import { Skeleton } from '@/components/ui/Skeleton'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog'; import CashFlowCard from '@/components/tracker/CashFlowCard'; import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter'; import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; import { MONTHS, FILTER_ALL, paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt, TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, TRACKER_SORT_OPTIONS, TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS, normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows, } from '@/lib/trackerUtils'; import { TRACKER_TABLE_COLUMNS, parseTrackerTableColumns, trackerTableColumnsToSetting } from '@/lib/trackerTableColumns'; import { FilterChip } from '@/components/tracker/FilterChip'; 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)); } 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; 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; 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 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; return (
{bankTracking.account_name} · {fmt(bankTracking.balance ?? 0)} balance {bankTracking.last_updated && ( <> · as of {fmtBalanceAge(bankTracking.last_updated)} )} {Number(bankTracking.pending_payments ?? 0) > 0 && ( <> · {fmt(bankTracking.pending_payments)} pending )}
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected
); } function TrackerColumnMenu({ visibleColumns, onColumnToggle, saving }: { visibleColumns: string[]; onColumnToggle?: (key: string, checked: boolean) => void; saving?: boolean; }) { const visibleSet = new Set(visibleColumns); return ( Table columns Bill {TRACKER_TABLE_COLUMNS.map(column => ( event.preventDefault()} onCheckedChange={checked => onColumnToggle?.(column.key, checked)} > {column.label} ))} ); } // ── Main page ────────────────────────────────────────────────────────────── 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: 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 ( Payment posted after month end A {attr.bill_name} payment of {fmt(attr.amount)} posted on{' '} {fmtDate(attr.original_date)} — after the previous month closed. Should it count for {priorMonth}?

What this does

Moves the paid date to {fmtDate(attr.suggested_date)} so it appears in the prior month's tracker. Amount and bank link are unchanged.

{remaining > 0 && (

{remaining} more similar payment{remaining > 1 ? 's' : ''} to review after this.

)}
); } // Client-side fallbacks for the tracker display settings, merged under whatever // the server returns (an absent key falls back to "shown"). Mirrors the list in // SettingsPage's "Tracker Layout". const TRACKER_SETTING_DEFAULTS: Record = { tracker_show_bank_projection_banner: 'true', tracker_bank_projection_banner_snoozed_until: '', tracker_show_search_sort: 'true', tracker_show_summary_cards: 'true', tracker_show_safe_to_spend: 'true', tracker_show_overdue_command_center: 'true', tracker_show_drift_insights: 'true', tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]', }; export default function TrackerPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigateTo = useNavigate(); const now = new Date(); // All navigation + filter state lives in the URL so views are bookmarkable/shareable. const year = Number(searchParams.get('year')) || now.getFullYear(); const month = Number(searchParams.get('month')) || (now.getMonth() + 1); 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(() => ({ category: searchParams.get('fc') || FILTER_ALL, cycle: searchParams.get('cy') || FILTER_ALL, autopay: searchParams.get('ap') === '1', firstBucket: searchParams.get('b1') === '1', fifteenthBucket: searchParams.get('b2') === '1', unpaid: searchParams.get('un') === '1', overdue: searchParams.get('ov') === '1', debt: searchParams.get('de') === '1', }), [searchParams]); const sortKey = normalizeTrackerSortKey(searchParams.get('sort') || TRACKER_SORT_DEFAULT); const hasSort = sortKey !== TRACKER_SORT_DEFAULT; const sortDir = hasSort ? normalizeTrackerSortDir(searchParams.get('dir') || TRACKER_SORT_DEFAULT_DIRS[sortKey] || TRACKER_SORT_ASC) : TRACKER_SORT_ASC; // replace: true keeps history clean for rapid navigation (e.g. search keystrokes) const updateParams = useCallback((patch: Patch) => { setSearchParams(prev => { const next = new URLSearchParams(prev); Object.entries(patch).forEach(([k, v]) => { if (v == null || v === '' || v === false) next.delete(k); else next.set(k, v === true ? '1' : String(v)); }); return next; }, { replace: true }); }, [setSearchParams]); const [bankSyncing, setBankSyncing] = useState(false); const [lateAttributions, setLateAttributions] = useState([]); // pending month-attribution prompts const [attrBusy, setAttrBusy] = useState(null); // payment_id being resolved const [pinUpcoming, setPinUpcoming] = useState(() => localStorage.getItem('tracker_pin_upcoming') === 'true'); const [editBillData, setEditBillData] = useState(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 [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); // Row to open in PaymentLedgerDialog via the overdue command center const [commandCenterPayRow, setCommandCenterPayRow] = useState(null); // Multi-select: bill ids checked for a bulk pay/skip action. const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkBusy, setBulkBusy] = useState(false); // Screen-reader announcement for drag/keyboard reorder (visually hidden). const [reorderAnnouncement, setReorderAnnouncement] = useState(''); // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data: driftDataRaw } = useDriftReport(); const driftData = driftDataRaw as { bills?: DriftBill[] } | undefined; // Display settings + SimpleFIN status via React Query (cached/deduped like the // rest of the app), with a save mutation that handles optimistic + rollback. const settingsQuery = useSettings(); const simplefinStatusQuery = useSimplefinStatus(); const saveSettings = useSaveSettings(); const trackerSettings = useMemo>( () => ({ ...TRACKER_SETTING_DEFAULTS, ...((settingsQuery.data as Record | undefined) ?? {}) }), [settingsQuery.data], ); const bankSyncStatus = (simplefinStatusQuery.data as BankSyncStatus | undefined) ?? null; const savingTrackerSetting = saveSettings.isPending; // 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. const invalidateData = useInvalidateTrackerData(); const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]); useEffect(() => { setOrderedRows(null); setMovingBillId(null); }, [dataUpdatedAt, year, month]); // Clear a stale multi-selection when navigating months (survives background // refetches within the same month). useEffect(() => { setSelectedIds(new Set()); }, [year, month]); // Surface a settings-load failure (React Query swallows it into isError). // The page still renders with TRACKER_SETTING_DEFAULTS. useEffect(() => { if (settingsQuery.isError) toast.error("Couldn't load tracker display settings — showing defaults."); }, [settingsQuery.isError]); // Listen for late-attribution events fired by BillModal's single-bill sync useEffect(() => { function handler(e: Event) { const attrs = (e as CustomEvent<{ attributions?: LateAttr[] }>).detail?.attributions; if (Array.isArray(attrs) && attrs.length > 0) { setLateAttributions(prev => [...prev, ...attrs]); } } window.addEventListener('tracker:late-attributions', handler); return () => window.removeEventListener('tracker:late-attributions', handler); }, []); function adjacentMonth(delta: number) { let nm = month + delta; let ny = year; if (nm > 12) { ny += 1; nm = 1; } if (nm < 1) { ny -= 1; nm = 12; } return { year: ny, month: nm }; } 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: number) { const { year: ny, month: nm } = adjacentMonth(delta); prefetchTracker(ny, nm); } function bankSyncMessage(result: SyncResult): string { const matched = result.auto_matched ?? 0; const newTx = result.transactions_new ?? 0; const billNames = result.matched_bills ?? []; if (matched > 0 && billNames.length > 0) { return `Synced — ${billNames.join(', ')} ✓` + (matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''); } if (matched > 0) return `Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`; if (newTx > 0) return `Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`; return 'Synced — no new transactions'; } async function handleBankSync() { setBankSyncing(true); // One toast that transitions loading → done, instead of a manual spinner + // a separate success/error toast. const promise = api.syncAllSources(); toast.promise(promise, { loading: 'Syncing bank…', success: (result: unknown) => bankSyncMessage(result as SyncResult), error: (err: unknown) => (err as { message?: string })?.message || 'Bank sync failed', }); try { 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 ?? []); invalidateData(); } catch { // toast.promise already surfaced the error } finally { setBankSyncing(false); } } function togglePinUpcoming() { setPinUpcoming(prev => { const next = !prev; localStorage.setItem('tracker_pin_upcoming', String(next)); return next; }); } // Show sync button when SimpleFIN is enabled, connected, and user has matching rules const showBankSync = bankSyncStatus?.enabled && bankSyncStatus?.has_connections && bankSyncStatus?.has_merchant_rules; async function handleOpenEditBill(row: TrackerRow) { try { const [bill, categories] = await Promise.all([ api.bill(row.id) as Promise, api.categories() as Promise, ]); setEditBillData({ bill, categories }); } catch (err) { toast.error((err as { message?: string })?.message || 'Failed to open bill'); } } async function handleOpenAddBill() { try { const categories = await api.categories() as Category[]; setEditBillData({ bill: null, categories }); } catch (err) { toast.error((err as { message?: string })?.message || 'Failed to open bill editor'); } } function goToday() { const n = new Date(); updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 }); } // Export the viewed month's activity as CSV via the existing /api/export // date-range endpoint (both bounds inclusive). async function handleExportMonthCsv() { const mm = String(month).padStart(2, '0'); const lastDay = new Date(year, month, 0).getDate(); const from = `${year}-${mm}-01`; const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`; try { await downloadFile(api.exportRangeUrl(from, to, 'csv'), `bills-${year}-${mm}.csv`); toast.success(`Exported ${from} – ${to} to CSV.`); } catch (err) { toast.error((err as { message?: string })?.message || 'Export failed'); } } // Jump to the calendar view for the month currently in view. function goToCalendar() { navigateTo(`/calendar?year=${year}&month=${month}`); } const handleToggleSelect = useCallback((id: number, next: boolean) => { setSelectedIds(prev => { const s = new Set(prev); if (next) s.add(id); else s.delete(id); return s; }); }, []); const clearSelection = useCallback(() => setSelectedIds(new Set()), []); // Pay every selected bill that still has a balance, in one bulk call (reuses // the same item shape + Undo as the per-bucket "Pay all due"). async function handlePaySelected() { const items = selectedRows .filter(r => !r.is_skipped && !rowIsPaid(r)) .map(r => { const threshold = Number(r.actual_amount ?? r.expected_amount ?? 0) || 0; const remaining = Math.max(threshold - (Number(r.total_paid) || 0), 0); return { bill_id: r.id, amount: remaining > 0 ? remaining : threshold, paid_date: paymentDateForTrackerMonth(year, month, r.due_day) }; }) .filter(item => item.amount > 0); if (items.length === 0) { toast.info('No unpaid bills selected.'); return; } const total = items.reduce((s, i) => s + i.amount, 0); setBulkBusy(true); try { const result = await api.bulkPay(items) as { created?: Array<{ id: number }> }; const created = result.created || []; if (created.length === 0) { toast.info('Those bills were already paid.'); } else { toast.success(`Paid ${created.length} bill${created.length === 1 ? '' : 's'} — ${fmt(total)}`, { action: { label: 'Undo', onClick: async () => { try { await Promise.all(created.map(p => api.deletePayment(p.id))); toast.success('Payments removed'); invalidateData(); } catch (err) { toast.error((err as { message?: string })?.message || 'Failed to undo payments.'); } }, }, }); } clearSelection(); invalidateData(); } catch (err) { toast.error((err as { message?: string })?.message || 'Failed to pay bills.'); } finally { setBulkBusy(false); } } // Skip every selected bill for this month. No bulk endpoint exists (skip has no // money effect), so fan out per-bill and aggregate the result; Undo un-skips. async function handleSkipSelected() { const toSkip = selectedRows.filter(r => !r.is_skipped); if (toSkip.length === 0) { toast.info('No skippable bills selected.'); return; } setBulkBusy(true); try { const results = await Promise.allSettled( toSkip.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: true, actual_amount: r.actual_amount, notes: r.monthly_notes })), ); const skipped = toSkip.filter((_, i) => results[i]?.status === 'fulfilled'); const failed = results.length - skipped.length; if (skipped.length > 0) { toast.success(`Skipped ${skipped.length} bill${skipped.length === 1 ? '' : 's'}${failed ? ` — ${failed} failed` : ''}`, { action: { label: 'Undo', onClick: async () => { try { await Promise.all(skipped.map(r => api.saveBillMonthlyState(r.id, { year, month, is_skipped: false, actual_amount: r.actual_amount, notes: r.monthly_notes }))); toast.success('Restored'); invalidateData(); } catch (err) { toast.error((err as { message?: string })?.message || 'Failed to undo.'); } }, }, }); } else { toast.error('Failed to skip the selected bills.'); } clearSelection(); invalidateData(); } finally { setBulkBusy(false); } } const rows = useMemo(() => orderedRows || data?.rows || [], [orderedRows, data]); const selectedRows = useMemo(() => rows.filter(r => selectedIds.has(r.id)), [rows, selectedIds]); const summary = (data?.summary ?? {}) as TrackerSummary; const bankTracking = data?.bank_tracking; const cashflow = data?.cashflow; const today = localDateString(); // Safe-to-spend projects from "now", so it only makes sense on the current month. const isCurrentMonth = today.startsWith(`${year}-${String(month).padStart(2, '0')}`); const bannerSnoozedUntil = trackerSettings.tracker_bank_projection_banner_snoozed_until || ''; const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort); const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards); const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center); const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend); const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights); const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) && (!bannerSnoozedUntil || bannerSnoozedUntil <= today); const visibleTableColumns = useMemo( () => parseTrackerTableColumns(trackerSettings.tracker_table_columns), [trackerSettings.tracker_table_columns] ); // Optimistic write + rollback now live in the useSaveSettings mutation; this // wrapper just fires it and owns the success/error toasts. function saveTrackerSettings(patch: Record, successMessage?: string) { saveSettings.mutate(patch, { onSuccess: () => { if (successMessage) toast.success(successMessage); }, onError: (err) => toast.error((err as { message?: string })?.message || 'Failed to update tracker setting'), }); } function snoozeBankProjectionBanner() { const until = new Date(); until.setDate(until.getDate() + 1); saveTrackerSettings({ tracker_bank_projection_banner_snoozed_until: localDateString(until), }, 'Bank projection banner snoozed until tomorrow.'); } function ignoreBankProjectionBanner() { saveTrackerSettings({ tracker_show_bank_projection_banner: 'false', tracker_bank_projection_banner_snoozed_until: '', }, 'Bank projection banner hidden. You can turn it back on in Settings.'); } function handleTableColumnToggle(columnKey: string, checked: boolean) { const nextColumns = checked ? [...visibleTableColumns, columnKey] : visibleTableColumns.filter(key => key !== columnKey); saveTrackerSettings({ tracker_table_columns: trackerTableColumnsToSetting(nextColumns), }); } function hideSearchSortPanel() { saveTrackerSettings({ tracker_show_search_sort: 'false' }); toast('Search & sort hidden.', { action: { label: 'Undo', onClick: () => saveTrackerSettings({ tracker_show_search_sort: 'true' }) }, }); } const toggleFilter = (key: BoolFilterKey) => { const paramMap: Record = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' }; updateParams({ [paramMap[key]]: !filters[key] }); }; 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: string) => { const normalizedKey = normalizeTrackerSortKey(key); if (normalizedKey === TRACKER_SORT_DEFAULT) { updateParams({ sort: null, dir: null }); return; } updateParams({ sort: normalizedKey, dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, }); }; const toggleSortDirection = () => { if (!hasSort) return; updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); }; const handleSortHeader = (key: string) => { const normalizedKey = normalizeTrackerSortKey(key); if (normalizedKey === TRACKER_SORT_DEFAULT) return; if (sortKey === normalizedKey) { updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); return; } updateParams({ sort: normalizedKey, dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, }); }; const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL || filters.cycle !== FILTER_ALL || filters.autopay || filters.firstBucket || filters.fifteenthBucket || filters.unpaid || filters.overdue || filters.debt || hasSort ); 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(); rows.forEach(row => { if (row.category_id && row.category_name) map.set(String(row.category_id), row.category_name); }); return Array.from(map, ([id, name]) => ({ id, name })).sort((a, b) => a.name.localeCompare(b.name)); }, [rows]); const cycleOptions = useMemo(() => ( Array.from(new Set(rows.map(scheduleValue))).sort() ), [rows]); // Defer the search term so the whole-list filter below (which re-scans every // row's haystack) runs off the urgent keystroke path — the input stays snappy // on big months while filtering catches up (React 19). const deferredSearch = useDeferredValue(search); const filteredRows = useMemo(() => { const q = deferredSearch.trim().toLowerCase(); return rows.filter(row => { const effectiveStatus = rowEffectiveStatus(row); if (filters.category !== FILTER_ALL && String(row.category_id ?? '') !== filters.category) return false; if (filters.cycle !== FILTER_ALL && scheduleValue(row) !== filters.cycle) return false; if (filters.autopay && !row.autopay_enabled) return false; if (filters.debt && !rowIsDebt(row)) return false; if (filters.unpaid && (row.is_skipped || rowIsPaid(row))) return false; if (filters.overdue && !(effectiveStatus === 'late' || effectiveStatus === 'missed')) return false; if (filters.firstBucket && !filters.fifteenthBucket && row.bucket !== '1st') return false; if (filters.fifteenthBucket && !filters.firstBucket && row.bucket !== '15th') return false; if (!q) return true; const haystack = [ row.name, row.category_name, row.notes, row.monthly_notes, scheduleValue(row), scheduleLabel(row), row.bucket, row.status, amountSearchText(row.expected_amount, row.actual_amount, row.total_paid, row.balance, row.current_balance, row.minimum_payment), ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); }, [filters, rows, deferredSearch]); // 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: Record = { missed: 0, late: 1, due_soon: 2, upcoming: 3 }; const sortedRows = hasSort ? sortTrackerRows(filteredRows, sortKey, sortDir) : pinUpcoming ? [...filteredRows].sort((a, b) => { // Rank on the reconciled status (same source the overdue filter uses), // so a bill that's paid-by-threshold but still carries a stale raw // 'late'/'missed' status sorts to the bottom instead of being pinned up. const ua = URGENCY_ORDER[rowEffectiveStatus(a)] ?? 99; const ub = URGENCY_ORDER[rowEffectiveStatus(b)] ?? 99; if (ua !== ub) return ua - ub; return (a.due_day ?? 99) - (b.due_day ?? 99); }) : filteredRows; const searchResultLabel = `${filteredRows.length} of ${rows.length} shown`; const first = sortedRows.filter(r => r.bucket === '1st'); const second = sortedRows.filter(r => r.bucket === '15th'); const reorderEnabled = !hasFilters && !loading && !isError && !pinUpcoming; async function persistTrackerOrder(nextRows: TrackerRow[], movedBillId: number | null) { const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index])); setOrderedRows(nextRows); setMovingBillId(movedBillId); try { await api.reorderBills(payload); toast.success('Bill order saved'); invalidateData(); } catch (err) { setOrderedRows(null); toast.error((err as { message?: string })?.message || 'Failed to save bill order'); } finally { setMovingBillId(null); } } function handleReorderBucket(bucket: string, orderedBucketRows: TrackerRow[]) { const sourceRows = rows; const nextRows = [...sourceRows]; const replacement = [...orderedBucketRows]; for (let i = 0; i < nextRows.length; i += 1) { 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)); if (moved) { const pos = orderedBucketRows.findIndex(r => r.id === moved.id) + 1; setReorderAnnouncement(`Moved ${moved.name} to position ${pos} of ${orderedBucketRows.length}.`); } persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id || null); } return (
{/* Visually-hidden live region: announces drag/keyboard reorder to screen readers */}
{reorderAnnouncement}
{/* ── Header ── */} {MONTHS[month - 1]} {year} )} glyph="tracker" meta={`${rows.length} ${rows.length === 1 ? 'bill' : 'bills'}`} actions={(
{showBankSync && ( )} {new Date(year, month - 1, 1).toLocaleString('en-US', { month: 'long' })} {year} Export CSV window.print()}> Print Keyboard shortcuts
{[ ['Move between bills', 'j / k · ↑ / ↓'], ['Edit focused bill', 'Enter'], ['Toggle paid', 'p'], ['Blur / deselect row', 'Esc'], ['Command palette', '⌘K / Ctrl+K'], ].map(([label, keys]) => (
{label} {keys}
))}

Focus any bill row (Tab or click), then use the keys above.

{MONTHS[month - 1]} {year}
)} /> {showSearchSort && (
)} >
toggleFilter('unpaid')}>Unpaid toggleFilter('overdue')}>Overdue toggleFilter('autopay')}>Autopay toggleFilter('firstBucket')}>1st bucket toggleFilter('fifteenthBucket')}>15th bucket toggleFilter('debt')}>Debt {filteredRows.length} of {rows.length} shown {hasSort && ( · sorted by {TRACKER_SORT_LABELS[sortKey]} {sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'} )}
)} {/* ── Summary cards (backend already excludes skipped from totals) ── */} {showSummaryCards && loading ? (
{summary.trend ? : null}
) : showSummaryCards ? (
{bankTracking?.enabled ? ( ) : ( { if (!summary.has_starting_amounts) return 'Set monthly starting cash'; if (cashflow?.has_data && cashflow.period_projected !== undefined) { const proj = Number(cashflow.period_projected); const sign = proj < 0 ? '−' : ''; return `→ ${sign}${fmt(Math.abs(proj))} projected by ${cashflow.period_end_label}`; } return ''; })()} onEdit={() => setEditStartingOpen(true)} /> )} 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. */} {!bankTracking?.enabled && ( )} {summary.trend ? [0]['trend']} /> : null}
) : null} {/* Compact month-progress line — quick "how far through the month am I?" */} {showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => { const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0); return (

{fmt(summary.paid_toward_due)} {' of '} {fmt(summary.total_expected)} {' paid'} {billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left}

); })()} {/* ── Safe to Spend ── */} {!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && ( setEditStartingOpen(true)} /> )} {/* ── Overdue Command Center ── */} {!isError && !loading && showOverdueCommandCenter && (summary?.count_late ?? 0) > 0 && ( setCommandCenterPayRow(row)} /> )} {/* ── Bank Projection Banner ── */} {!isError && !loading && showBankProjectionBanner && ( )} {/* ── Drift / Price-Change Insights ── */} {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && ( )} {/* ── Fetch error state ── */} {isError && ( refetch()} className="gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50" > Try again )} /> )} {/* ── Empty state ── */} {!isError && rows.length === 0 && data != null && ( Add a bill )} /> )} {/* ── Buckets — year and month passed so Row can open the correct monthly state dialog ── */} {!isError && loading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
{Array.from({ length: 3 }).map((_, i) => (
))}
)} {/* ── Filtered-to-empty state — bills exist this month but none match the active filters/search ── */} {!isError && !loading && rows.length > 0 && first.length === 0 && second.length === 0 && (
); }