- Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
+ Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
diff --git a/client/lib/version.js b/client/lib/version.js
index 09e796c..6a0f8f2 100644
--- a/client/lib/version.js
+++ b/client/lib/version.js
@@ -28,6 +28,11 @@ export const RELEASE_NOTES = {
title: 'Privacy and release notes',
desc: 'A public Privacy page is available from About, release notes can render images, and this update card now resets from the backend whenever the app version changes.',
},
+ {
+ icon: '❄️',
+ title: 'Ramsey Snowball mode',
+ desc: 'Debt Snowball now defaults to smallest-balance-first, keeps custom drag ordering behind a toggle, skips mortgages by default, and adds an inline Ramsey readiness checklist.',
+ },
{
icon: '🎛️',
title: 'Cleaner tracker and interface polish',
diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx
index 7ccaddb..30d7809 100644
--- a/client/pages/SnowballPage.jsx
+++ b/client/pages/SnowballPage.jsx
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff } from 'lucide-react';
+import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal';
@@ -30,6 +31,19 @@ function ordinal(n) {
case 1: return `${d}st`; case 2: return `${d}nd`; case 3: return `${d}rd`; default: return `${d}th`;
}
}
+function sortRamseyDebts(debts) {
+ return [...debts].sort((a, b) => {
+ if (a.current_balance == null && b.current_balance == null) return a.name.localeCompare(b.name);
+ if (a.current_balance == null) return 1;
+ if (b.current_balance == null) return -1;
+ const diff = Number(a.current_balance) - Number(b.current_balance);
+ return diff || a.name.localeCompare(b.name);
+ });
+}
+function isRamseyOrdered(debts) {
+ const sorted = sortRamseyDebts(debts);
+ 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
@@ -243,6 +257,71 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
);
}
+// ── Readiness strip ───────────────────────────────────────────────────────────
+function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, disabled }) {
+ return (
+
+
+
+ {allReady ? : }
+
+
+
Snowball Readiness
+
+ {allReady ? 'Ready to roll: attack the smallest balance first.' : `${readyCount} of ${totalCount} ready`}
+
- Bills in Credit Cards, Loans, or Mortgage categories appear here automatically.
+ Bills in Credit Cards, Loans, or Debt categories appear here automatically.
You can also enable "Include in Snowball" when editing any bill.
- Drag the grip handle to reorder · Click a balance to update it · Save Order to persist
+ {ramseyMode
+ ? 'Ramsey Mode keeps debts sorted by smallest balance · Click a balance to update it'
+ : 'Drag the grip handle to reorder · Click a balance to update it · Save Order to persist'}
diff --git a/routes/snowball.js b/routes/snowball.js
index 3cfdf39..8a07f2b 100644
--- a/routes/snowball.js
+++ b/routes/snowball.js
@@ -12,43 +12,91 @@ const DEBT_LIKE_CLAUSES = `(
AND (
LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%loan%'
- OR LOWER(c.name) LIKE '%mortgage%'
- OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%'
)
)
)`;
-const DEBT_QUERY = `
+function isRamseyMode(userId) {
+ const db = getDb();
+ const row = db.prepare(`
+ SELECT value
+ FROM user_settings
+ WHERE user_id = ? AND key = 'snowball_ramsey_mode'
+ `).get(userId);
+ return row ? row.value !== 'false' && row.value !== '0' : true;
+}
+
+function getUserBoolSetting(userId, key, fallback = false) {
+ const db = getDb();
+ const row = db.prepare(`
+ SELECT value
+ FROM user_settings
+ WHERE user_id = ? AND key = ?
+ `).get(userId, key);
+ if (!row) return fallback;
+ return row.value === 'true' || row.value === '1';
+}
+
+function upsertUserSetting(db, userId, key, value) {
+ db.prepare(`
+ INSERT INTO user_settings (user_id, key, value, updated_at)
+ VALUES (?, ?, ?, datetime('now'))
+ ON CONFLICT(user_id, key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = datetime('now')
+ `).run(userId, key, String(value));
+}
+
+function getDebtQuery(ramseyMode) {
+ const orderBy = ramseyMode
+ ? `
+ CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
+ b.current_balance ASC,
+ LOWER(b.name) ASC,
+ b.id ASC`
+ : `
+ CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
+ b.snowball_order ASC,
+ CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
+ b.current_balance ASC`;
+
+ return `
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ?
AND b.active = 1
AND ${DEBT_LIKE_CLAUSES}
- ORDER BY
- CASE WHEN b.snowball_order IS NULL THEN 1 ELSE 0 END ASC,
- b.snowball_order ASC,
- CASE WHEN b.current_balance IS NULL THEN 1 ELSE 0 END ASC,
- b.current_balance ASC
-`;
+ ORDER BY${orderBy}
+ `;
+}
+
+function getDebtBills(userId) {
+ const db = getDb();
+ return db.prepare(getDebtQuery(isRamseyMode(userId))).all(userId);
+}
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => {
- const db = getDb();
- res.json(db.prepare(DEBT_QUERY).all(req.user.id));
+ res.json(getDebtBills(req.user.id));
});
// GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
- res.json({ extra_payment: user?.snowball_extra_payment ?? 0 });
+ res.json({
+ extra_payment: user?.snowball_extra_payment ?? 0,
+ ramsey_mode: isRamseyMode(req.user.id),
+ ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
+ ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
+ });
});
// PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => {
- const { extra_payment } = req.body;
+ const { extra_payment, ramsey_mode, ready_current_on_bills, ready_emergency_fund } = req.body;
let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
@@ -63,8 +111,32 @@ router.patch('/settings', (req, res) => {
}
const db = getDb();
- db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
- res.json({ extra_payment: val });
+ const save = db.transaction(() => {
+ if (extra_payment !== undefined) {
+ db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
+ }
+
+ if (ramsey_mode !== undefined) {
+ upsertUserSetting(db, req.user.id, 'snowball_ramsey_mode', ramsey_mode ? 'true' : 'false');
+ }
+
+ if (ready_current_on_bills !== undefined) {
+ upsertUserSetting(db, req.user.id, 'snowball_ready_current_on_bills', ready_current_on_bills ? 'true' : 'false');
+ }
+
+ if (ready_emergency_fund !== undefined) {
+ upsertUserSetting(db, req.user.id, 'snowball_ready_emergency_fund', ready_emergency_fund ? 'true' : 'false');
+ }
+ });
+ save();
+
+ const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
+ res.json({
+ extra_payment: user?.snowball_extra_payment ?? 0,
+ ramsey_mode: isRamseyMode(req.user.id),
+ ready_current_on_bills: getUserBoolSetting(req.user.id, 'snowball_ready_current_on_bills'),
+ ready_emergency_fund: getUserBoolSetting(req.user.id, 'snowball_ready_emergency_fund'),
+ });
});
// GET /api/snowball/projection — snowball, avalanche, minimum-only projections
@@ -72,7 +144,7 @@ router.patch('/settings', (req, res) => {
router.get('/projection', (req, res) => {
const db = getDb();
- const bills = db.prepare(DEBT_QUERY).all(req.user.id);
+ const bills = getDebtBills(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;