diff --git a/client/hooks/useQueries.ts b/client/hooks/useQueries.ts index 0d064ec..c7ef2a0 100644 --- a/client/hooks/useQueries.ts +++ b/client/hooks/useQueries.ts @@ -1,7 +1,9 @@ -import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useCallback } from 'react'; import { api, type QueryParams } from '@/api'; +type SettingsMap = Record; + // Custom hook for fetching tracker data export function useTracker(year: number, month: number) { return useQuery({ @@ -52,6 +54,51 @@ export function useDriftReport() { }); } +// App display settings (tracker/spending layout toggles, table columns) — a +// string key/value map persisted server-side. Changes rarely; mutated via +// useSaveSettings below. +export function useSettings() { + return useQuery({ + queryKey: ['settings'], + queryFn: () => api.settings(), + staleTime: 1000 * 60 * 5, + }); +} + +// SimpleFIN connection status — decides whether the tracker shows the Sync +// button. Independent of tracker data, so it gets its own cache entry. +export function useSimplefinStatus() { + return useQuery({ + queryKey: ['simplefin-status'], + queryFn: () => api.simplefinStatus(), + staleTime: 1000 * 60 * 5, + }); +} + +// Persist a settings patch with an optimistic cache update + rollback on error. +// Replaces the hand-rolled optimistic/refetch-rollback the tracker used to do by +// hand. onSettled invalidates so the authoritative server values reconcile. +export function useSaveSettings() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (patch: Record) => api.saveSettings(patch), + onMutate: async (patch) => { + await queryClient.cancelQueries({ queryKey: ['settings'] }); + const previous = queryClient.getQueryData(['settings']); + queryClient.setQueryData(['settings'], (old) => ({ ...(old ?? {}), ...patch })); + return { previous }; + }, + onError: (_err, _patch, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(['settings'], context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['settings'] }); + }, + }); +} + // A single invalidation for every query a bill mutation can affect. Previously a // row action only refetch()'d the one tracker query passed down as `refresh`, so // the sidebar overdue badge (['overdue-count'], 2-min staleTime), the drift diff --git a/client/pages/TrackerPage.tsx b/client/pages/TrackerPage.tsx index 612d4b0..85cec3e 100644 --- a/client/pages/TrackerPage.tsx +++ b/client/pages/TrackerPage.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect, useMemo, useCallback, type ReactNode } from 'react'; +import { useState, useEffect, useMemo, useCallback, useDeferredValue, 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'; import { api } from '@/api'; -import { useTracker, useDriftReport, useInvalidateTrackerData, usePrefetchTracker } from '@/hooks/useQueries'; +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'; @@ -247,6 +247,20 @@ function LateAttributionDialog({ attr, remaining, busy, onAccept, onDismiss }: { ); } +// 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 now = new Date(); @@ -285,8 +299,6 @@ export default function TrackerPage() { }, { 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 @@ -298,17 +310,6 @@ export default function TrackerPage() { 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_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"]', - }); - const [savingTrackerSetting, setSavingTrackerSetting] = useState(false); // Row to open in PaymentLedgerDialog via the overdue command center const [commandCenterPayRow, setCommandCenterPayRow] = useState(null); @@ -317,6 +318,18 @@ export default function TrackerPage() { 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. @@ -328,18 +341,11 @@ export default function TrackerPage() { setMovingBillId(null); }, [dataUpdatedAt, year, month]); - // Load SimpleFIN status once to decide whether to show the sync button + // Surface a settings-load failure (React Query swallows it into isError). + // The page still renders with TRACKER_SETTING_DEFAULTS. useEffect(() => { - api.simplefinStatus() - .then(s => setBankSyncStatus(s as BankSyncStatus)) - .catch(() => setBankSyncStatus(null)); - }, []); - - useEffect(() => { - api.settings() - .then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record) }))) - .catch(() => toast.error("Couldn't load tracker display settings — showing defaults.")); - }, []); + 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(() => { @@ -466,21 +472,13 @@ export default function TrackerPage() { [trackerSettings.tracker_table_columns] ); - async function saveTrackerSettings(patch: Record, successMessage?: string) { - setSavingTrackerSetting(true); - setTrackerSettings(prev => ({ ...prev, ...patch })); - try { - const next = await api.saveSettings(patch) as Record; - setTrackerSettings(prev => ({ ...prev, ...next })); - if (successMessage) toast.success(successMessage); - } catch (err) { - toast.error((err as { message?: string })?.message || 'Failed to update tracker setting'); - api.settings() - .then(settings => setTrackerSettings(prev => ({ ...prev, ...(settings as Record) }))) - .catch(() => {}); - } finally { - setSavingTrackerSetting(false); - } + // 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() { @@ -576,8 +574,12 @@ export default function TrackerPage() { 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 = search.trim().toLowerCase(); + 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; @@ -603,7 +605,7 @@ export default function TrackerPage() { ].filter(Boolean).join(' ').toLowerCase(); return haystack.includes(q); }); - }, [filters, rows, search]); + }, [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 };