fix: remove client-side snowball projection, delegate to server with ?extra=N

- Delete 86-line computeLiveProjection() — drift risk eliminated
- GET /api/snowball/projection now accepts ?extra=N for unsaved amount preview
- Client uses debounced useEffect calling server instead of useMemo duplicate
This commit is contained in:
null 2026-06-03 21:50:29 -05:00
parent 36f7191289
commit c353dd9f40
4 changed files with 35 additions and 106 deletions

View File

@ -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.

View File

@ -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),

View File

@ -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 () => {

View File

@ -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 = {};