From 2e8bc07552af9e32cd276a86e783ebfa73291004 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 20:22:13 -0500 Subject: [PATCH] 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 --- client/hooks/useQueries.js | 42 +++++++++++ client/pages/SpendingPage.jsx | 130 +++++++++++++--------------------- 2 files changed, 90 insertions(+), 82 deletions(-) diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.js index 1c84f5f..be00230 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.js @@ -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() { return useQuery({ queryKey: ['snowball'], queryFn: () => api.snowball(), staleTime: 1000 * 60 * 2 }); } diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index c463a61..4abe71a 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -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 { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, @@ -615,100 +619,62 @@ export default function SpendingPage() { const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); - const [summary, setSummary] = useState(null); - const [transactions, setTransactions] = useState([]); - const [txTotal, setTxTotal] = useState(0); + const queryClient = useQueryClient(); 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 [loading, setLoading] = useState(true); - const [txLoading, setTxLoading] = useState(false); - const [budgets, setBudgets] = useState({}); // categoryId → amount + const { data: summary = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month); + const { data: txData, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); + 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 [spendingSettings, setSpendingSettings] = useState({}); 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 - const loadCategories = useCallback(async () => { - setCatError(null); - try { - const d = await api.categories(); - // Only show spending-enabled categories in the spending UI - setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled)); - } catch (err) { - setCatError(err.message || 'Failed to load categories'); - } - }, []); + const loadCategories = useCallback( + () => queryClient.invalidateQueries({ queryKey: ['spending-categories'] }), [queryClient]); + const loadCategoryGroups = useCallback( + () => queryClient.invalidateQueries({ queryKey: ['category-groups'] }), [queryClient]); + const loadSummary = useCallback( + () => queryClient.invalidateQueries({ queryKey: ['spending-summary', year, month] }), [queryClient, year, month]); + // Pagination moves the page (query re-fetches on the new key); a same-page call + // (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(() => { 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(() => { - loadSummary(); - loadTransactions(1); - }, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps + if (!summary) return; + const bmap = {}; + (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) { setSavingSpendingSetting(true);