BillTracker/routes/analytics.js

277 lines
9.4 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
function parseInteger(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : NaN;
}
function monthKey(year, month) {
return `${year}-${String(month).padStart(2, '0')}`;
}
function monthLabel(year, month) {
return new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', {
month: 'short',
year: '2-digit',
timeZone: 'UTC',
});
}
function addMonths(year, month, delta) {
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
}
function monthEndDate(year, month) {
const day = new Date(Date.UTC(year, month, 0)).getUTCDate();
return `${monthKey(year, month)}-${String(day).padStart(2, '0')}`;
}
function buildMonths(endYear, endMonth, count) {
return Array.from({ length: count }, (_, index) => {
const value = addMonths(endYear, endMonth, index - count + 1);
return {
...value,
key: monthKey(value.year, value.month),
label: monthLabel(value.year, value.month),
start: `${monthKey(value.year, value.month)}-01`,
end: monthEndDate(value.year, value.month),
};
});
}
function validateSummaryQuery(query) {
const now = new Date();
const year = parseInteger(query.year, now.getFullYear());
const month = parseInteger(query.month, now.getMonth() + 1);
const months = parseInteger(query.months, 12);
const categoryId = parseInteger(query.category_id, null);
const billId = parseInteger(query.bill_id, null);
const includeInactive = query.include_inactive === 'true';
const includeSkipped = query.include_skipped !== 'false';
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { error: 'month must be an integer between 1 and 12' };
}
if (!Number.isInteger(months) || months < 1 || months > 36) {
return { error: 'months must be an integer between 1 and 36' };
}
if (categoryId !== null && (!Number.isInteger(categoryId) || categoryId < 1)) {
return { error: 'category_id must be a positive integer' };
}
if (billId !== null && (!Number.isInteger(billId) || billId < 1)) {
return { error: 'bill_id must be a positive integer' };
}
return { year, month, months, categoryId, billId, includeInactive, includeSkipped };
}
function isMonthInPast(year, month) {
const now = new Date();
const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const targetMonthStart = new Date(year, month - 1, 1);
return targetMonthStart < currentMonthStart;
}
function buildBillWhere({ userId, categoryId, billId, includeInactive }) {
const clauses = ['b.user_id = ?'];
const params = [userId];
if (!includeInactive) clauses.push('b.active = 1');
if (categoryId) {
clauses.push('b.category_id = ?');
params.push(categoryId);
}
if (billId) {
clauses.push('b.id = ?');
params.push(billId);
}
return { where: clauses.join(' AND '), params };
}
router.get('/summary', (req, res) => {
const parsed = validateSummaryQuery(req.query);
if (parsed.error) return res.status(400).json(standardizeError(parsed.error, 'VALIDATION_ERROR', 'month'));
const db = getDb();
const userId = req.user.id;
const rangeMonths = buildMonths(parsed.year, parsed.month, parsed.months);
const startDate = rangeMonths[0].start;
const endDate = rangeMonths[rangeMonths.length - 1].end;
const billWhere = buildBillWhere({ ...parsed, userId });
const categories = db.prepare(`
SELECT id, name
FROM categories
WHERE user_id = ?
ORDER BY name COLLATE NOCASE
`).all(userId);
const bills = db.prepare(`
SELECT b.id, b.name, b.category_id, b.expected_amount, b.active, b.created_at,
c.name AS category_name
FROM bills b
LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
WHERE ${billWhere.where}
ORDER BY b.name COLLATE NOCASE
`).all(...billWhere.params);
if (!bills.length) {
return res.json({
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: [],
monthly_spending: [],
expected_vs_actual: [],
category_spend: [],
heatmap: { months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })), rows: [] },
generated_at: new Date().toISOString(),
});
}
const billIds = bills.map(b => b.id);
const placeholders = billIds.map(() => '?').join(',');
const paymentRows = db.prepare(`
SELECT p.bill_id,
substr(p.paid_date, 1, 7) AS month_key,
SUM(p.amount) AS total
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.bill_id IN (${placeholders})
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
GROUP BY p.bill_id, substr(p.paid_date, 1, 7)
`).all(userId, ...billIds, startDate, endDate);
const stateRows = db.prepare(`
SELECT m.bill_id, m.year, m.month, m.actual_amount, m.is_skipped
FROM monthly_bill_state m
JOIN bills b ON b.id = m.bill_id
WHERE b.user_id = ?
AND m.bill_id IN (${placeholders})
AND (m.year * 100 + m.month) BETWEEN ? AND ?
`).all(
userId,
...billIds,
rangeMonths[0].year * 100 + rangeMonths[0].month,
rangeMonths[rangeMonths.length - 1].year * 100 + rangeMonths[rangeMonths.length - 1].month,
);
const paymentByBillMonth = new Map(paymentRows.map(row => [`${row.bill_id}:${row.month_key}`, Number(row.total) || 0]));
const stateByBillMonth = new Map(stateRows.map(row => [`${row.bill_id}:${monthKey(row.year, row.month)}`, row]));
const monthly_spending = rangeMonths.map(m => {
const total = bills.reduce((sum, bill) => sum + (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0), 0);
return { month: m.key, label: m.label, total: Number(total.toFixed(2)) };
}).filter(row => row.total > 0);
const expected_vs_actual = rangeMonths.map(m => {
let expected = 0;
let actual = 0;
let skipped_count = 0;
for (const bill of bills) {
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
if (skipped) skipped_count += 1;
if (!skipped || parsed.includeSkipped) {
actual += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
if (!skipped) {
expected += state?.actual_amount ?? bill.expected_amount ?? 0;
}
}
return {
month: m.key,
label: m.label,
expected: Number(expected.toFixed(2)),
actual: Number(actual.toFixed(2)),
skipped_count,
};
}).filter(row => row.expected > 0 || row.actual > 0 || row.skipped_count > 0);
const categoryMap = new Map();
for (const bill of bills) {
const categoryId = bill.category_id || null;
const key = categoryId == null ? 'uncategorized' : String(categoryId);
const existing = categoryMap.get(key) || {
category_id: categoryId,
category_name: bill.category_name || 'Uncategorized',
total: 0,
};
for (const m of rangeMonths) {
existing.total += paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0;
}
categoryMap.set(key, existing);
}
const category_spend = Array.from(categoryMap.values())
.map(row => ({ ...row, total: Number(row.total.toFixed(2)) }))
.filter(row => row.total > 0)
.sort((a, b) => b.total - a.total);
const heatmapRows = bills.map(bill => {
const cells = rangeMonths.map(m => {
const paid = (paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0) > 0;
const state = stateByBillMonth.get(`${bill.id}:${m.key}`);
const skipped = !!state?.is_skipped;
let status = 'no_data';
if (skipped) status = 'skipped';
else if (paid) status = 'paid';
else if (isMonthInPast(m.year, m.month)) status = 'missed';
return {
month: m.key,
label: m.label,
status,
amount_paid: Number((paymentByBillMonth.get(`${bill.id}:${m.key}`) || 0).toFixed(2)),
};
});
return {
bill_id: bill.id,
bill_name: bill.name,
category_name: bill.category_name || 'Uncategorized',
active: !!bill.active,
cells: parsed.includeSkipped ? cells : cells.filter(cell => cell.status !== 'skipped'),
};
});
res.json({
range: { year: parsed.year, month: parsed.month, months: parsed.months, start: startDate, end: endDate },
filters: {
category_id: parsed.categoryId,
bill_id: parsed.billId,
include_inactive: parsed.includeInactive,
include_skipped: parsed.includeSkipped,
},
categories,
bills: bills.map(b => ({
id: b.id,
name: b.name,
category_id: b.category_id,
category_name: b.category_name || 'Uncategorized',
active: !!b.active,
})),
monthly_spending,
expected_vs_actual,
category_spend,
heatmap: {
months: rangeMonths.map(({ key, label, year, month }) => ({ key, label, year, month })),
rows: heatmapRows,
},
generated_at: new Date().toISOString(),
});
});
module.exports = router;