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 |
|
| Breaking change to frontend | Major | Under new major version |
|
||||||
| Database schema change | 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
|
### 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.
|
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 [txLoading, setTxLoading] = useState(false);
|
||||||
const [budgets, setBudgets] = useState({}); // categoryId → amount
|
const [budgets, setBudgets] = useState({}); // categoryId → amount
|
||||||
|
|
||||||
|
// loadCategories is stable — categories don't vary by month
|
||||||
const loadCategories = useCallback(async () => {
|
const loadCategories = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const d = await api.categories();
|
const d = await api.categories();
|
||||||
|
|
@ -414,21 +415,7 @@ export default function SpendingPage() {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
// loadTransactions is exposed so pagination buttons can call it with a page arg
|
||||||
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]);
|
|
||||||
|
|
||||||
const loadTransactions = useCallback(async (page = 1) => {
|
const loadTransactions = useCallback(async (page = 1) => {
|
||||||
setTxLoading(true);
|
setTxLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -447,8 +434,34 @@ export default function SpendingPage() {
|
||||||
}
|
}
|
||||||
}, [year, month, activeCat]);
|
}, [year, month, activeCat]);
|
||||||
|
|
||||||
|
// Load categories once on mount
|
||||||
useEffect(() => { loadCategories(); }, [loadCategories]);
|
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) => {
|
const navMonth = (dir) => {
|
||||||
let m = month + dir, y = year;
|
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`);
|
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 = [];
|
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) {
|
for (const bill of bills) {
|
||||||
const dueDate = resolveDueDate(bill, year, month);
|
const dueDate = resolveDueDate(bill, year, month);
|
||||||
if (!dueDate) continue;
|
if (!dueDate) continue;
|
||||||
|
|
||||||
const range = getCycleRange(year, month, bill);
|
const totalPaid = paidMap.get(bill.id) ?? 0;
|
||||||
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 isPaid = totalPaid >= bill.expected_amount;
|
const isPaid = totalPaid >= bill.expected_amount;
|
||||||
if (isPaid) continue;
|
if (isPaid) continue;
|
||||||
|
|
||||||
|
|
@ -354,7 +376,7 @@ async function runNotifications() {
|
||||||
if (type === 'due_today' && !recipient.notify_due) continue;
|
if (type === 'due_today' && !recipient.notify_due) continue;
|
||||||
if (type === 'overdue' && !recipient.notify_overdue) 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 meta = TYPE_META[type];
|
||||||
const subject = meta.subject(bill);
|
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