This commit is contained in:
null 2026-05-16 10:17:24 -05:00
parent bfc1521835
commit 9174ec3290
4 changed files with 356 additions and 41 deletions

View File

@ -27,12 +27,18 @@ function getOrdinalSuffix(day) {
const CAT_NONE = 'none'; const CAT_NONE = 'none';
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt'];
function isDebtCat(categories, catId) { function isDebtCat(categories, catId) {
if (!catId || catId === CAT_NONE) return false; if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId); const cat = categories.find(c => String(c.id) === catId);
return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; 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 }) { export default function BillModal({ bill, categories, onClose, onSave }) {
const isNew = !bill; const isNew = !bill;
@ -66,7 +72,8 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId); const isDebtCategory = isDebtCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt); const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
const validateName = (val) => { const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required'; if (!val || val.trim() === '') return 'Name is required';
@ -144,10 +151,10 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const handleSnowballVisibilityChange = (checked) => { const handleSnowballVisibilityChange = (checked) => {
if (checked) { if (checked) {
setSnowballExempt(false); setSnowballExempt(false);
setSnowballInclude(!isDebtCategory); setSnowballInclude(!isSnowballCategory);
} else { } else {
setSnowballInclude(false); 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')} className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/> />
Debt / Snowball Details Debt / Snowball Details
{isDebtCategory && ( {isSnowballCategory && (
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case"> <span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· auto-detected · snowball auto-detected
</span> </span>
)} )}
{!showOnSnowball && isDebtCategory && ( {!showOnSnowball && isSnowballCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case"> <span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt · exempt
</span> </span>
@ -473,7 +480,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</span> </span>
</label> </label>
<p className="text-[10px] text-muted-foreground/70 pl-6"> <p className="text-[10px] text-muted-foreground/70 pl-6">
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.
</p> </p>
</div> </div>

View File

@ -28,6 +28,11 @@ export const RELEASE_NOTES = {
title: 'Privacy and 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.', 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: '🎛️', icon: '🎛️',
title: 'Cleaner tracker and interface polish', title: 'Cleaner tracker and interface polish',

View File

@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton'; import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import BillModal from '@/components/BillModal'; 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`; 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) // Client-side snowball simulation (mirrors server snowballService)
// Returns the full projection shape so the panel and attack card both update // 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 (
<div className={cn(
'surface-elevated rounded-xl px-4 py-3 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between',
allReady && 'border border-emerald-500/30',
)}>
<div className="flex items-center gap-3 shrink-0">
<div className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg border',
allReady ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
{allReady ? <CheckCircle2 className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
</div>
<div>
<p className="text-sm font-semibold">Snowball Readiness</p>
<p className={cn('text-xs', allReady ? 'text-emerald-400' : 'text-muted-foreground')}>
{allReady ? 'Ready to roll: attack the smallest balance first.' : `${readyCount} of ${totalCount} ready`}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map(item => {
const Icon = item.ready ? CheckCircle2 : Circle;
const content = (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.label}</span>
</>
);
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 (
<button
key={item.id}
type="button"
className={className}
onClick={() => onToggle(item.id, !item.ready)}
disabled={disabled}
title={item.hint}
>
{content}
</button>
);
}
return (
<span key={item.id} className={className} title={item.hint}>
{content}
</span>
);
})}
</div>
</div>
);
}
// Pointer-based drag-and-drop hook (works on touch + mouse) // Pointer-based drag-and-drop hook (works on touch + mouse)
function useSortable(items, setItems, setDirty) { function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null); const [draggingIdx, setDraggingIdx] = useState(null);
@ -350,6 +429,9 @@ export default function SnowballPage() {
const [editBill, setEditBill] = useState(null); const [editBill, setEditBill] = useState(null);
const [extraPayment, setExtraPayment] = useState(''); 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 [savingSettings, setSavingSettings] = useState(false);
const extraPaymentRef = useRef(''); const extraPaymentRef = useRef('');
@ -379,6 +461,9 @@ export default function SnowballPage() {
setBills(billsArr); setBills(billsArr);
setDirty(false); setDirty(false);
const ep = settings.extra_payment > 0 ? String(settings.extra_payment) : ''; 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); setExtraPayment(ep);
extraPaymentRef.current = ep; extraPaymentRef.current = ep;
} catch (err) { } catch (err) {
@ -390,12 +475,7 @@ export default function SnowballPage() {
// auto-arrange // auto-arrange
const handleAutoArrange = () => { const handleAutoArrange = () => {
setBills(prev => [...prev].sort((a, b) => { setBills(prev => sortRamseyDebts(prev));
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;
}));
setDirty(true); setDirty(true);
toast.success('Arranged smallest-to-largest balance'); toast.success('Arranged smallest-to-largest balance');
}; };
@ -431,6 +511,47 @@ export default function SnowballPage() {
finally { setSavingSettings(false); } 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 // inline balance edit
const startEditBalance = (bill) => const startEditBalance = (bill) =>
setEditingBalance({ billId: bill.id, value: bill.current_balance != null ? String(bill.current_balance) : '' }); 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; } if (num === current?.current_balance) { setEditingBalance({ billId: null, value: '' }); return; }
try { try {
await api.updateBillBalance(billId, num); 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: '' }); setEditingBalance({ billId: null, value: '' });
loadProjection(); loadProjection();
} catch (err) { toast.error(err.message || 'Failed to update balance'); } } 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 totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0);
const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 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 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 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 // loading skeleton
if (loading) { if (loading) {
@ -522,14 +689,29 @@ export default function SnowballPage() {
sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} /> sub={unknownCount > 0 ? `+ ${unknownCount} unknown` : undefined} />
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} /> <StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" /> <StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)} <StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
sub="toward #1 target" highlight={extraAmt > 0} /> sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
highlight={!!liveAttackPayoff} />
</div> </div>
)} )}
{/* Toolbar */} {/* Toolbar */}
{bills.length > 0 && ( {bills.length > 0 && (
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
<div className="surface-elevated rounded-xl px-4 py-3 flex items-center gap-3 min-h-[58px]">
<Switch
id="ramsey-mode"
checked={ramseyMode}
onCheckedChange={handleRamseyModeChange}
disabled={savingSettings}
/>
<div>
<Label htmlFor="ramsey-mode" className="text-xs font-semibold cursor-pointer">Ramsey Mode</Label>
<p className="text-[10px] text-muted-foreground">
{ramseyMode ? 'Smallest balance first' : 'Custom drag order'}
</p>
</div>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
Extra monthly budget ($) Extra monthly budget ($)
@ -544,10 +726,17 @@ export default function SnowballPage() {
/> />
</div> </div>
<div className="flex items-center gap-2 pb-0.5"> <div className="flex items-center gap-2 pb-0.5">
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2"> <Button
<Zap className="h-3.5 w-3.5" /> Auto-arrange type="button"
variant="outline"
size="sm"
onClick={handleAutoArrange}
className="gap-2"
disabled={ramseyMode}
>
<Zap className="h-3.5 w-3.5" /> Restore Ramsey Order
</Button> </Button>
<Button type="button" size="sm" disabled={!dirty || saving} onClick={handleSaveOrder} className="gap-2"> <Button type="button" size="sm" disabled={ramseyMode || !dirty || saving} onClick={handleSaveOrder} className="gap-2">
<Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'} <Save className="h-3.5 w-3.5" /> {saving ? 'Saving…' : 'Save Order'}
</Button> </Button>
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>} {dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
@ -555,13 +744,41 @@ export default function SnowballPage() {
</div> </div>
)} )}
{bills.length > 0 && (
<ReadinessStrip
items={readinessItems}
readyCount={readinessReadyCount}
totalCount={readinessItems.length}
allReady={readinessAllReady}
onToggle={handleReadinessToggle}
disabled={savingSettings}
/>
)}
{bills.length > 0 && (customOrderDrift || missingMinCount > 0) && (
<div className="surface-elevated rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3 space-y-2">
{customOrderDrift && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
Custom order is active, so the payoff list no longer strictly follows smallest-balance-first.
</div>
)}
{missingMinCount > 0 && (
<div className="flex items-start gap-2 text-xs text-amber-300">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
{missingMinCount} debt{missingMinCount > 1 ? 's need' : ' needs'} a minimum payment before the projection can model the snowball cleanly.
</div>
)}
</div>
)}
{/* Empty state */} {/* Empty state */}
{bills.length === 0 && ( {bills.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 py-20 text-center gap-3">
<TrendingDown className="h-10 w-10 text-muted-foreground/30" /> <TrendingDown className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">No debt bills found</p> <p className="text-sm font-medium text-muted-foreground">No debt bills found</p>
<p className="text-xs text-muted-foreground/70 max-w-sm"> <p className="text-xs text-muted-foreground/70 max-w-sm">
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. You can also enable "Include in Snowball" when editing any bill.
</p> </p>
</div> </div>
@ -611,9 +828,14 @@ export default function SnowballPage() {
{/* Grip */} {/* Grip */}
<div <div
data-grip data-grip
onPointerDown={e => onPointerDown(e, index)} onPointerDown={e => { if (!ramseyMode) 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" className={cn(
aria-label="Drag to reorder" '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'}
> >
<GripVertical className="h-5 w-5" /> <GripVertical className="h-5 w-5" />
</div> </div>
@ -682,6 +904,13 @@ export default function SnowballPage() {
</div> </div>
)} )}
{bill.current_balance > 0 && bill.minimum_payment == null && (
<div className="inline-flex items-center gap-1 text-xs text-amber-400">
<AlertTriangle className="h-3 w-3" />
Needs minimum payment
</div>
)}
{isAttack && extraAmt > 0 && ( {isAttack && extraAmt > 0 && (
<div> <div>
<span className="text-xs text-muted-foreground">Throwing </span> <span className="text-xs text-muted-foreground">Throwing </span>
@ -753,7 +982,9 @@ export default function SnowballPage() {
})} })}
<p className="text-xs text-muted-foreground/50 text-center pt-1"> <p className="text-xs text-muted-foreground/50 text-center pt-1">
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'}
</p> </p>
</div> </div>

View File

@ -12,43 +12,91 @@ const DEBT_LIKE_CLAUSES = `(
AND ( AND (
LOWER(c.name) LIKE '%credit%' LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%loan%' OR LOWER(c.name) LIKE '%loan%'
OR LOWER(c.name) LIKE '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%' 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 SELECT b.*, c.name AS category_name
FROM bills b FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id LEFT JOIN categories c ON b.category_id = c.id AND c.user_id = b.user_id
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.active = 1 AND b.active = 1
AND ${DEBT_LIKE_CLAUSES} AND ${DEBT_LIKE_CLAUSES}
ORDER BY ORDER BY${orderBy}
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 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 // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); res.json(getDebtBills(req.user.id));
res.json(db.prepare(DEBT_QUERY).all(req.user.id));
}); });
// GET /api/snowball/settings — extra monthly payment for this user // GET /api/snowball/settings — extra monthly payment for this user
router.get('/settings', (req, res) => { router.get('/settings', (req, res) => {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id); 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 // PATCH /api/snowball/settings — save extra monthly payment
router.patch('/settings', (req, res) => { 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; let val = 0;
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
@ -63,8 +111,32 @@ router.patch('/settings', (req, res) => {
} }
const db = getDb(); const db = getDb();
const save = db.transaction(() => {
if (extra_payment !== undefined) {
db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id); db.prepare('UPDATE users SET snowball_extra_payment = ? WHERE id = ?').run(val, req.user.id);
res.json({ extra_payment: val }); }
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 // GET /api/snowball/projection — snowball, avalanche, minimum-only projections
@ -72,7 +144,7 @@ router.patch('/settings', (req, res) => {
router.get('/projection', (req, res) => { router.get('/projection', (req, res) => {
const db = getDb(); 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 user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(req.user.id);
const extra = user?.snowball_extra_payment ?? 0; const extra = user?.snowball_extra_payment ?? 0;