diff --git a/HISTORY.md b/HISTORY.md index c0b9fd8..58988db 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -10,6 +10,7 @@ ### πŸ” Snowball review +- **[Debt] Snowball page surfaces projection failures + smaller polish** β€” the payoff-projection panel silently swallowed fetch errors (`catch { /* non-fatal */ }`), so a failed load showed nothing with no way to recover; it now shows a "Couldn't load … Try again" state (and a subtle "showing the last result" banner when a *refresh* fails). `loadProjection` now uses the amount currently typed (not just the last-saved value), matching the debounced live preview. The over-50-years banner now reads "one or more debts won't pay off at this rate" (accurate for the unpayable-debt case too), and the extra-payment validation copy says "non-negative" (0 is allowed). (Snowball review #2, #3, #8; #9 was already handled by the input's on-blur auto-save.) - **[Debt] Payoff projection no longer fakes a "freedom" date when a debt can't be paid off** β€” if a debt's minimum payment never overcomes its interest and the rolling snowball can't cover it either, it never reaches $0. The simulation previously excluded that debt from `months_to_freedom` (a `Math.max` over payoff months treated "never" as 0), so it reported the *other* debts' last month as your payoff date β€” misleadingly optimistic. Now `months_to_freedom`/`payoff_date` are `null` when any active debt never clears, the result is flagged `capped`, and each such debt is marked `never_paid` (`services/snowballService.js`, `services/aprService.js`). (Snowball review #1) - **[Debt] Snowball plan API: consistent errors, less duplication, no N+1** β€” the four plan-lifecycle endpoints (pause/resume/complete/abandon) were near-identical copies and returned a plain `{error}` shape unlike the rest of the API; folded them into one `transitionPlan` helper returning `standardizeError` `{message, code}` with proper state guards + ownership scoping. `enrichPlanWithProgress` fetched each snapshot bill in its own query (`GET /plans` = plans Γ— debts queries) and wasn't user-scoped β€” now one `WHERE id IN (…) AND user_id = ?` batch. Test: `tests/snowballPlanRoute.test.js`. (Snowball review #4, #6, #7) - **[Debt] Added the missing test coverage for all payoff math** β€” the debt-payoff engine (`calculateSnowball`/`calculateAvalanche`/`calculateMinimumOnly`/`amortizationSchedule`/`monthsToPayoff`/`debtAprSnapshot`) had **zero** automated tests despite being the most math-heavy code in the app. Added `tests/snowballMath.test.js` (12 tests) with hand-calculated examples (0% and 12% APR amortization rows, snowball rolling, avalanche-beats-snowball interest, skip reasons, APR breakdown) and the unpayable-debt edge above. (Snowball review #5) diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 90eadb7..cadb4a5 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -102,8 +102,8 @@ function AvalancheComparison({ snowball, avalanche }) { ); } -function ProjectionPanel({ projection, projectionLoading, billCount }) { - if (projectionLoading) { +function ProjectionPanel({ projection, projectionLoading, projectionError, onRetry, billCount }) { + if (projectionLoading && !projection) { return (
@@ -111,6 +111,17 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
); } + if (projectionError && !projection) { + return ( +
+ +

Couldn’t load the payoff projection.

+ +
+ ); + } if (!projection) return null; const sb = projection.snowball; const av = projection.avalanche; @@ -131,10 +142,17 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) { )} + {projectionError && ( +
+ + Couldn’t refresh β€” showing the last result. + +
+ )} {sb.capped && (
- Payoff exceeds 50 years. Add extra monthly budget or increase minimum payments. + One or more debts won’t pay off at this rate. Add extra monthly budget or increase minimum payments.
)} {needsBalances && ( @@ -265,6 +283,8 @@ export default function SnowballPage() { const [projection, setProjection] = useState(null); const [projectionLoading, setProjectionLoading] = useState(false); + const [projectionError, setProjectionError] = useState(false); + const typedExtraRef = useRef(''); const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' }); @@ -276,11 +296,19 @@ export default function SnowballPage() { const [dropTargetId, setDropTargetId] = useState(null); // ── loading ─────────────────────────────────────────────────────────────── + // Keep the projection in sync with the amount CURRENTLY typed (not just the + // last-saved value), so refreshing after a balance edit doesn't drop an + // in-progress extra payment. Mirrors the debounced live-projection effect. const loadProjection = useCallback(async () => { setProjectionLoading(true); - try { setProjection(await api.snowballProjection()); } - catch { /* non-fatal */ } - finally { setProjectionLoading(false); } + try { + const extra = parseFloat(typedExtraRef.current); + const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; + setProjection(await api.snowballProjection(params)); + setProjectionError(false); + } catch { + setProjectionError(true); + } finally { setProjectionLoading(false); } }, []); const load = useCallback(async () => { @@ -374,7 +402,7 @@ export default function SnowballPage() { const handleSaveExtraPayment = async () => { const val = extraPayment.trim(); if (val !== '' && (isNaN(parseFloat(val)) || parseFloat(val) < 0)) { - toast.error('Extra payment must be a positive number'); return; + toast.error('Extra payment must be a non-negative number'); return; } if (val === extraPaymentRef.current) return; setSavingSettings(true); @@ -468,6 +496,7 @@ export default function SnowballPage() { // with the current input β€” no client-side duplicate of snowballService logic. const liveProjectionRef = useRef(null); useEffect(() => { + typedExtraRef.current = extraPayment; // keep loadProjection() in sync with the input const t = setTimeout(async () => { const extra = parseFloat(extraPayment); const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; @@ -475,8 +504,9 @@ export default function SnowballPage() { setProjectionLoading(true); const result = await api.snowballProjection(params); setProjection(result); + setProjectionError(false); } catch { - // non-fatal β€” keep showing last known projection + setProjectionError(true); // keep last projection, but surface the failure } finally { setProjectionLoading(false); } @@ -1007,6 +1037,8 @@ export default function SnowballPage() {