feat(tracker): live cross-query invalidation for the app shell (X3)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 18:19:30 -05:00
parent 10e159352a
commit c91c97ef41
3 changed files with 35 additions and 13 deletions

View File

@ -3,6 +3,7 @@
### 🐛 Tracker & bill-modal hardening ### 🐛 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) - **[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) - **[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) - **[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)

View File

@ -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'; import { api } from '@/api';
// Custom hook for fetching tracker data // Custom hook for fetching tracker data
@ -50,3 +51,19 @@ export function useDriftReport() {
refetchOnWindowFocus: false, 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]);
}

View File

@ -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 { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown, BellOff, EyeOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { useTracker, useDriftReport } from '@/hooks/useQueries'; import { useTracker, useDriftReport, useInvalidateTrackerData } from '@/hooks/useQueries';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
@ -262,7 +262,11 @@ export default function TrackerPage() {
// Use React Query for data fetching // Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); 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]); const driftedIds = useMemo(() => new Set((driftData?.bills ?? []).map(b => b.id)), [driftData]);
useEffect(() => { useEffect(() => {
@ -329,7 +333,7 @@ export default function TrackerPage() {
// Surface late-attribution prompts (payments that just crossed a month boundary) // Surface late-attribution prompts (payments that just crossed a month boundary)
if (attributions.length > 0) setLateAttributions(attributions); if (attributions.length > 0) setLateAttributions(attributions);
refetch(); invalidateData();
} catch (err) { } catch (err) {
toast.error(err.message || 'Bank sync failed'); toast.error(err.message || 'Bank sync failed');
} finally { } finally {
@ -564,7 +568,7 @@ export default function TrackerPage() {
try { try {
await api.reorderBills(payload); await api.reorderBills(payload);
toast.success('Bill order saved'); toast.success('Bill order saved');
refetch(); invalidateData();
} catch (err) { } catch (err) {
setOrderedRows(null); setOrderedRows(null);
toast.error(err.message || 'Failed to save bill order'); toast.error(err.message || 'Failed to save bill order');
@ -862,7 +866,7 @@ export default function TrackerPage() {
rows={rows} rows={rows}
year={year} year={year}
month={month} month={month}
refresh={refetch} refresh={invalidateData}
onPayNow={(row) => setCommandCenterPayRow(row)} onPayNow={(row) => setCommandCenterPayRow(row)}
/> />
)} )}
@ -881,7 +885,7 @@ export default function TrackerPage() {
{!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && ( {!isError && !loading && showDriftInsights && (driftData?.bills?.length ?? 0) > 0 && (
<DriftInsightPanel <DriftInsightPanel
driftBills={driftData.bills} driftBills={driftData.bills}
refresh={() => { refetch(); refetchDrift(); }} refresh={invalidateData}
/> />
)} )}
@ -953,8 +957,8 @@ export default function TrackerPage() {
)} )}
{!isError && (first.length > 0 || second.length > 0) && ( {!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5"> <div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} {first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('1st', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />} {second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={invalidateData} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} driftedIds={driftedIds} visibleColumns={visibleTableColumns} />}
</div> </div>
)} )}
@ -966,7 +970,7 @@ export default function TrackerPage() {
initialBill={editBillData.initialBill} initialBill={editBillData.initialBill}
categories={editBillData.categories} categories={editBillData.categories}
onClose={() => setEditBillData(null)} onClose={() => setEditBillData(null)}
onSave={() => refetch()} onSave={() => invalidateData()}
onDuplicate={bill => setEditBillData({ onDuplicate={bill => setEditBillData({
bill: null, bill: null,
initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }), initialBill: makeBillDraft(bill, { copy: true, categories: editBillData.categories }),
@ -981,7 +985,7 @@ export default function TrackerPage() {
onClose={() => setEditStartingOpen(false)} onClose={() => setEditStartingOpen(false)}
year={year} year={year}
month={month} month={month}
onSave={() => { setEditStartingOpen(false); refetch(); }} onSave={() => { setEditStartingOpen(false); invalidateData(); }}
/> />
{/* Income breakdown modal — opens when clicking the bank balance card */} {/* 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' }); const month = new Date(attr.suggested_date + 'T00:00:00').toLocaleDateString('en-US', { month: 'long' });
toast.success(`${attr.bill_name} payment moved to ${month}`); toast.success(`${attr.bill_name} payment moved to ${month}`);
setLateAttributions(prev => prev.slice(1)); // dismiss only on success setLateAttributions(prev => prev.slice(1)); // dismiss only on success
refetch(); invalidateData();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to reclassify payment — try again'); toast.error(err.message || 'Failed to reclassify payment — try again');
// keep the attribution in queue so user can retry // keep the attribution in queue so user can retry
@ -1029,7 +1033,7 @@ export default function TrackerPage() {
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount} threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)} defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)} onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }} onSaved={() => { setCommandCenterPayRow(null); invalidateData(); }}
/> />
)} )}