import { useState, useEffect, useMemo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { useTracker, useDriftReport } 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, fmt } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
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 { 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';
function fmtBalanceAge(isoStr) {
if (!isoStr) return null;
return new Date(isoStr).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function localDateString(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function settingEnabled(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
function BankProjectionBanner({ bankTracking, onSnooze, onIgnore, busy }) {
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
);
}
// ── Main page ──────────────────────────────────────────────────────────────
function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }) {
if (!attr) return null;
const fmtDate = (d) => 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 (
);
}
export default function TrackerPage() {
const [searchParams, setSearchParams] = useSearchParams();
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') || '';
const filters = {
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',
};
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) => {
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]);
// Edit Bill modal: { bill, categories } when open, null when closed
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 [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();
const [trackerSettings, setTrackerSettings] = useState({
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
});
const [savingTrackerSetting, setSavingTrackerSetting] = useState(false);
// Row to open in PaymentLedgerDialog via the overdue command center
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, refetch: refetchDrift } = useDriftReport();
const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => {
setOrderedRows(null);
setMovingBillId(null);
}, [dataUpdatedAt, year, month]);
// Load SimpleFIN status once to decide whether to show the sync button
useEffect(() => {
api.simplefinStatus()
.then(setBankSyncStatus)
.catch(() => setBankSyncStatus(null));
}, []);
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync
useEffect(() => {
function handler(e) {
const attrs = e.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 navigate(delta) {
let nm = month + delta;
let ny = year;
if (nm > 12) { ny += 1; nm = 1; }
if (nm < 1) { ny -= 1; nm = 12; }
updateParams({ year: ny, month: nm });
}
async function handleBankSync() {
setBankSyncing(true);
try {
const result = await api.syncAllSources();
const matched = result.auto_matched ?? 0;
const newTx = result.transactions_new ?? 0;
const billNames = result.matched_bills ?? [];
const attributions = result.late_attributions ?? [];
if (matched > 0 && billNames.length > 0) {
toast.success(
`Synced — ${billNames.join(', ')} ✓` +
(matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''),
{ duration: 5000 }
);
} else if (matched > 0) {
toast.success(`Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`);
} else if (newTx > 0) {
toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`);
} else {
toast.success('Synced — no new transactions');
}
// Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions);
refetch();
} catch (err) {
toast.error(err.message || 'Bank sync failed');
} 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) {
try {
const [bill, categories] = await Promise.all([
api.bill(row.id),
api.categories(),
]);
setEditBillData({ bill, categories });
} catch (err) {
toast.error(err.message);
}
}
async function handleOpenAddBill() {
try {
const categories = await api.categories();
setEditBillData({ bill: null, categories });
} catch (err) {
toast.error(err.message || 'Failed to open bill editor');
}
}
function goToday() {
const n = new Date();
updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 });
}
const rows = orderedRows || data?.rows || [];
const summary = data?.summary || {};
const bankTracking = data?.bank_tracking;
const cashflow = data?.cashflow;
const today = localDateString();
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 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]
);
async function saveTrackerSettings(patch, successMessage) {
setSavingTrackerSetting(true);
setTrackerSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch);
setTrackerSettings(prev => ({ ...prev, ...next }));
if (successMessage) toast.success(successMessage);
} catch (err) {
toast.error(err.message || 'Failed to update tracker setting');
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
} finally {
setSavingTrackerSetting(false);
}
}
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, checked) {
const nextColumns = checked
? [...visibleTableColumns, columnKey]
: visibleTableColumns.filter(key => key !== columnKey);
saveTrackerSettings({
tracker_table_columns: trackerTableColumnsToSetting(nextColumns),
});
}
const toggleFilter = (key) => {
const paramMap = { 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' };
updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value });
};
const setSort = (key) => {
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) => {
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 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]);
const filteredRows = useMemo(() => {
const q = search.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, 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 sortedRows = hasSort
? sortTrackerRows(filteredRows, sortKey, sortDir)
: pinUpcoming
? [...filteredRows].sort((a, b) => {
const ua = URGENCY_ORDER[a.status] ?? 99;
const ub = URGENCY_ORDER[b.status] ?? 99;
if (ua !== ub) return ua - ub;
return (a.due_day ?? 99) - (b.due_day ?? 99);
})
: filteredRows;
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, movedBillId) {
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');
refetch();
} catch (err) {
setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order');
} finally {
setMovingBillId(null);
}
}
function handleReorderBucket(bucket, orderedBucketRows) {
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 moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id));
persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id);
}
return (
{/* ── Header ── */}
Monthly Overview
{MONTHS[month - 1]}
{year}
{rows.length} {rows.length === 1 ? 'bill' : 'bills'}
{showBankSync && (
)}
{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 && }
) : 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)}
/>
)}
{summary.trend && }
) : null}
{/* ── 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 && (
{ refetch(); refetchDrift(); }}
/>
)}
{/* ── Fetch error state ── */}
{isError && (
Failed to load tracker data
{error?.message || 'An unexpected error occurred.'}
)}
{/* ── Empty state ── */}
{!isError && rows.length === 0 && data !== null && (
)}
{/* ── 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) => (
))}
)}
{!isError && (first.length > 0 || second.length > 0) && (
{first.length > 0 && handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
{second.length > 0 && handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} onColumnToggle={handleTableColumnToggle} columnsSaving={savingTrackerSetting} />}
)}
{/* Edit Bill modal — opened by clicking a bill name in any tracker row */}
{editBillData && (
setEditBillData(null)}
onSave={() => { setEditBillData(null); refetch(); }}
onDuplicate={bill => setEditBillData({
bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
categories: editBillData.categories,
})}
/>
)}
{/* Edit Starting Amounts modal */}
setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
{/* Income breakdown modal — opens when clicking the bank balance card */}
{bankTracking?.enabled && (
setIncomeModalOpen(false)}
year={year}
month={month}
bankTracking={bankTracking}
/>
)}
{/* Late-attribution dialog — fires after sync when a payment just crossed a month boundary */}
{lateAttributions.length > 0 && (
{
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}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success
refetch();
} catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry
} finally {
setAttrBusy(null);
}
}}
onDismiss={() => setLateAttributions(prev => prev.slice(1))}
/>
)}
{/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
{commandCenterPayRow && (
setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
/>
)}
);
}