From 440f872d976c6a7ccd475696ad21a8c81281dbdf Mon Sep 17 00:00:00 2001 From: null Date: Thu, 14 May 2026 03:00:01 -0500 Subject: [PATCH] snowball bug fixes --- client/api.js | 1 + client/components/BillModal.jsx | 40 ++++++++++++++++++----- client/pages/SnowballPage.jsx | 56 +++++++++++++++++++++++++++++---- db/database.js | 32 +++++++++++++++++++ db/schema.sql | 7 +++++ routes/bills.js | 28 +++++++++++++++-- routes/snowball.js | 15 ++++++--- scripts/seedDemoData.js | 7 +++-- services/billsService.js | 5 +++ 9 files changed, 167 insertions(+), 24 deletions(-) diff --git a/client/api.js b/client/api.js index 0ce2506..54be6c9 100644 --- a/client/api.js +++ b/client/api.js @@ -143,6 +143,7 @@ export const api = { createBill: (data) => post('/bills', data), updateBill: (id, data) => put(`/bills/${id}`, data), 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}`), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index d8d2fe6..8aa614f 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -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 [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); + const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt); const [showDebtSection, setShowDebtSection] = useState( () => 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 [errors, setErrors] = useState({}); const isDebtCategory = isDebtCat(categories, categoryId); + const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt); const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; @@ -128,7 +134,21 @@ export default function BillModal({ bill, categories, onClose, onSave }) { const handleCategoryChange = (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) { @@ -170,6 +190,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) { current_balance: currentBalance === '' ? null : parseFloat(currentBalance), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), snowball_include: snowballInclude, + snowball_exempt: snowballExempt, }; setBusy(true); try { @@ -355,7 +376,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {

- {/* Debt / Credit Details — collapsible */} + {/* Debt / Snowball Details — collapsible */}
{showDebtSection && ( @@ -438,16 +464,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {

- Force this bill onto the debt snowball page. + Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.

diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 46d6e81..e7df064 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -1,5 +1,5 @@ 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 { api } from '@/api'; import { Button } from '@/components/ui/button'; @@ -166,6 +166,30 @@ function useSortable(items, setItems, setDirty) { 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) => { // Only trigger on the grip handle (data-grip attr) if (!e.currentTarget.dataset.grip) return; @@ -190,18 +214,16 @@ function useSortable(items, setItems, setDirty) { const onPointerMove = useCallback((e) => { if (state.current.fromIdx === null) return; - const { containerEl, startY, itemHeight, currentIdx } = state.current; + const { containerEl, currentIdx } = state.current; if (!containerEl) return; - const dy = e.clientY - startY; - const shift = Math.round(dy / itemHeight); - const newIdx = Math.max(0, Math.min(items.length - 1, state.current.fromIdx + shift)); + const newIdx = Math.max(0, Math.min(items.length - 1, indexFromPointer(e.clientX, e.clientY))); if (newIdx !== currentIdx) { state.current.currentIdx = newIdx; setDraggingIdx(newIdx); // visual feedback on where card will land } - }, [items.length]); + }, [indexFromPointer, items.length]); const onPointerUp = useCallback((e) => { const { fromIdx, currentIdx } = state.current; @@ -331,6 +353,18 @@ export default function SnowballPage() { } 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 ───────────────────────────────────────────────────────────────── const totalBalance = bills.reduce((s, b) => s + (b.current_balance || 0), 0); const totalMinPayment = bills.reduce((s, b) => s + (b.minimum_payment || 0), 0); @@ -442,6 +476,7 @@ export default function SnowballPage() {
Edit +
{/* Stats row */} diff --git a/db/database.js b/db/database.js index 44cced4..0822c02 100644 --- a/db/database.js +++ b/db/database.js @@ -44,6 +44,7 @@ const COLUMN_WHITELIST = new Set([ // bills table columns 'history_visibility', 'interest_rate', 'user_id', 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', + 'snowball_exempt', // sessions table columns 'created_at', ]); @@ -716,6 +717,21 @@ function reconcileLegacyMigrations() { } 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'); } + }, + { + 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': { description: 'payments: balance_delta column', sql: ['ALTER TABLE payments DROP COLUMN balance_delta'] + }, + 'v0.51': { + description: 'bills: snowball_exempt column', + sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] } }; diff --git a/db/schema.sql b/db/schema.sql index 66f4795..224905b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS bills ( account_info TEXT, has_2fa INTEGER NOT NULL DEFAULT 0, 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, created_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, method TEXT, notes TEXT, + balance_delta REAL, created_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, must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, + snowball_extra_payment REAL NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); diff --git a/routes/bills.js b/routes/bills.js index 8dba089..fab3a7c 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -147,8 +147,8 @@ router.post('/', (req, res) => { (user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, billing_cycle, autopay_enabled, autodraft_status, website, username, account_info, has_2fa, notes, history_visibility, active, cycle_type, cycle_day, - current_balance, minimum_payment, snowball_order, snowball_include) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?) + current_balance, minimum_payment, snowball_order, snowball_include, snowball_exempt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) `).run( req.user.id, normalized.name, @@ -173,6 +173,7 @@ router.post('/', (req, res) => { normalized.minimum_payment, normalized.snowball_order, normalized.snowball_include, + normalized.snowball_exempt, ); 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 = ?, website = ?, username = ?, account_info = ?, has_2fa = ?, notes = ?, active = ?, 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') WHERE id = ? AND user_id = ? `).run( @@ -232,6 +233,7 @@ router.put('/:id', (req, res) => { normalized.minimum_payment, normalized.snowball_order, normalized.snowball_include, + normalized.snowball_exempt, req.params.id, req.user.id, ); @@ -519,4 +521,24 @@ router.patch('/:id/balance', (req, res) => { 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; diff --git a/routes/snowball.js b/routes/snowball.js index 2056775..b288b8e 100644 --- a/routes/snowball.js +++ b/routes/snowball.js @@ -6,11 +6,16 @@ const { calculateSnowball, calculateAvalanche } = require('../services/snowballS const DEBT_LIKE_CLAUSES = `( b.snowball_include = 1 - OR 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%' + OR ( + COALESCE(b.snowball_exempt, 0) = 0 + 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%' + ) + ) )`; // GET /api/snowball — server-filtered debt bills, pre-sorted by snowball_order diff --git a/scripts/seedDemoData.js b/scripts/seedDemoData.js index 6250aaf..83686fa 100644 --- a/scripts/seedDemoData.js +++ b/scripts/seedDemoData.js @@ -128,9 +128,9 @@ function seedDemoData(userId = null) { const insertBill = db.prepare(` INSERT INTO bills (user_id, name, category_id, due_day, billing_cycle, 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) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1) `); for (const billData of BILLS) { @@ -152,7 +152,8 @@ function seedDemoData(userId = null) { billData.currentBalance ?? null, billData.minPayment ?? null, billData.snowballOrder ?? null, - billData.snowballInclude ?? 0 + billData.snowballInclude ?? 0, + billData.snowballExempt ?? 0 ); billsCreated++; } catch (err) { diff --git a/services/billsService.js b/services/billsService.js index a527857..07647e0 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -226,6 +226,11 @@ function validateBillData(data, existingBill = null) { ? (data.snowball_include ? 1 : 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 { errors, normalized: {