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:
null 2026-07-03 17:08:41 -05:00
parent 2b315f5d18
commit 836dbdb9ae
2 changed files with 41 additions and 8 deletions

View File

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

View File

@ -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">Couldnt 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" />
Couldnt 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 wont 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>