refactor(analytics): migrate AnalyticsPage to React Query (R5.1)

Replaced the manual useState(data/loading/error) + load useCallback + useEffect
(and the R3 request-seq guard) with a useAnalyticsSummary(params) query hook.
React Query now handles caching, dedup, cancellation, and out-of-order responses
via the params-encoded key; keepPreviousData keeps the last result visible while
a new month/filter loads. Refresh -> refetch; the redundant page-load error toast
is dropped in favor of the existing inline error state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 20:04:14 -05:00
parent 65c61d71fa
commit bb024ce161
2 changed files with 23 additions and 29 deletions

View File

@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { api } from '@/api'; import { api } from '@/api';
@ -67,3 +67,17 @@ export function useInvalidateTrackerData() {
} }
}, [queryClient]); }, [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,
});
}

View File

@ -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 { formatUSD, formatUSDWhole } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { useAnalyticsSummary } from '@/hooks/useQueries';
import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/Skeleton'; import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -490,9 +489,6 @@ export default function AnalyticsPage() {
const [includeInactive, setIncludeInactive] = useState(false); const [includeInactive, setIncludeInactive] = useState(false);
const [includeSkipped, setIncludeSkipped] = useState(true); const [includeSkipped, setIncludeSkipped] = useState(true);
const [trendMode, setTrendMode] = useState('line'); const [trendMode, setTrendMode] = useState('line');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [visible, setVisible] = useState({ const [visible, setVisible] = useState({
monthlyTrend: true, monthlyTrend: true,
expectedActual: true, expectedActual: true,
@ -512,26 +508,10 @@ export default function AnalyticsPage() {
include_skipped: includeSkipped, include_skipped: includeSkipped,
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]); }), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
const requestSeq = useRef(0); // React Query handles caching, dedup, cancellation, and out-of-order responses.
const load = useCallback(async () => { const { data, isPending, isFetching, error: queryError, refetch } = useAnalyticsSummary(params);
// Ignore out-of-order responses: only the newest request updates state, so a const loading = isPending;
// slow response for old params can't overwrite fresh data (or setState after const error = queryError ? (queryError.message || 'Failed to load analytics.') : '';
// 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]);
const forecastRows = useMemo( const forecastRows = useMemo(
() => linearForecast(data?.monthly_spending || [], forecastHorizon), () => linearForecast(data?.monthly_spending || [], forecastHorizon),
@ -579,8 +559,8 @@ export default function AnalyticsPage() {
</p> </p>
</div> </div>
<div className="analytics-actions flex flex-wrap gap-2"> <div className="analytics-actions flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none"> <Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="flex-1 sm:flex-none">
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} /> <RefreshCw className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')} />
Refresh Refresh
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none"> <Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">