- No categories yet. Add one above.
+
+
+
+
+
Categories
+
+ {plural(categories.length, 'category')}
+ /
+ {plural(totalBills, 'bill')}
+
- ) : (
-
- {categories.map((cat) => (
-
-
- {cat.name}
- {cat.bill_count > 0 && (
-
- {cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
-
- )}
-
-
-
openRename(cat)}
- >
-
-
-
openDelete(cat)}
- >
-
-
-
+
+
+
+
+
+
+ {loading ? (
+
Loading...
+ ) : categories.length === 0 ? (
+
+ No categories yet. Add one above.
- ))}
+ ) : (
+
+ {categories.map((cat) => {
+ const isExpanded = expanded.has(cat.id);
+ const preview = billPreview(cat.bill_names);
+ return (
+
+ toggleCategory(cat.id)}
+ onKeyDown={event => onRowKeyDown(event, cat.id)}
+ className={cn(
+ 'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
+ 'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
+ isExpanded && 'bg-muted/25',
+ )}
+ >
+
+
+
+
+
+
+
+ {cat.name}
+
+
+
+ {cat.name}
+ {preview}
+
+
+
+
+
+
+
+
+
openRename(event, cat)}
+ aria-label={`Rename ${cat.name}`}
+ >
+
+
+
openDelete(event, cat)}
+ aria-label={`Delete ${cat.name}`}
+ >
+
+
+
+
+
+ {isExpanded && }
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ Category totals include active and inactive bills in your account only.
- )}
-
{/* /card */}
+
- {/* Rename dialog */}
-
{ if (!open) setRenameTarget(null); }}
- title="Rename Category"
- label="Name"
- defaultValue={renameTarget?.name ?? ''}
- placeholder="Category name"
- confirmLabel="Rename"
- loading={renaming}
- onConfirm={handleRename}
- />
+ { if (!open) setRenameTarget(null); }}
+ title="Rename Category"
+ label="Name"
+ defaultValue={renameTarget?.name ?? ''}
+ placeholder="Category name"
+ confirmLabel="Rename"
+ loading={renaming}
+ onConfirm={handleRename}
+ />
- {/* Delete dialog */}
- { if (!open) setDeleteTarget(null); }}>
-
-
- Delete {deleteTarget?.name}?
-
- Bills in this category will become uncategorized. This cannot be undone.
-
-
-
- Cancel
-
- {deleting ? 'Deleting…' : 'Delete Category'}
-
-
-
-
-
-
+
{ if (!open) setDeleteTarget(null); }}>
+
+
+ Delete {deleteTarget?.name}?
+
+ Bills in this category will become uncategorized. No bills or payments will be deleted.
+
+
+
+ Cancel
+
+ {deleting ? 'Deleting...' : 'Delete Category'}
+
+
+
+
+
+
);
}
diff --git a/client/pages/SummaryPage.jsx b/client/pages/SummaryPage.jsx
new file mode 100644
index 0000000..a65268a
--- /dev/null
+++ b/client/pages/SummaryPage.jsx
@@ -0,0 +1,387 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { toast } from 'sonner';
+import {
+ CalendarDays,
+ CheckCircle2,
+ ChevronLeft,
+ ChevronRight,
+ Edit3,
+ Loader2,
+ Minus,
+ Printer,
+ RotateCcw,
+ Save,
+} from 'lucide-react';
+import { api } from '@/api.js';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { cn, fmt } from '@/lib/utils';
+
+const MONTHS = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+];
+
+function selectedFromToday() {
+ const now = new Date();
+ return { year: now.getFullYear(), month: now.getMonth() + 1 };
+}
+
+function shiftMonth(year, month, delta) {
+ const next = new Date(year, month - 1 + delta, 1);
+ return { year: next.getFullYear(), month: next.getMonth() + 1 };
+}
+
+function monthLabel(year, month) {
+ return `${MONTHS[month - 1]} ${year}`;
+}
+
+function moneyClass(value) {
+ return value >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-destructive';
+}
+
+function StatusMark({ expense }) {
+ if (expense.is_skipped) {
+ return (
+
+ Skipped
+
+ );
+ }
+
+ if (expense.is_paid) {
+ return (
+
+
+ Paid
+
+ );
+ }
+
+ return (
+
+
+ Open
+
+ );
+}
+
+function SummaryChart({ rows = [] }) {
+ const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
+ const chartRows = rows.map((row, index) => ({
+ ...row,
+ label: row.type === 'Savings'
+ ? Number(row.amount) >= 0 ? 'Savings' : 'Shortfall'
+ : row.type,
+ color: index === 0
+ ? 'hsl(var(--chart-1))'
+ : index === 1
+ ? 'hsl(var(--chart-3))'
+ : Number(row.amount) >= 0
+ ? 'hsl(var(--chart-2))'
+ : 'hsl(var(--destructive))',
+ width: Math.max(2, (Math.abs(Number(row.amount) || 0) / max) * 100),
+ }));
+
+ return (
+
+ {chartRows.map(row => (
+
+
{row.label}
+
+
+ {fmt(row.amount)}
+
+
+ ))}
+
+ );
+}
+
+function ExpenseRow({ expense }) {
+ return (
+
+
+
{expense.name}
+
+ {expense.category_name && {expense.category_name} }
+ Due day {expense.due_day}
+ {expense.actual_amount !== null && Monthly amount }
+
+
+
{fmt(expense.display_amount)}
+
+
+
+
+ );
+}
+
+export default function SummaryPage() {
+ const [selected, setSelected] = useState(selectedFromToday);
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState('');
+ const [incomeLabel, setIncomeLabel] = useState('Salary');
+ const [incomeAmount, setIncomeAmount] = useState('0');
+ const [editingIncome, setEditingIncome] = useState(false);
+
+ const loadSummary = useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const result = await api.summary(selected.year, selected.month);
+ setData(result);
+ setIncomeLabel(result.income?.label || 'Salary');
+ setIncomeAmount(String(result.income?.amount ?? 0));
+ setEditingIncome(false);
+ } catch (err) {
+ setError(err.message || 'Summary could not be loaded.');
+ toast.error(err.message || 'Summary could not be loaded.');
+ } finally {
+ setLoading(false);
+ }
+ }, [selected.month, selected.year]);
+
+ useEffect(() => {
+ loadSummary();
+ }, [loadSummary]);
+
+ const summary = data?.summary || {};
+ const expenses = data?.expenses || [];
+
+ const generatedLabel = useMemo(() => {
+ if (!data?.generated_at) return '';
+ return new Date(data.generated_at).toLocaleString();
+ }, [data?.generated_at]);
+
+ async function saveIncome() {
+ const amount = Number(incomeAmount);
+ if (!Number.isFinite(amount) || amount < 0) {
+ toast.error('Enter a valid income amount.');
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await api.saveSummaryIncome({
+ year: selected.year,
+ month: selected.month,
+ label: incomeLabel.trim() || 'Salary',
+ amount,
+ });
+ toast.success('Income saved.');
+ await loadSummary();
+ } catch (err) {
+ toast.error(err.message || 'Income could not be saved.');
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function moveMonth(delta) {
+ setSelected(current => shiftMonth(current.year, current.month, delta));
+ }
+
+ function resetToday() {
+ setSelected(selectedFromToday());
+ }
+
+ return (
+
+
+
BillTracker Summary
+
{monthLabel(selected.year, selected.month)}
+ {generatedLabel &&
Generated {generatedLabel}
}
+
+
+
+
+
Summary
+
Plan income, expenses, and monthly result.
+
+
+
+
+ Today
+
+
window.print()} className="sm:w-auto">
+
+ Print / PDF
+
+
+
+
+
+
moveMonth(-1)} aria-label="Previous month">
+
+
+
+
+
{monthLabel(selected.year, selected.month)}
+
+
moveMonth(1)} aria-label="Next month">
+
+
+
+
+ {loading && (
+
+
+
+ Loading summary...
+
+
+ )}
+
+ {!loading && error && (
+
+
+ {error}
+ Retry
+
+
+ )}
+
+ {!loading && !error && data && (
+ <>
+
+
+ Monthly Plan
+ {monthLabel(data.year, data.month)}
+
+
+
+
+
+
Income
+ setEditingIncome(value => !value)}
+ >
+
+ {editingIncome ? 'Close' : 'Edit'}
+
+
+
+
+
+
{data.income?.label || 'Salary'}
+ {Number(summary.income_total || 0) === 0 && (
+
Add income to calculate savings.
+ )}
+
+
{fmt(summary.income_total)}
+
+
+ {editingIncome && (
+
+
+ Label
+ setIncomeLabel(event.target.value)} placeholder="Salary" />
+
+
+ Amount
+ setIncomeAmount(event.target.value)}
+ />
+
+
+ {saving ? : }
+ Save
+
+
+ )}
+
+
+
+
+
+
Expenses
+
Skipped bills are shown but not counted.
+
+
+ Paid
+
+
+
+ {expenses.length === 0 ? (
+
+ No bills found for this month.
+
+ ) : (
+
+ {expenses.map(expense => (
+
+ ))}
+
+ )}
+
+
+
+
+
Fully Paid Expenses
+
{summary.paid_expense_count || 0} / {summary.expense_count || 0}
+
+
+
Expenses
+
{fmt(summary.expense_total)}
+
+
+
Result
+
{fmt(summary.result)}
+
+
+
+ window.print()} className="summary-actions w-full">
+
+ Print / PDF
+
+
+
+
+
+
+ Total amount per type
+
+ Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
+
+
+
+
+
+
+
+
+ Generated {generatedLabel || 'now'}
+
+ >
+ )}
+
+ );
+}
diff --git a/db/database.js b/db/database.js
index 60a697b..0afdccc 100644
--- a/db/database.js
+++ b/db/database.js
@@ -4,7 +4,18 @@ const fs = require('fs');
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bills.db');
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
-const DEFAULT_CATEGORIES = ['Housing', 'Utilities', 'Subscriptions', 'Insurance', 'Loans', 'Other'];
+const DEFAULT_CATEGORIES = [
+ 'Housing',
+ 'Utilities',
+ 'Credit Cards',
+ 'Loans',
+ 'Insurance',
+ 'Subscriptions',
+ 'Phone & Internet',
+ 'Transportation',
+ 'Medical',
+ 'Other',
+];
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
@@ -141,6 +152,22 @@ function runMigrations() {
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)');
console.log('[migration] monthly_bill_state table ensured');
+ // -- monthly_income: per-user monthly income for Summary planning (v0.18.1)
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS monthly_income (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
+ month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
+ label TEXT NOT NULL DEFAULT 'Salary',
+ amount REAL NOT NULL DEFAULT 0,
+ created_at TEXT DEFAULT (datetime('now')),
+ updated_at TEXT DEFAULT (datetime('now')),
+ UNIQUE(user_id, year, month)
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)');
+
// ── import_sessions: temporary preview state (v0.38) ─────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions (
@@ -349,14 +376,8 @@ function seedDefaults() {
insert.run(key, value);
}
- const insertCat = db.prepare(
- 'INSERT INTO categories (name) VALUES (?)'
- );
-
- for (const name of DEFAULT_CATEGORIES) {
- const existing = db.prepare('SELECT id FROM categories WHERE user_id IS NULL AND name = ? COLLATE NOCASE').get(name);
- if (!existing) insertCat.run(name);
- }
+ // Category defaults are user-scoped. They are applied by
+ // ensureUserDefaultCategories(userId) when user-owned category/bill data is read.
}
function ensureUserDefaultCategories(userId) {
diff --git a/routes/categories.js b/routes/categories.js
index 140126c..c753227 100644
--- a/routes/categories.js
+++ b/routes/categories.js
@@ -6,7 +6,60 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
router.get('/', (req, res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
- res.json(db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY name ASC').all(req.user.id));
+
+ const categories = db.prepare(`
+ SELECT id, user_id, name, created_at, updated_at
+ FROM categories
+ WHERE user_id = ?
+ ORDER BY name COLLATE NOCASE ASC
+ `).all(req.user.id);
+
+ const billsByCategory = db.prepare(`
+ SELECT
+ b.id,
+ b.category_id,
+ b.name,
+ b.active,
+ b.expected_amount,
+ b.due_day,
+ COUNT(p.id) AS payment_count,
+ COALESCE(SUM(p.amount), 0) AS total_paid,
+ MAX(p.paid_date) AS last_paid_date
+ FROM bills b
+ LEFT JOIN payments p
+ ON p.bill_id = b.id
+ AND p.deleted_at IS NULL
+ WHERE b.user_id = ?
+ AND b.category_id = ?
+ GROUP BY b.id
+ ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC
+ `);
+
+ const shaped = categories.map(category => {
+ const bills = billsByCategory.all(req.user.id, category.id).map(bill => ({
+ ...bill,
+ active: !!bill.active,
+ payment_count: Number(bill.payment_count || 0),
+ total_paid: Number(bill.total_paid || 0),
+ last_paid_date: bill.last_paid_date || null,
+ }));
+
+ const activeBillCount = bills.filter(bill => bill.active).length;
+ const inactiveBillCount = bills.length - activeBillCount;
+ const paymentCount = bills.reduce((sum, bill) => sum + bill.payment_count, 0);
+
+ return {
+ ...category,
+ bill_count: activeBillCount,
+ active_bill_count: activeBillCount,
+ inactive_bill_count: inactiveBillCount,
+ payment_count: paymentCount,
+ bill_names: bills.map(bill => bill.name),
+ bills,
+ };
+ });
+
+ res.json(shaped);
});
// POST /api/categories
diff --git a/routes/summary.js b/routes/summary.js
new file mode 100644
index 0000000..30a9a7e
--- /dev/null
+++ b/routes/summary.js
@@ -0,0 +1,173 @@
+const express = require('express');
+const router = express.Router();
+const { getDb } = require('../db/database');
+const { getCycleRange } = require('../services/statusService');
+
+const DEFAULT_INCOME_LABEL = 'Salary';
+
+function parseYearMonth(source) {
+ const now = new Date();
+ const year = parseInt(source.year || now.getFullYear(), 10);
+ const month = parseInt(source.month || now.getMonth() + 1, 10);
+
+ if (Number.isNaN(year) || year < 2000 || year > 2100) {
+ return { error: 'year must be a 4-digit integer between 2000 and 2100' };
+ }
+ if (Number.isNaN(month) || month < 1 || month > 12) {
+ return { error: 'month must be an integer between 1 and 12' };
+ }
+
+ return { year, month };
+}
+
+function money(value) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : 0;
+}
+
+function getIncome(db, userId, year, month) {
+ const row = db.prepare(`
+ SELECT id, label, amount
+ FROM monthly_income
+ WHERE user_id = ? AND year = ? AND month = ?
+ `).get(userId, year, month);
+
+ return {
+ id: row?.id || null,
+ label: row?.label || DEFAULT_INCOME_LABEL,
+ amount: money(row?.amount),
+ };
+}
+
+function buildSummary(db, userId, year, month) {
+ const income = getIncome(db, userId, year, month);
+ const { start, end } = getCycleRange(year, month);
+
+ const billRows = db.prepare(`
+ SELECT
+ b.id AS bill_id,
+ b.name,
+ b.expected_amount,
+ b.due_day,
+ c.name AS category_name,
+ m.actual_amount,
+ m.is_skipped
+ FROM bills b
+ LEFT JOIN categories c ON c.id = b.category_id AND c.user_id = b.user_id
+ LEFT JOIN monthly_bill_state m ON m.bill_id = b.id AND m.year = ? AND m.month = ?
+ WHERE b.user_id = ? AND b.active = 1
+ ORDER BY b.due_day ASC, b.name ASC
+ `).all(year, month, userId);
+
+ const billIds = billRows.map(row => row.bill_id);
+ const paymentMap = new Map();
+
+ if (billIds.length > 0) {
+ const placeholders = billIds.map(() => '?').join(', ');
+ const payments = db.prepare(`
+ SELECT p.bill_id, COUNT(p.id) AS payment_count, SUM(p.amount) AS paid_amount
+ 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
+ `).all(userId, ...billIds, start, end);
+
+ for (const row of payments) {
+ paymentMap.set(row.bill_id, {
+ payment_count: row.payment_count || 0,
+ paid_amount: money(row.paid_amount),
+ });
+ }
+ }
+
+ const expenses = billRows.map(row => {
+ const payment = paymentMap.get(row.bill_id) || { payment_count: 0, paid_amount: 0 };
+ const hasActual = row.actual_amount !== null && row.actual_amount !== undefined;
+ const displayAmount = money(hasActual ? row.actual_amount : row.expected_amount);
+ const paidAmount = money(payment.paid_amount);
+
+ return {
+ bill_id: row.bill_id,
+ name: row.name,
+ expected_amount: money(row.expected_amount),
+ actual_amount: hasActual ? money(row.actual_amount) : null,
+ display_amount: displayAmount,
+ is_paid: payment.payment_count > 0,
+ paid_amount: paidAmount,
+ payment_count: payment.payment_count,
+ is_skipped: !!row.is_skipped,
+ due_day: row.due_day,
+ category_name: row.category_name || null,
+ };
+ });
+
+ const countedExpenses = expenses.filter(expense => !expense.is_skipped);
+ const incomeTotal = money(income.amount);
+ const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0);
+ const paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
+ const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
+ const result = incomeTotal - expenseTotal;
+
+ return {
+ year,
+ month,
+ income,
+ expenses,
+ summary: {
+ income_total: incomeTotal,
+ expense_total: expenseTotal,
+ paid_expense_count: paidExpenseCount,
+ expense_count: countedExpenses.length,
+ paid_total: paidTotal,
+ remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
+ result,
+ },
+ chart: [
+ { type: 'Income', amount: incomeTotal },
+ { type: 'Expenses', amount: expenseTotal },
+ { type: 'Savings', amount: result },
+ ],
+ generated_at: new Date().toISOString(),
+ };
+}
+
+router.get('/', (req, res) => {
+ const parsed = parseYearMonth(req.query);
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
+
+ const db = getDb();
+ res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
+});
+
+router.put('/income', (req, res) => {
+ const parsed = parseYearMonth(req.body || {});
+ if (parsed.error) return res.status(400).json({ error: parsed.error });
+
+ const amount = Number(req.body?.amount);
+ if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
+ return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
+ }
+
+ const label = String(req.body?.label || DEFAULT_INCOME_LABEL).trim().slice(0, 80) || DEFAULT_INCOME_LABEL;
+ const db = getDb();
+
+ db.prepare(`
+ INSERT INTO monthly_income (user_id, year, month, label, amount, updated_at)
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
+ ON CONFLICT(user_id, year, month) DO UPDATE SET
+ label = excluded.label,
+ amount = excluded.amount,
+ updated_at = datetime('now')
+ `).run(req.user.id, parsed.year, parsed.month, label, amount);
+
+ res.json({
+ year: parsed.year,
+ month: parsed.month,
+ income: getIncome(db, req.user.id, parsed.year, parsed.month),
+ });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 0401dc4..0919f8b 100644
--- a/server.js
+++ b/server.js
@@ -47,6 +47,7 @@ app.use('/api/payments', requireAuth, requireUser, require('./routes/paymen
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
+app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/notifications', requireAuth, require('./routes/notifications'));
app.use('/api/status', requireAuth, require('./routes/status'));