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
|
### 🐛 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.
|
- **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.
|
- **`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'),
|
snowballSettings: () => get('/snowball/settings'),
|
||||||
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
|
saveSnowballSettings: (data) => _fetch('PATCH', '/snowball/settings', data),
|
||||||
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
|
saveSnowballOrder: (items) => _fetch('PATCH', '/snowball/order', items),
|
||||||
snowballProjection: () => get('/snowball/projection'),
|
snowballProjection: (params = {}) => get(`/snowball/projection${queryString(params)}`),
|
||||||
snowballPlans: () => get('/snowball/plans'),
|
snowballPlans: () => get('/snowball/plans'),
|
||||||
snowballActivePlan: () => get('/snowball/plans/active'),
|
snowballActivePlan: () => get('/snowball/plans/active'),
|
||||||
startSnowballPlan: (data) => post('/snowball/plans', data),
|
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 { ArrowDown, ArrowUp, GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
|
@ -50,95 +50,6 @@ function isRamseyOrdered(debts) {
|
||||||
return debts.every((debt, index) => debt.id === sorted[index]?.id);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── StatCard ──────────────────────────────────────────────────────────────────
|
||||||
function StatCard({ label, value, sub, highlight }) {
|
function StatCard({ label, value, sub, highlight }) {
|
||||||
|
|
@ -544,21 +455,31 @@ export default function SnowballPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── live projection (updates as user types extra amount) ─────────────────
|
// ── live projection (debounced server call as user types extra amount) ──────
|
||||||
// Full simulation runs client-side so both the attack card and the
|
// Passes ?extra=N to the projection endpoint so the server simulation runs
|
||||||
// projection panel update instantly — no network round-trip.
|
// with the current input — no client-side duplicate of snowballService logic.
|
||||||
const liveSnowball = useMemo(
|
const liveProjectionRef = useRef(null);
|
||||||
() => computeLiveProjection(bills, extraPayment),
|
useEffect(() => {
|
||||||
[bills, extraPayment],
|
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
|
// Attack card payoff date and display projection come directly from server result
|
||||||
const liveAttackPayoff = liveSnowball?.debts?.[0]?.payoff_display ?? null;
|
const liveAttackPayoff = projection?.snowball?.debts?.[0]?.payoff_display ?? null;
|
||||||
|
const displayProjection = projection;
|
||||||
// Panel merges live snowball with server avalanche (avalanche doesn't need to be live)
|
|
||||||
const displayProjection = liveSnowball
|
|
||||||
? { snowball: liveSnowball, avalanche: projection?.avalanche }
|
|
||||||
: projection;
|
|
||||||
|
|
||||||
// ── plan lifecycle ────────────────────────────────────────────────────────
|
// ── plan lifecycle ────────────────────────────────────────────────────────
|
||||||
const handleStartPlan = async () => {
|
const handleStartPlan = async () => {
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,13 @@ router.get('/projection', (req, res) => {
|
||||||
const ramseyMode = isRamseyMode(req.user.id);
|
const ramseyMode = isRamseyMode(req.user.id);
|
||||||
const bills = getDebtBills(req.user.id, ramseyMode);
|
const bills = getDebtBills(req.user.id, ramseyMode);
|
||||||
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
|
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)
|
// Build a lookup of APR snapshots keyed by bill id (computed once from current balances)
|
||||||
const aprByBill = {};
|
const aprByBill = {};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue