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:
parent
03009e2a83
commit
313aafcb79
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue