From c91c97ef41693de9c9fd4664045e971f4b0da7ef Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 18:19:30 -0500 Subject: [PATCH] feat(tracker): live cross-query invalidation for the app shell (X3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app never called invalidateQueries; a Tracker mutation only refetch()'d the one tracker query. So the sidebar overdue badge (['overdue-count'], 2-min staleTime), drift report, and bills list stayed stale after pay/skip/ edit β€” you could clear your last overdue bill and still see '3' for minutes. Added useInvalidateTrackerData() (tracker + overdue-count + drift-report + bills) and wired it into rows, BillModal.onSave, bank-sync, reorder, and the payment/late-attribution handlers. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + client/hooks/useQueries.js | 19 ++++++++++++++++++- client/pages/TrackerPage.jsx | 28 ++++++++++++++++------------ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index a1046c7..148eb5d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ ### πŸ› Tracker & bill-modal hardening +- **[Tracker] Sidebar overdue badge (and drift/bills) went stale after row actions** β€” the app never called `queryClient.invalidateQueries`; a Tracker mutation only `refetch()`'d the single tracker query passed down as `refresh`. So after paying/skipping/editing a bill, the sidebar **overdue badge** (`['overdue-count']`, 2-minute staleTime) kept its old number for up to two minutes β€” you could clear your last overdue bill and still see "3" β€” and the drift report / bills list didn't update either. Added a `useInvalidateTrackerData()` hook (invalidates `tracker` + `overdue-count` + `drift-report` + `bills`) and wired it in place of the bare `refetch` handed to the rows, `BillModal.onSave`, bank-sync, reorder, and the payment/late-attribution handlers, so the whole shell updates live. (Tracker X3) - **[Notifications] "Reminder days before" was a dead setting β€” the notifier ignored it** β€” every bill has a `reminder_days_before` column (default 3) and the bill modal exposed a "Reminder Days" control for subscriptions, but `services/notificationService.js` used a hard-coded schedule (early reminder always at exactly 3 days out) and never read the column. A user who set "remind me 7 days before" still only got the fixed 3-day/1-day/today reminders. The early reminder now fires at the bill's own `reminder_days_before` lead (only when β‰₯ 2 days, so it never collides with the 1-day/same-day reminders), and the email subject + body say "due in N days" using that value. The lead-time selection was pulled into a pure, exported `reminderTypeFor(bill, diffDays)` so it's unit-tested directly (`tests/notificationLeadTime.test.js`) β€” default 3 stays backwards-compatible. The **"Reminder Days Before" control now shows for every bill** (not just subscriptions), and saving a non-subscription bill no longer clobbers the column back to 3. (Tracker BM3) - **[Bill modal/SimpleFIN] Importing bank payments didn't refresh the payment list or the Tracker** β€” the two flows in the bill modal that *create* payments β€” **Sync** (`syncBillSimplefinPayments`) and a **merchant-rule historical import** (`onRulesChanged` β†’ `importHistoricalPayments`) β€” only reloaded the linked-transactions list, unlike the unmatch handlers which correctly reload payments *and* linked transactions *and* call `onSave`. So after importing, say, 3 payments from bank history, the modal's Payment History stayed stale and the Tracker row behind it kept showing "due/overdue" even though the bill was now covered β€” until you closed and reopened. Both paths now `await Promise.all([loadPayments(), loadLinkedTransactions()])` then `onSave?.()`, matching the unmatch pattern, so imported payments appear immediately and the Tracker updates live. (The SimpleFIN *search/preview/candidate* flow was already correct.) (Tracker BM4) - **[Tracker/SimpleFIN] Bank card's "unpaid this month" and "remaining" over-counted off-month bills** β€” `buildBankTracking` (`services/trackerService.js`) summed `expected_amount` for *all* active unpaid bills via SQL with no occurrence gate, so an annual or off-month quarterly bill inflated `unpaid_this_month` (and therefore the bank `remaining`) even though the Tracker rows beside it correctly excluded it β€” the same class of bug as QA-B5-02, still live on the bank path. `getTracker` now derives the unpaid total from the already-gated rows (via `resolveDueDate`), netting partial payments, and passes it into `buildBankTracking`. Also made `summary.remaining` / `total_remaining` use the bank card's own remaining when bank tracking is on (they previously used manual starting-amount math even in bank mode, disagreeing with safe-to-spend), and switched a stray `balance / 100` to `fromCents`. New test file `tests/trackerService.test.js` covers the gating fix, summary totals, the bank-mode remaining agreement, cents↔dollars integrity, and `getOverdueCount` gating β€” the dense Tracker aggregation had no dedicated tests before. (Tracker T1) diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.js index a980b31..2a66c6e 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.js @@ -1,4 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; import { api } from '@/api'; // Custom hook for fetching tracker data @@ -50,3 +51,19 @@ export function useDriftReport() { refetchOnWindowFocus: false, }); } + +// 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 +// report and the bills list stayed stale after paying/skipping/editing a bill β€” +// e.g. clearing your last overdue bill still showed "3" on the badge for minutes. +// Hand this to rows / BillModal.onSave / payment + sync handlers so the whole +// shell updates live. Returns a stable callback safe to use as an effect dep. +export function useInvalidateTrackerData() { + const queryClient = useQueryClient(); + return useCallback(() => { + for (const key of [['tracker'], ['overdue-count'], ['drift-report'], ['bills']]) { + queryClient.invalidateQueries({ queryKey: key }); + } + }, [queryClient]); +} diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 39ee84f..0dd0984 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -3,7 +3,7 @@ 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.js'; -import { useTracker, useDriftReport } from '@/hooks/useQueries'; +import { useTracker, useDriftReport, useInvalidateTrackerData } from '@/hooks/useQueries'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import BillModal from '@/components/BillModal'; import { makeBillDraft } from '@/lib/billDrafts'; @@ -262,7 +262,11 @@ export default function TrackerPage() { // Use React Query for data fetching const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); - const { data: driftData, refetch: refetchDrift } = useDriftReport(); + const { data: driftData } = useDriftReport(); + // 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. + const invalidateData = useInvalidateTrackerData(); const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]); useEffect(() => { @@ -329,7 +333,7 @@ export default function TrackerPage() { // Surface late-attribution prompts (payments that just crossed a month boundary) if (attributions.length > 0) setLateAttributions(attributions); - refetch(); + invalidateData(); } catch (err) { toast.error(err.message || 'Bank sync failed'); } finally { @@ -564,7 +568,7 @@ export default function TrackerPage() { try { await api.reorderBills(payload); toast.success('Bill order saved'); - refetch(); + invalidateData(); } catch (err) { setOrderedRows(null); toast.error(err.message || 'Failed to save bill order'); @@ -862,7 +866,7 @@ export default function TrackerPage() { rows={rows} year={year} month={month} - refresh={refetch} + refresh={invalidateData} onPayNow={(row) => setCommandCenterPayRow(row)} /> )} @@ -881,7 +885,7 @@ export default function TrackerPage() { {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && ( { refetch(); refetchDrift(); }} + refresh={invalidateData} /> )} @@ -953,8 +957,8 @@ export default function TrackerPage() { )} {!isError && (first.length > 0 || second.length > 0) && (
- {first.length > 0 && handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} - {second.length > 0 && handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} + {first.length > 0 && handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} + {second.length > 0 && handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
)} @@ -966,7 +970,7 @@ export default function TrackerPage() { initialBill={editBillData.initialBill} categories={editBillData.categories} onClose={() => setEditBillData(null)} - onSave={() => refetch()} + onSave={() => invalidateData()} onDuplicate={bill => setEditBillData({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), @@ -981,7 +985,7 @@ export default function TrackerPage() { onClose={() => setEditStartingOpen(false)} year={year} month={month} - onSave={() => { setEditStartingOpen(false); refetch(); }} + onSave={() => { setEditStartingOpen(false); invalidateData(); }} /> {/* Income breakdown modal β€” opens when clicking the bank balance card */} @@ -1008,7 +1012,7 @@ export default function TrackerPage() { 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(); + invalidateData(); } catch (err) { toast.error(err.message || 'Failed to reclassify payment β€” try again'); // keep the attribution in queue so user can retry @@ -1029,7 +1033,7 @@ export default function TrackerPage() { threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount} defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)} onClose={() => setCommandCenterPayRow(null)} - onSaved={() => { setCommandCenterPayRow(null); refetch(); }} + onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }} /> )}