refactor(tracker): move settings/simplefin onto React Query + defer search (plan B)

- B1: replace the two raw useEffect/api fetches (simplefinStatus, settings) with
  useSimplefinStatus() and useSettings() query hooks — cached/deduped like the
  rest of the app. Tracker settings now derive from the ['settings'] cache
  merged under module-level TRACKER_SETTING_DEFAULTS.
- B2: settings save is now useSaveSettings() — a useMutation with optimistic
  cache update + rollback on error, replacing the hand-rolled optimistic/refetch.
- B3: useDeferredValue on the search term feeding filteredRows so typing stays
  snappy on large months (matches BillsPage/SubscriptionsPage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 14:43:13 -05:00
parent 03009e2a83
commit 313aafcb79
2 changed files with 93 additions and 44 deletions

View File

@ -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<string, unknown>;
// 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<string, string>) => api.saveSettings(patch),
onMutate: async (patch) => {
await queryClient.cancelQueries({ queryKey: ['settings'] });
const previous = queryClient.getQueryData<SettingsMap>(['settings']);
queryClient.setQueryData<SettingsMap>(['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

View File

@ -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<string, string> = {
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<BankSyncStatus | null>(null);
const [bankSyncing, setBankSyncing] = useState(false);
const [lateAttributions, setLateAttributions] = useState<LateAttr[]>([]); // pending month-attribution prompts
const [attrBusy, setAttrBusy] = useState<number | null>(null); // payment_id being resolved
@ -298,17 +310,6 @@ export default function TrackerPage() {
const [orderedRows, setOrderedRows] = useState<TrackerRow[] | null>(null);
const [movingBillId, setMovingBillId] = useState<number | null>(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [trackerSettings, setTrackerSettings] = useState<Record<string, string>>({
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<TrackerRow | null>(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<Record<string, string>>(
() => ({ ...TRACKER_SETTING_DEFAULTS, ...((settingsQuery.data as Record<string, string> | 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<string, string>) })))
.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<string, string>, successMessage?: string) {
setSavingTrackerSetting(true);
setTrackerSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch) as Record<string, string>;
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<string, string>) })))
.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<string, string>, 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<string, number> = { missed: 0, late: 1, due_soon: 2, upcoming: 3 };