v0.28.
This commit is contained in:
parent
bfc1521835
commit
9174ec3290
|
|
@ -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 && (
|
||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||
· auto-detected
|
||||
· snowball auto-detected
|
||||
</span>
|
||||
)}
|
||||
{!showOnSnowball && isDebtCategory && (
|
||||
{!showOnSnowball && isSnowballCategory && (
|
||||
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||
· exempt
|
||||
</span>
|
||||
|
|
@ -473,7 +480,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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) ─────────────────
|
||||
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} />
|
||||
<StatCard label="Monthly Minimums" value={fmt(totalMinPayment)} />
|
||||
<StatCard label="Extra / Month" value={extraAmt > 0 ? fmt(extraAmt) : '—'} sub="snowball accelerator" />
|
||||
<StatCard label="Total Attack" value={fmt(totalMinPayment + extraAmt)}
|
||||
sub="toward #1 target" highlight={extraAmt > 0} />
|
||||
<StatCard label="Next Win" value={liveAttackPayoff || 'Add details'}
|
||||
sub={attackBill ? `${attackBill.name} · attacking ${fmt(attackAmount)}/mo` : undefined}
|
||||
highlight={!!liveAttackPayoff} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
{bills.length > 0 && (
|
||||
<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">
|
||||
<Label className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Extra monthly budget ($)
|
||||
|
|
@ -544,10 +726,17 @@ export default function SnowballPage() {
|
|||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pb-0.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAutoArrange} className="gap-2">
|
||||
<Zap className="h-3.5 w-3.5" /> Auto-arrange
|
||||
<Button
|
||||
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 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'}
|
||||
</Button>
|
||||
{dirty && <span className="text-xs text-amber-400">Unsaved changes</span>}
|
||||
|
|
@ -555,13 +744,41 @@ export default function SnowballPage() {
|
|||
</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 */}
|
||||
{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">
|
||||
<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-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.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -611,9 +828,14 @@ export default function SnowballPage() {
|
|||
{/* Grip */}
|
||||
<div
|
||||
data-grip
|
||||
onPointerDown={e => 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'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
|
|
@ -682,6 +904,13 @@ export default function SnowballPage() {
|
|||
</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 && (
|
||||
<div>
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue