fix(snowball): surface projection errors + polish (Snowball #2,#3,#8)
- The payoff projection panel swallowed fetch errors silently; now shows a "Couldn't load … Try again" state (no projection) and a subtle "showing the last result" retry banner when a refresh fails. - loadProjection() now uses the currently-typed extra payment (via a ref that mirrors the input), consistent with the debounced live preview, so refreshing after a balance edit never drops an in-progress extra. - Copy: extra-payment validation says "non-negative" (0 is valid); the capped banner now reads "one or more debts won't pay off at this rate" (accurate for the unpayable-debt case from the #1 fix, not just >50 years). (#9 unsaved-preview hint was unnecessary — the input already auto-saves on blur.) Build clean; client suite pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2b315f5d18
commit
836dbdb9ae
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="surface-elevated rounded-xl p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
|
|
@ -111,6 +111,17 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
if (projectionError && !projection) {
|
||||
return (
|
||||
<div className="surface-elevated rounded-xl p-5 text-center">
|
||||
<AlertCircle className="mx-auto h-6 w-6 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">Couldn’t load the payoff projection.</p>
|
||||
<Button size="sm" variant="outline" className="mt-3 gap-1.5" onClick={onRetry}>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Try again
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!projection) return null;
|
||||
const sb = projection.snowball;
|
||||
const av = projection.avalanche;
|
||||
|
|
@ -131,10 +142,17 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{projectionError && (
|
||||
<div className="flex items-center gap-2 px-5 py-2 bg-muted/40 border-b border-border/40 text-xs text-muted-foreground">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Couldn’t refresh — showing the last result.
|
||||
<button type="button" onClick={onRetry} className="ml-auto underline hover:text-foreground">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
{sb.capped && (
|
||||
<div className="flex items-start gap-2 px-5 py-3 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
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.
|
||||
</div>
|
||||
)}
|
||||
{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() {
|
|||
<ProjectionPanel
|
||||
projection={displayProjection}
|
||||
projectionLoading={projectionLoading}
|
||||
projectionError={projectionError}
|
||||
onRetry={loadProjection}
|
||||
billCount={bills.length}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue