diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.tsx similarity index 87% rename from client/pages/TrackerPage.jsx rename to client/pages/TrackerPage.tsx index 09a3b39..d532f27 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.tsx @@ -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; + 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 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 ( @@ -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(() => ({ 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(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([]); // 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); + 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 [orderedRows, setOrderedRows] = useState(null); + const [movingBillId, setMovingBillId] = useState(null); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); - const [trackerSettings, setTrackerSettings] = useState({ + const [trackerSettings, setTrackerSettings] = useState>({ 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(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) }))) .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, + api.categories() as Promise, ]); 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, successMessage?: string) { setSavingTrackerSetting(true); setTrackerSettings(prev => ({ ...prev, ...patch })); try { - const next = await api.saveSettings(patch); + const next = await api.saveSettings(patch) as Record; 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) }))) .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 = { 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(); 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 = { 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() { - {summary.trend && } + {summary.trend ? : null} ) : showSummaryCards ? (
@@ -866,7 +927,7 @@ export default function TrackerPage() { 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() { /> )} - {summary.trend && } + {summary.trend ? [0]['trend']} /> : null}
) : 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 (

@@ -928,7 +989,7 @@ export default function TrackerPage() { {/* ── Drift / Price-Change Insights ── */} {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && ( )} @@ -954,7 +1015,7 @@ export default function TrackerPage() { )} {/* ── Empty state ── */} - {!isError && rows.length === 0 && data !== null && ( + {!isError && rows.length === 0 && data != null && (

@@ -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, 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(); }}