refactor(snowball): migrate SnowballPage to React Query (R5.5)

useSnowball + useSnowballSettings + useSnowballActivePlan + useSnowballPlans
(+ shared useCategories). The settings-derived form fields (extra payment,
ramsey mode, ready flags) are seeded via a settings-synced effect; the many
optimistic list/plan edits route through queryClient.setQueryData wrappers;
load() is an invalidate wrapper. The debounced live-projection stays a client
computation (not page data). Removed the now-dead loadPlans (hooks auto-fetch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 20:18:14 -05:00
parent a37697d492
commit 1cb0325560
2 changed files with 63 additions and 31 deletions

View File

@ -110,6 +110,30 @@ export function useSummary(year, month) {
});
}
export function useSnowball() {
return useQuery({ queryKey: ['snowball'], queryFn: () => api.snowball(), staleTime: 1000 * 60 * 2 });
}
export function useSnowballSettings() {
return useQuery({ queryKey: ['snowball-settings'], queryFn: () => api.snowballSettings(), staleTime: 1000 * 60 * 2 });
}
export function useSnowballActivePlan() {
return useQuery({
queryKey: ['snowball-active-plan'],
queryFn: () => api.snowballActivePlan().catch(() => null),
staleTime: 1000 * 60 * 2,
});
}
export function useSnowballPlans() {
return useQuery({
queryKey: ['snowball-plans'],
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
staleTime: 1000 * 60 * 2,
});
}
export function useSubscriptions() {
return useQuery({
queryKey: ['subscriptions'],

View File

@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
useSnowball, useSnowballSettings, useSnowballActivePlan, useSnowballPlans, useCategories,
} from '@/hooks/useQueries';
import { ArrowDown, ArrowUp, GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
@ -266,10 +270,22 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
// Page
export default function SnowballPage() {
const [bills, setBills] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const queryClient = useQueryClient();
const { data: bills = [], isPending: billsPending, error: snowballError } = useSnowball();
const { data: categories = [] } = useCategories();
const { data: settings } = useSnowballSettings();
const { data: activePlan = null } = useSnowballActivePlan();
const { data: allPlans = [] } = useSnowballPlans();
const loading = billsPending;
const loadError = snowballError ? (snowballError.message || 'Failed to load snowball data') : null;
// Optimistic list/plan edits write through the query cache so existing call
// sites (setBills(prev => ), setActivePlan(), setAllPlans(prev => )) work.
const setBills = useCallback((u) => queryClient.setQueryData(['snowball'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const setActivePlan = useCallback((u) => queryClient.setQueryData(['snowball-active-plan'],
prev => (typeof u === 'function' ? u(prev ?? null) : u)), [queryClient]);
const setAllPlans = useCallback((u) => queryClient.setQueryData(['snowball-plans'],
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [editBill, setEditBill] = useState(null);
@ -288,8 +304,6 @@ export default function SnowballPage() {
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
const [activePlan, setActivePlan] = useState(null);
const [allPlans, setAllPlans] = useState([]);
const [startingPlan, setStartingPlan] = useState(false);
const [readinessWarnOpen, setReadinessWarnOpen] = useState(false);
const [draggingId, setDraggingId] = useState(null);
@ -311,33 +325,27 @@ export default function SnowballPage() {
} finally { setProjectionLoading(false); }
}, []);
const load = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const [billsArr, catsArr, settings] = await Promise.all([
api.snowball(), api.categories(), api.snowballSettings(),
]);
setCategories(catsArr);
setBills(billsArr);
setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund);
setExtraPayment(ep);
extraPaymentRef.current = ep;
} catch (err) {
setLoadError(err.message || 'Failed to load snowball data');
} finally { setLoading(false); }
}, []);
const load = useCallback(() => Promise.all([
queryClient.invalidateQueries({ queryKey: ['snowball'] }),
queryClient.invalidateQueries({ queryKey: ['categories'] }),
queryClient.invalidateQueries({ queryKey: ['snowball-settings'] }),
]), [queryClient]);
const loadPlans = useCallback(() => {
api.snowballActivePlan().then(p => setActivePlan(p)).catch(() => setActivePlan(null));
api.snowballPlans().then(d => setAllPlans(d?.plans ?? [])).catch(err => console.error('[SnowballPage] failed to load plan history', err));
}, []);
// Seed the editable settings form (extra payment, ramsey mode, ready flags)
// from the loaded settings whenever they change this is what `load` did inline.
useEffect(() => {
if (!settings) return;
setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : '';
setRamseyMode(settings.ramsey_mode !== false);
setReadyCurrentOnBills(!!settings.ready_current_on_bills);
setReadyEmergencyFund(!!settings.ready_emergency_fund);
setExtraPayment(ep);
extraPaymentRef.current = ep;
}, [settings]);
useEffect(() => { Promise.all([load(), loadProjection()]); loadPlans(); }, [load, loadProjection, loadPlans]);
// The live projection stays a debounced client computation (not page data).
useEffect(() => { loadProjection(); }, [loadProjection]);
// auto-arrange
const handleAutoArrange = () => {