From 9174ec3290da299c99efdf45b94132c35609b111 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 16 May 2026 10:17:24 -0500 Subject: [PATCH] v0.28. --- client/components/BillModal.jsx | 21 ++- client/lib/version.js | 5 + client/pages/SnowballPage.jsx | 267 +++++++++++++++++++++++++++++--- routes/snowball.js | 104 +++++++++++-- 4 files changed, 356 insertions(+), 41 deletions(-) diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index 8aa614f..0ae2578 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -27,12 +27,18 @@ function getOrdinalSuffix(day) { const CAT_NONE = 'none'; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; +const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt']; function isDebtCat(categories, catId) { if (!catId || catId === CAT_NONE) return false; const cat = categories.find(c => String(c.id) === catId); return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; } +function isSnowballCat(categories, catId) { + if (!catId || catId === CAT_NONE) return false; + const cat = categories.find(c => String(c.id) === catId); + return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; +} export default function BillModal({ bill, categories, onClose, onSave }) { const isNew = !bill; @@ -66,7 +72,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) { const [errors, setErrors] = useState({}); const isDebtCategory = isDebtCat(categories, categoryId); - const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt); + const isSnowballCategory = isSnowballCat(categories, categoryId); + const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt); const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; @@ -144,10 +151,10 @@ export default function BillModal({ bill, categories, onClose, onSave }) { const handleSnowballVisibilityChange = (checked) => { if (checked) { setSnowballExempt(false); - setSnowballInclude(!isDebtCategory); + setSnowballInclude(!isSnowballCategory); } else { setSnowballInclude(false); - setSnowballExempt(isDebtCategory); + setSnowballExempt(isSnowballCategory); } }; @@ -387,12 +394,12 @@ export default function BillModal({ bill, categories, onClose, onSave }) { className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')} /> Debt / Snowball Details - {isDebtCategory && ( + {isSnowballCategory && ( - · auto-detected + · snowball auto-detected )} - {!showOnSnowball && isDebtCategory && ( + {!showOnSnowball && isSnowballCategory && ( · exempt @@ -473,7 +480,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {

- 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`} +

+
+
+
+ {items.map(item => { + const Icon = item.ready ? CheckCircle2 : Circle; + const content = ( + <> + + {item.label} + + ); + const className = cn( + 'inline-flex h-8 max-w-full items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors', + item.ready + ? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-400' + : 'border-border/60 bg-muted/30 text-muted-foreground', + item.manual && !disabled && 'hover:bg-muted/60 cursor-pointer', + item.manual && disabled && 'opacity-60 cursor-not-allowed', + ); + + if (item.manual) { + return ( + + ); + } + + return ( + + {content} + + ); + })} +
+
+ ); +} + // ── Pointer-based drag-and-drop hook (works on touch + mouse) ───────────────── function useSortable(items, setItems, setDirty) { const [draggingIdx, setDraggingIdx] = useState(null); @@ -350,6 +429,9 @@ export default function SnowballPage() { const [editBill, setEditBill] = useState(null); const [extraPayment, setExtraPayment] = useState(''); + const [ramseyMode, setRamseyMode] = useState(true); + const [readyCurrentOnBills, setReadyCurrentOnBills] = useState(false); + const [readyEmergencyFund, setReadyEmergencyFund] = useState(false); const [savingSettings, setSavingSettings] = useState(false); const extraPaymentRef = useRef(''); @@ -379,6 +461,9 @@ export default function SnowballPage() { setBills(billsArr); setDirty(false); const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : ''; + setRamseyMode(settings.ramsey_mode !== false); + setReadyCurrentOnBills(!!settings.ready_current_on_bills); + setReadyEmergencyFund(!!settings.ready_emergency_fund); setExtraPayment(ep); extraPaymentRef.current = ep; } catch (err) { @@ -390,12 +475,7 @@ export default function SnowballPage() { // ── auto-arrange ────────────────────────────────────────────────────────── const handleAutoArrange = () => { - setBills(prev => [...prev].sort((a, b) => { - if (a.current_balance == null && b.current_balance == null) return 0; - if (a.current_balance == null) return 1; - if (b.current_balance == null) return -1; - return a.current_balance - b.current_balance; - })); + setBills(prev => sortRamseyDebts(prev)); setDirty(true); toast.success('Arranged smallest-to-largest balance'); }; @@ -431,6 +511,47 @@ export default function SnowballPage() { finally { setSavingSettings(false); } }; + const handleRamseyModeChange = async (checked) => { + setSavingSettings(true); + try { + const result = await api.saveSnowballSettings({ ramsey_mode: checked }); + const nextMode = result.ramsey_mode !== false; + setRamseyMode(nextMode); + setDirty(false); + if (nextMode) setBills(prev => sortRamseyDebts(prev)); + else load(); + loadProjection(); + toast.success(nextMode ? 'Ramsey Mode enabled' : 'Custom order enabled'); + } catch (err) { + toast.error(err.message || 'Failed to save Snowball mode'); + } finally { + setSavingSettings(false); + } + }; + + const handleReadinessToggle = async (id, checked) => { + const payload = id === 'current_on_bills' + ? { ready_current_on_bills: checked } + : { ready_emergency_fund: checked }; + const previous = id === 'current_on_bills' ? readyCurrentOnBills : readyEmergencyFund; + + if (id === 'current_on_bills') setReadyCurrentOnBills(checked); + else setReadyEmergencyFund(checked); + + setSavingSettings(true); + try { + const result = await api.saveSnowballSettings(payload); + setReadyCurrentOnBills(!!result.ready_current_on_bills); + setReadyEmergencyFund(!!result.ready_emergency_fund); + } catch (err) { + if (id === 'current_on_bills') setReadyCurrentOnBills(previous); + else setReadyEmergencyFund(previous); + toast.error(err.message || 'Failed to save readiness'); + } finally { + setSavingSettings(false); + } + }; + // ── inline balance edit ─────────────────────────────────────────────────── const startEditBalance = (bill) => setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' }); @@ -443,7 +564,10 @@ export default function SnowballPage() { if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; } try { await api.updateBillBalance(billId, num); - setBills(prev => prev.map(b => b.id === billId ? { ...b, current_balance: num } : b)); + setBills(prev => { + const next = prev.map(b => b.id === billId ? { ...b, current_balance: num } : b); + return ramseyMode ? sortRamseyDebts(next) : next; + }); setEditingBalance({ billId: null, value: '' }); loadProjection(); } catch (err) { toast.error(err.message || 'Failed to update balance'); } @@ -481,7 +605,50 @@ export default function SnowballPage() { const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); const unknownCount = bills.filter(b => b.current_balance == null).length; + const missingMinCount = bills.filter(b => b.current_balance > 0 && b.minimum_payment == null).length; const extraAmt = parseFloat(extraPayment) || 0; + const attackBill = bills[0] ?? null; + const attackAmount = attackBill ? (attackBill.minimum_payment || 0) + extraAmt : 0; + const customOrderDrift = !ramseyMode && bills.length > 1 && !isRamseyOrdered(bills); + const mortgageIncluded = bills.some(b => /mortgage|housing/i.test(b.category_name || b.name || '')); + const readinessItems = [ + { + id: 'current_on_bills', + label: 'Current on bills', + ready: readyCurrentOnBills, + manual: true, + hint: 'Confirm all bills are current before starting the snowball.', + }, + { + id: 'emergency_fund', + label: '$1,000 emergency fund', + ready: readyEmergencyFund, + manual: true, + hint: 'Confirm the starter emergency fund is set aside.', + }, + { + id: 'debts_entered', + label: 'Debts entered', + ready: bills.length > 0 && !mortgageIncluded, + hint: mortgageIncluded + ? 'Mortgage or housing debt is included; Ramsey Baby Step 2 excludes the house.' + : 'Enter consumer debts and leave the mortgage out of this snowball.', + }, + { + id: 'minimums_entered', + label: 'Minimums entered', + ready: bills.length > 0 && missingMinCount === 0, + hint: 'Every debt with a balance needs a minimum payment.', + }, + { + id: 'extra_set', + label: 'Extra amount set', + ready: extraAmt > 0, + hint: 'Set the extra monthly budget you can throw at the current target.', + }, + ]; + const readinessReadyCount = readinessItems.filter(item => item.ready).length; + const readinessAllReady = readinessReadyCount === readinessItems.length; // ── loading skeleton ────────────────────────────────────────────────────── if (loading) { @@ -522,14 +689,29 @@ export default function SnowballPage() { sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} /> 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" /> - 0} /> + )} {/* Toolbar */} {bills.length > 0 && (
+
+ +
+ +

+ {ramseyMode ? 'Smallest balance first' : 'Custom drag order'} +

+
+
- - {dirty && Unsaved changes} @@ -555,13 +744,41 @@ export default function SnowballPage() {
)} + {bills.length > 0 && ( + + )} + + {bills.length > 0 && (customOrderDrift || missingMinCount > 0) && ( +
+ {customOrderDrift && ( +
+ + Custom order is active, so the payoff list no longer strictly follows smallest-balance-first. +
+ )} + {missingMinCount > 0 && ( +
+ + {missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment before the projection can model the snowball cleanly. +
+ )} +
+ )} + {/* Empty state */} {bills.length === 0 && (

No debt bills found

- 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.

@@ -611,9 +828,14 @@ export default function SnowballPage() { {/* Grip */}
onPointerDown(e, index)} - className="flex items-center px-3 text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors touch-none shrink-0" - aria-label="Drag to reorder" + onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }} + className={cn( + 'flex items-center px-3 transition-colors touch-none shrink-0', + ramseyMode + ? 'text-muted-foreground/10 cursor-not-allowed' + : 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing', + )} + aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'} >
@@ -682,6 +904,13 @@ export default function SnowballPage() {
)} + {bill.current_balance > 0 && bill.minimum_payment == null && ( +
+ + Needs minimum payment +
+ )} + {isAttack && extraAmt > 0 && (
Throwing @@ -753,7 +982,9 @@ export default function SnowballPage() { })}

- 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;