From c353dd9f4087442a503d1d29e42e4998ae28ac31 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 3 Jun 2026 21:50:29 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20remove=20client-side=20snowball=20projec?= =?UTF-8?q?tion,=20delegate=20to=20server=20with=20=3Fextra=3DN=20-=20Dele?= =?UTF-8?q?te=2086-line=20computeLiveProjection()=20=E2=80=94=20drift=20ri?= =?UTF-8?q?sk=20eliminated=20-=20GET=20/api/snowball/projection=20now=20ac?= =?UTF-8?q?cepts=20=3Fextra=3DN=20for=20unsaved=20amount=20preview=20-=20C?= =?UTF-8?q?lient=20uses=20debounced=20useEffect=20calling=20server=20inste?= =?UTF-8?q?ad=20of=20useMemo=20duplicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HISTORY.md | 2 + client/api.js | 2 +- client/pages/SnowballPage.jsx | 129 +++++++--------------------------- routes/snowball.js | 8 ++- 4 files changed, 35 insertions(+), 106 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index c890071..c398e37 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -28,6 +28,8 @@ ### 🐛 Fixed +- **Client snowball projection replaced with server call** — `computeLiveProjection()` in `SnowballPage.jsx` was an 86-line client-side reimplementation of `snowballService.js`. A comment acknowledged the duplication but there was no mechanism to detect drift — a bug fix on the server would silently diverge from the client preview. The function has been deleted. `GET /api/snowball/projection` now accepts an optional `?extra=N` query parameter that overrides the stored extra payment for that request without saving it, giving the client a way to preview an unsaved amount using the authoritative server simulation. The `useMemo` live projection is replaced with a 220 ms debounced `useEffect` that calls the endpoint; the existing `projectionLoading` state and loading indicator fire naturally. Drift between client and server projections is now mechanically impossible. + - **Snowball order PATCH validates all rows before writing** — `PATCH /api/snowball/order` previously iterated through the submitted array with a `continue` on invalid entries, silently skipping bad rows and always returning `{ success: true }`. Any item with a non-integer or negative `id`/`snowball_order` now immediately returns `400` with the specific index and value that failed. The transaction only runs after all items pass validation. Response now includes `updated` count. Soft-deleted bills are also excluded from the UPDATE (`deleted_at IS NULL`), which simultaneously closes issue #53. - **`isRamseyMode()` called once per request** — `getDebtBills()` previously hid the `isRamseyMode()` DB query inside itself. Routes that also needed the mode value (or called `getDebtBills` alongside other `isRamseyMode` calls) triggered multiple identical queries per request. `getDebtBills` now accepts an optional pre-fetched `ramseyMode` parameter; the `GET /`, `GET /projection`, and `POST /plans` routes call `isRamseyMode` once and pass the result in. `PATCH /settings` uses the body value directly when `ramsey_mode` was part of the request, falling back to a DB read only when it wasn't. diff --git a/client/api.js b/client/api.js index 6469f61..645d10f 100644 --- a/client/api.js +++ b/client/api.js @@ -221,7 +221,7 @@ export const api = { snowballSettings: () => get('/snowball/settings'), saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data), saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items), - snowballProjection: () => get('/snowball/projection'), + snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`), snowballPlans: () => get('/snowball/plans'), snowballActivePlan: () => get('/snowball/plans/active'), startSnowballPlan: (data) => post('/snowball/plans', data), diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 52e687d..85dd50e 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; 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'; @@ -50,95 +50,6 @@ function isRamseyOrdered(debts) { return debts.every((debt, index) => debt.id === sorted[index]?.id); } -// ── Client-side snowball simulation (mirrors server snowballService) ─────────── -// Returns the full projection shape so the panel and attack card both update -// instantly as the user types the extra payment — no network round-trip needed. -function computeLiveProjection(bills, extraPayment) { - const extra = Math.max(0, Number(extraPayment) || 0); - - const active = []; - const skipped = []; - - for (const d of bills) { - const bal = Number(d.current_balance); - if (d.current_balance == null || !Number.isFinite(bal) || bal <= 0) { - skipped.push({ id: d.id, name: d.name, reason: 'no_balance' }); - continue; - } - active.push({ - id: d.id, - name: d.name, - balance: bal, - minPayment: Math.max(0, Number(d.minimum_payment) || 0), - monthlyRate: Math.max(0, Number(d.interest_rate) || 0) / 100 / 12, - payoffMonth: null, - totalInterest: 0, - }); - } - - if (active.length === 0) return null; - - let rollingExtra = extra; - let month = 0; - - while (active.some(d => d.balance > 0) && month < 600) { - month++; - const targetIdx = active.findIndex(d => d.balance > 0); - for (let i = 0; i < active.length; i++) { - const d = active[i]; - if (d.balance <= 0) continue; - const interest = d.balance * d.monthlyRate; - d.balance += interest; - d.totalInterest += interest; - const payment = Math.min(d.balance, i === targetIdx ? d.minPayment + rollingExtra : d.minPayment); - d.balance = Math.max(0, d.balance - payment); - if (d.balance < 0.005) d.balance = 0; - } - for (const d of active) { - if (d.balance === 0 && d.payoffMonth === null) { - d.payoffMonth = month; - rollingExtra += d.minPayment; - } - } - } - - const now = new Date(); - const baseYear = now.getFullYear(); - const baseMo = now.getMonth(); - - function monthLabel(m) { - const d = new Date(baseYear, baseMo + m, 1); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; - } - function monthDisplay(m) { - return new Date(baseYear, baseMo + m, 1) - .toLocaleDateString('en-US', { year: 'numeric', month: 'long' }); - } - - const debts = active.map(d => ({ - id: d.id, - name: d.name, - payoff_month: d.payoffMonth, - payoff_date: d.payoffMonth ? monthLabel(d.payoffMonth) : null, - payoff_display: d.payoffMonth ? monthDisplay(d.payoffMonth) : null, - total_interest: Math.round(d.totalInterest * 100) / 100, - months: d.payoffMonth, - })); - - const maxMonth = Math.max(0, ...active.map(d => d.payoffMonth || 0)); - const totalInterest = active.reduce((s, d) => s + d.totalInterest, 0); - - return { - months_to_freedom: maxMonth || null, - total_interest_paid: Math.round(totalInterest * 100) / 100, - payoff_date: maxMonth ? monthLabel(maxMonth) : null, - payoff_display: maxMonth ? monthDisplay(maxMonth) : null, - debts, - skipped, - extra_payment: extra, - capped: month >= 600, - }; -} // ── StatCard ────────────────────────────────────────────────────────────────── function StatCard({ label, value, sub, highlight }) { @@ -544,21 +455,31 @@ export default function SnowballPage() { } }; - // ── live projection (updates as user types extra amount) ───────────────── - // Full simulation runs client-side so both the attack card and the - // projection panel update instantly — no network round-trip. - const liveSnowball = useMemo( - () => computeLiveProjection(bills, extraPayment), - [bills, extraPayment], - ); + // ── live projection (debounced server call as user types extra amount) ────── + // Passes ?extra=N to the projection endpoint so the server simulation runs + // with the current input — no client-side duplicate of snowballService logic. + const liveProjectionRef = useRef(null); + useEffect(() => { + const t = setTimeout(async () => { + const extra = parseFloat(extraPayment); + const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; + try { + setProjectionLoading(true); + const result = await api.snowballProjection(params); + setProjection(result); + } catch { + // non-fatal — keep showing last known projection + } finally { + setProjectionLoading(false); + } + }, 220); + liveProjectionRef.current = t; + return () => clearTimeout(t); + }, [extraPayment]); - // Attack card uses the first debt's payoff date from the live simulation - const liveAttackPayoff = liveSnowball?.debts?.[0]?.payoff_display ?? null; - - // Panel merges live snowball with server avalanche (avalanche doesn't need to be live) - const displayProjection = liveSnowball - ? { snowball: liveSnowball, avalanche: projection?.avalanche } - : projection; + // Attack card payoff date and display projection come directly from server result + const liveAttackPayoff = projection?.snowball?.debts?.[0]?.payoff_display ?? null; + const displayProjection = projection; // ── plan lifecycle ──────────────────────────────────────────────────────── const handleStartPlan = async () => { diff --git a/routes/snowball.js b/routes/snowball.js index e93e635..b04da87 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -151,7 +151,13 @@ router.get('/projection', (req, res) => { const ramseyMode = isRamseyMode(req.user.id); const bills = getDebtBills(req.user.id, ramseyMode); const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); - const extra = user?.snowball_extra_payment ?? 0; + + // Allow an optional ?extra=N override so the client can preview an unsaved + // extra payment without a round-trip save. Falls back to the stored value. + const queryExtra = req.query.extra !== undefined ? parseFloat(req.query.extra) : NaN; + const extra = Number.isFinite(queryExtra) && queryExtra >= 0 + ? queryExtra + : (user?.snowball_extra_payment ?? 0); // Build a lookup of APR snapshots keyed by bill id (computed once from current balances) const aprByBill = {};