diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.js index 2a66c6e..4ab3d83 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.js @@ -1,4 +1,4 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useCallback } from 'react'; import { api } from '@/api'; @@ -67,3 +67,17 @@ export function useInvalidateTrackerData() { } }, [queryClient]); } + +// ── Page data (migrated off manual useEffect + load()) ─────────────────────── +// The queryKey encodes the params, so React Query handles caching, request +// dedup, cancellation, and out-of-order responses — no manual sequence guards. +// keepPreviousData keeps the last result visible while a new month/filter loads. + +export function useAnalyticsSummary(params) { + return useQuery({ + queryKey: ['analytics-summary', params], + queryFn: () => api.analyticsSummary(params), + staleTime: 1000 * 60 * 2, + placeholderData: keepPreviousData, + }); +} diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.jsx index cc4348f..211dff9 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.jsx @@ -1,8 +1,7 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { formatUSD, formatUSDWhole } from '@/lib/money'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; -import { toast } from 'sonner'; -import { api } from '@/api'; +import { useAnalyticsSummary } from '@/hooks/useQueries'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/Skeleton'; import { cn } from '@/lib/utils'; @@ -490,9 +489,6 @@ export default function AnalyticsPage() { const [includeInactive, setIncludeInactive] = useState(false); const [includeSkipped, setIncludeSkipped] = useState(true); const [trendMode, setTrendMode] = useState('line'); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); const [visible, setVisible] = useState({ monthlyTrend: true, expectedActual: true, @@ -512,26 +508,10 @@ export default function AnalyticsPage() { include_skipped: includeSkipped, }), [billId, categoryId, includeInactive, includeSkipped, month, months, year]); - const requestSeq = useRef(0); - const load = useCallback(async () => { - // Ignore out-of-order responses: only the newest request updates state, so a - // slow response for old params can't overwrite fresh data (or setState after - // unmount). Same guard the Bank ledger uses. - const seq = ++requestSeq.current; - setLoading(true); - setError(''); - try { - const result = await api.analyticsSummary(params); - if (seq === requestSeq.current) setData(result); - } catch (err) { - if (seq === requestSeq.current) setError(err.message || 'Failed to load analytics.'); - toast.error(err.message || 'Failed to load analytics.'); - } finally { - if (seq === requestSeq.current) setLoading(false); - } - }, [params]); - - useEffect(() => { load(); }, [load]); + // React Query handles caching, dedup, cancellation, and out-of-order responses. + const { data, isPending, isFetching, error: queryError, refetch } = useAnalyticsSummary(params); + const loading = isPending; + const error = queryError ? (queryError.message || 'Failed to load analytics.') : ''; const forecastRows = useMemo( () => linearForecast(data?.monthly_spending || [], forecastHorizon), @@ -579,8 +559,8 @@ export default function AnalyticsPage() {

-