Compare commits

...

17 Commits

Author SHA1 Message Date
kaspa 43bd58910a init 2026-05-04 16:38:03 -05:00
kaspa 5c828a1aa3 init 2026-05-04 14:21:21 -05:00
kaspa e8d592d326 init 2026-05-04 14:19:22 -05:00
kaspa 6be7d8956e init 2026-05-04 14:18:31 -05:00
kaspa f7c78a4a31 init 2026-05-04 14:17:32 -05:00
kaspa eb5aa72d56 demo 2026-05-04 14:14:29 -05:00
kaspa ea651dd7c9 1 2026-05-04 13:56:00 -05:00
kaspa ee3fcf8bf8 correct 2026-05-04 13:51:38 -05:00
kaspa 59c45d192d readme 2026-05-04 13:41:06 -05:00
kaspa 0c7824c52f readme 2026-05-04 13:38:19 -05:00
kaspa cb0e119941 img 2026-05-04 13:20:07 -05:00
kaspa bb5afcafb2 calendar 2026-05-04 13:14:32 -05:00
kaspa eb908ce934 logo 2026-05-03 22:33:21 -05:00
kaspa 25c768d013 corrected admin view 2026-05-03 20:40:48 -05:00
kaspa c59ad6cb70 init 2026-05-03 20:25:08 -05:00
kaspa 678ff5eb19 init 2026-05-03 20:02:32 -05:00
kaspa 7cbdefbcfe init 2026-05-03 19:56:19 -05:00
47 changed files with 3558 additions and 544 deletions

View File

@ -1,7 +1,59 @@
# Bill Tracker — Changelog
## 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.
- 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 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
### Branding
- Replaced the top-navbar dollar-sign placeholder and duplicate text/version brand stack with the selected `/img/logo.png` BillTracker logo.
- The logo now serves as the BillTracker brand in the top navigation while preserving the existing navbar height and route behavior.
- Login now uses the BillTracker logo, shows linked build/version information near the login actions, and uses the authentik icon for OIDC login.
- Admin Authentication Methods now uses subtle authentik branding in the OIDC toggle/configuration/test-login controls.
- Cropped transparent padding from the BillTracker logo asset so it renders larger and more readably in the unchanged-height navbar.
- Promoted the transparent `logo_cut.png` artwork to the served `/img/logo.png` asset and enlarged the login-page logo while keeping the login card layout compact.
- Login logo sizing now follows the login form width so the brand grows and shrinks with the sign-in column instead of rendering too small.
- Legacy `/login.html` now redirects to the modern React `/login` screen so the old static login page is no longer served by stale links.
- 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.
### 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
- **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
@ -19,6 +71,7 @@
- **authentik client auth method**: Admin OIDC settings now include an advanced `client_secret_basic` / `client_secret_post` token endpoint authentication method selector. The default remains `client_secret_basic`, matching the previous `openid-client` behavior.
- **Admin user role management**: Admin Users table now lets an admin promote another user to `admin` or demote an admin back to `user`, with protections against changing your own role or removing the last admin account.
- **Single-user mode recovery**: User Settings now shows a Login Mode section while single-user mode is active, allowing the default user to restore multi-user login without needing access to Admin routes.
- **Admin navigation parity**: Admin users now keep the normal app navigation and get an Admin link after Status; `/admin` uses the same top nav so admins can return to Tracker/Bills/Categories/Profile/Settings/Status without typing a URL. Backend `/admin` protection remains unchanged.
- **Admin-controlled auth method toggles** in Admin panel (Authentication Methods card):
- `local_login_enabled` — enable/disable local username/password login (default: enabled)
- `oidc_login_enabled` — enable/disable OIDC/authentik login (default: disabled)

261
README.md Normal file
View File

@ -0,0 +1,261 @@
# BillTracker
<p align="center">
<img src="docs/images/logo_cut.png" alt="Tracker logo">
</p>
BillTracker is a self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history. It runs as a Node/Express server with a React frontend and stores data in SQLite. This product was produced with the assistance of AI.
<p align="center">
Demo Server
https://t1.scheller.ltd/
<br>
Username: guest
<br>
Password: guest123
</p>
## Screenshots
![Analytics screenshot](docs/images/login.png)
![Analytics screenshot](docs/images/tracker.png)
![Analytics screenshot](docs/images/Analytics.png)
![Calendar screenshot](docs/images/Calendar.png)
## What Is BillTracker?
BillTracker helps a household or small self-hosted setup keep bill data in one place:
- recurring bill records with due day, expected amount, category, notes, autopay details, and optional APR
- monthly tracker with payments, skipped bills, actual monthly amounts, and notes
- calendar view for due dates and payments
- analytics for monthly spending, expected vs actual totals, category spend, and payment history
- categories, profile, display name, notification preferences, password changes, and data tools
- admin user management, authentication settings, backups, cleanup, and status checks
## Features
- Tracker for month-by-month bill status, payment entry, notes, skipped bills, overdue totals, and month navigation
- Bills page for creating, editing, deactivating, reactivating, deleting, and controlling inactive bill history
- Calendar page with a monthly grid, bill due dates, payments, and progress summary
- Analytics page with date range, category/bill filters, charts, heatmap, and print output
- User-owned categories
- Settings for theme, currency, date format, and grace period
- Profile page with display name, notification preferences, password change, imports, exports, and import history
- User exports to Excel workbook or BillTracker user SQLite export
- XLSX spreadsheet import with preview and import decisions
- User SQLite import from exports created by this app
- Admin users, role management, password resets, full database backups/restores, scheduled backups, cleanup, auth settings, and status page
- Local username/password login and optional authentik/OIDC login
## Quick Start
Install dependencies:
```bash
npm install
```
Run the API and Vite frontend for development:
```bash
npm run dev
```
Build the frontend:
```bash
npm run build
```
Start the production server:
```bash
npm start
```
The production server serves `dist/` and listens on `PORT`, defaulting to `3000`.
Useful scripts present in this repo:
```bash
npm run dev:api
npm run dev:ui
npm run build
npm start
node scripts/test-import.js
node scripts/test-oidc-smoke.js
node scripts/test-cookie-options.js
```
## Docker
Docker files are included. The compose file runs the published image on host port `3030` and stores app data under `/data` in the container.
```bash
docker compose up -d
```
On first start without an existing database, create the admin account with:
```bash
INIT_ADMIN_USER=admin
INIT_ADMIN_PASS=change-this-password
```
Remove or change those first-run values after the initial admin account exists.
## Configuration
Most app settings are configured in the web UI. User-facing settings live under Settings/Profile. Server-wide settings such as users, backups, cleanup, and authentication methods live in Admin.
Real environment variables used by the app:
```bash
PORT=3000
NODE_ENV=production
DB_PATH=/path/to/bills.db
BACKUP_PATH=/path/to/backups
INIT_ADMIN_USER=admin
INIT_ADMIN_PASS=change-this-password
HTTPS=true
COOKIE_SECURE=true
CORS_ORIGIN=https://bills.example.com
```
OIDC environment fallback variables are supported when the matching Admin database setting is blank:
```bash
OIDC_PROVIDER_NAME=authentik
OIDC_ISSUER_URL=https://yourURL.com/application/o/bills/.well-known/openid-configuration
OIDC_CLIENT_ID=<client-id>
OIDC_CLIENT_SECRET=<client-secret>
OIDC_TOKEN_AUTH_METHOD=client_secret_basic
OIDC_REDIRECT_URI=https://bills.example.com/api/auth/oidc/callback
OIDC_SCOPES="openid email profile groups"
OIDC_ADMIN_GROUP=bill-tracker-admins
OIDC_AUTO_PROVISION=true
```
Database-backed Admin settings take precedence over environment fallback values.
## Authentication
BillTracker supports local username/password login by default. Admins can create users, reset user passwords, promote/demote users, and configure login methods.
Optional authentik/OIDC login can be enabled in Admin. OIDC uses authorization code flow with PKCE, state and nonce validation, and `openid-client` token validation. OIDC users can be auto-provisioned when enabled.
Admin role is never granted by default through OIDC. Set an authentik admin group in BillTracker; only users whose OIDC `groups` claim includes that configured group become app admins.
BillTracker includes lockout checks so local login cannot be disabled unless OIDC is configured, enabled, and mapped to an admin group.
## authentik Setup
In authentik, create an OAuth2/OpenID provider/application for BillTracker:
- Client type: confidential
- Redirect URI: `https://bills.example.com/api/auth/oidc/callback`
- Scopes: `openid email profile groups`
- Groups claim: make sure authentik sends `groups`
- Admin group: create or choose the authentik group that should become BillTracker admins
In BillTracker, go to Admin -> Authentication Methods and set:
- Provider name: `authentik`
- Issuer/discovery URL: `https://yourURL.com/application/o/bills/.well-known/openid-configuration`
- Client ID and client secret from authentik
- Redirect URI matching the authentik allowed redirect URI
- Scopes: `openid email profile groups`
- Admin group: the exact authentik group name for BillTracker admins
- Auto-provision users: enabled if you want valid authentik users created on first login
The backend accepts either the provider issuer base URL or the full discovery URL. For authentik, the full discovery URL example is:
```text
https://yourURL.com/application/o/bills/.well-known/openid-configuration
```
Keep local login enabled until you have tested authentik login with an admin-group user.
## Data, Imports, Exports, And Backups
BillTracker stores data in SQLite. By default the database is `db/bills.db`; set `DB_PATH` for a different location. In Docker, the image sets `DB_PATH=/data/db/bills.db` and `BACKUP_PATH=/data/backups`.
User data is scoped to the signed-in user. User exports include bills, categories, payments, monthly bill state, notes, and export metadata. They do not include password hashes, sessions, admin settings, SMTP credentials, backup files, server paths, or other users' data.
Data tools:
- XLSX spreadsheet import with preview before apply
- user SQLite import from BillTracker user exports
- user SQLite export
- Excel workbook export
- import history
- admin full database backup, import, download, restore, delete, scheduled backups, and retention
Backups and exports contain sensitive financial data. The code writes SQLite backup files with restrictive file permissions, but backup/export encryption is not implemented. Protect downloaded files and backup storage yourself.
## Security Notes
- Auth is required for user data routes.
- Admin routes require an admin session.
- User-owned bill, category, payment, import, and export routes derive ownership from the authenticated session.
- Local login, password change, import, export, admin actions, and OIDC routes have per-IP in-memory rate limits.
- CORS is disabled unless `CORS_ORIGIN` is set.
- Baseline security headers are sent; HSTS is sent only when `HTTPS=true`.
- Session cookies are `httpOnly`, `sameSite=strict`, and marked secure when `COOKIE_SECURE=true`, `HTTPS=true`, or the request appears to be HTTPS.
- OIDC validation is handled through `openid-client` using discovered provider metadata and JWKS.
- Protect database files, backups, and exports as sensitive financial records.
## Reverse Proxy And HTTPS
Run BillTracker behind HTTPS for normal use. If TLS terminates at a reverse proxy, forward `X-Forwarded-Proto: https` so secure-cookie detection can work. You can also set `HTTPS=true` or `COOKIE_SECURE=true`.
Set `CORS_ORIGIN` only when the frontend and backend are served from different origins. For the normal same-origin deployment, leave it unset.
## Project Structure
```text
client/ React app, pages, layout, UI components
db/ SQLite connection, schema, startup migrations
middleware/ auth checks, rate limits, security headers
routes/ Express API routes
services/ auth, OIDC, backups, imports, cleanup, status, notifications
workers/ daily background tasks
setup/ first-run admin setup
scripts/ migrations and smoke/import tests
public/ legacy static assets
img/ app/runtime images and source screenshots
docs/images/ README images
```
## Upgrading
For a direct Node install:
```bash
git pull
npm install
npm run build
npm start
```
Restart your process manager after building. The app initializes the SQLite schema and runs additive migrations on startup; the Docker entrypoint also runs `scripts/migrate-db.js` before starting unless `RUN_DB_MIGRATIONS=false`.
For Docker, pull/rebuild the image, recreate the container, and keep the `/data` volume mounted.
## Known Limitations
- Admin backups and user exports are not encrypted by the app.
- OIDC single logout is not implemented.
- Content-Security-Policy is intentionally deferred.
- Rate limiting is in-memory, so counters reset on restart and are not shared across multiple app instances.
- authentik live login must be tested in your deployment with your authentik provider.
- The XLSX parser dependency has known upstream security advisories; the import route is authenticated, file-size limited, and parses cells as data.
## License
License: Not specified.

View File

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

View File

@ -6,10 +6,13 @@ import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
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';
import StatusPage from '@/pages/StatusPage';
import AnalyticsPage from '@/pages/AnalyticsPage';
import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
import DataPage from '@/pages/DataPage';
import ProfilePage from '@/pages/ProfilePage';
@ -35,8 +38,10 @@ function RequireAuth({ children, role }) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin');
// Role mismatch
if (role && user.role !== role) {
if (!roleAllowed) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;
}
@ -71,8 +76,11 @@ 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 />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="data" element={<DataPage />} />
<Route path="profile" element={<ProfilePage />} />

View File

@ -108,6 +108,13 @@ export const api = {
tracker: (y, m) => get(`/tracker?year=${y}&month=${m}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
// 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'),
@ -141,6 +148,16 @@ export const api = {
settings: () => get('/settings'),
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: () => get('/status'),

View File

@ -91,7 +91,7 @@ export default function BillModal({ bill, categories, onClose, onSave }) {
</DialogHeader>
<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 */}
<div className="col-span-2 space-y-1.5">

View File

@ -10,138 +10,257 @@ function hasHistoricalVisibility(bill) {
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
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
return (
<Table>
<TableHeader className="bg-muted border-b border-border/70">
<TableRow className="hover:bg-transparent border-0">
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
<TableHead className="px-6 py-3 w-72" />
</TableRow>
</TableHeader>
<TableBody>
<>
<div className="grid gap-3 p-3 lg:hidden">
{bills.map((bill) => (
<TableRow
<MobileBillRow
key={bill.id}
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
>
{/* Bill name */}
<TableCell className="px-6 py-4">
<div className="flex items-center gap-2">
<button
type="button"
className="text-left text-sm font-medium 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>
{hasHistoricalVisibility(bill) && (
<span
className="inline-flex h-5 w-5 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>
</TableCell>
{/* Category */}
<TableCell className="px-6 py-4">
{bill.category_name ? (
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Due day */}
<TableCell className="px-6 py-4 w-24">
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
</TableCell>
{/* Expected amount */}
<TableCell className="px-6 py-4 w-28 text-right">
<span className="font-mono text-sm tabular-nums text-muted-foreground">
${Number(bill.expected_amount).toFixed(2)}
</span>
</TableCell>
{/* Billing cycle — field is billing_cycle, not cycle */}
<TableCell className="px-6 py-4 w-28">
<span className="text-xs text-muted-foreground capitalize">
{bill.billing_cycle || 'monthly'}
</span>
</TableCell>
{/* Flags */}
<TableCell className="px-6 py-4 w-24">
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
<div className="flex items-center gap-1.5">
{!!bill.autopay_enabled && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
)}
{!!bill.has_2fa && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
)}
</div>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Actions — visible on row hover */}
<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">
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 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-7 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-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete?.(bill)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
bill={bill}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
/>
))}
</TableBody>
</div>
</Table>
<div className="hidden lg:block">
<Table className="min-w-[900px]">
<TableHeader className="bg-muted border-b border-border/70">
<TableRow className="hover:bg-transparent border-0">
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
<TableHead className="px-6 py-3 w-72" />
</TableRow>
</TableHeader>
<TableBody>
{bills.map((bill) => (
<TableRow
key={bill.id}
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
>
{/* Bill name */}
<TableCell className="px-6 py-4">
<div className="flex items-center gap-2">
<button
type="button"
className="text-left text-sm font-medium 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>
{hasHistoricalVisibility(bill) && (
<span
className="inline-flex h-5 w-5 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>
</TableCell>
{/* Category */}
<TableCell className="px-6 py-4">
{bill.category_name ? (
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Due day */}
<TableCell className="px-6 py-4 w-24">
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
</TableCell>
{/* Expected amount */}
<TableCell className="px-6 py-4 w-28 text-right">
<span className="font-mono text-sm tabular-nums text-muted-foreground">
${Number(bill.expected_amount).toFixed(2)}
</span>
</TableCell>
{/* Billing cycle — field is billing_cycle, not cycle */}
<TableCell className="px-6 py-4 w-28">
<span className="text-xs text-muted-foreground capitalize">
{bill.billing_cycle || 'monthly'}
</span>
</TableCell>
{/* Flags */}
<TableCell className="px-6 py-4 w-24">
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
<div className="flex items-center gap-1.5">
{!!bill.autopay_enabled && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
)}
{!!bill.has_2fa && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
)}
</div>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Actions — visible on row hover */}
<TableCell className="px-6 py-4 w-72 text-right">
<div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 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-7 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-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete?.(bill)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
);
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
Activity, 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';
@ -16,12 +16,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { APP_VERSION } from '@/lib/version';
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' },
{ to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/status', icon: Activity, label: 'Status' },
];
@ -32,21 +34,21 @@ const adminNavItems = [
function BrandBlock({ adminMode = false }) {
return (
<NavLink to={adminMode ? '/admin' : '/'} className="flex items-center gap-3 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary text-primary-foreground font-bold text-sm shadow-sm shadow-primary/25">
$
</div>
<div className="min-w-0 leading-tight">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold tracking-tight text-foreground">BillTracker</span>
{adminMode && (
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
Admin
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground/70 tabular-nums">v{APP_VERSION}</span>
</div>
<NavLink
to={adminMode ? '/admin' : '/'}
aria-label="BillTracker"
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<img
src="/img/logo.png"
alt="BillTracker"
className="h-16 w-auto max-w-[9rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
/>
{adminMode && (
<span className="hidden sm:inline-flex rounded-full border border-destructive/25 bg-destructive/10 px-2 py-0.5 text-[10px] font-semibold uppercase text-destructive">
Admin
</span>
)}
</NavLink>
);
}
@ -98,14 +100,16 @@ function UserMenu({ adminMode = false }) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
{!adminMode && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
</DropdownMenuItem>
{user?.role === 'admin' && !adminMode && (
<DropdownMenuItem onSelect={() => navigate('/admin')}>
<ShieldCheck className="h-4 w-4" />
Admin
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={handleLogout}>
@ -119,14 +123,17 @@ function UserMenu({ adminMode = false }) {
export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false);
const items = adminMode ? adminNavItems : userNavItems;
const { user } = useAuth();
const items = user?.role === 'admin'
? [...userNavItems, ...adminNavItems]
: userNavItems;
return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
<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} />
<nav className="hidden items-center gap-1 md:flex">
<nav className="hidden items-center gap-1 lg:flex">
{items.map(item => (
<NavPill key={item.to} item={item} />
))}
@ -139,7 +146,7 @@ export default function Sidebar({ adminMode = false }) {
type="button"
variant="outline"
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-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}
@ -150,7 +157,7 @@ export default function Sidebar({ adminMode = false }) {
</div>
{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">
{items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />

View File

@ -26,7 +26,7 @@ function AlertDialogContent({ className, ...props }) {
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
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
)}
{...props}

View File

@ -26,7 +26,7 @@ const DialogContent = React.forwardRef(({ className, children, ...props }, ref)
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}

View File

@ -131,3 +131,84 @@
@apply surface overflow-hidden shadow-sm;
}
}
@media print {
body {
background: white !important;
color: #111827 !important;
}
header,
.analytics-screen-header,
.analytics-controls,
.analytics-actions,
.summary-screen-header,
.summary-controls,
.summary-actions,
.summary-edit-actions,
.summary-income-form {
display: none !important;
}
main,
main > div {
max-width: none !important;
padding: 0 !important;
}
.analytics-page,
.summary-page {
color: #111827 !important;
}
.analytics-report-meta,
.analytics-print-footer,
.summary-print-meta,
.summary-print-footer {
display: block !important;
margin-bottom: 1rem;
}
.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,
.summary-print-meta p,
.summary-print-footer {
color: #4b5563 !important;
font-size: 12px;
margin: 0.125rem 0;
}
.analytics-range {
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,
.summary-card,
.summary-chart-card {
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 RELEASE_NOTES = {
version: '0.16.2',
date: '2026-05-03',
version: '0.18.1',
date: '2026-05-04',
highlights: [
{ icon: '🗄️', title: 'SQLite data import', desc: 'Preview and import user-owned SQLite exports created by this app.' },
{ icon: '🧾', title: 'Import tools layout', desc: 'Spreadsheet and SQLite import tools now sit side by side in Profile.' },
{ icon: '📦', title: 'Exports below imports', desc: 'User data export downloads now live below the import tools.' },
{ icon: '🎨', title: 'Material Design theme', desc: 'Light mode defaults to the shadcn Material Design theme tokens.' },
{ icon: '📅', title: 'Due day editing', desc: 'Bill due dates are edited as recurring day-of-month values.' },
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' },
{ icon: '🧭', title: 'Tablet-safe navigation', desc: 'The top navigation uses the compact menu on tablet sizes to avoid horizontal overflow.' },
{ icon: '📊', title: 'Responsive analytics', desc: 'Analytics controls, charts, and the pay heatmap resize or scroll cleanly on smaller screens.' },
{ icon: '🪟', title: 'Viewport-safe dialogs', desc: 'Dialogs and confirmations fit mobile screens and scroll internally when content is long.' },
{ icon: '🖥️', title: 'Desktop preserved', desc: 'Existing desktop layouts remain on the same large-screen breakpoints.' },
],
};

View File

@ -23,14 +23,16 @@ import AppNavigation from '@/components/layout/Sidebar';
// Helpers
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
function SectionHeading({ children }) {
return <h2 className="text-base font-semibold text-foreground">{children}</h2>;
}
function FieldRow({ label, children }) {
return (
<div className="grid grid-cols-[200px_1fr] items-center gap-4">
<Label className="text-right text-muted-foreground">{label}</Label>
<div className="grid gap-2 lg:grid-cols-[200px_1fr] lg:items-center lg:gap-4">
<Label className="text-muted-foreground lg:text-right">{label}</Label>
{children}
</div>
);
@ -716,6 +718,12 @@ function AuthMethodsCard() {
{/* OIDC / authentik login toggle */}
<FieldRow label="authentik / OIDC login">
<div className="flex items-center gap-3">
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
<Toggle
checked={form.oidc_login_enabled}
onChange={v => set('oidc_login_enabled', v)}
@ -730,7 +738,15 @@ function AuthMethodsCard() {
</FieldRow>
<div className="space-y-4 pt-2 border-t border-border">
<p className="text-sm font-medium text-muted-foreground pt-1">authentik / OIDC configuration</p>
<div className="flex items-center gap-2 pt-1 text-sm font-medium text-muted-foreground">
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="h-5 w-5 shrink-0 object-contain"
/>
<span>authentik / OIDC configuration</span>
</div>
<FieldRow label="Provider name">
<Input
@ -741,20 +757,20 @@ function AuthMethodsCard() {
/>
</FieldRow>
<FieldRow label="Issuer URL">
<FieldRow label="Issuer / discovery URL">
<div className="space-y-1">
<Input
value={form.oidc_issuer_url}
onChange={e => set('oidc_issuer_url', e.target.value)}
placeholder="https://auth.example.com/application/o/bill-tracker/"
placeholder="https://yourURL.com/application/o/bills/.well-known/openid-configuration"
className="max-w-xl h-8 text-sm"
/>
<p className={issuerEndpointWarning ? 'text-xs text-amber-500' : 'text-xs text-muted-foreground'}>
Use the authentik provider issuer URL, not the authorize/token/userinfo endpoint.
Use the authentik provider issuer URL or full discovery URL, for example https://yourURL.com/application/o/bills/.well-known/openid-configuration.
</p>
{issuerEndpointWarning && (
<p className="text-xs text-amber-500">
This looks like an authorization endpoint. In authentik, copy the OpenID Configuration Issuer value.
This looks like an authorization endpoint. In authentik, copy the provider issuer or OpenID Configuration URL.
</p>
)}
</div>
@ -920,6 +936,12 @@ function AuthMethodsCard() {
disabled={!data?.oidc_login_enabled || !data?.oidc_configured}
onClick={() => { window.location.href = '/api/auth/oidc/login?redirect_to=/admin'; }}
>
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-4 w-4 shrink-0 object-contain"
/>
Test authentik Login
</Button>
<Button onClick={handleSave} disabled={!canSave}>
@ -1152,7 +1174,7 @@ function AddUserCard({ onCreated }) {
<CardTitle>Add User</CardTitle>
</CardHeader>
<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">
<Label htmlFor="new-uname">Username</Label>
<Input
@ -1399,8 +1421,8 @@ function BackupManagementCard() {
</Button>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<div className="overflow-x-auto rounded-lg border border-border">
<table className="min-w-[860px] w-full text-sm">
<thead className="bg-muted/40">
<tr className="border-b border-border">
<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">
{/* ── 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>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Manage
@ -443,7 +443,7 @@ export default function BillsPage() {
{/* ── Active Bills ── */}
<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">
Active Bills
</span>
@ -493,7 +493,7 @@ export default function BillsPage() {
{showInactive && (
<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">
Inactive Bills
</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

@ -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-6 py-4 border-b border-border/50 flex items-center gap-3">
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm">
<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 px-6 py-4 hover:bg-muted/30 transition-colors"
>
<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

@ -7,10 +7,14 @@ import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { APP_VERSION } from '@/lib/version';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
export default function LoginPage() {
const navigate = useNavigate();
const { setUser, refresh } = useAuth();
@ -120,19 +124,17 @@ export default function LoginPage() {
};
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">
{/* Logo / Brand */}
<div className="flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary text-primary-foreground
flex items-center justify-center font-bold text-lg shadow-sm">
$
</div>
<span className="text-xl font-semibold tracking-tight">
BillTracker
</span>
<div className="flex justify-center">
<img
src="/img/logo.png"
alt="BillTracker"
className="h-auto w-[82%] max-w-[22rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
/>
</div>
{/* Card */}
@ -152,6 +154,12 @@ export default function LoginPage() {
className="w-full"
onClick={() => { window.location.href = authMode.oidc_login_url; }}
>
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-5 w-5 shrink-0 object-contain"
/>
Continue with {providerName}
</Button>
)}
@ -205,6 +213,17 @@ export default function LoginPage() {
</form>
)}
<p className="text-center text-xs text-muted-foreground">
<a
href={BUILD_LINK_URL}
target="_blank"
rel="noreferrer"
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
Build v{APP_VERSION}
</a>
</p>
</div>
</div>

View File

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

View File

@ -201,7 +201,7 @@ export default function StatusPage() {
<div>
{/* 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>
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
<p className="text-sm text-muted-foreground mt-0.5">

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

@ -650,7 +650,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{row.payments && row.payments.length > 0 && (
<Button
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"
onClick={() => setEditPayment(row.payments[0])}
>
@ -661,7 +661,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
{/* Monthly state editor (gear icon) — always available */}
<Button
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)`}
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
function Bucket({ label, rows, year, month, refresh, onEditBill }) {
// Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals
@ -746,35 +926,51 @@ function Bucket({ label, rows, year, month, refresh, onEditBill }) {
</span>
</div>
<Table>
<TableHeader>
<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-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
Notes
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
</TableBody>
</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>
<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-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Status</TableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground border-l border-border pl-4">
Notes
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => (
<Row
key={r.id}
row={r}
year={year}
month={month}
refresh={refresh}
index={i}
onEditBill={onEditBill}
/>
))}
</TableBody>
</Table>
</div>
</div>
);
}
@ -835,7 +1031,7 @@ export default function TrackerPage() {
<div className="space-y-5">
{/* ── 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>
<p className="text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground mb-0.5">
Monthly Overview
@ -875,7 +1071,7 @@ export default function TrackerPage() {
</div>
{/* ── 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="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />

BIN
client/public/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

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

@ -1,18 +1,18 @@
version: "3.8"
services:
bill-tracker:
build: .
image: bill-tracker:local
image: dream.scheller.ltd/null/billtracker:latest
container_name: bill-tracker
environment:
NODE_ENV: production
DB_PATH: /data/db/bills.db
BACKUP_PATH: /data/backups
ports:
- "${PORT:-3000}:3000"
volumes:
- bill-data:/data # persistent DB + backups
restart: unless-stopped
volumes:
bill-data:
driver: local
ports:
- "3030:3000"
environment:
INIT_ADMIN_USER: admin
INIT_ADMIN_PASS: changeme123
volumes:
- /portainer/hosting/bill-tracker/data:/data
restart: unless-stopped

BIN
docs/images/Analytics.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
docs/images/Calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
docs/images/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/images/logo_cut.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
docs/images/tracker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

BIN
img/Analytics.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
img/Calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
img/logo_cut.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
img/tracker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="/img/logo.png">
<title>Bill Tracker</title>
</head>
<body>

View File

@ -27,7 +27,7 @@ function requireAuth(req, res, next) {
}
function requireUser(req, res, next) {
if (req.user?.role !== 'user') {
if (!['user', 'admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Access denied: user account required' });
}
next();

4
package-lock.json generated
View File

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

View File

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

BIN
public/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -3,172 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bill Tracker — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1526;
--surface: #162236;
--surface-2: #1c2d47;
--border: rgba(255,255,255,0.07);
--border-strong: rgba(255,255,255,0.14);
--text: #e2e8f0;
--text-muted: #8faab8;
--text-faint: #506070;
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: rgba(99,102,241,0.18);
--danger: #f43f5e;
--danger-light: rgba(244,63,94,0.15);
--radius: 6px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
font-family: var(--font);
font-size: 14px;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(99,102,241,0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(34,211,165,0.04) 0%, transparent 50%);
}
.card {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 12px;
padding: 36px 32px;
width: 100%;
max-width: 360px;
box-shadow: 0 24px 60px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
}
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.logo-icon {
width: 36px; height: 36px;
background: var(--primary);
border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 17px; color: white;
box-shadow: 0 0 16px rgba(99,102,241,0.4);
}
.logo-text { font-size: 18px; font-weight: 700; color: var(--text); }
h2 { font-size: 15px; font-weight: 500; margin-bottom: 22px; color: var(--text-muted); }
.form-group { margin-bottom: 14px; }
label {
display: block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--text-muted);
margin-bottom: 6px;
}
input {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font);
color: var(--text);
background: var(--surface-2);
transition: border-color .15s, box-shadow .15s;
}
input::placeholder { color: var(--text-faint); }
input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
.btn {
width: 100%;
padding: 10px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font);
font-weight: 600;
cursor: pointer;
background: var(--primary);
color: white;
margin-top: 6px;
transition: background .15s, box-shadow .15s;
}
.btn:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.error {
background: var(--danger-light);
color: var(--danger);
border: 1px solid rgba(244,63,94,0.3);
border-radius: var(--radius);
padding: 9px 12px;
font-size: 13px;
margin-bottom: 14px;
display: none;
}
.error.show { display: block; }
</style>
<meta http-equiv="refresh" content="0; url=/login">
<title>BillTracker — Sign In</title>
<script>
window.location.replace('/login');
</script>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">$</div>
<span class="logo-text">BillTracker</span>
</div>
<h2>Sign in to your account</h2>
<div class="error" id="error-msg"></div>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" autocapitalize="none" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button class="btn" type="submit" id="submit-btn">Sign In</button>
</form>
</div>
<script>
// If single-user mode is active, no login needed — go straight to the app
fetch('/api/auth/mode').then(r => r.json()).then(d => {
if (d.auth_mode === 'single') { location.href = '/'; return; }
});
// Redirect if already logged in
fetch('/api/auth/me').then(r => {
if (r.ok) return r.json().then(d => {
location.href = d.user.role === 'admin' ? '/admin.html' : '/';
});
});
document.getElementById('login-form').onsubmit = async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const err = document.getElementById('error-msg');
btn.disabled = true;
btn.textContent = 'Signing in…';
err.classList.remove('show');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
location.href = data.user.role === 'admin' ? '/admin.html' : '/';
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
btn.disabled = false;
btn.textContent = 'Sign In';
}
};
</script>
<p><a href="/login">Continue to BillTracker sign in</a></p>
</body>
</html>

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

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

@ -46,6 +46,9 @@ app.use('/api/bills', requireAuth, requireUser, require('./routes/bills'
app.use('/api/payments', requireAuth, requireUser, require('./routes/payments'));
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'));
app.use('/api/version', require('./routes/version')); // public
@ -61,6 +64,7 @@ app.use('/api/import', requireAuth, requireUser, importLimiter, require('
app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
// ── Modern UI (Vite build) ────────────────────────────────────────────────────
app.get('/login.html', (req, res) => res.redirect(302, '/login'));
app.use(express.static(DIST));
app.get('*', (req, res) => res.sendFile(path.join(DIST, 'index.html')));

View File

@ -7,6 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
publicDir: 'client/public',
resolve: {
alias: { '@': path.resolve(__dirname, './client') },
},