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:
parent
65c61d71fa
commit
bb024ce161
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue