From 59d32f46861f57c687757fce65b3fa19eefb6196 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 4 Jun 2026 21:00:59 -0500 Subject: [PATCH] perf: composite DB indexes, notification N+1 batching, spending page double-fetch fix --- HISTORY.md | 6 ----- client/pages/SpendingPage.jsx | 45 +++++++++++++++++++++------------ db/database.js | 14 ++++++++++ services/notificationService.js | 41 ++++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 30 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 305b1a6..68ba6a9 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1855,12 +1855,6 @@ Bill Tracker follows [Semantic Versioning](https://semver.org/): `MAJOR.MINOR.PA | Breaking change to frontend | Major | Under new major version | | Database schema change | Major | Under new major version | -### Current Version - -- **Current Version**: v0.19.0 -- **Package.json**: `version: "0.19.0"` -- **HISTORY.md**: Top entry matches current version - ### Version Sync The version in `package.json` and top of `HISTORY.md` must always be in sync. After any change that qualifies for a bump, update both files and document in HISTORY.md under the appropriate version section. diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index 9e42540..bf06c85 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -404,6 +404,7 @@ export default function SpendingPage() { const [txLoading, setTxLoading] = useState(false); const [budgets, setBudgets] = useState({}); // categoryId → amount + // loadCategories is stable — categories don't vary by month const loadCategories = useCallback(async () => { try { const d = await api.categories(); @@ -414,21 +415,7 @@ export default function SpendingPage() { } }, []); - const loadSummary = useCallback(async () => { - setLoading(true); - try { - const d = await api.spendingSummary({ year, month }); - setSummary(d); - const bmap = {}; - (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); - setBudgets(bmap); - } catch (err) { - toast.error(err.message || 'Failed to load spending summary'); - } finally { - setLoading(false); - } - }, [year, month]); - + // loadTransactions is exposed so pagination buttons can call it with a page arg const loadTransactions = useCallback(async (page = 1) => { setTxLoading(true); try { @@ -447,8 +434,34 @@ export default function SpendingPage() { } }, [year, month, activeCat]); + // Load categories once on mount useEffect(() => { loadCategories(); }, [loadCategories]); - useEffect(() => { loadSummary(); loadTransactions(1); }, [loadSummary, loadTransactions]); + + // Load summary and transactions whenever month/category filter changes. + // Depends on primitive values directly — avoids the double-fetch that + // happened when useCallback references were used as deps (both effects + // would fire whenever year/month changed). + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoading(true); + try { + const d = await api.spendingSummary({ year, month }); + if (cancelled) return; + setSummary(d); + const bmap = {}; + (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); + setBudgets(bmap); + } catch (err) { + if (!cancelled) toast.error(err.message || 'Failed to load spending summary'); + } finally { + if (!cancelled) setLoading(false); + } + }; + run(); + loadTransactions(1); + return () => { cancelled = true; }; + }, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps const navMonth = (dir) => { let m = month + dir, y = year; diff --git a/db/database.js b/db/database.js index 4dfc926..a9f037a 100644 --- a/db/database.js +++ b/db/database.js @@ -2928,6 +2928,20 @@ function runMigrations() { console.log(`[v0.90] merchant rules re-normalized (${billFixed} bill rules updated), rejection expiry column ensured`); } + }, + { + version: 'v0.91', + description: 'performance: composite indexes on user_id+deleted_at for categories, bills, payments', + dependsOn: ['v0.90'], + run: function() { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_categories_user_deleted ON categories(user_id, deleted_at); + CREATE INDEX IF NOT EXISTS idx_bills_user_deleted ON bills(user_id, deleted_at); + CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active, deleted_at); + CREATE INDEX IF NOT EXISTS idx_payments_bill_deleted ON payments(bill_id, deleted_at); + `); + console.log('[v0.91] composite indexes created on categories, bills, payments'); + } } ]; diff --git a/services/notificationService.js b/services/notificationService.js index 62b9898..d4e1d96 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -309,16 +309,38 @@ async function runNotifications() { const errors = []; + // Batch-fetch all payments for active bills this cycle to avoid N+1 queries. + // Bills use different cycle ranges per bill, so we use a broad month window + // and the per-bill cycle check happens in memory below. + const billIds = bills.map(b => b.id); + const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; + const monthEnd = new Date(year, month, 0).toISOString().slice(0, 10); + const paidMap = new Map(); + if (billIds.length > 0) { + const placeholders = billIds.map(() => '?').join(','); + const paidRows = db.prepare(` + SELECT bill_id, SUM(amount) AS paid_sum + FROM payments + WHERE bill_id IN (${placeholders}) + AND paid_date BETWEEN ? AND ? + AND deleted_at IS NULL + GROUP BY bill_id + `).all(...billIds, monthStart, monthEnd); + for (const row of paidRows) paidMap.set(row.bill_id, row.paid_sum); + } + + // Batch-fetch all notifications already sent today to avoid N×M per-bill-per-recipient queries. + const sentRows = db.prepare(` + SELECT bill_id, user_id, type FROM notifications + WHERE year = ? AND month = ? AND sent_date = ? + `).all(year, month, today); + const sentSet = new Set(sentRows.map(n => `${n.bill_id}:${n.user_id}:${n.type}`)); + for (const bill of bills) { const dueDate = resolveDueDate(bill, year, month); if (!dueDate) continue; - const range = getCycleRange(year, month, bill); - const payments = db.prepare( - 'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL' - ).all(bill.id, range.start, range.end); - - const totalPaid = payments.reduce((s, p) => s + p.amount, 0); + const totalPaid = paidMap.get(bill.id) ?? 0; const isPaid = totalPaid >= bill.expected_amount; if (isPaid) continue; @@ -354,7 +376,7 @@ async function runNotifications() { if (type === 'due_today' && !recipient.notify_due) continue; if (type === 'overdue' && !recipient.notify_overdue) continue; - if (hasNotification(db, bill.id, recipient.id, year, month, type, today)) continue; + if (sentSet.has(`${bill.id}:${recipient.id}:${type}`)) continue; const meta = TYPE_META[type]; const subject = meta.subject(bill); @@ -384,7 +406,10 @@ async function runNotifications() { } } - if (sent) recordNotification(db, bill.id, recipient.id, year, month, type, today); + if (sent) { + recordNotification(db, bill.id, recipient.id, year, month, type, today); + sentSet.add(`${bill.id}:${recipient.id}:${type}`); + } } }