This commit is contained in:
_null 2026-05-04 13:14:32 -05:00
parent d46b85da8a
commit 969139251d
26 changed files with 2096 additions and 192 deletions

View File

@ -1,5 +1,23 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.18.1
### 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.
- Added Calendar to the top navigation after Tracker while preserving the existing desktop and mobile nav behavior.
- Improved mobile and tablet responsive rendering across the top navigation, page headers, dialogs, dense tables, Tracker, Bills, Categories, Settings, Status, Admin, Analytics, and Login views.
- Preserved the current desktop layout by keeping existing desktop-oriented layouts at `lg` and above while adding mobile/tablet stacking, scrolling, and tap-friendly controls below that breakpoint.
- Tablet navigation now uses the compact menu to avoid horizontal overflow; user menu, theme toggle, and admin-only navigation remain reachable.
- Dialogs and destructive confirmations now respect mobile viewport width/height and scroll internally when content is long.
- Dense Tracker, Bills, Admin, Analytics, and import/history style tables use horizontal scrolling or mobile stacking so actions remain reachable on smaller screens.
- Tracker and Bills now use stacked mobile/tablet bill rows below `lg`, reducing sideways scrolling for normal bill review, quick payment, and bill actions while preserving the desktop table layouts.
- 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.
## v0.18 ## v0.18
### Branding ### Branding
@ -14,6 +32,13 @@
- Vite now copies only modern React public assets from `client/public`, preventing legacy `public/*.html`, CSS, and JS files from being emitted into `dist`. - Vite now copies only modern React public assets from `client/public`, preventing legacy `public/*.html`, CSS, and JS files from being emitted into `dist`.
- No backend, auth, tracker, bills, categories, settings, status, admin, or navigation-link behavior was changed. - No backend, auth, tracker, bills, categories, settings, status, admin, or navigation-link behavior was changed.
### Analytics
- Added a user-scoped Analytics API at `GET /api/analytics/summary` using existing bills, payments, categories, and monthly bill state data without schema changes.
- Added an Analytics page with date range controls, category and bill filters, inactive/skipped toggles, chart visibility toggles, and a line/area trend option.
- Added monthly spending trend, expected vs actual spend, category spending donut, and pay-on-time heatmap views.
- Added print and browser save-as-PDF report output with print CSS that hides navigation, controls, and interactive actions.
- Analytics queries are scoped to the signed-in user and do not accept or expose cross-user aggregation.
### Security ### Security
- **OIDC ID token signature verification** now uses `openid-client@5` for full cryptographic validation via JWKS: signature, issuer, audience, expiry, nonce, and `sub` presence — tokens without a valid signature are rejected - **OIDC ID token signature verification** now uses `openid-client@5` for full cryptographic validation via JWKS: signature, issuer, audience, expiry, nonce, and `sub` presence — tokens without a valid signature are rejected
- **OIDC client cache** invalidation path added; cache is keyed by issuer/client/redirect so Admin panel credential changes pick up a fresh client - **OIDC client cache** invalidation path added; cache is keyed by issuer/client/redirect so Admin panel credential changes pick up a fresh client

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": ".."
}
],
"settings": {}
}

View File

@ -6,10 +6,12 @@ import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import AdminPage from '@/pages/AdminPage'; import AdminPage from '@/pages/AdminPage';
import TrackerPage from '@/pages/TrackerPage'; import TrackerPage from '@/pages/TrackerPage';
import CalendarPage from '@/pages/CalendarPage';
import BillsPage from '@/pages/BillsPage'; import BillsPage from '@/pages/BillsPage';
import CategoriesPage from '@/pages/CategoriesPage'; import CategoriesPage from '@/pages/CategoriesPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import StatusPage from '@/pages/StatusPage'; import StatusPage from '@/pages/StatusPage';
import AnalyticsPage from '@/pages/AnalyticsPage';
import ReleaseNotesPage from '@/pages/ReleaseNotesPage'; import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
import DataPage from '@/pages/DataPage'; import DataPage from '@/pages/DataPage';
import ProfilePage from '@/pages/ProfilePage'; import ProfilePage from '@/pages/ProfilePage';
@ -73,8 +75,10 @@ export default function App() {
} }
> >
<Route index element={<TrackerPage />} /> <Route index element={<TrackerPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="bills" element={<BillsPage />} /> <Route path="bills" element={<BillsPage />} />
<Route path="categories" element={<CategoriesPage />} /> <Route path="categories" element={<CategoriesPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="data" element={<DataPage />} /> <Route path="data" element={<DataPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />

View File

@ -108,6 +108,9 @@ export const api = {
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`), tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`), upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
// Calendar
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
// Bills // Bills
bills: () => get('/bills'), bills: () => get('/bills'),
allBills: () => get('/bills?inactive=true'), allBills: () => get('/bills?inactive=true'),
@ -141,6 +144,16 @@ export const api = {
settings: () => get('/settings'), settings: () => get('/settings'),
saveSettings: (data) => put('/settings', data), saveSettings: (data) => put('/settings', data),
// Analytics
analyticsSummary: (params = {}) => {
const qs = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') qs.set(key, String(value));
});
const query = qs.toString();
return get(`/analytics/summary${query ? `?${query}` : ''}`);
},
// Status // Status
status: () => get('/status'), status: () => get('/status'),

View File

@ -91,7 +91,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</DialogHeader> </DialogHeader>
<form id="bill-modal-form" onSubmit={handleSubmit}> <form id="bill-modal-form" onSubmit={handleSubmit}>
<div className="grid grid-cols-2 gap-x-5 gap-y-4 py-2"> <div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
{/* Name */} {/* Name */}
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">

View File

@ -10,10 +10,127 @@ function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (visibility && visibility !== 'default'); return !!bill.has_history_ranges || (visibility && visibility !== 'default');
} }
function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) {
const hasHistory = hasHistoricalVisibility(bill);
return (
<div className="rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
onClick={() => onEdit?.(bill.id)}
title={`Edit ${bill.name}`}
>
{bill.name}
</button>
{hasHistory && (
<span
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
title="Historical visibility configured"
aria-label="Historical visibility configured"
>
<History className="h-3 w-3" />
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
bill.active
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-muted text-muted-foreground',
)}>
{bill.active ? 'Active' : 'Inactive'}
</span>
{!!bill.autopay_enabled && (
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500">AP</span>
)}
{!!bill.has_2fa && (
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-400">2FA</span>
)}
</div>
</div>
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-foreground">
${Number(bill.expected_amount).toFixed(2)}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="mt-0.5 text-sm text-foreground">Day {bill.due_day}</p>
</div>
<div className="min-w-0">
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{bill.category_name || '—'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Cycle</p>
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2.5 text-xs',
bill.active
? 'text-muted-foreground hover:text-destructive'
: 'text-emerald-500 hover:text-emerald-400',
)}
onClick={() => onToggle?.(bill)}
>
{bill.active ? 'Deactivate' : 'Activate'}
</Button>
{!bill.active && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
onClick={() => onHistory?.(bill)}
>
History
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete?.(bill)}
>
Delete
</Button>
</div>
</div>
);
}
// Accepts row action handlers from BillsPage // Accepts row action handlers from BillsPage
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) { export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
return ( return (
<Table> <>
<div className="grid gap-3 p-3 lg:hidden">
{bills.map((bill) => (
<MobileBillRow
key={bill.id}
bill={bill}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
/>
))}
</div>
<div className="hidden lg:block">
<Table className="min-w-[900px]">
<TableHeader className="bg-muted border-b border-border/70"> <TableHeader className="bg-muted border-b border-border/70">
<TableRow className="hover:bg-transparent border-0"> <TableRow className="hover:bg-transparent border-0">
@ -103,7 +220,7 @@ export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onH
{/* Actions — visible on row hover */} {/* Actions — visible on row hover */}
<TableCell className="px-6 py-4 w-72 text-right"> <TableCell className="px-6 py-4 w-72 text-right">
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -143,5 +260,7 @@ export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onH
</TableBody> </TableBody>
</Table> </Table>
</div>
</>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { import {
Activity, ChevronDown, LayoutGrid, LogOut, Menu, Receipt, Activity, BarChart3, CalendarDays, ChevronDown, LayoutGrid, LogOut, Menu, Receipt,
Settings, ShieldCheck, Tag, User, X, Settings, ShieldCheck, Tag, User, X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -19,8 +19,10 @@ import {
const userNavItems = [ const userNavItems = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true }, { to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' },
{ to: '/bills', icon: Receipt, label: 'Bills' }, { to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' }, { to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/settings', icon: Settings, label: 'Settings' }, { to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/status', icon: Activity, label: 'Status' }, { to: '/status', icon: Activity, label: 'Status' },
]; ];
@ -130,7 +132,7 @@ export default function Sidebar({ adminMode = false }) {
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8"> <div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
<BrandBlock adminMode={adminMode} /> <BrandBlock adminMode={adminMode} />
<nav className="hidden items-center gap-1 md:flex"> <nav className="hidden items-center gap-1 lg:flex">
{items.map(item => ( {items.map(item => (
<NavPill key={item.to} item={item} /> <NavPill key={item.to} item={item} />
))} ))}
@ -143,7 +145,7 @@ export default function Sidebar({ adminMode = false }) {
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
className="md:hidden rounded-full bg-card/90" className="lg:hidden rounded-full bg-card/90"
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'} aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)} onClick={() => setMobileOpen(v => !v)}
@ -154,7 +156,7 @@ export default function Sidebar({ adminMode = false }) {
</div> </div>
{mobileOpen && ( {mobileOpen && (
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 md:hidden"> <div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1"> <nav className="mx-auto grid max-w-[1500px] gap-1">
{items.map(item => ( {items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} /> <NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />

View File

@ -26,7 +26,7 @@ function AlertDialogContent({ className, ...props }) {
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-md translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]', 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-md max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className className
)} )}
{...props} {...props}

View File

@ -26,7 +26,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border border-border/70 bg-card p-6 text-card-foreground shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]', 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-1rem)] max-w-lg max-h-[calc(100svh-1rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl border border-border/70 bg-card p-4 text-card-foreground shadow-xl duration-200 sm:w-full sm:max-h-[calc(100svh-2rem)] sm:p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className className
)} )}
{...props} {...props}

View File

@ -131,3 +131,63 @@
@apply surface overflow-hidden shadow-sm; @apply surface overflow-hidden shadow-sm;
} }
} }
@media print {
body {
background: white !important;
color: #111827 !important;
}
header,
.analytics-screen-header,
.analytics-controls,
.analytics-actions {
display: none !important;
}
main,
main > div {
max-width: none !important;
padding: 0 !important;
}
.analytics-page {
color: #111827 !important;
}
.analytics-report-meta,
.analytics-print-footer {
display: block !important;
margin-bottom: 1rem;
}
.analytics-report-meta h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 0.25rem;
}
.analytics-report-meta p,
.analytics-print-footer {
color: #4b5563 !important;
font-size: 12px;
margin: 0.125rem 0;
}
.analytics-range {
margin-bottom: 1rem;
}
.analytics-chart-grid {
display: block !important;
}
.analytics-chart {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 1rem;
border: 1px solid #d1d5db !important;
box-shadow: none !important;
background: white !important;
}
}

View File

@ -1,14 +1,14 @@
export const APP_VERSION = '0.16.2'; export const APP_VERSION = '0.18.1';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.16.2', version: '0.18.1',
date: '2026-05-03', date: '2026-05-04',
highlights: [ highlights: [
{ icon: '🗄️', title: 'SQLite data import', desc: 'Preview and import user-owned SQLite exports created by this app.' }, { icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
{ icon: '🧾', title: 'Import tools layout', desc: 'Spreadsheet and SQLite import tools now sit side by side in Profile.' }, { icon: '🧭', title: 'Tablet-safe navigation', desc: 'The top navigation uses the compact menu on tablet sizes to avoid horizontal overflow.' },
{ icon: '📦', title: 'Exports below imports', desc: 'User data export downloads now live below the import tools.' }, { icon: '📊', title: 'Responsive analytics', desc: 'Analytics controls, charts, and the pay heatmap resize or scroll cleanly on smaller screens.' },
{ icon: '🎨', title: 'Material Design theme', desc: 'Light mode defaults to the shadcn Material Design theme tokens.' }, { icon: '🪟', title: 'Viewport-safe dialogs', desc: 'Dialogs and confirmations fit mobile screens and scroll internally when content is long.' },
{ icon: '📅', title: 'Due day editing', desc: 'Bill due dates are edited as recurring day-of-month values.' }, { icon: '🖥️', title: 'Desktop preserved', desc: 'Existing desktop layouts remain on the same large-screen breakpoints.' },
], ],
}; };

View File

@ -31,8 +31,8 @@ function SectionHeading({ children }) {
function FieldRow({ label, children }) { function FieldRow({ label, children }) {
return ( return (
<div className="grid grid-cols-[200px_1fr] items-center gap-4"> <div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
<Label className="text-right text-muted-foreground">{label}</Label> <Label className="text-muted-foreground lg:text-right">{label}</Label>
{children} {children}
</div> </div>
); );
@ -1174,7 +1174,7 @@ function AddUserCard({ onCreated }) {
<CardTitle>Add User</CardTitle> <CardTitle>Add User</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleCreate} className="flex items-end gap-3"> <form onSubmit={handleCreate} className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="space-y-1.5 flex-1"> <div className="space-y-1.5 flex-1">
<Label htmlFor="new-uname">Username</Label> <Label htmlFor="new-uname">Username</Label>
<Input <Input
@ -1421,8 +1421,8 @@ function BackupManagementCard() {
</Button> </Button>
</div> </div>
<div className="rounded-lg border border-border overflow-hidden"> <div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm"> <table className="min-w-[860px] w-full text-sm">
<thead className="bg-muted/40"> <thead className="bg-muted/40">
<tr className="border-b border-border"> <tr className="border-b border-border">
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th> <th className="text-left px-4 py-3 text-muted-foreground font-medium">Backup</th>

View File

@ -0,0 +1,565 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
const RANGE_OPTIONS = [6, 12, 24, 36];
const MONTH_OPTIONS = [
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
];
const CHART_OPTIONS = [
['monthlyTrend', 'Monthly trend'],
['expectedActual', 'Expected vs actual'],
['categorySpend', 'Category spend'],
['heatmap', 'Pay heatmap'],
];
const PALETTE = ['#7c3aed', '#10b981', '#ec4899', '#3b82f6', '#f59e0b', '#14b8a6', '#ef4444', '#8b5cf6'];
function currentMonth() {
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function money(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
});
}
function fullMoney(value) {
return (Number(value) || 0).toLocaleString(undefined, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
});
}
function formatRange(range) {
if (!range?.start || !range?.end) return 'Selected range';
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
}
function hasData(rows, keys) {
return rows?.some(row => keys.some(key => Number(row[key]) > 0));
}
function EmptyState({ label = 'No analytics data for this selection.' }) {
return (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 px-4 text-center text-sm text-muted-foreground">
{label}
</div>
);
}
function ChartCard({ title, subtitle, children, summary }) {
return (
<section className="analytics-chart surface-elevated p-5">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
</div>
{summary && <div className="shrink-0 text-right text-sm font-semibold tabular-nums">{summary}</div>}
</div>
{children}
</section>
);
}
function SvgFrame({ children, height = 260 }) {
return (
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
<svg viewBox={`0 0 720 ${height}`} role="img" className="h-auto w-full">
{children}
</svg>
</div>
);
}
function LineChart({ rows, area = false }) {
if (!hasData(rows, ['total'])) return <EmptyState />;
const width = 720;
const height = 260;
const pad = { left: 58, right: 24, top: 24, bottom: 46 };
const chartW = width - pad.left - pad.right;
const chartH = height - pad.top - pad.bottom;
const max = Math.max(...rows.map(r => r.total), 1);
const points = rows.map((row, index) => {
const x = pad.left + (rows.length === 1 ? chartW / 2 : (index / (rows.length - 1)) * chartW);
const y = pad.top + chartH - (row.total / max) * chartH;
return { ...row, x, y };
});
const line = points.map(p => `${p.x},${p.y}`).join(' ');
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
return (
<SvgFrame height={height}>
{[0, 0.25, 0.5, 0.75, 1].map(tick => {
const y = pad.top + chartH - tick * chartH;
return (
<g key={tick}>
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
</g>
);
})}
{area && <polygon points={areaPoints} fill="#7c3aed" opacity="0.16" />}
<polyline points={line} fill="none" stroke="#7c3aed" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
{points.map((point, index) => (
<g key={point.month}>
<circle cx={point.x} cy={point.y} r="4.5" fill="#7c3aed" />
{(rows.length <= 12 || index % 3 === 0) && (
<text x={point.x} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
{point.label}
</text>
)}
<title>{`${point.label}: ${fullMoney(point.total)}`}</title>
</g>
))}
</SvgFrame>
);
}
function GroupedBarChart({ rows }) {
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
const width = 720;
const height = 280;
const pad = { left: 58, right: 24, top: 24, bottom: 50 };
const chartW = width - pad.left - pad.right;
const chartH = height - pad.top - pad.bottom;
const max = Math.max(...rows.flatMap(r => [r.expected, r.actual]), 1);
const groupW = chartW / rows.length;
const barW = Math.max(5, Math.min(17, groupW * 0.28));
return (
<SvgFrame height={height}>
{[0, 0.5, 1].map(tick => {
const y = pad.top + chartH - tick * chartH;
return (
<g key={tick}>
<line x1={pad.left} x2={pad.left + chartW} y1={y} y2={y} stroke="currentColor" opacity="0.09" />
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">{money(max * tick)}</text>
</g>
);
})}
{rows.map((row, index) => {
const center = pad.left + index * groupW + groupW / 2;
const expectedH = (row.expected / max) * chartH;
const actualH = (row.actual / max) * chartH;
return (
<g key={row.month}>
<rect x={center - barW - 1} y={pad.top + chartH - expectedH} width={barW} height={expectedH} rx="4" fill="#8b5cf6">
<title>{`${row.label} expected: ${fullMoney(row.expected)}`}</title>
</rect>
<rect x={center + 1} y={pad.top + chartH - actualH} width={barW} height={actualH} rx="4" fill="#10b981">
<title>{`${row.label} actual: ${fullMoney(row.actual)}`}</title>
</rect>
{(rows.length <= 12 || index % 3 === 0) && (
<text x={center} y={height - 18} fontSize="12" fill="currentColor" opacity="0.65" textAnchor="middle">
{row.label}
</text>
)}
</g>
);
})}
<g transform={`translate(${width - 190}, 18)`} fontSize="12" fill="currentColor">
<rect width="10" height="10" rx="2" fill="#8b5cf6" /><text x="16" y="10">Expected</text>
<rect x="92" width="10" height="10" rx="2" fill="#10b981" /><text x="108" y="10">Actual</text>
</g>
</SvgFrame>
);
}
function DonutChart({ rows }) {
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
if (!total) return <EmptyState />;
let cumulative = 0;
const radius = 78;
const circumference = 2 * Math.PI * radius;
return (
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
<div className="flex justify-center">
<svg viewBox="0 0 220 220" role="img" className="h-56 w-56">
<circle cx="110" cy="110" r={radius} fill="none" stroke="currentColor" strokeWidth="30" opacity="0.08" />
{rows.map((row, index) => {
const value = Number(row.total || 0);
const dash = (value / total) * circumference;
const segment = (
<circle
key={row.category_name}
cx="110"
cy="110"
r={radius}
fill="none"
stroke={PALETTE[index % PALETTE.length]}
strokeWidth="30"
strokeDasharray={`${dash} ${circumference - dash}`}
strokeDashoffset={-cumulative}
transform="rotate(-90 110 110)"
>
<title>{`${row.category_name}: ${fullMoney(value)}`}</title>
</circle>
);
cumulative += dash;
return segment;
})}
<text x="110" y="104" textAnchor="middle" fontSize="13" fill="currentColor" opacity="0.65">Total</text>
<text x="110" y="126" textAnchor="middle" fontSize="22" fontWeight="700" fill="currentColor">{money(total)}</text>
</svg>
</div>
<div className="space-y-2">
{rows.map((row, index) => (
<div key={row.category_name} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-sm" style={{ backgroundColor: PALETTE[index % PALETTE.length] }} />
<span className="truncate">{row.category_name}</span>
</span>
<span className="shrink-0 font-medium tabular-nums">{fullMoney(row.total)}</span>
</div>
))}
</div>
</div>
);
}
const HEATMAP_CLASS = {
paid: 'bg-emerald-500/85 border-emerald-400/40',
skipped: 'bg-sky-500/70 border-sky-400/40',
missed: 'bg-red-500/75 border-red-400/40',
no_data: 'bg-muted border-border',
};
function Heatmap({ heatmap }) {
const rows = heatmap?.rows || [];
const months = heatmap?.months || [];
if (!rows.length || !months.length) return <EmptyState />;
return (
<div className="space-y-4">
<div className="overflow-x-auto rounded-lg border border-border/60">
<div className="min-w-[760px]">
<div
className="grid border-b border-border/60 bg-muted/30 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
>
<div className="px-3 py-2">Bill</div>
{months.map(month => <div key={month.key} className="px-1 py-2 text-center">{month.label}</div>)}
</div>
{rows.map(row => (
<div
key={row.bill_id}
className="grid border-b border-border/40 last:border-b-0"
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
>
<div className="min-w-0 px-3 py-2">
<p className="truncate text-sm font-medium">{row.bill_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
</div>
{months.map(month => {
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 };
return (
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
<span
className={cn('h-5 w-5 rounded border', HEATMAP_CLASS[cell.status] || HEATMAP_CLASS.no_data)}
title={`${row.bill_name}, ${month.label}: ${cell.status.replace('_', ' ')}${cell.amount_paid ? ` (${fullMoney(cell.amount_paid)})` : ''}`}
/>
</div>
);
})}
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{[
['paid', 'Paid'],
['skipped', 'Skipped'],
['missed', 'Missed'],
['no_data', 'No data'],
].map(([status, label]) => (
<span key={status} className="inline-flex items-center gap-1.5">
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
{label}
</span>
))}
</div>
</div>
);
}
function Field({ label, children }) {
return (
<label className="space-y-1.5">
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
{children}
</label>
);
}
function ControlSelect({ value, onChange, children, className }) {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={cn('h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50', className)}
>
{children}
</select>
);
}
export default function AnalyticsPage() {
const initial = currentMonth();
const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month);
const [months, setMonths] = useState(12);
const [categoryId, setCategoryId] = useState('');
const [billId, setBillId] = useState('');
const [includeInactive, setIncludeInactive] = useState(false);
const [includeSkipped, setIncludeSkipped] = useState(true);
const [trendMode, setTrendMode] = useState('line');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [visible, setVisible] = useState({
monthlyTrend: true,
expectedActual: true,
categorySpend: true,
heatmap: true,
});
const params = useMemo(() => ({
year,
month,
months,
category_id: categoryId,
bill_id: billId,
include_inactive: includeInactive,
include_skipped: includeSkipped,
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.analyticsSummary(params);
setData(result);
} catch (err) {
setError(err.message || 'Failed to load analytics.');
toast.error(err.message || 'Failed to load analytics.');
} finally {
setLoading(false);
}
}, [params]);
useEffect(() => { load(); }, [load]);
const reset = () => {
const next = currentMonth();
setYear(next.year);
setMonth(next.month);
setMonths(12);
setCategoryId('');
setBillId('');
setIncludeInactive(false);
setIncludeSkipped(true);
setTrendMode('line');
setVisible({ monthlyTrend: true, expectedActual: true, categorySpend: true, heatmap: true });
};
const totalCategorySpend = data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0;
const activeCharts = CHART_OPTIONS.filter(([key]) => visible[key]).map(([, label]) => label).join(', ') || 'None';
const filterSummary = [
categoryId ? `Category: ${data?.categories?.find(c => String(c.id) === String(categoryId))?.name || categoryId}` : 'All categories',
billId ? `Bill: ${data?.bills?.find(b => String(b.id) === String(billId))?.name || billId}` : 'All bills',
includeInactive ? 'Includes inactive bills' : 'Active bills only',
includeSkipped ? 'Shows skipped months' : 'Hides skipped months',
].join(' | ');
return (
<div className="analytics-page space-y-6">
<div className="analytics-report-meta hidden">
<h1>BillTracker Analytics</h1>
<p>{formatRange(data?.range)}</p>
<p>{filterSummary}</p>
<p>Visible charts: {activeCharts}</p>
<p>Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}</p>
</div>
<div className="analytics-screen-header flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
<p className="mt-0.5 text-sm text-muted-foreground">
Spending trends, category breakdowns, and payment history.
</p>
</div>
<div className="analytics-actions flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="flex-1 sm:flex-none">
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
<Printer className="h-3.5 w-3.5" />
Print
</Button>
<Button size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
<Printer className="h-3.5 w-3.5" />
Print / Save PDF
</Button>
</div>
</div>
<section className="analytics-controls surface-elevated p-4">
<div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
<Field label="Ending month">
<ControlSelect value={String(month)} onChange={value => setMonth(Number(value))}>
{MONTH_OPTIONS.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</ControlSelect>
</Field>
<Field label="Ending year">
<input
type="number"
min="2000"
max="2100"
value={year}
onChange={e => setYear(Number(e.target.value))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50"
/>
</Field>
<Field label="Range">
<ControlSelect value={String(months)} onChange={value => setMonths(Number(value))}>
{RANGE_OPTIONS.map(value => <option key={value} value={value}>{value} months</option>)}
</ControlSelect>
</Field>
<Field label="Category">
<ControlSelect value={categoryId} onChange={value => { setCategoryId(value); setBillId(''); }}>
<option value="">All categories</option>
{(data?.categories || []).map(category => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</ControlSelect>
</Field>
<Field label="Bill">
<ControlSelect value={billId} onChange={setBillId}>
<option value="">All bills</option>
{(data?.bills || []).map(bill => (
<option key={bill.id} value={bill.id}>{bill.name}{bill.active ? '' : ' (inactive)'}</option>
))}
</ControlSelect>
</Field>
<Field label="Trend style">
<ControlSelect value={trendMode} onChange={setTrendMode}>
<option value="line">Line</option>
<option value="area">Area</option>
</ControlSelect>
</Field>
</div>
<Button type="button" variant="outline" onClick={reset}>
<RotateCcw className="h-4 w-4" />
Reset filters
</Button>
</div>
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-4">
{CHART_OPTIONS.map(([key, label]) => (
<label key={key} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={visible[key]}
onChange={e => setVisible(prev => ({ ...prev, [key]: e.target.checked }))}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
{label}
</label>
))}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeInactive}
onChange={e => setIncludeInactive(e.target.checked)}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
Include inactive bills
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeSkipped}
onChange={e => setIncludeSkipped(e.target.checked)}
className="h-4 w-4 rounded border-input bg-background accent-primary"
/>
Show skipped months
</label>
</div>
</section>
<div className="analytics-range text-sm text-muted-foreground">
{data ? (
<>
Reporting on <span className="font-medium text-foreground">{formatRange(data.range)}</span>.
<span className="ml-2">{filterSummary}</span>
</>
) : 'Preparing analytics...'}
</div>
{loading && (
<div className="grid gap-5 lg:grid-cols-2">
{[1, 2, 3, 4].map(item => <div key={item} className="h-80 animate-pulse rounded-2xl bg-muted/50" />)}
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{!loading && !error && data && (
<div className="analytics-chart-grid grid gap-5 xl:grid-cols-2">
{visible.monthlyTrend && (
<ChartCard title="Monthly spending trend" subtitle="Actual payments grouped by paid month.">
<LineChart rows={data.monthly_spending || []} area={trendMode === 'area'} />
</ChartCard>
)}
{visible.expectedActual && (
<ChartCard title="Expected vs actual spend" subtitle="Expected uses monthly override amount when present, otherwise the bill estimate.">
<GroupedBarChart rows={data.expected_vs_actual || []} />
</ChartCard>
)}
{visible.categorySpend && (
<ChartCard title="Spending by category" subtitle="Payments grouped by bill category." summary={fullMoney(totalCategorySpend)}>
<DonutChart rows={data.category_spend || []} />
</ChartCard>
)}
{visible.heatmap && (
<div className="xl:col-span-2">
<ChartCard title="Pay-on-time heatmap" subtitle="Bill status by month. Future/current unpaid months show as no data.">
<Heatmap heatmap={data.heatmap} />
</ChartCard>
</div>
)}
{!Object.values(visible).some(Boolean) && (
<div className="xl:col-span-2">
<EmptyState label="Select at least one chart to show analytics." />
</div>
)}
</div>
)}
<div className="analytics-print-footer hidden text-xs text-muted-foreground">
Generated from BillTracker Analytics on {new Date(data?.generated_at || Date.now()).toLocaleString()}.
</div>
</div>
);
}

View File

@ -418,7 +418,7 @@ export default function BillsPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-end justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5"> <p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Manage Manage
@ -443,7 +443,7 @@ export default function BillsPage() {
{/* ── Active Bills ── */} {/* ── Active Bills ── */}
<div className="rounded-xl border border-border overflow-hidden bg-card"> <div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border"> <div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground"> <span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Active Bills Active Bills
</span> </span>
@ -493,7 +493,7 @@ export default function BillsPage() {
{showInactive && ( {showInactive && (
<div className="rounded-xl border border-border overflow-hidden bg-card"> <div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex items-center justify-between px-6 py-3 bg-muted/30 border-b border-border"> <div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground"> <span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Inactive Bills Inactive Bills
</span> </span>

View File

@ -0,0 +1,455 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { CalendarDays, ChevronLeft, ChevronRight, CircleDollarSign, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function currentMonth() {
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 displayStatus(status) {
if (status === 'due_soon') return 'Due';
if (status === 'late') return 'Late';
return status ? status.charAt(0).toUpperCase() + status.slice(1) : 'Due';
}
function statusTone(status) {
if (status === 'paid' || status === 'autodraft') return 'bg-emerald-500/15 text-emerald-500 border-emerald-500/25';
if (status === 'skipped') return 'bg-muted text-muted-foreground border-border';
if (status === 'late' || status === 'missed') return 'bg-destructive/15 text-destructive border-destructive/25';
return 'bg-primary/10 text-primary border-primary/25';
}
function LegendItem({ className, label }) {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn('h-2.5 w-2.5 rounded-full border', className)} />
{label}
</span>
);
}
function SummaryProgress({ summary }) {
const percent = Number(summary?.paid_percent || 0);
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<CircleDollarSign className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Total Expenses Paid</CardTitle>
</div>
<CardDescription>Monthly progress across active, unskipped bills.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end justify-between gap-4">
<div>
<p className="font-mono text-2xl font-semibold tracking-tight">
{fmt(summary?.paid_total)}
<span className="mx-2 text-sm font-normal text-muted-foreground">/</span>
<span className="text-base text-muted-foreground">{fmt(summary?.expected_total)}</span>
</p>
<p className="mt-1 text-sm text-muted-foreground">{fmt(summary?.remaining_total)} remaining</p>
</div>
<div className="text-right">
<p className="text-2xl font-semibold">{percent}%</p>
<p className="text-xs text-muted-foreground">paid</p>
</div>
</div>
<div className="mt-4 h-3 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{summary?.bill_count || 0} active bills</span>
<span>{summary?.paid_count || 0} paid</span>
{!!summary?.skipped_count && <span>{summary.skipped_count} skipped</span>}
{!!summary?.missed_count && <span className="text-destructive">{summary.missed_count} late or missed</span>}
</div>
</CardContent>
</Card>
);
}
function DayIndicators({ day }) {
const summary = day.status_summary;
const hasPaid = summary.paid_count > 0;
const hasDue = summary.due_count > summary.paid_count + summary.skipped_count + summary.missed_count;
const hasSkipped = summary.skipped_count > 0;
const hasMissed = summary.missed_count > 0;
const paymentOnly = day.payments.length > 0 && day.bills_due.length === 0;
return (
<div className="mt-auto flex flex-wrap gap-1">
{hasPaid && <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" title="Paid" />}
{(hasDue || paymentOnly) && <span className="h-1.5 w-1.5 rounded-full bg-primary" title="Due or payment" />}
{hasSkipped && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" title="Skipped" />}
{hasMissed && <span className="h-1.5 w-1.5 rounded-full bg-destructive" title="Missed or late" />}
</div>
);
}
function CalendarGrid({ data, selectedDate, onSelectDay }) {
const firstWeekday = new Date(data.year, data.month - 1, 1).getDay();
const cells = [
...Array.from({ length: firstWeekday }, (_, index) => ({ type: 'blank', key: `blank-${index}` })),
...data.days.map(day => ({ type: 'day', key: day.date, day })),
];
const today = todayStr();
return (
<Card className="overflow-hidden">
<div className="grid grid-cols-7 border-b border-border/70 bg-muted/30">
{WEEKDAYS.map(day => (
<div key={day} className="px-1 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground sm:text-xs">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{cells.map(cell => {
if (cell.type === 'blank') {
return <div key={cell.key} className="min-h-16 border-b border-r border-border/50 bg-muted/10 sm:min-h-24" />;
}
const day = cell.day;
const isToday = day.date === today;
const isSelected = day.date === selectedDate;
const summary = day.status_summary;
const hasActivity = day.bills_due.length > 0 || day.payments.length > 0;
const isPaidDay = summary.due_count > 0 && summary.paid_count >= summary.due_count - summary.skipped_count;
const hasMissed = summary.missed_count > 0;
return (
<button
key={day.date}
type="button"
onClick={() => onSelectDay(day)}
className={cn(
'flex min-h-16 flex-col border-b border-r border-border/50 p-1.5 text-left transition-colors sm:min-h-24 sm:p-2',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
hasActivity && 'bg-primary/[0.03] hover:bg-accent/60',
isPaidDay && 'bg-emerald-500/[0.07]',
hasMissed && 'bg-destructive/[0.08]',
isSelected && 'ring-2 ring-primary ring-inset',
)}
aria-label={`View ${fmtDate(day.date)}`}
>
<div className="flex items-start justify-between gap-1">
<span className={cn(
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium sm:text-sm',
isToday && 'border border-primary bg-primary/10 text-primary',
)}>
{day.day}
</span>
{summary.due_count > 0 && (
<span className="rounded bg-background/75 px-1 font-mono text-[10px] text-muted-foreground">
{summary.due_count}
</span>
)}
</div>
<div className="mt-1 hidden min-w-0 space-y-0.5 sm:block">
{day.bills_due.slice(0, 2).map(bill => (
<p key={bill.bill_id} className="truncate text-[11px] text-muted-foreground">
{bill.name}
</p>
))}
{day.bills_due.length > 2 && (
<p className="text-[11px] text-muted-foreground">+{day.bills_due.length - 2} more</p>
)}
</div>
<DayIndicators day={day} />
</button>
);
})}
</div>
</Card>
);
}
function DayDetailDialog({ day, open, onOpenChange }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold">{day ? fmtDate(day.date) : 'Day details'}</DialogTitle>
<p className="text-sm text-muted-foreground">Bills due and payments recorded for this date.</p>
</DialogHeader>
{day && (
<div className="space-y-5">
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bills Due</h3>
{day.bills_due.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
No bills are due on this day.
</div>
) : (
<div className="space-y-2">
{day.bills_due.map(bill => (
<div key={bill.bill_id} className="rounded-lg border border-border/60 bg-background/60 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{bill.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{bill.category_name || 'Uncategorized'}</p>
</div>
<Badge variant="outline" className={cn('shrink-0 capitalize', statusTone(bill.status))}>
{displayStatus(bill.status)}
</Badge>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>
<p>Expected</p>
<p className="font-mono text-sm text-foreground">{fmt(bill.effective_amount)}</p>
</div>
<div>
<p>Paid</p>
<p className="font-mono text-sm text-emerald-500">{fmt(bill.paid_amount)}</p>
</div>
<div>
<p>Due</p>
<p className="font-mono text-sm text-foreground">{fmtDate(bill.due_date)}</p>
</div>
</div>
</div>
))}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Payments</h3>
{day.payments.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 p-4 text-sm text-muted-foreground">
No payments were recorded on this day.
</div>
) : (
<div className="space-y-2">
{day.payments.map(payment => (
<div key={payment.payment_id} className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/60 p-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{payment.bill_name}</p>
<p className="text-xs text-muted-foreground">{payment.method || 'Payment'}</p>
</div>
<span className="font-mono text-sm text-emerald-500">{fmt(payment.amount)}</span>
</div>
))}
</div>
)}
</section>
<div className="flex flex-wrap justify-end gap-2 border-t border-border/60 pt-4">
<Button asChild variant="outline" size="sm">
<Link to="/">Open Tracker</Link>
</Button>
<Button asChild size="sm">
<Link to="/bills">Manage Bills</Link>
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
export default function CalendarPage() {
const initial = currentMonth();
const [year, setYear] = useState(initial.year);
const [month, setMonth] = useState(initial.month);
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedDay, setSelectedDay] = useState(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await api.calendar(year, month);
setData(result);
setSelectedDay(current => current ? result.days.find(day => day.date === current.date) || null : null);
} catch (err) {
setError(err.message || 'Calendar data could not be loaded.');
toast.error(err.message || 'Calendar data could not be loaded.');
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => { load(); }, [load]);
const monthLabel = useMemo(() => `${MONTHS[month - 1]} ${year}`, [year, month]);
const hasAnyBills = Number(data?.summary?.bill_count || 0) + Number(data?.summary?.skipped_count || 0) > 0;
function navigate(delta) {
const next = shiftMonth(year, month, delta);
setYear(next.year);
setMonth(next.month);
setSelectedDay(null);
setDetailOpen(false);
}
function goToday() {
const next = currentMonth();
setYear(next.year);
setMonth(next.month);
setSelectedDay(null);
setDetailOpen(false);
}
return (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
Monthly Calendar
</p>
<h1 className="text-3xl font-semibold tracking-tight">Calendar</h1>
<p className="mt-1 text-sm text-muted-foreground">
View bills, payments, and monthly progress by date.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center rounded-full border border-border/70 bg-card/90 p-1 shadow-sm">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="min-w-40 px-3 text-center text-sm font-semibold">{monthLabel}</div>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full" onClick={() => navigate(1)} aria-label="Next month">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</Button>
</div>
</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-card/70 px-4 py-3">
<LegendItem className="border-emerald-500 bg-emerald-500" label="Paid" />
<LegendItem className="border-primary bg-primary" label="Due" />
<LegendItem className="border-muted-foreground/50 bg-muted-foreground/50" label="Skipped" />
<LegendItem className="border-destructive bg-destructive" label="Missed/Late" />
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="h-5 w-5 rounded-full border border-primary bg-primary/10" />
Today
</span>
</div>
{loading && (
<Card>
<CardContent className="flex min-h-[360px] items-center justify-center p-6 text-sm text-muted-foreground">
Loading calendar...
</CardContent>
</Card>
)}
{!loading && error && (
<Card>
<CardContent className="flex min-h-[260px] flex-col items-center justify-center gap-3 p-6 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={load}>Try again</Button>
</CardContent>
</Card>
)}
{!loading && !error && data && (
<>
<CalendarGrid
data={data}
selectedDate={selectedDay?.date}
onSelectDay={day => {
setSelectedDay(day);
setDetailOpen(true);
}}
/>
{!hasAnyBills && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 p-6 text-center">
<CalendarDays className="h-8 w-8 text-muted-foreground" />
<div>
<p className="text-sm font-medium">No bills on this calendar yet.</p>
<p className="mt-1 text-sm text-muted-foreground">Add a bill to start seeing due dates and payment progress.</p>
</div>
<Button asChild size="sm">
<Link to="/bills">Add bill</Link>
</Button>
</CardContent>
</Card>
)}
</>
)}
</div>
<div className="space-y-4">
<SummaryProgress summary={data?.summary} />
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Selected Day</CardTitle>
<CardDescription>Tap a date to inspect bills and payments.</CardDescription>
</CardHeader>
<CardContent>
{selectedDay ? (
<div className="space-y-3">
<p className="text-sm font-semibold">{fmtDate(selectedDay.date)}</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-muted/40 p-3">
<p className="text-xs text-muted-foreground">Due</p>
<p className="font-mono font-semibold">{fmt(selectedDay.status_summary.total_due)}</p>
</div>
<div className="rounded-lg bg-muted/40 p-3">
<p className="text-xs text-muted-foreground">Paid</p>
<p className="font-mono font-semibold text-emerald-500">{fmt(selectedDay.status_summary.total_paid)}</p>
</div>
</div>
<Button className="w-full" size="sm" onClick={() => setDetailOpen(true)}>
View day details
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">No day selected.</p>
)}
</CardContent>
</Card>
</div>
</div>
<DayDetailDialog
day={selectedDay}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</div>
);
}

View File

@ -122,8 +122,8 @@ export default function CategoriesPage() {
<div className="table-surface"> <div className="table-surface">
{/* Card header with inline add form */} {/* Card header with inline add form */}
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3"> <div className="px-4 py-4 border-b border-border/50 flex items-center gap-3 sm:px-6">
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm"> <form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-sm sm:flex-row">
<Input <Input
ref={addInputRef} ref={addInputRef}
value={newName} value={newName}
@ -151,7 +151,7 @@ export default function CategoriesPage() {
{categories.map((cat) => ( {categories.map((cat) => (
<div <div
key={cat.id} key={cat.id}
className="group flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-colors" 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"> <div className="flex items-center gap-3">
<span className="text-sm font-medium">{cat.name}</span> <span className="text-sm font-medium">{cat.name}</span>

View File

@ -124,7 +124,7 @@ export default function LoginPage() {
}; };
return ( return (
<div className="min-h-screen bg-background flex items-center justify-center p-6"> <div className="min-h-screen bg-background flex items-center justify-center p-4 sm:p-6">
<div className="w-full max-w-sm space-y-6"> <div className="w-full max-w-sm space-y-6">

View File

@ -31,8 +31,8 @@ function SectionCard({ title, children }) {
function SettingRow({ label, description, children }) { function SettingRow({ label, description, children }) {
return ( return (
<div className="px-6 py-4 flex items-center justify-between"> <div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 mr-8"> <div className="flex-1 min-w-0 sm:mr-8">
<p className="text-sm font-medium">{label}</p> <p className="text-sm font-medium">{label}</p>
{description && ( {description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p> <p className="text-xs text-muted-foreground mt-0.5">{description}</p>

View File

@ -201,7 +201,7 @@ export default function StatusPage() {
<div> <div>
{/* Page header — flat on background */} {/* Page header — flat on background */}
<div className="flex items-center justify-between mb-8"> <div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1> <h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">

View File

@ -650,7 +650,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{row.payments && row.payments.length > 0 && ( {row.payments && row.payments.length > 0 && (
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent" className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title="Edit payment" title="Edit payment"
onClick={() => setEditPayment(row.payments[0])} onClick={() => setEditPayment(row.payments[0])}
> >
@ -661,7 +661,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Monthly state editor (gear icon) — always available */} {/* Monthly state editor (gear icon) — always available */}
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent" className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`} title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)} onClick={() => setShowMbs(true)}
> >
@ -698,6 +698,186 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
); );
} }
function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [showMbs, setShowMbs] = useState(false);
const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount;
const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day);
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold;
const isPaid = row.status === 'paid' || row.status === 'autodraft' || isPaidByThreshold;
const isSkipped = !!row.is_skipped;
const effectiveStatus = isSkipped
? 'skipped'
: (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft')
? 'paid'
: row.status;
const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || '');
const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0);
async function handleQuickPay() {
const val = parseFloat(amountRef.current?.value);
if (!val || val <= 0) { toast.error('Enter a payment amount'); return; }
try {
await api.quickPay({ bill_id: row.id, amount: val, paid_date: defaultPaymentDate });
toast.success('Marked as paid');
refresh();
} catch (err) {
toast.error(err.message);
}
}
return (
<>
<div
className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg,
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{row.autopay_enabled && (
<span
className="inline-flex shrink-0 rounded bg-sky-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-sky-500"
title="Autopay"
>
AP
</span>
)}
<button
type="button"
onClick={() => onEditBill?.(row)}
className={cn(
'min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground',
'underline-offset-2 transition-colors hover:text-primary hover:underline',
isSkipped && 'line-through',
)}
title="Edit bill"
>
{row.name}
</button>
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>
{row.monthly_notes}
</p>
)}
</div>
<StatusBadge status={effectiveStatus} />
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-muted-foreground sm:grid-cols-4">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="mt-0.5 font-mono text-sm text-foreground">{fmtDate(row.due_date)}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{row.category_name || 'Uncategorized'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Expected</p>
<p className={cn('mt-0.5 font-mono text-sm', row.actual_amount != null ? 'text-amber-500' : 'text-foreground')}>
{fmt(threshold)}
</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Remaining</p>
<p className={cn('mt-0.5 font-mono text-sm', remaining > 0 ? 'text-foreground' : 'text-emerald-500')}>
{fmt(remaining)}
</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Paid </span>
<span className="font-mono text-emerald-500">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
</div>
<div className="rounded-md bg-muted/45 px-2 py-1.5">
<span className="text-muted-foreground">Date </span>
<span className="font-mono text-foreground">{row.last_paid_date ? fmtDate(row.last_paid_date) : '—'}</span>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{!isPaid && !isSkipped && (
<div className="flex items-center gap-1.5">
<Input
ref={amountRef}
type="number" min="0" step="0.01"
defaultValue={threshold}
className="h-8 w-24 text-right font-mono text-sm bg-background/70 border-border/60"
title="Payment amount"
aria-label={`${row.name} payment amount`}
/>
<Button
size="sm" variant="default"
onClick={handleQuickPay}
className="h-8 px-3 text-xs font-semibold"
>
Pay
</Button>
</div>
)}
{row.payments && row.payments.length > 0 && (
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title="Edit payment"
onClick={() => setEditPayment(row.payments[0])}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Payment
</Button>
)}
<Button
size="sm" variant="ghost"
className="h-8 px-2.5 text-xs text-muted-foreground hover:text-foreground"
title={`Edit ${MONTHS[month - 1]} state (actual amount, notes, skip)`}
onClick={() => setShowMbs(true)}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
Month
</Button>
</div>
</div>
<div className="rounded-md border border-border/50 bg-muted/25 px-2 py-1.5">
<NotesCell row={row} refresh={refresh} />
</div>
</div>
{editPayment && (
<PaymentModal
payment={editPayment}
onClose={() => setEditPayment(null)}
onSave={refresh}
/>
)}
{showMbs && (
<MonthlyStateDialog
row={row}
year={year}
month={month}
open={showMbs}
onOpenChange={setShowMbs}
onSaved={refresh}
/>
)}
</>
);
}
// Bucket // Bucket
function Bucket({ label, rows, year, month, refresh, onEditBill }) { function Bucket({ label, rows, year, month, refresh, onEditBill }) {
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
@ -746,7 +926,22 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
</span> </span>
</div> </div>
<Table> <div className="grid gap-3 p-3 lg:hidden">
{rows.map((r, i) => (
<MobileTrackerRow
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
</div>
<div className="hidden lg:block">
<Table className="min-w-[1120px]">
<TableHeader> <TableHeader>
<TableRow className="border-border hover:bg-transparent"> <TableRow className="border-border hover:bg-transparent">
<TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead> <TableHead className="w-[18%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Bill</TableHead>
@ -776,6 +971,7 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
</div>
); );
} }
@ -835,7 +1031,7 @@ export default function TrackerPage() {
<div className="space-y-5"> <div className="space-y-5">
{/* ── Header ── */} {/* ── Header ── */}
<div className="flex items-end justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5"> <p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Monthly Overview Monthly Overview
@ -875,7 +1071,7 @@ export default function TrackerPage() {
</div> </div>
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {/* ── Summary cards (backend already excludes skipped from totals) ── */}
<div className="flex gap-3"> <div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard type="expected" value={summary.total_expected} /> <SummaryCard type="expected" value={summary.total_expected} />
<SummaryCard type="paid" value={summary.total_paid} /> <SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} /> <SummaryCard type="remaining" value={summary.remaining} />

BIN
img/Selection_1490.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.17", "version": "0.18.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.17", "version": "0.18.1",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.18", "version": "0.18.1",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

276
routes/analytics.js Normal file
View File

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

179
routes/calendar.js Normal file
View File

@ -0,0 +1,179 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate();
return Math.min(Math.max(parseInt(day || 1, 10), 1), daysInMonth);
}
function toDateString(year, month, day) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function emptyDay(year, month, day) {
return {
date: toDateString(year, month, day),
day,
bills_due: [],
payments: [],
status_summary: {
due_count: 0,
paid_count: 0,
skipped_count: 0,
missed_count: 0,
total_due: 0,
total_paid: 0,
},
};
}
// GET /api/calendar?year=2026&month=5
router.get('/', (req, res) => {
const db = getDb();
const now = new Date();
const year = parseInt(req.query.year || now.getFullYear(), 10);
const month = parseInt(req.query.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) {
return res.status(400).json({ error: 'year must be a 4-digit integer between 2000 and 2100' });
}
if (isNaN(month) || month < 1 || month > 12) {
return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
}
const today = now.toISOString().slice(0, 10);
const daysInMonth = new Date(year, month, 0).getDate();
const { start, end } = getCycleRange(year, month);
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
const dayByDate = new Map(days.map(day => [day.date, day]));
const bills = db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id
WHERE b.active = 1 AND b.user_id = ?
ORDER BY b.due_day ASC, b.name ASC
`).all(req.user.id);
const paymentsByBillStmt = db.prepare(`
SELECT *
FROM payments
WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
ORDER BY paid_date DESC
`);
const monthlyStateStmt = db.prepare(`
SELECT actual_amount, notes, is_skipped
FROM monthly_bill_state
WHERE bill_id = ? AND year = ? AND month = ?
`);
const payments = db.prepare(`
SELECT
p.id AS payment_id,
p.bill_id,
b.name AS bill_name,
p.amount,
p.paid_date,
p.method,
p.notes
FROM payments p
JOIN bills b ON p.bill_id = b.id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
ORDER BY p.paid_date ASC, b.name ASC
`).all(req.user.id, start, end);
for (const payment of payments) {
const day = dayByDate.get(payment.paid_date);
if (day) {
day.payments.push({
payment_id: payment.payment_id,
bill_id: payment.bill_id,
bill_name: payment.bill_name,
amount: payment.amount,
paid_date: payment.paid_date,
method: payment.method || null,
notes: payment.notes || null,
});
day.status_summary.total_paid += payment.amount || 0;
}
}
const calendarBills = bills.map(bill => {
const billPayments = paymentsByBillStmt.all(bill.id, start, end);
const row = buildTrackerRow(bill, billPayments, year, month, today);
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = monthlyState?.actual_amount ?? null;
const isSkipped = !!monthlyState?.is_skipped;
const effectiveAmount = actualAmount ?? row.expected_amount;
const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= effectiveAmount;
const isAutodraft = row.status === 'autodraft';
const status = isSkipped
? 'skipped'
: isPaidByThreshold
? 'paid'
: row.status;
const isPaid = status === 'paid' || isAutodraft;
const dueDay = clampDay(year, month, bill.due_day);
const dueDate = toDateString(year, month, dueDay);
return {
bill_id: bill.id,
name: bill.name,
due_date: dueDate,
due_day: dueDay,
expected_amount: row.expected_amount,
actual_amount: actualAmount,
effective_amount: effectiveAmount,
category_name: bill.category_name || null,
is_paid: isPaid,
is_skipped: isSkipped,
paid_amount: row.total_paid || 0,
status,
};
});
for (const bill of calendarBills) {
const day = dayByDate.get(bill.due_date);
if (!day) continue;
day.bills_due.push(bill);
day.status_summary.due_count += 1;
if (bill.is_paid) day.status_summary.paid_count += 1;
if (bill.is_skipped) day.status_summary.skipped_count += 1;
if (!bill.is_paid && !bill.is_skipped && (bill.status === 'late' || bill.status === 'missed')) {
day.status_summary.missed_count += 1;
}
if (!bill.is_skipped) day.status_summary.total_due += bill.effective_amount || 0;
}
const activeBills = calendarBills.filter(bill => !bill.is_skipped);
const expectedTotal = activeBills.reduce((sum, bill) => sum + (bill.effective_amount || 0), 0);
const paidTotal = activeBills.reduce((sum, bill) => sum + (bill.paid_amount || 0), 0);
const remainingTotal = Math.max(0, expectedTotal - paidTotal);
const paidPercent = expectedTotal > 0 ? Math.min(100, Math.round((paidTotal / expectedTotal) * 100)) : 0;
res.json({
year,
month,
today,
days,
summary: {
expected_total: expectedTotal,
paid_total: paidTotal,
remaining_total: remainingTotal,
paid_percent: paidPercent,
bill_count: activeBills.length,
paid_count: activeBills.filter(bill => bill.is_paid).length,
skipped_count: calendarBills.filter(bill => bill.is_skipped).length,
missed_count: activeBills.filter(bill => bill.status === 'late' || bill.status === 'missed').length,
},
});
});
module.exports = router;

View File

@ -46,6 +46,8 @@ app.use('/api/bills', requireAuth, requireUser, require('./routes/bills'
app.use('/api/payments', requireAuth, requireUser, require('./routes/payments')); app.use('/api/payments', requireAuth, requireUser, require('./routes/payments'));
app.use('/api/categories', requireAuth, requireUser, require('./routes/categories')); app.use('/api/categories', requireAuth, requireUser, require('./routes/categories'));
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings')); app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/notifications', requireAuth, require('./routes/notifications')); app.use('/api/notifications', requireAuth, require('./routes/notifications'));
app.use('/api/status', requireAuth, require('./routes/status')); app.use('/api/status', requireAuth, require('./routes/status'));
app.use('/api/version', require('./routes/version')); // public app.use('/api/version', require('./routes/version')); // public