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:
parent
10e159352a
commit
c91c97ef41
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue