v0.28.
This commit is contained in:
parent
bfc1521835
commit
9174ec3290
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue