perf: composite DB indexes, notification N+1 batching, spending page double-fetch fix
This commit is contained in:
parent
803e91da28
commit
59d32f4686
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue