diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.js index a47ba55..1c84f5f 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.js @@ -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'], diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index cadb4a5..9e51e74 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -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 = () => {