snowball bug fixes
This commit is contained in:
parent
cd61c2ef7f
commit
440f872d97
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Debt / Credit Details — collapsible */}
|
||||
{/* Debt / Snowball Details — collapsible */}
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -365,12 +386,17 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
<ChevronDown
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
|
||||
/>
|
||||
Debt / Credit Details
|
||||
Debt / Snowball Details
|
||||
{isDebtCategory && (
|
||||
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
||||
· auto-detected
|
||||
</span>
|
||||
)}
|
||||
{!showOnSnowball && isDebtCategory && (
|
||||
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
||||
· exempt
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showDebtSection && (
|
||||
|
|
@ -438,16 +464,16 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
|
|||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snowballInclude}
|
||||
onChange={e => setSnowballInclude(e.target.checked)}
|
||||
checked={showOnSnowball}
|
||||
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border accent-emerald-500"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Include in Snowball
|
||||
Show on Debt Snowball
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div
|
||||
key={bill.id}
|
||||
data-card
|
||||
data-card-index={index}
|
||||
className={cn(
|
||||
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
|
||||
isAttack ? 'border-emerald-500/40' : 'border-border/40',
|
||||
|
|
@ -490,6 +525,15 @@ export default function SnowballPage() {
|
|||
>
|
||||
Edit
|
||||
</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>
|
||||
|
||||
{/* Stats row */}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue