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 (
+
+ 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() {