This commit is contained in:
_null 2026-05-04 16:38:03 -05:00
parent 0ef9362817
commit b019487423
11 changed files with 1089 additions and 150 deletions

View File

@ -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

View File

@ -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() {
>
<Route index element={<TrackerPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="summary" element={<SummaryPage />} />
<Route path="bills" element={<BillsPage />} />
<Route path="categories" element={<CategoriesPage />} />
<Route path="analytics" element={<AnalyticsPage />} />

View File

@ -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'),

View File

@ -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' },

View File

@ -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;

View File

@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
title={details || label}
aria-label={details || label}
className={cn(
'inline-flex h-6 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
toneClass,
)}
>
{value}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 leading-relaxed">
<p>{label}</p>
{details && details !== label && <p className="opacity-85">{details}</p>}
</TooltipContent>
</Tooltip>
);
}
function StatChips({ category }) {
const names = billPreview(category.bill_names);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Chip
value={category.active_bill_count || 0}
label={plural(category.active_bill_count || 0, 'active bill')}
details={names}
tone="active"
/>
<Chip
value={category.inactive_bill_count || 0}
label={plural(category.inactive_bill_count || 0, 'inactive bill')}
details={names}
/>
<Chip
value={category.payment_count || 0}
label={plural(category.payment_count || 0, 'payment')}
details={names}
tone="info"
/>
</div>
);
}
function ChipLegend() {
const items = [
['Active', 'active'],
['Inactive', 'muted'],
['Payments', 'info'],
];
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{items.map(([label, tone]) => (
<span key={label} className="inline-flex items-center gap-1.5">
<span className={cn(
'h-2.5 w-2.5 rounded-full border',
tone === 'active' && 'border-primary/30 bg-primary/45',
tone === 'muted' && 'border-border bg-muted',
tone === 'info' && 'border-sky-500/30 bg-sky-500/45',
)} />
{label}
</span>
))}
</div>
);
}
function StatusPill({ active }) {
return (
<span className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
active
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-border bg-muted text-muted-foreground',
)}>
{active ? 'Active' : 'Inactive'}
</span>
);
}
function BillName({ bill }) {
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span title={label} className="font-medium text-foreground underline-offset-4 hover:underline">
{bill.name}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 leading-relaxed">
<p>{label}</p>
<p className="opacity-85">
{plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid
</p>
</TooltipContent>
</Tooltip>
);
}
function ExpandedBills({ category }) {
const bills = category.bills || [];
if (!bills.length) {
return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>No bills in this category yet.</span>
<Button asChild variant="outline" size="sm" className="w-fit">
<Link to="/bills">Open Bills</Link>
</Button>
</div>
</div>
);
}
return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
<table className="w-full text-sm">
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-semibold">Bill</th>
<th className="px-4 py-3 text-left font-semibold">Status</th>
<th className="px-4 py-3 text-right font-semibold">Expected</th>
<th className="px-4 py-3 text-right font-semibold">Due</th>
<th className="px-4 py-3 text-right font-semibold">Paid</th>
<th className="px-4 py-3 text-right font-semibold">Payments</th>
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{bills.map(bill => (
<tr key={bill.id} className="hover:bg-muted/25">
<td className="px-4 py-3"><BillName bill={bill} /></td>
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
<td className="px-4 py-3 text-right tabular-nums">{bill.due_day}</td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 lg:hidden">
{bills.map(bill => (
<div key={bill.id} className="rounded-lg border border-border/60 bg-background/75 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm"><BillName bill={bill} /></p>
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
</div>
<StatusPill active={bill.active} />
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<p className="text-muted-foreground">Expected</p>
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.expected_amount)}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.total_paid)}</p>
</div>
<div>
<p className="text-muted-foreground">Payments</p>
<p className="mt-0.5 font-semibold tabular-nums">{bill.payment_count || 0}</p>
</div>
<div>
<p className="text-muted-foreground">Last Paid</p>
<p className="mt-0.5 font-semibold tabular-nums">{fmtDate(bill.last_paid_date)}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
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 (
<div>
{/* Page header — floats on bg-background */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5">{categories.length} categories</p>
</div>
</div>
{/* Card layer — lifted above page background */}
<div className="table-surface">
{/* Card header with inline add form */}
<div className="px-4 py-4 border-b border-border/50 flex items-center gap-3 sm:px-6">
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-sm sm:flex-row">
<Input
ref={addInputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New category name…"
disabled={adding}
className="h-8 text-sm"
/>
<Button type="submit" size="sm" className="h-8" disabled={adding || !newName.trim()}>
<Plus className="h-3.5 w-3.5 mr-1" />
{adding ? 'Adding…' : 'Add'}
</Button>
</form>
</div>
{/* Category list */}
{loading ? (
<div className="py-16 text-center text-muted-foreground text-sm">Loading</div>
) : categories.length === 0 ? (
<div className="py-16 text-center text-muted-foreground text-sm">
No categories yet. Add one above.
<TooltipProvider delayDuration={180}>
<div>
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{plural(categories.length, 'category')}</span>
<span aria-hidden="true">/</span>
<span>{plural(totalBills, 'bill')}</span>
</p>
</div>
) : (
<div className="divide-y divide-border/50">
{categories.map((cat) => (
<div
key={cat.id}
className="group flex items-center justify-between gap-3 px-4 py-4 hover:bg-muted/30 transition-colors sm:px-6"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{cat.name}</span>
{cat.bill_count > 0 && (
<span className="text-xs text-muted-foreground">
{cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
</span>
)}
</div>
<div className="flex items-center gap-1 opacity-70 hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openRename(cat)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => openDelete(cat)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<ChipLegend />
</div>
<div className="table-surface overflow-hidden">
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
<Input
ref={addInputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New category name..."
disabled={adding}
className="h-9 text-sm"
/>
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{adding ? 'Adding...' : 'Add'}
</Button>
</form>
</div>
{loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
) : categories.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
No categories yet. Add one above.
</div>
))}
) : (
<div className="divide-y divide-border/50">
{categories.map((cat) => {
const isExpanded = expanded.has(cat.id);
const preview = billPreview(cat.bill_names);
return (
<section key={cat.id} className="bg-card/35">
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
title={preview}
onClick={() => 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',
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-180 text-foreground',
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate text-sm font-semibold tracking-tight text-foreground" title={preview}>
{cat.name}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 leading-relaxed">
<p className="font-medium">{cat.name}</p>
<p className="opacity-85">{preview}</p>
</TooltipContent>
</Tooltip>
<StatChips category={cat} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(event) => openRename(event, cat)}
aria-label={`Rename ${cat.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(event) => openDelete(event, cat)}
aria-label={`Delete ${cat.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isExpanded && <ExpandedBills category={cat} />}
</section>
);
})}
</div>
)}
</div>
<div className="mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<ReceiptText className="h-3.5 w-3.5" />
<span>Category totals include active and inactive bills in your account only.</span>
</div>
)}
</div>{/* /card */}
</div>
{/* Rename dialog */}
<InputDialog
open={!!renameTarget}
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
title="Rename Category"
label="Name"
defaultValue={renameTarget?.name ?? ''}
placeholder="Category name"
confirmLabel="Rename"
loading={renaming}
onConfirm={handleRename}
/>
<InputDialog
open={!!renameTarget}
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
title="Rename Category"
label="Name"
defaultValue={renameTarget?.name ?? ''}
placeholder="Category name"
confirmLabel="Rename"
loading={renaming}
onConfirm={handleRename}
/>
{/* Delete dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
<AlertDialogDescription>
Bills in this category will become uncategorized. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting…' : 'Delete Category'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
<AlertDialogDescription>
Bills in this category will become uncategorized. No bills or payments will be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete Category'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
}

View File

@ -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 (
<span className="inline-flex min-w-16 items-center justify-center rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
Skipped
</span>
);
}
if (expense.is_paid) {
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
Paid
</span>
);
}
return (
<span className="inline-flex min-w-16 items-center justify-center gap-1 rounded-full bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
<Minus className="h-3.5 w-3.5" />
Open
</span>
);
}
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 (
<div className="space-y-3">
{chartRows.map(row => (
<div key={row.type} className="grid gap-2 sm:grid-cols-[5.75rem_minmax(0,1fr)_7rem] sm:items-center">
<div className="text-sm font-medium text-foreground">{row.label}</div>
<div className="h-7 rounded-full bg-muted/70 p-1">
<div
className="h-full rounded-full transition-[width]"
style={{ width: `${Math.min(row.width, 100)}%`, backgroundColor: row.color }}
title={`${row.label}: ${fmt(row.amount)}`}
/>
</div>
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}>
{fmt(row.amount)}
</div>
</div>
))}
</div>
);
}
function ExpenseRow({ expense }) {
return (
<div className="grid gap-2 border-b border-border/60 px-1 py-3 last:border-0 sm:grid-cols-[minmax(0,1fr)_7.5rem_5.5rem] sm:items-center">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{expense.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{expense.category_name && <span>{expense.category_name}</span>}
<span>Due day {expense.due_day}</span>
{expense.actual_amount !== null && <span>Monthly amount</span>}
</div>
</div>
<div className="text-sm font-semibold text-foreground sm:text-right">{fmt(expense.display_amount)}</div>
<div className="sm:justify-self-end" aria-label={expense.is_paid ? 'Paid' : expense.is_skipped ? 'Skipped' : 'Open'}>
<StatusMark expense={expense} />
</div>
</div>
);
}
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 (
<div className="summary-page mx-auto max-w-3xl space-y-5">
<div className="summary-print-meta hidden">
<h1>BillTracker Summary</h1>
<p>{monthLabel(selected.year, selected.month)}</p>
{generatedLabel && <p>Generated {generatedLabel}</p>}
</div>
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1>
<p className="mt-1 text-sm text-muted-foreground">Plan income, expenses, and monthly result.</p>
</div>
<div className="summary-actions flex gap-2">
<Button variant="outline" onClick={resetToday} className="sm:w-auto">
<RotateCcw className="h-4 w-4" />
Today
</Button>
<Button onClick={() => window.print()} className="sm:w-auto">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</div>
</div>
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card/95 p-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 px-2 text-center">
<CalendarDays className="hidden h-4 w-4 text-muted-foreground sm:block" />
<div className="truncate text-base font-semibold text-foreground">{monthLabel(selected.year, selected.month)}</div>
</div>
<Button variant="ghost" size="icon" onClick={() => moveMonth(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && (
<Card>
<CardContent className="flex min-h-72 items-center justify-center p-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading summary...
</CardContent>
</Card>
)}
{!loading && error && (
<Card className="border-destructive/40">
<CardContent className="space-y-3 p-6">
<p className="text-sm font-medium text-destructive">{error}</p>
<Button variant="outline" onClick={loadSummary}>Retry</Button>
</CardContent>
</Card>
)}
{!loading && !error && data && (
<>
<Card className="summary-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Monthly Plan</CardTitle>
<CardDescription>{monthLabel(data.year, data.month)}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2>
<Button
type="button"
variant="ghost"
size="sm"
className="summary-edit-actions h-7 px-2"
onClick={() => setEditingIncome(value => !value)}
>
<Edit3 className="h-3.5 w-3.5" />
{editingIncome ? 'Close' : 'Edit'}
</Button>
</div>
<div className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div>
{Number(summary.income_total || 0) === 0 && (
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div>
)}
</div>
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
</div>
{editingIncome && (
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[minmax(0,1fr)_10rem_auto] md:items-end">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span>
<Input value={incomeLabel} onChange={event => setIncomeLabel(event.target.value)} placeholder="Salary" />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Amount</span>
<Input
type="number"
min="0"
step="0.01"
value={incomeAmount}
onChange={event => setIncomeAmount(event.target.value)}
/>
</label>
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save
</Button>
</div>
)}
</section>
<section className="space-y-3">
<div className="flex items-end justify-between gap-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Expenses</h2>
<p className="mt-1 text-xs text-muted-foreground">Skipped bills are shown but not counted.</p>
</div>
<div className="hidden text-xs font-semibold uppercase tracking-wide text-muted-foreground sm:block">
Paid
</div>
</div>
{expenses.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-sm text-muted-foreground">
No bills found for this month.
</div>
) : (
<div className="rounded-2xl border border-border/60 bg-background/70 px-3">
{expenses.map(expense => (
<ExpenseRow key={expense.bill_id} expense={expense} />
))}
</div>
)}
</section>
<section className="space-y-3 rounded-2xl border border-border/60 bg-muted/40 p-4">
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Fully Paid Expenses</div>
<div className="text-base font-bold text-foreground">{summary.paid_expense_count || 0} / {summary.expense_count || 0}</div>
</div>
<div className="flex items-center justify-between gap-4">
<div className="text-sm font-medium text-muted-foreground">Expenses</div>
<div className="text-base font-semibold text-foreground">{fmt(summary.expense_total)}</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border/60 pt-3">
<div className="text-base font-semibold text-foreground">Result</div>
<div className={cn('text-2xl font-bold', moneyClass(summary.result || 0))}>{fmt(summary.result)}</div>
</div>
</section>
<Button onClick={() => window.print()} className="summary-actions w-full">
<Printer className="h-4 w-4" />
Print / PDF
</Button>
</CardContent>
</Card>
<Card className="summary-chart-card">
<CardHeader className="pb-3">
<CardTitle className="text-xl">Total amount per type</CardTitle>
<CardDescription>
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}.
</CardDescription>
</CardHeader>
<CardContent>
<SummaryChart rows={data.chart || []} />
</CardContent>
</Card>
<div className="summary-print-footer hidden text-xs text-muted-foreground">
Generated {generatedLabel || 'now'}
</div>
</>
)}
</div>
);
}

View File

@ -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) {

View File

@ -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

173
routes/summary.js Normal file
View File

@ -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;

View File

@ -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'));