snowball bug fixes

This commit is contained in:
null 2026-05-14 03:00:01 -05:00
parent cd61c2ef7f
commit 440f872d97
9 changed files with 167 additions and 24 deletions

View File

@ -143,6 +143,7 @@ export const api = {
createBill: (data) => post('/bills', data), createBill: (data) => post('/bills', data),
updateBill: (id, data) => put(`/bills/${id}`, data), updateBill: (id, data) => put(`/bills/${id}`, data),
updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }),
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`), deleteBill: (id) => del(`/bills/${id}`),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),

View File

@ -54,13 +54,19 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance)); const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance));
const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment));
const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include);
const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt);
const [showDebtSection, setShowDebtSection] = useState( const [showDebtSection, setShowDebtSection] = useState(
() => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE) () => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE)
|| !!bill?.snowball_include
|| !!bill?.snowball_exempt
|| bill?.current_balance != null
|| bill?.minimum_payment != null
); );
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const isDebtCategory = isDebtCat(categories, categoryId); const isDebtCategory = isDebtCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt);
const validateName = (val) => { const validateName = (val) => {
if (!val || val.trim() === '') return 'Name is required'; if (!val || val.trim() === '') return 'Name is required';
@ -128,7 +134,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
const handleCategoryChange = (val) => { const handleCategoryChange = (val) => {
setCategoryId(val); setCategoryId(val);
if (isDebtCat(categories, val)) setShowDebtSection(true); if (isDebtCat(categories, val)) {
setShowDebtSection(true);
} else {
setSnowballExempt(false);
}
};
const handleSnowballVisibilityChange = (checked) => {
if (checked) {
setSnowballExempt(false);
setSnowballInclude(!isDebtCategory);
} else {
setSnowballInclude(false);
setSnowballExempt(isDebtCategory);
}
}; };
async function handleSubmit(e) { async function handleSubmit(e) {
@ -170,6 +190,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
current_balance: currentBalance === '' ? null : parseFloat(currentBalance), current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_include: snowballInclude, snowball_include: snowballInclude,
snowball_exempt: snowballExempt,
}; };
setBusy(true); setBusy(true);
try { try {
@ -355,7 +376,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</p> </p>
</div> </div>
{/* Debt / Credit Details — collapsible */} {/* Debt / Snowball Details — collapsible */}
<div className="col-span-2"> <div className="col-span-2">
<button <button
type="button" type="button"
@ -365,12 +386,17 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
<ChevronDown <ChevronDown
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 / Credit Details Debt / Snowball Details
{isDebtCategory && ( {isDebtCategory && (
<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 · auto-detected
</span> </span>
)} )}
{!showOnSnowball && isDebtCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt
</span>
)}
</button> </button>
{showDebtSection && ( {showDebtSection && (
@ -438,16 +464,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
<label className="flex items-center gap-2.5 cursor-pointer group"> <label className="flex items-center gap-2.5 cursor-pointer group">
<input <input
type="checkbox" type="checkbox"
checked={snowballInclude} checked={showOnSnowball}
onChange={e => setSnowballInclude(e.target.checked)} onChange={e => handleSnowballVisibilityChange(e.target.checked)}
className="h-4 w-4 rounded border-border accent-emerald-500" className="h-4 w-4 rounded border-border accent-emerald-500"
/> />
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors"> <span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Include in Snowball Show on Debt Snowball
</span> </span>
</label> </label>
<p className="text-[10px] text-muted-foreground/70 pl-6"> <p className="text-[10px] text-muted-foreground/70 pl-6">
Force this bill onto the debt snowball page. Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.
</p> </p>
</div> </div>

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle } from 'lucide-react'; import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } 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';
@ -166,6 +166,30 @@ function useSortable(items, setItems, setDirty) {
containerEl: null, containerEl: null,
}); });
const indexFromPointer = useCallback((clientX, clientY) => {
const direct = document.elementFromPoint(clientX, clientY)?.closest?.('[data-card-index]');
if (direct?.dataset?.cardIndex != null) {
const idx = Number(direct.dataset.cardIndex);
if (Number.isInteger(idx)) return idx;
}
const cards = [...(state.current.containerEl?.querySelectorAll('[data-card-index]') || [])];
if (cards.length === 0) return state.current.currentIdx;
let nearestIdx = state.current.currentIdx;
let nearestDistance = Infinity;
for (const card of cards) {
const rect = card.getBoundingClientRect();
const centerY = rect.top + rect.height / 2;
const distance = Math.abs(clientY - centerY);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIdx = Number(card.dataset.cardIndex);
}
}
return Number.isInteger(nearestIdx) ? nearestIdx : state.current.currentIdx;
}, []);
const onPointerDown = useCallback((e, index) => { const onPointerDown = useCallback((e, index) => {
// Only trigger on the grip handle (data-grip attr) // Only trigger on the grip handle (data-grip attr)
if (!e.currentTarget.dataset.grip) return; if (!e.currentTarget.dataset.grip) return;
@ -190,18 +214,16 @@ function useSortable(items, setItems, setDirty) {
const onPointerMove = useCallback((e) => { const onPointerMove = useCallback((e) => {
if (state.current.fromIdx === null) return; if (state.current.fromIdx === null) return;
const { containerEl, startY, itemHeight, currentIdx } = state.current; const { containerEl, currentIdx } = state.current;
if (!containerEl) return; if (!containerEl) return;
const dy = e.clientY - startY; const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY)));
const shift = Math.round(dy / itemHeight);
const newIdx = Math.max(0, Math.min(items.length - 1, state.current.fromIdx + shift));
if (newIdx !== currentIdx) { if (newIdx !== currentIdx) {
state.current.currentIdx = newIdx; state.current.currentIdx = newIdx;
setDraggingIdx(newIdx); // visual feedback on where card will land setDraggingIdx(newIdx); // visual feedback on where card will land
} }
}, [items.length]); }, [indexFromPointer, items.length]);
const onPointerUp = useCallback((e) => { const onPointerUp = useCallback((e) => {
const { fromIdx, currentIdx } = state.current; const { fromIdx, currentIdx } = state.current;
@ -331,6 +353,18 @@ export default function SnowballPage() {
} catch (err) { toast.error(err.message || 'Failed to update balance'); } } catch (err) { toast.error(err.message || 'Failed to update balance'); }
}; };
const removeFromSnowball = async (bill) => {
try {
await api.updateBillSnowball(bill.id, { snowball_include: false, snowball_exempt: true });
setBills(prev => prev.filter(b => b.id !== bill.id));
setDirty(true);
toast.success(`${bill.name} removed from Snowball`);
loadProjection();
} catch (err) {
toast.error(err.message || 'Failed to remove bill from Snowball');
}
};
// stats // stats
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);
@ -442,6 +476,7 @@ export default function SnowballPage() {
<div <div
key={bill.id} key={bill.id}
data-card data-card
data-card-index={index}
className={cn( className={cn(
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none', 'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
isAttack ? 'border-emerald-500/40' : 'border-border/40', isAttack ? 'border-emerald-500/40' : 'border-border/40',
@ -490,6 +525,15 @@ export default function SnowballPage() {
> >
Edit Edit
</button> </button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
title="Remove from Snowball"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
</div> </div>
{/* Stats row */} {/* Stats row */}

View File

@ -44,6 +44,7 @@ const COLUMN_WHITELIST = new Set([
// bills table columns // bills table columns
'history_visibility', 'interest_rate', 'user_id', 'history_visibility', 'interest_rate', 'user_id',
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
'snowball_exempt',
// sessions table columns // sessions table columns
'created_at', 'created_at',
]); ]);
@ -716,6 +717,21 @@ function reconcileLegacyMigrations() {
} }
console.log('[migration] payments: balance_delta column added'); console.log('[migration] payments: balance_delta column added');
} }
},
{
version: 'v0.51',
description: 'bills: snowball_exempt column for hiding debt-like bills',
check: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
return cols.includes('snowball_exempt');
},
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('snowball_exempt')) {
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
}
console.log('[migration] bills: snowball_exempt column added');
}
} }
]; ];
@ -1236,6 +1252,18 @@ function runMigrations() {
} }
console.log('[migration] payments: balance_delta column added'); console.log('[migration] payments: balance_delta column added');
} }
},
{
version: 'v0.51',
description: 'bills: snowball_exempt column for hiding debt-like bills',
dependsOn: ['v0.50'],
run: function() {
const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name);
if (!cols.includes('snowball_exempt')) {
db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
}
console.log('[migration] bills: snowball_exempt column added');
}
} }
]; ];
@ -1622,6 +1650,10 @@ const ROLLBACK_SQL_MAP = {
'v0.50': { 'v0.50': {
description: 'payments: balance_delta column', description: 'payments: balance_delta column',
sql: ['ALTER TABLE payments DROP COLUMN balance_delta'] sql: ['ALTER TABLE payments DROP COLUMN balance_delta']
},
'v0.51': {
description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']
} }
}; };

View File

@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS bills (
account_info TEXT, account_info TEXT,
has_2fa INTEGER NOT NULL DEFAULT 0, has_2fa INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,
current_balance REAL,
minimum_payment REAL,
snowball_order INTEGER,
snowball_include INTEGER NOT NULL DEFAULT 0,
snowball_exempt INTEGER NOT NULL DEFAULT 0,
notes TEXT, notes TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
@ -39,6 +44,7 @@ CREATE TABLE IF NOT EXISTS payments (
paid_date TEXT NOT NULL, paid_date TEXT NOT NULL,
method TEXT, method TEXT,
notes TEXT, notes TEXT,
balance_delta REAL,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );
@ -58,6 +64,7 @@ CREATE TABLE IF NOT EXISTS users (
is_default_admin INTEGER NOT NULL DEFAULT 0, is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1, first_login INTEGER NOT NULL DEFAULT 1,
snowball_extra_payment REAL NOT NULL DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))
); );

View File

@ -147,8 +147,8 @@ router.post('/', (req, res) => {
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount,
interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username,
account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day, account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day,
current_balance, minimum_payment, snowball_order, snowball_include) current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
req.user.id, req.user.id,
normalized.name, normalized.name,
@ -173,6 +173,7 @@ router.post('/', (req, res) => {
normalized.minimum_payment, normalized.minimum_payment,
normalized.snowball_order, normalized.snowball_order,
normalized.snowball_include, normalized.snowball_include,
normalized.snowball_exempt,
); );
const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid); const created = db.prepare('SELECT * FROM bills WHERE id = ?').get(result.lastInsertRowid);
@ -205,7 +206,7 @@ router.put('/:id', (req, res) => {
expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?, expected_amount = ?, interest_rate = ?, billing_cycle = ?, autopay_enabled = ?, autodraft_status = ?,
website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?,
history_visibility = ?, cycle_type = ?, cycle_day = ?, history_visibility = ?, cycle_type = ?, cycle_day = ?,
current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, current_balance = ?, minimum_payment = ?, snowball_order = ?, snowball_include = ?, snowball_exempt = ?,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`).run( `).run(
@ -232,6 +233,7 @@ router.put('/:id', (req, res) => {
normalized.minimum_payment, normalized.minimum_payment,
normalized.snowball_order, normalized.snowball_order,
normalized.snowball_include, normalized.snowball_include,
normalized.snowball_exempt,
req.params.id, req.params.id,
req.user.id, req.user.id,
); );
@ -519,4 +521,24 @@ router.patch('/:id/balance', (req, res) => {
res.json({ id: billId, current_balance: val }); res.json({ id: billId, current_balance: val });
}); });
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
router.patch('/:id/snowball', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const include = req.body.snowball_include ? 1 : 0;
const exempt = req.body.snowball_exempt ? 1 : 0;
db.prepare(`
UPDATE bills
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(include, exempt, billId, req.user.id);
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
});
module.exports = router; module.exports = router;

View File

@ -6,11 +6,16 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS
const DEBT_LIKE_CLAUSES = `( const DEBT_LIKE_CLAUSES = `(
b.snowball_include = 1 b.snowball_include = 1
OR LOWER(c.name) LIKE '%credit%' OR (
OR LOWER(c.name) LIKE '%loan%' COALESCE(b.snowball_exempt, 0) = 0
OR LOWER(c.name) LIKE '%mortgage%' AND (
OR LOWER(c.name) LIKE '%housing%' LOWER(c.name) LIKE '%credit%'
OR LOWER(c.name) LIKE '%debt%' OR LOWER(c.name) LIKE '%loan%'
OR LOWER(c.name) LIKE '%mortgage%'
OR LOWER(c.name) LIKE '%housing%'
OR LOWER(c.name) LIKE '%debt%'
)
)
)`; )`;
// GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order

View File

@ -128,9 +128,9 @@ function seedDemoData(userId = null) {
const insertBill = db.prepare(` const insertBill = db.prepare(`
INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle,
expected_amount, autopay_enabled, interest_rate, expected_amount, autopay_enabled, interest_rate,
current_balance, minimum_payment, snowball_order, snowball_include, current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt,
active, is_seeded) active, is_seeded)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1)
`); `);
for (const billData of BILLS) { for (const billData of BILLS) {
@ -152,7 +152,8 @@ function seedDemoData(userId = null) {
billData.currentBalance ?? null, billData.currentBalance ?? null,
billData.minPayment ?? null, billData.minPayment ?? null,
billData.snowballOrder ?? null, billData.snowballOrder ?? null,
billData.snowballInclude ?? 0 billData.snowballInclude ?? 0,
billData.snowballExempt ?? 0
); );
billsCreated++; billsCreated++;
} catch (err) { } catch (err) {

View File

@ -226,6 +226,11 @@ function validateBillData(data, existingBill = null) {
? (data.snowball_include ? 1 : 0) ? (data.snowball_include ? 1 : 0)
: (existingBill?.snowball_include ?? 0); : (existingBill?.snowball_include ?? 0);
// snowball_exempt — manual override to hide an auto-detected debt-like bill
normalized.snowball_exempt = data.snowball_exempt !== undefined
? (data.snowball_exempt ? 1 : 0)
: (existingBill?.snowball_exempt ?? 0);
return { return {
errors, errors,
normalized: { normalized: {