refactor(spending): migrate SpendingPage to React Query (R5.6)

useSpendingSummary + useSpendingTransactions (paginated via a page-keyed query
with keepPreviousData) + useSpendingCategories + useCategoryGroups. Pagination
is now setTxPage (query refetches on the new key); a same-page loadTransactions
call invalidates to force a refresh. The editable budgets map seeds from the
summary via an effect; optimistic budget/summary and categorize edits route
through setQueryData wrappers; the R3 sequence guards are removed (React Query
handles races). load* calls became invalidate wrappers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 20:22:13 -05:00
parent 1cb0325560
commit 2e8bc07552
2 changed files with 90 additions and 82 deletions

View File

@ -110,6 +110,48 @@ export function useSummary(year, month) {
}); });
} }
export function useSpendingSummary(year, month) {
return useQuery({
queryKey: ['spending-summary', year, month],
queryFn: () => api.spendingSummary({ year, month }),
staleTime: 1000 * 60 * 2,
placeholderData: keepPreviousData,
});
}
export function useSpendingTransactions({ year, month, activeCat, page }) {
return useQuery({
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
queryFn: () => {
const params = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat;
return api.spendingTransactions(params);
},
staleTime: 1000 * 60 * 2,
placeholderData: keepPreviousData,
});
}
export function useSpendingCategories() {
return useQuery({
queryKey: ['spending-categories'],
queryFn: async () => {
const d = await api.categories();
return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled);
},
staleTime: 1000 * 60 * 5,
});
}
export function useCategoryGroups() {
return useQuery({
queryKey: ['category-groups'],
queryFn: () => api.categoryGroups().then(g => g || []).catch(() => []),
staleTime: 1000 * 60 * 5,
});
}
export function useSnowball() { export function useSnowball() {
return useQuery({ queryKey: ['snowball'], queryFn: () => api.snowball(), staleTime: 1000 * 60 * 2 }); return useQuery({ queryKey: ['snowball'], queryFn: () => api.snowball(), staleTime: 1000 * 60 * 2 });
} }

View File

@ -1,4 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useSpendingSummary, useSpendingTransactions, useSpendingCategories, useCategoryGroups,
} from '@/hooks/useQueries';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X,
@ -615,100 +619,62 @@ export default function SpendingPage() {
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1); const [month, setMonth] = useState(now.getMonth() + 1);
const [summary, setSummary] = useState(null); const queryClient = useQueryClient();
const [transactions, setTransactions] = useState([]);
const [txTotal, setTxTotal] = useState(0);
const [txPage, setTxPage] = useState(1); const [txPage, setTxPage] = useState(1);
const [txPages, setTxPages] = useState(1);
const [categories, setCategories] = useState([]);
const [categoryGroups, setCategoryGroups] = useState([]);
const [activeCat, setActiveCat] = useState(undefined); // undefined = all const [activeCat, setActiveCat] = useState(undefined); // undefined = all
const [loading, setLoading] = useState(true); const { data: summary = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
const [txLoading, setTxLoading] = useState(false); const { data: txData, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
const [budgets, setBudgets] = useState({}); // categoryId amount const transactions = useMemo(() => txData?.transactions || [], [txData]);
const txTotal = txData?.total || 0;
const txPages = txData?.pages || 1;
const { data: categories = [], error: catErrObj } = useSpendingCategories();
const { data: categoryGroups = [] } = useCategoryGroups();
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
// Optimistic edits write through the query cache so existing call sites work.
const setSummary = useCallback((u) => queryClient.setQueryData(['spending-summary', year, month],
prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, year, month]);
const setTransactions = useCallback((u) => queryClient.setQueryData(
['spending-transactions', year, month, activeCat ?? 'all', txPage],
prev => {
const nextList = typeof u === 'function' ? u(prev?.transactions || []) : u;
return prev ? { ...prev, transactions: nextList } : prev;
}), [queryClient, year, month, activeCat, txPage]);
const [budgets, setBudgets] = useState({}); // categoryId amount (seeded from summary)
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
const [spendingSettings, setSpendingSettings] = useState({}); const [spendingSettings, setSpendingSettings] = useState({});
const [savingSpendingSetting, setSavingSpendingSetting] = useState(false); const [savingSpendingSetting, setSavingSpendingSetting] = useState(false);
const [summaryError, setSummaryError] = useState(null);
const [txError, setTxError] = useState(null);
const [catError, setCatError] = useState(null);
// loadCategories is stable categories don't vary by month // loadCategories is stable categories don't vary by month
const loadCategories = useCallback(async () => { const loadCategories = useCallback(
setCatError(null); () => queryClient.invalidateQueries({ queryKey: ['spending-categories'] }), [queryClient]);
try { const loadCategoryGroups = useCallback(
const d = await api.categories(); () => queryClient.invalidateQueries({ queryKey: ['category-groups'] }), [queryClient]);
// Only show spending-enabled categories in the spending UI const loadSummary = useCallback(
setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled)); () => queryClient.invalidateQueries({ queryKey: ['spending-summary', year, month] }), [queryClient, year, month]);
} catch (err) { // Pagination moves the page (query re-fetches on the new key); a same-page call
setCatError(err.message || 'Failed to load categories'); // (e.g. the Refresh button) forces a refetch of the current page.
} const loadTransactions = useCallback((page = 1) => {
}, []); if (page === txPage) queryClient.invalidateQueries({ queryKey: ['spending-transactions'] });
else setTxPage(page);
}, [txPage, queryClient]);
const loadCategoryGroups = useCallback(async () => {
try {
setCategoryGroups((await api.categoryGroups()) || []);
} catch {
// non-fatal group headers simply won't render
}
}, []);
// loadTransactions is exposed so pagination buttons can call it with a page arg.
// The sequence guard ignores out-of-order responses (fast month/category nav or
// rapid paging) so a slow older request can't overwrite fresher data.
const txSeq = useRef(0);
const loadTransactions = useCallback(async (page = 1) => {
const seq = ++txSeq.current;
setTxLoading(true);
setTxError(null);
try {
const params = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat;
const d = await api.spendingTransactions(params);
if (seq !== txSeq.current) return; // superseded
setTransactions(d.transactions || []);
setTxTotal(d.total || 0);
setTxPages(d.pages || 1);
setTxPage(page);
} catch (err) {
if (seq === txSeq.current) setTxError(err.message || 'Failed to load transactions');
} finally {
if (seq === txSeq.current) setTxLoading(false);
}
}, [year, month, activeCat]);
const summarySeq = useRef(0);
const loadSummary = useCallback(async () => {
const seq = ++summarySeq.current;
setLoading(true);
setSummaryError(null);
try {
const d = await api.spendingSummary({ year, month });
if (seq !== summarySeq.current) return; // superseded
setSummary(d);
const bmap = {};
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap);
if (d.category_groups) setCategoryGroups(d.category_groups);
} catch (err) {
if (seq === summarySeq.current) setSummaryError(err.message || 'Failed to load spending summary');
} finally {
if (seq === summarySeq.current) setLoading(false);
}
}, [year, month]);
// Load categories, groups, and settings once on mount
useEffect(() => { loadCategories(); loadCategoryGroups(); }, [loadCategories, loadCategoryGroups]);
useEffect(() => { useEffect(() => {
api.settings().then(setSpendingSettings).catch(() => {}); api.settings().then(setSpendingSettings).catch(() => {});
}, []); }, []);
// Load summary and transactions whenever month/category filter changes. // Reset to page 1 whenever the month or category filter changes.
useEffect(() => { setTxPage(1); }, [year, month, activeCat]);
// Seed the editable budgets map from the loaded summary whenever it changes
// (this is what loadSummary used to do inline).
useEffect(() => { useEffect(() => {
loadSummary(); if (!summary) return;
loadTransactions(1); const bmap = {};
}, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps (summary.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap);
}, [summary]);
async function saveSpendingSetting(patch, successMessage) { async function saveSpendingSetting(patch, successMessage) {
setSavingSpendingSetting(true); setSavingSpendingSetting(true);