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:
parent
36f7191289
commit
c353dd9f40
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
Loading…
Reference in New Issue