perf: composite DB indexes, notification N+1 batching, spending page double-fetch fix

This commit is contained in:
null 2026-06-04 21:00:59 -05:00
parent 803e91da28
commit 59d32f4686
4 changed files with 76 additions and 30 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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');
}
}
];

View File

@ -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}`);
}
}
}