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 { 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,
});
}

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 { 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() {
</p>
</div>
<div className="analytics-actions flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none">
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="flex-1 sm:flex-none">
<RefreshCw className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')} />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">