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:
parent
1cb0325560
commit
2e8bc07552
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue