diff --git a/HISTORY.md b/HISTORY.md index 99a82a2..11859dd 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,14 +1,21 @@ # Bill Tracker — Changelog -## Unreleased - -### Changed -- Rewrote README.md as a simpler self-hosting guide based on the implemented app, setup, auth, data, and security behavior. -- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used. - ## v0.18.1 ### Changed +- Updated Admin authentik/OIDC issuer help text to show the authentik discovery URL example and clarify that issuer base or full discovery URL can be used. +- Updated the default category seed list to the top 10 common bill categories, safely filling missing user-scoped defaults without renaming or deleting existing categories. +- Categories now return user-scoped active/inactive bill counts, payment counts, bill name previews, and compact bill detail data. +- Categories page now shows compact stat chips for active bills, inactive bills, and payments with a subtle legend. +- Removed the category-level total paid chip from Categories while keeping bill-level paid totals in expanded details. +- Category rows now expand to show bills in that category, with hover/tap summaries for chips and bill names. +- Improved Categories page mobile and tablet layout so chips wrap cleanly and expanded bill details stay readable without page-level horizontal scrolling. +- Added a Summary page for monthly planning with income, expenses, paid expense count, result/savings, and browser Print / PDF output. +- Added minimal user-scoped monthly income support for the Summary page. +- Added a user-scoped `GET /api/summary` endpoint and income save endpoint using existing bills, payments, and monthly bill state data. +- Summary includes a simple income, expenses, and savings chart without adding a new chart library. +- Cleaned up the Summary page layout with a centered planner view, display-first Monthly Plan card, compact income editing, cleaner expense rows, and a calmer chart card. +- Summary Print / PDF behavior remains browser-based and no backend/payment behavior was changed. - Added a Calendar page with a month grid for user-owned bills and payments, compact day indicators, a legend, monthly progress summary, and day detail dialog. - Added a user-scoped `GET /api/calendar` endpoint for one-month calendar data using existing bills, payments, categories, and monthly bill state records without schema changes. - Calendar status and totals respect monthly actual amount overrides, skipped bills, existing due-day clamping, and existing tracker-style late/missed status behavior. @@ -22,7 +29,9 @@ - Tracker mobile notes stay contained in each bill row, so long notes can truncate or scroll locally without forcing the whole bill list sideways. ### Notes -- No schema, auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made. +- No auth behavior, tracker/payment/bill business logic, admin permissions, or desktop redesign changes were made. +- No Tracker, Bills, payment, analytics, calendar, auth, or admin behavior was changed for the Categories page updates. +- No Tracker, Bills, payment, Calendar, Analytics, auth, or admin behavior was changed for the Summary page updates. ## v0.18 diff --git a/client/App.jsx b/client/App.jsx index 1e3e1fc..8a4b61a 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -7,6 +7,7 @@ import LoginPage from '@/pages/LoginPage'; import AdminPage from '@/pages/AdminPage'; import TrackerPage from '@/pages/TrackerPage'; import CalendarPage from '@/pages/CalendarPage'; +import SummaryPage from '@/pages/SummaryPage'; import BillsPage from '@/pages/BillsPage'; import CategoriesPage from '@/pages/CategoriesPage'; import SettingsPage from '@/pages/SettingsPage'; @@ -76,6 +77,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> diff --git a/client/api.js b/client/api.js index c976643..44f1996 100644 --- a/client/api.js +++ b/client/api.js @@ -111,6 +111,10 @@ export const api = { // Calendar calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`), + // Summary + summary: (y, m) => get(`/summary?year=${y}&month=${m}`), + saveSummaryIncome: (data) => put('/summary/income', data), + // Bills bills: () => get('/bills'), allBills: () => get('/bills?inactive=true'), diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index 25f3d81..90dfd2e 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { - Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt, + Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt, Settings, ShieldCheck, Tag, User, X, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -20,6 +20,7 @@ import { const userNavItems = [ { to: '/', icon: LayoutGrid, label: 'Tracker', end: true }, { to: '/calendar', icon: CalendarDays, label: 'Calendar' }, + { to: '/summary', icon: ClipboardList, label: 'Summary' }, { to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/categories', icon: Tag, label: 'Categories' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' }, diff --git a/client/index.css b/client/index.css index 633e64d..5f37990 100644 --- a/client/index.css +++ b/client/index.css @@ -141,7 +141,12 @@ header, .analytics-screen-header, .analytics-controls, - .analytics-actions { + .analytics-actions, + .summary-screen-header, + .summary-controls, + .summary-actions, + .summary-edit-actions, + .summary-income-form { display: none !important; } @@ -151,24 +156,30 @@ padding: 0 !important; } - .analytics-page { + .analytics-page, + .summary-page { color: #111827 !important; } .analytics-report-meta, - .analytics-print-footer { + .analytics-print-footer, + .summary-print-meta, + .summary-print-footer { display: block !important; margin-bottom: 1rem; } - .analytics-report-meta h1 { + .analytics-report-meta h1, + .summary-print-meta h1 { font-size: 22px; font-weight: 700; margin-bottom: 0.25rem; } .analytics-report-meta p, - .analytics-print-footer { + .analytics-print-footer, + .summary-print-meta p, + .summary-print-footer { color: #4b5563 !important; font-size: 12px; margin: 0.125rem 0; @@ -178,11 +189,21 @@ margin-bottom: 1rem; } + .summary-page input { + border: 0 !important; + background: white !important; + box-shadow: none !important; + color: #111827 !important; + padding-left: 0 !important; + } + .analytics-chart-grid { display: block !important; } - .analytics-chart { + .analytics-chart, + .summary-card, + .summary-chart-card { break-inside: avoid; page-break-inside: avoid; margin-bottom: 1rem; diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index 869059d..dad430b 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -1,32 +1,238 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { Link } from 'react-router-dom'; import { toast } from 'sonner'; -import { Plus, Pencil, Trash2 } from 'lucide-react'; +import { + ChevronDown, Plus, Pencil, Trash2, ReceiptText, +} from 'lucide-react'; import { api } from '@/api.js'; -import { Button } from '@/components/ui/button'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { InputDialog } from '@/components/ui/input-dialog'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; -import { buttonVariants } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; +import { + Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, +} from '@/components/ui/tooltip'; +import { cn, fmt, fmtDate } from '@/lib/utils'; -// ─── CategoriesPage ─────────────────────────────────────────────────────────── +function plural(count, label) { + return `${count} ${label}${count === 1 ? '' : 's'}`; +} + +function billPreview(names = []) { + if (!names.length) return 'No bills in this category yet.'; + const visible = names.slice(0, 4).join(', '); + const more = names.length > 4 ? `, +${names.length - 4} more` : ''; + return `${visible}${more}`; +} + +function Chip({ value, label, tone = 'muted', details }) { + const toneClass = { + active: 'border-primary/25 bg-primary/10 text-primary', + muted: 'border-border bg-muted/55 text-muted-foreground', + info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', + }[tone]; + + return ( + + + + {value} + + + +

{label}

+ {details && details !== label &&

{details}

} +
+
+ ); +} + +function StatChips({ category }) { + const names = billPreview(category.bill_names); + return ( +
+ + + +
+ ); +} + +function ChipLegend() { + const items = [ + ['Active', 'active'], + ['Inactive', 'muted'], + ['Payments', 'info'], + ]; + + return ( +
+ {items.map(([label, tone]) => ( + + + {label} + + ))} +
+ ); +} + +function StatusPill({ active }) { + return ( + + {active ? 'Active' : 'Inactive'} + + ); +} + +function BillName({ bill }) { + const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`; + return ( + + + + {bill.name} + + + +

{label}

+

+ {plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid +

+
+
+ ); +} + +function ExpandedBills({ category }) { + const bills = category.bills || []; + + if (!bills.length) { + return ( +
+
+ No bills in this category yet. + +
+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + + {bills.map(bill => ( + + + + + + + + + + ))} + +
BillStatusExpectedDuePaidPaymentsLast Paid
{fmt(bill.expected_amount)}{bill.due_day}{fmt(bill.total_paid)}{bill.payment_count || 0}{fmtDate(bill.last_paid_date)}
+
+ +
+ {bills.map(bill => ( +
+
+
+

+

Due day {bill.due_day}

+
+ +
+
+
+

Expected

+

{fmt(bill.expected_amount)}

+
+
+

Paid

+

{fmt(bill.total_paid)}

+
+
+

Payments

+

{bill.payment_count || 0}

+
+
+

Last Paid

+

{fmtDate(bill.last_paid_date)}

+
+
+
+ ))} +
+
+ ); +} export default function CategoriesPage() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); + const [expanded, setExpanded] = useState(() => new Set()); const addInputRef = useRef(null); - // Rename dialog state - const [renameTarget, setRenameTarget] = useState(null); // { id, name } + const [renameTarget, setRenameTarget] = useState(null); const [renaming, setRenaming] = useState(false); - - // Delete dialog state - const [deleteTarget, setDeleteTarget] = useState(null); // { id, name } + const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const load = useCallback(async () => { @@ -42,7 +248,21 @@ export default function CategoriesPage() { useEffect(() => { load(); }, [load]); - // ── Add ────────────────────────────────────────────────────────────────────── + function toggleCategory(id) { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function onRowKeyDown(event, id) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggleCategory(id); + } + } async function handleAdd(e) { e.preventDefault(); @@ -66,9 +286,8 @@ export default function CategoriesPage() { } } - // ── Rename ─────────────────────────────────────────────────────────────────── - - function openRename(cat) { + function openRename(event, cat) { + event.stopPropagation(); setRenameTarget(cat); } @@ -86,9 +305,8 @@ export default function CategoriesPage() { } } - // ── Delete ─────────────────────────────────────────────────────────────────── - - function openDelete(cat) { + function openDelete(event, cat) { + event.stopPropagation(); setDeleteTarget(cat); } @@ -97,129 +315,178 @@ export default function CategoriesPage() { try { await api.deleteCategory(deleteTarget.id); toast.success(`"${deleteTarget.name}" deleted`); + setExpanded(prev => { + const next = new Set(prev); + next.delete(deleteTarget.id); + return next; + }); setDeleteTarget(null); load(); } catch (err) { - toast.error(err.message); + toast.error(err.message || 'Could not delete category.'); } finally { setDeleting(false); } } - // ── Render ─────────────────────────────────────────────────────────────────── + const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0); return ( -
- {/* Page header — floats on bg-background */} -
-
-

Categories

-

{categories.length} categories

-
-
- - {/* Card layer — lifted above page background */} -
- - {/* Card header with inline add form */} -
-
- setNewName(e.target.value)} - placeholder="New category name…" - disabled={adding} - className="h-8 text-sm" - /> - -
-
- - {/* Category list */} - {loading ? ( -
Loading…
- ) : categories.length === 0 ? ( -
- 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'} - - )} -
-
- - -
+ +
+ +
+
+
+ setNewName(e.target.value)} + placeholder="New category name..." + disabled={adding} + className="h-9 text-sm" + /> + +
+
+ + {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}

+
+
+ +
+
+
+ +
+ + +
+
+ + {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.

+
+
+ + +
+
+ +
+ +
+ +
{monthLabel(selected.year, selected.month)}
+
+ +
+ + {loading && ( + + + + Loading summary... + + + )} + + {!loading && error && ( + + +

{error}

+ +
+
+ )} + + {!loading && !error && data && ( + <> + + + Monthly Plan + {monthLabel(data.year, data.month)} + + + +
+
+

Income

+ +
+ +
+
+
{data.income?.label || 'Salary'}
+ {Number(summary.income_total || 0) === 0 && ( +
Add income to calculate savings.
+ )} +
+
{fmt(summary.income_total)}
+
+ + {editingIncome && ( +
+ + + +
+ )} +
+ +
+
+
+

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