Compare commits

..

No commits in common. "c86acd7d75c2ae8878bdce346ca19743a6f6741f" and "1fe71a7d5e8b946ea3a0cecf86184a3e9ecdeafb" have entirely different histories.

7 changed files with 56 additions and 101 deletions

View File

@ -9,10 +9,6 @@
- **[Tracker] Killed the getTracker N+1 (was ~23 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1) - **[Tracker] Killed the getTracker N+1 (was ~23 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)
### ⚛️ React Query — deeper adoption
- **[Client] Completed the React Query adoption with global error handling, prefetching, and mutations** — building on the page migration: (1) a global `QueryCache.onError` handler surfaces a toast when a *background refetch* fails while stale data is on screen (pages still render inline errors on the initial load), restoring the load-error feedback centrally; (2) `usePrefetchTracker()` warms the adjacent month's cache on month-nav hover/focus, so clicking prev/next is instant; (3) the quick-pay action — previously duplicated verbatim across the desktop and mobile tracker rows — is now a shared `useQuickPay()` `useMutation` hook (its `isPending` replaces a manual busy flag, and the tracker/badge caches invalidate on settle). (React Query mutation follow-ups: pay/skip and other writes can adopt the same hook pattern incrementally.)
### ⚛️ React Query migration (all manual-fetch pages) ### ⚛️ React Query migration (all manual-fetch pages)
- **[Client] Moved the 7 remaining manual-fetch pages onto React Query** — Analytics, Bills, Subscriptions, Summary, Snowball, Spending, and Bank transactions each fetched with hand-rolled `useEffect` + `load()` + `useState(data/loading/error)`. Migrated them all to `useQuery` hooks (in `client/hooks/useQueries.js`) whose keys encode the params, so React Query now provides caching, request dedup, cancellation, and out-of-order-response safety for free — the manual request-sequence guards added earlier were removed. `keepPreviousData` keeps the last result visible while a new month/filter/page loads. Mutations that previously called `load()` now `invalidateQueries`, and optimistic edits (list reorder/delete, categorize, budget/plan changes) write through `queryClient.setQueryData` wrappers so every existing call site works unchanged. Editable form fields seeded from the data (Summary starting-amounts/income, Snowball settings, Spending budgets) are now seeded via a data-synced effect; pagination (Spending/Bank) is a page-keyed query. Bonus: because Bills reuses the shared `['bills']` cache, bill mutations there now also refresh the Tracker/overdue badge live. Each page was its own commit, verified with `npm run lint` (0 errors) + `npm run test:client` + `npm run build`. (R5) - **[Client] Moved the 7 remaining manual-fetch pages onto React Query** — Analytics, Bills, Subscriptions, Summary, Snowball, Spending, and Bank transactions each fetched with hand-rolled `useEffect` + `load()` + `useState(data/loading/error)`. Migrated them all to `useQuery` hooks (in `client/hooks/useQueries.js`) whose keys encode the params, so React Query now provides caching, request dedup, cancellation, and out-of-order-response safety for free — the manual request-sequence guards added earlier were removed. `keepPreviousData` keeps the last result visible while a new month/filter/page loads. Mutations that previously called `load()` now `invalidateQueries`, and optimistic edits (list reorder/delete, categorize, budget/plan changes) write through `queryClient.setQueryData` wrappers so every existing call site works unchanged. Editable form fields seeded from the data (Summary starting-amounts/income, Snowball settings, Spending budgets) are now seeded via a data-synced effect; pagination (Spending/Bank) is a page-keyed query. Bonus: because Bills reuses the shared `['bills']` cache, bill mutations there now also refresh the Tracker/overdue badge live. Each page was its own commit, verified with `npm run lint` (0 errors) + `npm run test:client` + `npm run build`. (R5)

View File

@ -13,21 +13,10 @@ import ErrorBoundary from '@/components/ErrorBoundary';
import PageLoader from '@/components/PageLoader'; import PageLoader from '@/components/PageLoader';
// TanStack Query // TanStack Query
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { toast } from 'sonner';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
// Global error handling: pages render an inline error on the *initial* load
// (no data yet), so only surface a toast when a *background refetch* fails
// while stale data is still shown otherwise the failure would be silent.
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(error?.message || 'Could not refresh — showing the last data.');
}
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 2, // 2 minutes staleTime: 1000 * 60 * 2, // 2 minutes

View File

@ -12,7 +12,6 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { useQuickPay } from '@/hooks/usePaymentActions';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton'; import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
@ -46,7 +45,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
const [confirmUnpay, setConfirmUnpay] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false);
const [suggestionLoading, setSuggestionLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false);
const quickPay = useQuickPay(); const [quickPaySaving, setQuickPaySaving] = useState(false);
// Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared // Optimistic paid/unpaid flip instant on tap, rolled back on error, cleared
// when fresh server data arrives (effect below). // when fresh server data arrives (effect below).
const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [optimisticPaid, setOptimisticPaid] = useState(undefined);
@ -75,11 +74,33 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0); const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
const summary = paymentSummary(row, threshold); const summary = paymentSummary(row, threshold);
function handleQuickPay() { async function handleQuickPay() {
if (quickPay.isPending) return; if (quickPaySaving) return;
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
quickPay.run(row, val, defaultPaymentDate); setQuickPaySaving(true);
try {
const payment = await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success(`${row.name}${fmt(val)} paid`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.deletePayment(payment.id);
toast.success('Payment removed');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to undo payment.');
}
},
},
});
refresh();
} catch (err) {
toast.error(err.message || 'Failed to add payment.');
} finally {
setQuickPaySaving(false);
}
} }
async function performTogglePaid() { async function performTogglePaid() {
@ -343,15 +364,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60" className="tracker-number h-8 w-24 text-right text-sm font-medium bg-background/70 border-border/60"
title="Payment amount" title="Payment amount"
aria-label={`${row.name} payment amount`} aria-label={`${row.name} payment amount`}
disabled={quickPay.isPending} disabled={quickPaySaving}
/> />
<Button <Button
size="sm" variant="default" size="sm" variant="default"
onClick={handleQuickPay} onClick={handleQuickPay}
disabled={quickPay.isPending} disabled={quickPaySaving}
className="h-8 px-3 text-xs font-semibold" className="h-8 px-3 text-xs font-semibold"
> >
{quickPay.isPending ? 'Adding...' : 'Add'} {quickPaySaving ? 'Adding...' : 'Add'}
</Button> </Button>
</div> </div>
)} )}

View File

@ -16,7 +16,6 @@ import {
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import PaymentModal from '@/components/tracker/PaymentModal'; import PaymentModal from '@/components/tracker/PaymentModal';
import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils';
import { useQuickPay } from '@/hooks/usePaymentActions';
import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns';
import { StatusBadge } from '@/components/tracker/StatusBadge'; import { StatusBadge } from '@/components/tracker/StatusBadge';
import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentProgress } from '@/components/tracker/PaymentProgress';
@ -39,7 +38,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [showUpdateNudge, setShowUpdateNudge] = useState(false);
const [nudgeAmount, setNudgeAmount] = useState(null); const [nudgeAmount, setNudgeAmount] = useState(null);
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const quickPay = useQuickPay();
const visibleColumnSet = new Set(visibleColumns); const visibleColumnSet = new Set(visibleColumns);
const showColumn = key => visibleColumnSet.has(key); const showColumn = key => visibleColumnSet.has(key);
@ -81,10 +79,31 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
function handleQuickPay() { async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value); const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; } if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
quickPay.run(row, val, defaultPaymentDate); try {
const payment = await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
// Specific message + Undo, matching the un-pay affordance (quick-pay is
// just as reversible deleting the payment we just created).
toast.success(`${row.name}${fmt(val)} paid`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.deletePayment(payment.id);
toast.success('Payment removed');
refresh?.();
} catch (err) {
toast.error(err.message || 'Failed to undo payment.');
}
},
},
});
refresh();
} catch (err) {
toast.error(err.message || 'Failed to add payment.');
}
} }
async function performTogglePaid() { async function performTogglePaid() {

View File

@ -1,42 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { fmt } from '@/lib/utils';
import { useInvalidateTrackerData } from '@/hooks/useQueries';
// Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a
// reversible Undo toast. A React Query mutation: `isPending` comes for free and
// the tracker/badge caches are invalidated on settle. Shared by the desktop and
// mobile tracker rows so the flow lives in one place.
export function useQuickPay() {
const invalidate = useInvalidateTrackerData();
const mutation = useMutation({
mutationFn: (payload) => api.quickPay(payload),
onSettled: () => invalidate(),
});
const run = (row, val, paidDate) => mutation.mutate(
{ bill_id: row.id, amount: val, paid_date: paidDate },
{
onSuccess: (payment) => {
toast.success(`${row.name}${fmt(val)} paid`, {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.deletePayment(payment.id);
toast.success('Payment removed');
invalidate();
} catch (err) {
toast.error(err.message || 'Failed to undo payment.');
}
},
},
});
},
onError: (err) => toast.error(err.message || 'Failed to add payment.'),
},
);
return { run, isPending: mutation.isPending };
}

View File

@ -68,19 +68,6 @@ export function useInvalidateTrackerData() {
}, [queryClient]); }, [queryClient]);
} }
// Prefetch a tracker month into the cache (e.g. on month-nav hover) so switching
// to it is instant. No-op if it's already cached and fresh.
export function usePrefetchTracker() {
const queryClient = useQueryClient();
return useCallback((year, month) => {
queryClient.prefetchQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
staleTime: 1000 * 60 * 5,
});
}, [queryClient]);
}
// ── Page data (migrated off manual useEffect + load()) ─────────────────────── // ── Page data (migrated off manual useEffect + load()) ───────────────────────
// The queryKey encodes the params, so React Query handles caching, request // The queryKey encodes the params, so React Query handles caching, request
// dedup, cancellation, and out-of-order responses — no manual sequence guards. // dedup, cancellation, and out-of-order responses — no manual sequence guards.

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, useInvalidateTrackerData, usePrefetchTracker } 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';
@ -301,23 +301,12 @@ export default function TrackerPage() {
return () => window.removeEventListener('tracker:late-attributions', handler); return () => window.removeEventListener('tracker:late-attributions', handler);
}, []); }, []);
function adjacentMonth(delta) { function navigate(delta) {
let nm = month + delta; let nm = month + delta;
let ny = year; let ny = year;
if (nm > 12) { ny += 1; nm = 1; } if (nm > 12) { ny += 1; nm = 1; }
if (nm < 1) { ny -= 1; nm = 12; } if (nm < 1) { ny -= 1; nm = 12; }
return { year: ny, month: nm }; updateParams({ year: ny, month: nm });
}
function navigate(delta) {
updateParams(adjacentMonth(delta));
}
// Prefetch the adjacent month on hover/focus so clicking prev/next is instant.
const prefetchTracker = usePrefetchTracker();
function prefetchAdjacent(delta) {
const { year: ny, month: nm } = adjacentMonth(delta);
prefetchTracker(ny, nm);
} }
function bankSyncMessage(result) { function bankSyncMessage(result) {
@ -662,8 +651,6 @@ export default function TrackerPage() {
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
onMouseEnter={() => prefetchAdjacent(-1)}
onFocus={() => prefetchAdjacent(-1)}
className="h-7 w-7 hover:bg-white/5" className="h-7 w-7 hover:bg-white/5"
aria-label="Previous month" aria-label="Previous month"
> >
@ -675,8 +662,6 @@ export default function TrackerPage() {
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
onClick={() => navigate(1)} onClick={() => navigate(1)}
onMouseEnter={() => prefetchAdjacent(1)}
onFocus={() => prefetchAdjacent(1)}
className="h-7 w-7 hover:bg-white/5" className="h-7 w-7 hover:bg-white/5"
aria-label="Next month" aria-label="Next month"
> >