This commit is contained in:
_null 2026-05-04 20:12:57 -05:00
parent b019487423
commit d1efeece04
22 changed files with 1030 additions and 131 deletions

View File

@ -1,5 +1,27 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.18.3
### Added
- Added an `Other` monthly starting amount alongside the 1st and 15th amounts.
- New `monthly_starting_amounts` records store user-scoped, month-specific starting cash with `first_amount`, `fifteenth_amount`, and `other_amount`.
- New `GET /api/monthly-starting-amounts` and `PUT /api/monthly-starting-amounts` endpoints manage monthly starting balances.
- Tracker renamed the “Total Expected” card to “Starting” and shows the selected months combined starting amount.
- The Tracker Starting card now has an edit control for setting 1st, 15th, and Other monthly amounts.
- Summary now uses monthly starting balances as the planning base and shows a Starting Balance section.
- Remaining balances deduct paid bills: due days 1-14 from the 1st bucket, due days 15-31 from the 15th bucket, and total paid from combined remaining.
- Added monthly starting amounts to user SQLite and Excel exports, and to user SQLite imports.
- Added a public About page with app version, stack, AI-assistance note, and Release Notes access.
- Release Notes are now available without login.
### Notes
- Starting balances are not bills and are not payments.
- Remaining values can go negative when paid bills exceed starting cash; overages are not blocked.
- Previous month remaining is exposed to Summary as informational context only when available.
- Navigation now groups Overview, Summary, Bills, and Categories under Tracker, and groups Profile, Settings, and Data in the user menu.
- System Status is admin-only and appears in the Admin Panel navigation.
- No payment behavior, bill behavior, Calendar, Analytics, auth, or admin behavior was changed.
## v0.18.1 ## v0.18.1
### Changed ### Changed

View File

@ -2,6 +2,7 @@
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import Layout from '@/components/layout/Layout'; import Layout from '@/components/layout/Layout';
import AppNavigation from '@/components/layout/Sidebar';
import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog'; import { ReleaseNotesDialog } from '@/components/ReleaseNotesDialog';
import LoginPage from '@/pages/LoginPage'; import LoginPage from '@/pages/LoginPage';
import AdminPage from '@/pages/AdminPage'; import AdminPage from '@/pages/AdminPage';
@ -14,6 +15,7 @@ import SettingsPage from '@/pages/SettingsPage';
import StatusPage from '@/pages/StatusPage'; import StatusPage from '@/pages/StatusPage';
import AnalyticsPage from '@/pages/AnalyticsPage'; import AnalyticsPage from '@/pages/AnalyticsPage';
import ReleaseNotesPage from '@/pages/ReleaseNotesPage'; import ReleaseNotesPage from '@/pages/ReleaseNotesPage';
import AboutPage from '@/pages/AboutPage';
import DataPage from '@/pages/DataPage'; import DataPage from '@/pages/DataPage';
import ProfilePage from '@/pages/ProfilePage'; import ProfilePage from '@/pages/ProfilePage';
@ -48,6 +50,17 @@ function RequireAuth({ children, role }) {
return children; return children;
} }
function AdminShell({ children }) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
<AppNavigation adminMode />
<main className="mx-auto max-w-5xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{children}
</main>
</div>
);
}
export default function App() { export default function App() {
const { user } = useAuth(); const { user } = useAuth();
@ -58,6 +71,8 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/release-notes" element={<ReleaseNotesPage />} />
<Route <Route
path="/admin" path="/admin"
@ -67,6 +82,24 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/admin/status"
element={
<RequireAuth role="admin">
<AdminShell>
<StatusPage />
</AdminShell>
</RequireAuth>
}
/>
<Route
path="/status"
element={
<RequireAuth role="admin">
<Navigate to="/admin/status" replace />
</RequireAuth>
}
/>
<Route <Route
element={ element={
@ -84,8 +117,6 @@ export default function App() {
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="data" element={<DataPage />} /> <Route path="data" element={<DataPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="status" element={<StatusPage />} />
<Route path="release-notes" element={<ReleaseNotesPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@ -114,6 +114,8 @@ export const api = {
// Summary // Summary
summary: (y, m) => get(`/summary?year=${y}&month=${m}`), summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
saveSummaryIncome: (data) => put('/summary/income', data), saveSummaryIncome: (data) => put('/summary/income', data),
getMonthlyStartingAmounts: (y, m) => get(`/monthly-starting-amounts?year=${y}&month=${m}`),
updateMonthlyStartingAmounts: (data) => put('/monthly-starting-amounts', data),
// Bills // Bills
bills: () => get('/bills'), bills: () => get('/bills'),
@ -162,6 +164,7 @@ export const api = {
status: () => get('/status'), status: () => get('/status'),
// Version (public) // Version (public)
about: () => get('/about'),
version: () => get('/version'), version: () => get('/version'),
releaseHistory: () => get('/version/history'), releaseHistory: () => get('/version/history'),

View File

@ -1,4 +1,4 @@
import { Outlet } from 'react-router-dom'; import { Link, Outlet } from 'react-router-dom';
import AppNavigation from './Sidebar'; import AppNavigation from './Sidebar';
export default function Layout() { export default function Layout() {
@ -11,6 +11,10 @@ export default function Layout() {
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<footer className="mx-auto flex w-full max-w-[1500px] flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4 pb-6 text-xs text-muted-foreground sm:px-6 lg:px-8">
<Link to="/about" className="underline-offset-4 hover:text-foreground hover:underline">About</Link>
<Link to="/release-notes" className="underline-offset-4 hover:text-foreground hover:underline">Release Notes</Link>
</footer>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, LayoutGrid, LogOut, Menu, Receipt, Activity, BarChart3, CalendarDays, ChevronDown, ClipboardList, Database, Info, LayoutGrid, LogOut, Menu, Receipt,
Settings, ShieldCheck, Tag, User, X, Settings, ShieldCheck, Tag, User, X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -18,18 +18,20 @@ import {
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
const userNavItems = [ const userNavItems = [
{ to: '/', icon: LayoutGrid, label: 'Tracker', end: true },
{ to: '/calendar', icon: CalendarDays, label: 'Calendar' }, { 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: '/analytics', icon: BarChart3, label: 'Analytics' },
{ to: '/settings', icon: Settings, label: 'Settings' },
{ to: '/status', icon: Activity, label: 'Status' },
]; ];
const adminNavItems = [ const adminNavItems = [
{ to: '/admin', icon: ShieldCheck, label: 'Admin', end: true }, { to: '/admin', icon: ShieldCheck, label: 'Admin Panel', end: true },
{ to: '/admin/status', icon: Activity, label: 'System Status' },
];
const trackerItems = [
{ to: '/', icon: LayoutGrid, label: 'Overview', end: true },
{ to: '/summary', icon: ClipboardList, label: 'Summary' },
{ to: '/bills', icon: Receipt, label: 'Bills' },
{ to: '/categories', icon: Tag, label: 'Categories' },
]; ];
function BrandBlock({ adminMode = false }) { function BrandBlock({ adminMode = false }) {
@ -74,6 +76,45 @@ function NavPill({ item, onNavigate }) {
); );
} }
function TrackerMenu({ onNavigate }) {
const location = useLocation();
const navigate = useNavigate();
const isTrackerActive = trackerItems.some(item => (
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-2 rounded-full px-3 py-2 text-sm font-medium transition-all',
'focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
isTrackerActive
? 'bg-primary text-primary-foreground shadow-sm shadow-primary/20'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
)}
>
<LayoutGrid className="h-4 w-4" />
Tracker
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{trackerItems.map(item => {
const Icon = item.icon;
return (
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
<Icon className="h-4 w-4" />
{item.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function UserMenu({ adminMode = false }) { function UserMenu({ adminMode = false }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@ -101,16 +142,36 @@ function UserMenu({ adminMode = false }) {
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel> <DropdownMenuLabel className="truncate">{name}</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{user?.role === 'admin' && !adminMode && (
<>
<DropdownMenuItem onSelect={() => navigate('/admin')}>
<ShieldCheck className="h-4 w-4" />
Admin Panel
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate('/admin/status')}>
<Activity className="h-4 w-4" />
System Status
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={() => navigate('/profile')}> <DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" /> <User className="h-4 w-4" />
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
{user?.role === 'admin' && !adminMode && ( <DropdownMenuItem onSelect={() => navigate('/settings')}>
<DropdownMenuItem onSelect={() => navigate('/admin')}> <Settings className="h-4 w-4" />
<ShieldCheck className="h-4 w-4" /> Settings
Admin </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuItem onSelect={() => navigate('/data')}>
)} <Database className="h-4 w-4" />
Data
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => navigate('/about')}>
<Info className="h-4 w-4" />
About
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem destructive onSelect={handleLogout}> <DropdownMenuItem destructive onSelect={handleLogout}>
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
@ -124,9 +185,7 @@ function UserMenu({ adminMode = false }) {
export default function Sidebar({ adminMode = false }) { export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const items = user?.role === 'admin' const items = adminMode ? adminNavItems : userNavItems;
? [...userNavItems, ...adminNavItems]
: userNavItems;
return ( 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"> <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">
@ -134,6 +193,7 @@ export default function Sidebar({ adminMode = false }) {
<BrandBlock adminMode={adminMode} /> <BrandBlock adminMode={adminMode} />
<nav className="hidden items-center gap-1 lg:flex"> <nav className="hidden items-center gap-1 lg:flex">
{!adminMode && <TrackerMenu />}
{items.map(item => ( {items.map(item => (
<NavPill key={item.to} item={item} /> <NavPill key={item.to} item={item} />
))} ))}
@ -159,6 +219,9 @@ export default function Sidebar({ adminMode = false }) {
{mobileOpen && ( {mobileOpen && (
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden"> <div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1"> <nav className="mx-auto grid max-w-[1500px] gap-1">
{!adminMode && trackerItems.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))}
{items.map(item => ( {items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} /> <NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
))} ))}

View File

@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, Info, Sparkles } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function AboutPage() {
const [about, setAbout] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setAbout(await api.about());
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const stack = about?.stack || {};
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-3xl space-y-5">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/login">
<ArrowLeft className="h-3.5 w-3.5" />
Back
</Link>
</Button>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<Info className="h-5 w-5" />
</div>
<CardTitle className="text-2xl">{about?.name || 'BillTracker'}</CardTitle>
<CardDescription>
{loading ? 'Loading app information...' : about?.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Version</p>
<p className="mt-1 font-mono text-lg font-bold">v{about?.version || '...'}</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Backend</p>
<p className="mt-1 text-sm font-semibold">{stack.backend || 'Node.js / Express'}</p>
</div>
<div className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Storage</p>
<p className="mt-1 text-sm font-semibold">{stack.database || 'SQLite'}</p>
</div>
</div>
<div className="rounded-xl border border-border/70 bg-muted/35 p-4">
<div className="flex items-start gap-3">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div>
<p className="text-sm font-semibold">Produced with AI assistance</p>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
BillTracker is self-hosted software for personal bill planning and history. This product was produced with the assistance of AI.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button asChild>
<Link to="/release-notes">Release Notes</Link>
</Button>
<Button asChild variant="outline">
<Link to="/login">Sign In</Link>
</Button>
</div>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@ -157,30 +157,32 @@ function ExpandedBills({ category }) {
} }
return ( return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6"> <div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-5">
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block"> <div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground"> <thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
<tr> <tr>
<th className="px-4 py-3 text-left font-semibold">Bill</th> <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-left font-semibold">State</th>
<th className="px-4 py-3 text-right font-semibold">Expected</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">Paid</th>
<th className="px-4 py-3 text-right font-semibold">Payments</th> <th className="px-4 py-3 text-right font-semibold">History</th>
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/50"> <tbody className="divide-y divide-border/50">
{bills.map(bill => ( {bills.map(bill => (
<tr key={bill.id} className="hover:bg-muted/25"> <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">
<BillName bill={bill} />
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
</td>
<td className="px-4 py-3"><StatusPill active={bill.active} /></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 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 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">
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td> <p className="tabular-nums">{plural(bill.payment_count || 0, 'payment')}</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">{fmtDate(bill.last_paid_date)}</p>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@ -329,41 +331,60 @@ export default function CategoriesPage() {
} }
} }
const totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0); const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0);
const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0);
const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0);
return ( return (
<TooltipProvider delayDuration={180}> <TooltipProvider delayDuration={180}>
<div> <div className="mx-auto w-full max-w-5xl space-y-5">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Categories</h1> <div className="flex items-center gap-2">
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/70 bg-card shadow-sm">
<span>{plural(categories.length, 'category')}</span> <ReceiptText className="h-4 w-4 text-primary" />
<span aria-hidden="true">/</span> </div>
<span>{plural(totalBills, 'bill')}</span> <div>
</p> <h1 className="text-2xl font-bold tracking-tight">Categories</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Organize bills by purpose, status, and payment activity.</p>
</div>
</div>
</div> </div>
<ChipLegend /> <ChipLegend />
</div> </div>
<div className="table-surface overflow-hidden"> <div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row"> {[
<Input ['Categories', categories.length],
ref={addInputRef} ['Active bills', activeBills],
value={newName} ['Inactive', inactiveBills],
onChange={(e) => setNewName(e.target.value)} ['Payments', paymentCount],
placeholder="New category name..." ].map(([label, value]) => (
disabled={adding} <div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm">
className="h-9 text-sm" <p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
/> <p className="mt-1 font-mono text-xl font-bold text-foreground">{value}</p>
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}> </div>
<Plus className="mr-1.5 h-3.5 w-3.5" /> ))}
{adding ? 'Adding...' : 'Add'}
</Button>
</form>
</div> </div>
<form onSubmit={handleAdd} className="flex min-w-0 flex-col gap-2 rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm sm:flex-row md:flex-col lg:flex-row">
<Input
ref={addInputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New category name..."
disabled={adding}
className="h-9 min-w-0 text-sm"
/>
<Button type="submit" size="sm" className="h-9 shrink-0 sm:w-auto" disabled={adding || !newName.trim()}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{adding ? 'Adding...' : 'Add'}
</Button>
</form>
</div>
<div className="table-surface overflow-hidden rounded-xl">
{loading ? ( {loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div> <div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
) : categories.length === 0 ? ( ) : categories.length === 0 ? (
@ -385,7 +406,7 @@ export default function CategoriesPage() {
onClick={() => toggleCategory(cat.id)} onClick={() => toggleCategory(cat.id)}
onKeyDown={event => onRowKeyDown(event, cat.id)} onKeyDown={event => onRowKeyDown(event, cat.id)}
className={cn( 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', 'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center',
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset', 'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isExpanded && 'bg-muted/25', isExpanded && 'bg-muted/25',
)} )}
@ -412,10 +433,11 @@ export default function CategoriesPage() {
</Tooltip> </Tooltip>
<StatChips category={cat} /> <StatChips category={cat} />
</div> </div>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{preview}</p>
</div> </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"> <div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -447,11 +469,8 @@ export default function CategoriesPage() {
)} )}
</div> </div>
<div className="mt-4 text-xs text-muted-foreground"> <div className="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"> Category totals include active and inactive bills in your account only.
<ReceiptText className="h-3.5 w-3.5" />
<span>Category totals include active and inactive bills in your account only.</span>
</div>
</div> </div>
<InputDialog <InputDialog

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@ -224,6 +224,14 @@ export default function LoginPage() {
Build v{APP_VERSION} Build v{APP_VERSION}
</a> </a>
</p> </p>
<div className="flex items-center justify-center gap-3 text-xs text-muted-foreground">
<Link to="/about" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
About
</Link>
<Link to="/release-notes" className="underline-offset-4 transition-colors hover:text-foreground hover:underline">
Release Notes
</Link>
</div>
</div> </div>
</div> </div>

View File

@ -83,13 +83,14 @@ export default function ReleaseNotesPage() {
const history = data?.history || ''; const history = data?.history || '';
return ( return (
<div className="space-y-5"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-4xl space-y-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<Button asChild variant="ghost" size="sm" className="mb-2 -ml-2"> <Button asChild variant="ghost" size="sm" className="mb-2 -ml-2">
<Link to="/status"> <Link to="/about">
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
Status About
</Link> </Link>
</Button> </Button>
<h1 className="text-2xl font-bold tracking-tight">Release Notes</h1> <h1 className="text-2xl font-bold tracking-tight">Release Notes</h1>
@ -129,6 +130,7 @@ export default function ReleaseNotesPage() {
</div> </div>
)} )}
</div> </div>
</main>
</div> </div>
); );
} }

View File

@ -81,8 +81,8 @@ function SummaryChart({ rows = [] }) {
const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0))); const max = Math.max(1, ...rows.map(row => Math.abs(Number(row.amount) || 0)));
const chartRows = rows.map((row, index) => ({ const chartRows = rows.map((row, index) => ({
...row, ...row,
label: row.type === 'Savings' label: row.type === 'Remaining'
? Number(row.amount) >= 0 ? 'Savings' : 'Shortfall' ? Number(row.amount) >= 0 ? 'Remaining' : 'Shortfall'
: row.type, : row.type,
color: index === 0 color: index === 0
? 'hsl(var(--chart-1))' ? 'hsl(var(--chart-1))'
@ -106,7 +106,7 @@ function SummaryChart({ rows = [] }) {
title={`${row.label}: ${fmt(row.amount)}`} title={`${row.label}: ${fmt(row.amount)}`}
/> />
</div> </div>
<div className={cn('text-sm font-semibold sm:text-right', row.type === 'Savings' ? moneyClass(row.amount) : 'text-foreground')}> <div className={cn('text-sm font-semibold sm:text-right', row.type === 'Remaining' ? moneyClass(row.amount) : 'text-foreground')}>
{fmt(row.amount)} {fmt(row.amount)}
</div> </div>
</div> </div>
@ -140,9 +140,10 @@ export default function SummaryPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [incomeLabel, setIncomeLabel] = useState('Salary'); const [startingFirst, setStartingFirst] = useState('0');
const [incomeAmount, setIncomeAmount] = useState('0'); const [startingFifteenth, setStartingFifteenth] = useState('0');
const [editingIncome, setEditingIncome] = useState(false); const [startingOther, setStartingOther] = useState('0');
const [editingStarting, setEditingStarting] = useState(false);
const loadSummary = useCallback(async () => { const loadSummary = useCallback(async () => {
setLoading(true); setLoading(true);
@ -150,9 +151,10 @@ export default function SummaryPage() {
try { try {
const result = await api.summary(selected.year, selected.month); const result = await api.summary(selected.year, selected.month);
setData(result); setData(result);
setIncomeLabel(result.income?.label || 'Salary'); setStartingFirst(String(result.starting_amounts?.first_amount ?? 0));
setIncomeAmount(String(result.income?.amount ?? 0)); setStartingFifteenth(String(result.starting_amounts?.fifteenth_amount ?? 0));
setEditingIncome(false); setStartingOther(String(result.starting_amounts?.other_amount ?? 0));
setEditingStarting(false);
} catch (err) { } catch (err) {
setError(err.message || 'Summary could not be loaded.'); setError(err.message || 'Summary could not be loaded.');
toast.error(err.message || 'Summary could not be loaded.'); toast.error(err.message || 'Summary could not be loaded.');
@ -167,31 +169,35 @@ export default function SummaryPage() {
const summary = data?.summary || {}; const summary = data?.summary || {};
const expenses = data?.expenses || []; const expenses = data?.expenses || [];
const starting = data?.starting_amounts || {};
const generatedLabel = useMemo(() => { const generatedLabel = useMemo(() => {
if (!data?.generated_at) return ''; if (!data?.generated_at) return '';
return new Date(data.generated_at).toLocaleString(); return new Date(data.generated_at).toLocaleString();
}, [data?.generated_at]); }, [data?.generated_at]);
async function saveIncome() { async function saveStartingAmounts() {
const amount = Number(incomeAmount); const first = Number(startingFirst);
if (!Number.isFinite(amount) || amount < 0) { const fifteenth = Number(startingFifteenth);
toast.error('Enter a valid income amount.'); const other = Number(startingOther);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
toast.error('Enter non-negative starting amounts.');
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await api.saveSummaryIncome({ await api.updateMonthlyStartingAmounts({
year: selected.year, year: selected.year,
month: selected.month, month: selected.month,
label: incomeLabel.trim() || 'Salary', first_amount: first,
amount, fifteenth_amount: fifteenth,
other_amount: other,
}); });
toast.success('Income saved.'); toast.success('Starting amounts saved.');
await loadSummary(); await loadSummary();
} catch (err) { } catch (err) {
toast.error(err.message || 'Income could not be saved.'); toast.error(err.message || 'Starting amounts could not be saved.');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -216,7 +222,7 @@ export default function SummaryPage() {
<div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="summary-screen-header flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Summary</h1> <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> <p className="mt-1 text-sm text-muted-foreground">Plan starting balance, expenses, and monthly result.</p>
</div> </div>
<div className="summary-actions flex gap-2"> <div className="summary-actions flex gap-2">
<Button variant="outline" onClick={resetToday} className="sm:w-auto"> <Button variant="outline" onClick={resetToday} className="sm:w-auto">
@ -272,46 +278,91 @@ export default function SummaryPage() {
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Income</h2> <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Starting Balance</h2>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="summary-edit-actions h-7 px-2" className="summary-edit-actions h-7 px-2"
onClick={() => setEditingIncome(value => !value)} onClick={() => setEditingStarting(value => !value)}
> >
<Edit3 className="h-3.5 w-3.5" /> <Edit3 className="h-3.5 w-3.5" />
{editingIncome ? 'Close' : 'Edit'} {editingStarting ? 'Close' : 'Edit'}
</Button> </Button>
</div> </div>
<div className="summary-income-display flex items-center justify-between gap-4 rounded-2xl bg-muted/45 px-4 py-3"> <div className="grid gap-3 rounded-2xl bg-muted/45 p-4 sm:grid-cols-3">
<div className="min-w-0"> <div>
<div className="truncate text-sm font-semibold text-foreground">{data.income?.label || 'Salary'}</div> <div className="text-xs font-medium text-muted-foreground">1st</div>
{Number(summary.income_total || 0) === 0 && ( <div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.first_amount)}</div>
<div className="mt-0.5 text-xs text-muted-foreground">Add income to calculate savings.</div> </div>
)} <div>
<div className="text-xs font-medium text-muted-foreground">15th</div>
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.fifteenth_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Other</div>
<div className="mt-1 font-mono text-base font-bold text-foreground">{fmt(starting.other_amount)}</div>
</div>
<div className="border-t border-border/60 pt-3 sm:col-span-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<div className="text-xs font-medium text-muted-foreground">Total starting</div>
<div className="mt-1 font-mono text-lg font-bold text-foreground">{fmt(starting.combined_amount)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Paid</div>
<div className="mt-1 font-mono text-lg font-bold text-emerald-600 dark:text-emerald-400">{fmt(starting.paid_total)}</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground">Total remaining</div>
<div className={cn('mt-1 font-mono text-lg font-bold', moneyClass(starting.combined_remaining || 0))}>
{fmt(starting.combined_remaining)}
</div>
</div>
</div>
</div> </div>
<div className="shrink-0 text-lg font-bold text-foreground">{fmt(summary.income_total)}</div>
</div> </div>
{editingIncome && ( {data.previous_month && (
<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"> <div className="rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
Previous month remaining: {fmt(data.previous_month.combined_remaining)}
</div>
)}
{editingStarting && (
<div className="summary-income-form grid gap-3 rounded-2xl border border-border/60 bg-background/80 p-3 md:grid-cols-[1fr_1fr_1fr_auto] md:items-end">
<label className="space-y-1"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Label</span> <span className="text-xs font-medium text-muted-foreground">1st</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 <Input
type="number" type="number"
min="0" min="0"
step="0.01" step="0.01"
value={incomeAmount} value={startingFirst}
onChange={event => setIncomeAmount(event.target.value)} onChange={event => setStartingFirst(event.target.value)}
/> />
</label> </label>
<Button onClick={saveIncome} disabled={saving} className="summary-edit-actions w-full md:w-auto"> <label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">15th</span>
<Input
type="number"
min="0"
step="0.01"
value={startingFifteenth}
onChange={event => setStartingFifteenth(event.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Other</span>
<Input
type="number"
min="0"
step="0.01"
value={startingOther}
onChange={event => setStartingOther(event.target.value)}
/>
</label>
<Button onClick={saveStartingAmounts} 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" />} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Save
</Button> </Button>
@ -369,7 +420,7 @@ export default function SummaryPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-xl">Total amount per type</CardTitle> <CardTitle className="text-xl">Total amount per type</CardTitle>
<CardDescription> <CardDescription>
Income, planned expenses, and {Number(summary.result || 0) >= 0 ? 'savings' : 'shortfall'} for {monthLabel(data.year, data.month)}. Starting balance, planned expenses, and {Number(summary.result || 0) >= 0 ? 'remaining' : 'shortfall'} for {monthLabel(data.year, data.month)}.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -60,8 +60,8 @@ const STATUS_META = {
// Summary cards // Summary cards
const CARD_DEFS = { const CARD_DEFS = {
expected: { starting: {
label: 'Total Expected', label: 'Starting',
icon: TrendingUp, icon: TrendingUp,
bar: 'from-slate-400 to-slate-300', bar: 'from-slate-400 to-slate-300',
glow: '', glow: '',
@ -96,7 +96,7 @@ const CARD_DEFS = {
}, },
}; };
function SummaryCard({ type, value }) { function SummaryCard({ type, value, onEdit, hint }) {
const def = CARD_DEFS[type]; const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0); const isActive = def.activateWhen(value || 0);
const Icon = def.icon; const Icon = def.icon;
@ -118,6 +118,16 @@ function SummaryCard({ type, value }) {
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{def.label} {def.label}
</p> </p>
{type === 'starting' && onEdit && (
<button
onClick={onEdit}
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
title="Edit monthly starting amounts"
aria-label="Edit monthly starting amounts"
>
<Settings2 className="h-4 w-4" />
</button>
)}
</div> </div>
<p className={cn( <p className={cn(
'text-[1.75rem] font-bold tracking-tight font-mono leading-none', 'text-[1.75rem] font-bold tracking-tight font-mono leading-none',
@ -125,6 +135,7 @@ function SummaryCard({ type, value }) {
)}> )}>
{fmt(value)} {fmt(value)}
</p> </p>
{hint && <p className="mt-2 text-[11px] text-muted-foreground">{hint}</p>}
</div> </div>
); );
} }
@ -397,6 +408,186 @@ function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }) {
); );
} }
function StartingAmountsEditDialog({ open, onClose, year, month, onSave }) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [firstAmount, setFirstAmount] = useState('0');
const [fifteenthAmount, setFifteenthAmount] = useState('0');
const [otherAmount, setOtherAmount] = useState('0');
const [preview, setPreview] = useState(null);
const monthName = `${MONTHS[month - 1]} ${year}`;
const localFirst = Number(firstAmount) || 0;
const localFifteenth = Number(fifteenthAmount) || 0;
const localOther = Number(otherAmount) || 0;
const totalStarting = localFirst + localFifteenth + localOther;
const paidSoFar = Number(preview?.paid_total || 0);
const firstRemaining = localFirst - Number(preview?.paid_from_first || 0);
const fifteenthRemaining = localFifteenth - Number(preview?.paid_from_fifteenth || 0);
const totalRemaining = totalStarting - paidSoFar;
useEffect(() => {
let alive = true;
async function loadStartingAmounts() {
if (!open) return;
setLoading(true);
setError('');
try {
const result = await api.getMonthlyStartingAmounts(year, month);
if (!alive) return;
setPreview(result);
setFirstAmount(String(result.first_amount ?? 0));
setFifteenthAmount(String(result.fifteenth_amount ?? 0));
setOtherAmount(String(result.other_amount ?? 0));
} catch (err) {
if (!alive) return;
setError(err.message || 'Monthly starting amounts could not be loaded.');
} finally {
if (alive) setLoading(false);
}
}
loadStartingAmounts();
return () => { alive = false; };
}, [open, year, month]);
async function handleSave(e) {
e.preventDefault();
const first = Number(firstAmount);
const fifteenth = Number(fifteenthAmount);
const other = Number(otherAmount);
if (![first, fifteenth, other].every(value => Number.isFinite(value) && value >= 0)) {
setError('Starting amounts must be non-negative numbers.');
return;
}
setSaving(true);
setError('');
try {
await api.updateMonthlyStartingAmounts({
year,
month,
first_amount: first,
fifteenth_amount: fifteenth,
other_amount: other,
});
toast.success('Monthly starting amounts saved.');
onSave();
} catch (err) {
setError(err.message || 'Monthly starting amounts could not be saved.');
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92vh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-semibold tracking-tight">Monthly Starting Amounts</DialogTitle>
<p className="text-sm text-muted-foreground">{monthName}</p>
</DialogHeader>
<form id="starting-amounts-form" onSubmit={handleSave} className="space-y-5">
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<label className="space-y-1.5">
<Label htmlFor="starting-first" className="text-xs uppercase tracking-wider text-muted-foreground">
1st
</Label>
<Input
id="starting-first"
type="number"
min="0"
step="0.01"
value={firstAmount}
disabled={loading || saving}
onChange={e => setFirstAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-fifteenth" className="text-xs uppercase tracking-wider text-muted-foreground">
15th
</Label>
<Input
id="starting-fifteenth"
type="number"
min="0"
step="0.01"
value={fifteenthAmount}
disabled={loading || saving}
onChange={e => setFifteenthAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
<label className="space-y-1.5">
<Label htmlFor="starting-other" className="text-xs uppercase tracking-wider text-muted-foreground">
Other
</Label>
<Input
id="starting-other"
type="number"
min="0"
step="0.01"
value={otherAmount}
disabled={loading || saving}
onChange={e => setOtherAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</label>
</div>
<div className="rounded-xl border border-border/60 bg-muted/35 p-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Total starting</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalStarting)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Paid so far</p>
<p className="mt-1 font-mono text-lg font-bold text-emerald-500">{fmt(paidSoFar)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Total remaining</p>
<p className="mt-1 font-mono text-lg font-bold">{fmt(totalRemaining)}</p>
</div>
</div>
<div className="mt-4 grid gap-2 border-t border-border/60 pt-3 text-sm sm:grid-cols-3">
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">1st remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(firstRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">15th remaining</span>
<span className="font-mono font-semibold sm:block">{fmt(fifteenthRemaining)}</span>
</div>
<div className="flex justify-between gap-3 sm:block">
<span className="text-muted-foreground">Other</span>
<span className="font-mono font-semibold sm:block">{fmt(localOther)}</span>
</div>
</div>
</div>
</form>
<DialogFooter className="mt-2">
<Button type="button" variant="ghost" disabled={saving} onClick={onClose} className="text-xs">
Cancel
</Button>
<Button type="submit" form="starting-amounts-form" disabled={loading || saving} className="text-xs">
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Payment modal // Payment modal
function PaymentModal({ payment, onClose, onSave }) { function PaymentModal({ payment, onClose, onSave }) {
const [amount, setAmount] = useState(String(payment.amount)); const [amount, setAmount] = useState(String(payment.amount));
@ -983,6 +1174,8 @@ export default function TrackerPage() {
const [data, setData] = useState(null); const [data, setData] = useState(null);
// Edit Bill modal: { bill, categories } when open, null when closed // Edit Bill modal: { bill, categories } when open, null when closed
const [editBillData, setEditBillData] = useState(null); const [editBillData, setEditBillData] = useState(null);
// Edit Starting Amounts modal: true when open, false when closed
const [editStartingOpen, setEditStartingOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@ -1072,7 +1265,12 @@ export default function TrackerPage() {
{/* ── Summary cards (backend already excludes skipped from totals) ── */} {/* ── Summary cards (backend already excludes skipped from totals) ── */}
<div className="grid grid-cols-2 gap-3 lg:flex"> <div className="grid grid-cols-2 gap-3 lg:flex">
<SummaryCard type="expected" value={summary.total_expected} /> <SummaryCard
type="starting"
value={summary.total_starting}
hint={!summary.has_starting_amounts ? 'Set monthly starting cash' : ''}
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} /> <SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} /> <SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard type="overdue" value={summary.overdue} /> <SummaryCard type="overdue" value={summary.overdue} />
@ -1105,6 +1303,15 @@ export default function TrackerPage() {
/> />
)} )}
{/* Edit Starting Amounts modal */}
<StartingAmountsEditDialog
open={editStartingOpen}
onClose={() => setEditStartingOpen(false)}
year={year}
month={month}
onSave={() => { setEditStartingOpen(false); load(); }}
/>
</div> </div>
); );
} }

View File

@ -168,7 +168,30 @@ function runMigrations() {
`); `);
db.exec('CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(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) ───────────────────── // -- monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th (v0.18.2)
db.exec(`
CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
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),
first_amount REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
notes TEXT,
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_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)');
// ── monthly_starting_amounts: add other_amount column (v0.18.3) ─────────────
const startingCols = db.prepare('PRAGMA table_info(monthly_starting_amounts)').all().map(c => c.name);
if (!startingCols.includes('other_amount')) {
db.exec('ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)');
console.log('[migration] monthly_starting_amounts.other_amount column added');
}
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS import_sessions ( CREATE TABLE IF NOT EXISTS import_sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,

4
package-lock.json generated
View File

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

View File

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

24
routes/about.js Normal file
View File

@ -0,0 +1,24 @@
const express = require('express');
const router = express.Router();
let pkg;
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
router.get('/', (req, res) => {
res.json({
name: 'BillTracker',
version: pkg.version,
description: 'A self-hosted app for tracking recurring bills, monthly payments, due dates, categories, and personal bill history.',
stack: {
backend: 'Node.js / Express',
frontend: 'React',
database: 'SQLite',
},
ai_assisted: true,
links: {
release_notes: '/release-notes',
},
});
});
module.exports = router;

View File

@ -106,8 +106,25 @@ router.delete('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json({ error: 'Category not found' }); if (!cat) return res.status(404).json({ error: 'Category not found' });
db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true }); const deleteCategory = db.transaction(() => {
const bills = db.prepare(`
UPDATE bills
SET category_id = NULL, updated_at = datetime('now')
WHERE category_id = ? AND user_id = ?
`).run(req.params.id, req.user.id);
const deleted = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?')
.run(req.params.id, req.user.id);
return {
deleted: deleted.changes,
uncategorized_bills: bills.changes,
};
});
const result = deleteCategory();
res.json({ success: true, ...result });
}); });
module.exports = router; module.exports = router;

View File

@ -109,6 +109,12 @@ function getUserExportData(userId) {
WHERE b.user_id = ? WHERE b.user_id = ?
ORDER BY m.year, m.month, m.bill_id ORDER BY m.year, m.month, m.bill_id
`).all(userId); `).all(userId);
const monthlyStartingAmounts = db.prepare(`
SELECT id, year, month, first_amount, fifteenth_amount, other_amount, notes, created_at, updated_at
FROM monthly_starting_amounts
WHERE user_id = ?
ORDER BY year, month
`).all(userId);
const notes = [ const notes = [
...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })), ...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })),
...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })), ...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })),
@ -117,16 +123,17 @@ function getUserExportData(userId) {
const metadata = { const metadata = {
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
export_type: 'user_data', export_type: 'user_data',
includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Notes', 'Export metadata'], includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'],
counts: { counts: {
bills: bills.length, bills: bills.length,
payments: payments.length, payments: payments.length,
categories: categories.length, categories: categories.length,
monthly_bill_state: monthlyState.length, monthly_bill_state: monthlyState.length,
monthly_starting_amounts: monthlyStartingAmounts.length,
notes: notes.length, notes: notes.length,
}, },
}; };
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes }; return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
} }
router.get('/user-excel', (req, res) => { router.get('/user-excel', (req, res) => {
@ -137,6 +144,7 @@ router.get('/user-excel', (req, res) => {
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.payments), 'Payments');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_starting_amounts), 'Monthly Starting Amounts');
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes');
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
@ -155,6 +163,7 @@ router.get('/user-db', (req, res) => {
CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT); CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT);
CREATE TABLE monthly_starting_amounts (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER, first_amount REAL, fifteenth_amount REAL, other_amount REAL, notes TEXT, created_at TEXT, updated_at TEXT);
CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT); CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT);
`); `);
const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)'); const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)');
@ -170,6 +179,7 @@ router.get('/user-db', (req, res) => {
insertRows('bills', data.bills); insertRows('bills', data.bills);
insertRows('payments', data.payments); insertRows('payments', data.payments);
insertRows('monthly_bill_state', data.monthly_bill_state); insertRows('monthly_bill_state', data.monthly_bill_state);
insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
insertRows('notes', data.notes.map(n => ({ insertRows('notes', data.notes.map(n => ({
type: n.type, type: n.type,
bill_id: n.bill_id ?? null, bill_id: n.bill_id ?? null,

View File

@ -0,0 +1,146 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { getCycleRange } = require('../services/statusService');
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 getStartingAmounts(db, userId, year, month) {
const row = db.prepare(`
SELECT first_amount, fifteenth_amount, other_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, month);
return {
first_amount: money(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0),
};
}
function calculatePaidDeductions(db, userId, year, month) {
const { start, end } = getCycleRange(year, month);
// Paid from first bucket: bills with due_day 1-14
const firstPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND b.due_day BETWEEN 1 AND 14
`).get(userId, start, end);
// Paid from fifteenth bucket: bills with due_day 15-31
const fifteenthPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND b.due_day BETWEEN 15 AND 31
`).get(userId, start, end);
const totalPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
`).get(userId, start, end);
return {
paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid),
paid_total: money(totalPaid.paid),
};
}
function buildStartingAmountsResponse(db, userId, year, month) {
const amounts = getStartingAmounts(db, userId, year, month);
const paid = calculatePaidDeductions(db, userId, year, month);
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
const paid_total = paid.paid_total;
return {
year,
month,
first_amount: amounts.first_amount,
fifteenth_amount: amounts.fifteenth_amount,
other_amount: amounts.other_amount,
combined_amount,
paid_from_first: paid.paid_from_first,
paid_from_fifteenth: paid.paid_from_fifteenth,
paid_total,
first_remaining: amounts.first_amount - paid.paid_from_first,
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
other_remaining: amounts.other_amount,
combined_remaining: combined_amount - paid_total,
};
}
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(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
});
router.put('/', (req, res) => {
const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const firstAmount = Number(req.body?.first_amount);
if (!Number.isFinite(firstAmount) || firstAmount < 0 || firstAmount > 1000000000) {
return res.status(400).json({ error: 'first_amount must be a number between 0 and 1000000000' });
}
const fifteenthAmount = Number(req.body?.fifteenth_amount);
if (!Number.isFinite(fifteenthAmount) || fifteenthAmount < 0 || fifteenthAmount > 1000000000) {
return res.status(400).json({ error: 'fifteenth_amount must be a number between 0 and 1000000000' });
}
const otherAmount = Number(req.body?.other_amount);
if (!Number.isFinite(otherAmount) || otherAmount < 0 || otherAmount > 1000000000) {
return res.status(400).json({ error: 'other_amount must be a number between 0 and 1000000000' });
}
const db = getDb();
db.prepare(`
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, year, month) DO UPDATE SET
first_amount = excluded.first_amount,
fifteenth_amount = excluded.fifteenth_amount,
other_amount = excluded.other_amount,
updated_at = datetime('now')
`).run(req.user.id, parsed.year, parsed.month, firstAmount, fifteenthAmount, otherAmount);
res.json(buildStartingAmountsResponse(db, req.user.id, parsed.year, parsed.month));
});
module.exports = router;

View File

@ -25,6 +25,85 @@ function money(value) {
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
function getStartingAmounts(db, userId, year, month) {
const row = db.prepare(`
SELECT first_amount, fifteenth_amount, other_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, year, month);
return {
first_amount: money(row?.first_amount || 0),
fifteenth_amount: money(row?.fifteenth_amount || 0),
other_amount: money(row?.other_amount || 0),
};
}
function calculatePaidDeductions(db, userId, year, month) {
const { start, end } = getCycleRange(year, month);
// Paid from first bucket: bills with due_day 1-14
const firstPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND b.due_day BETWEEN 1 AND 14
`).get(userId, start, end);
// Paid from fifteenth bucket: bills with due_day 15-31
const fifteenthPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
AND b.due_day BETWEEN 15 AND 31
`).get(userId, start, end);
const totalPaid = db.prepare(`
SELECT COALESCE(SUM(p.amount), 0) AS paid
FROM payments p
JOIN bills b ON b.id = p.bill_id
WHERE b.user_id = ?
AND p.paid_date BETWEEN ? AND ?
AND p.deleted_at IS NULL
`).get(userId, start, end);
return {
paid_from_first: money(firstPaid.paid),
paid_from_fifteenth: money(fifteenthPaid.paid),
paid_total: money(totalPaid.paid),
};
}
function buildStartingAmountsSummary(db, userId, year, month) {
const amounts = getStartingAmounts(db, userId, year, month);
const paid = calculatePaidDeductions(db, userId, year, month);
const combined_amount = amounts.first_amount + amounts.fifteenth_amount + amounts.other_amount;
const paid_total = paid.paid_total;
return {
year,
month,
first_amount: amounts.first_amount,
fifteenth_amount: amounts.fifteenth_amount,
other_amount: amounts.other_amount,
combined_amount,
paid_from_first: paid.paid_from_first,
paid_from_fifteenth: paid.paid_from_fifteenth,
paid_total,
first_remaining: amounts.first_amount - paid.paid_from_first,
fifteenth_remaining: amounts.fifteenth_amount - paid.paid_from_fifteenth,
other_remaining: amounts.other_amount,
combined_remaining: combined_amount - paid_total,
};
}
function getIncome(db, userId, year, month) { function getIncome(db, userId, year, month) {
const row = db.prepare(` const row = db.prepare(`
SELECT id, label, amount SELECT id, label, amount
@ -109,26 +188,57 @@ function buildSummary(db, userId, year, month) {
const expenseTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.display_amount), 0); 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 paidTotal = countedExpenses.reduce((sum, expense) => sum + money(expense.paid_amount), 0);
const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length; const paidExpenseCount = countedExpenses.filter(expense => expense.is_paid).length;
const result = incomeTotal - expenseTotal; const starting_amounts = buildStartingAmountsSummary(db, userId, year, month);
const planBaseTotal = money(starting_amounts.combined_amount);
const result = planBaseTotal - expenseTotal;
// Previous month context
let previous_month = null;
if (month > 1) {
const prevMonth = month - 1;
const prevYear = year;
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
if (prevStarting.combined_amount > 0) {
previous_month = {
year: prevYear,
month: prevMonth,
combined_remaining: prevStarting.combined_remaining,
};
}
} else if (year > 2000) {
const prevMonth = 12;
const prevYear = year - 1;
const prevStarting = buildStartingAmountsSummary(db, userId, prevYear, prevMonth);
if (prevStarting.combined_amount > 0) {
previous_month = {
year: prevYear,
month: prevMonth,
combined_remaining: prevStarting.combined_remaining,
};
}
}
return { return {
year, year,
month, month,
income, income,
expenses, expenses,
starting_amounts,
previous_month,
summary: { summary: {
income_total: incomeTotal, income_total: incomeTotal,
starting_total: planBaseTotal,
expense_total: expenseTotal, expense_total: expenseTotal,
paid_expense_count: paidExpenseCount, paid_expense_count: paidExpenseCount,
expense_count: countedExpenses.length, expense_count: countedExpenses.length,
paid_total: paidTotal, paid_total: starting_amounts.paid_total,
remaining_expense_total: Math.max(0, expenseTotal - paidTotal), remaining_expense_total: Math.max(0, expenseTotal - paidTotal),
result, result,
}, },
chart: [ chart: [
{ type: 'Income', amount: incomeTotal }, { type: 'Starting', amount: planBaseTotal },
{ type: 'Expenses', amount: expenseTotal }, { type: 'Expenses', amount: expenseTotal },
{ type: 'Savings', amount: result }, { type: 'Remaining', amount: result },
], ],
generated_at: new Date().toISOString(), generated_at: new Date().toISOString(),
}; };

View File

@ -51,20 +51,32 @@ router.get('/', (req, res) => {
return row; return row;
}); });
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
const totalOverdue = rows const totalOverdue = rows
.filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed')) .filter(r => !r.is_skipped && (r.status === 'late' || r.status === 'missed'))
.reduce((s, r) => s + r.balance, 0); .reduce((s, r) => s + r.balance, 0);
const activeRows = rows.filter(r => !r.is_skipped); const activeRows = rows.filter(r => !r.is_skipped);
// Get starting amounts for this month
const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(req.user.id, year, month);
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
const activeTotalExpected = activeRows.reduce((s, r) => s + r.expected_amount, 0);
res.json({ res.json({
year, month, today: todayStr, year, month, today: todayStr,
summary: { summary: {
total_expected: activeRows.reduce((s, r) => s + r.expected_amount, 0), total_expected: activeTotalExpected,
total_paid: activeRows.reduce((s, r) => s + r.total_paid, 0), total_starting: totalStarting,
remaining: Math.max(0, activeRows.reduce((s, r) => s + r.expected_amount, 0) - activeRows.reduce((s, r) => s + r.total_paid, 0)), has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : Math.max(0, activeTotalExpected - activeTotalPaid),
overdue: totalOverdue, overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length, count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length, count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,

View File

@ -48,9 +48,11 @@ app.use('/api/categories', requireAuth, requireUser, require('./routes/catego
app.use('/api/settings', requireAuth, requireUser, require('./routes/settings')); app.use('/api/settings', requireAuth, requireUser, require('./routes/settings'));
app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar')); app.use('/api/calendar', requireAuth, requireUser, require('./routes/calendar'));
app.use('/api/summary', requireAuth, requireUser, require('./routes/summary')); app.use('/api/summary', requireAuth, requireUser, require('./routes/summary'));
app.use('/api/monthly-starting-amounts', requireAuth, requireUser, require('./routes/monthly-starting-amounts'));
app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics')); app.use('/api/analytics', requireAuth, requireUser, require('./routes/analytics'));
app.use('/api/notifications', requireAuth, require('./routes/notifications')); app.use('/api/notifications', requireAuth, require('./routes/notifications'));
app.use('/api/status', requireAuth, require('./routes/status')); app.use('/api/status', requireAuth, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public
app.use('/api/version', require('./routes/version')); // public app.use('/api/version', require('./routes/version')); // public
// Profile — password-change rate limit applied inside the route file // Profile — password-change rate limit applied inside the route file

View File

@ -187,6 +187,23 @@ function sanitizeMonthlyState(row, validBillIds) {
}; };
} }
function sanitizeMonthlyStartingAmounts(row) {
const year = toInt(row.year);
const month = toInt(row.month);
if (year < 2000 || year > 2100 || month < 1 || month > 12) return null;
return {
old_id: toInt(row.id),
year,
month,
first_amount: Math.max(0, toNumber(row.first_amount, 0) ?? 0),
fifteenth_amount: Math.max(0, toNumber(row.fifteenth_amount, 0) ?? 0),
other_amount: Math.max(0, toNumber(row.other_amount, 0) ?? 0),
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function readExportData(src) { function readExportData(src) {
const names = tableNames(src); const names = tableNames(src);
const missing = REQUIRED_TABLES.filter(t => !names.has(t)); const missing = REQUIRED_TABLES.filter(t => !names.has(t));
@ -210,12 +227,16 @@ function readExportData(src) {
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean); .map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at']) const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean); .map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
const monthlyStartingAmounts = names.has('monthly_starting_amounts')
? selectKnown(src, 'monthly_starting_amounts', ['id', 'year', 'month', 'first_amount', 'fifteenth_amount', 'other_amount', 'notes', 'created_at', 'updated_at'])
.map(sanitizeMonthlyStartingAmounts).filter(Boolean)
: [];
const notes = names.has('notes') const notes = names.has('notes')
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes']) ? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
.map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes) .map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes)
: []; : [];
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes }; return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes };
} }
function existingLookups(db, userId) { function existingLookups(db, userId) {
@ -271,6 +292,13 @@ function buildPreview(userId, data, originalFilename) {
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict', action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(m.bill_id) ? 'Will import if monthly state is missing' : 'Referenced bill is not present in export', reason: billPlanByOldId.has(m.bill_id) ? 'Will import if monthly state is missing' : 'Referenced bill is not present in export',
})); }));
const monthlyStartingAmountsPlan = data.monthly_starting_amounts.map(m => ({
old_id: m.old_id,
year: m.year,
month: m.month,
action: 'create_or_skip_duplicate',
reason: 'Will import if monthly starting amounts are missing',
}));
const summary = { const summary = {
categories: { categories: {
@ -297,6 +325,12 @@ function buildPreview(userId, data, originalFilename) {
skip: 0, skip: 0,
conflict: monthlyPlan.filter(x => x.action === 'conflict').length, conflict: monthlyPlan.filter(x => x.action === 'conflict').length,
}, },
monthly_starting_amounts: {
total: data.monthly_starting_amounts.length,
create: data.monthly_starting_amounts.length,
skip: 0,
conflict: 0,
},
notes: { notes: {
total: data.notes.length, total: data.notes.length,
create: 0, create: 0,
@ -319,6 +353,7 @@ function buildPreview(userId, data, originalFilename) {
categories: data.categories.length, categories: data.categories.length,
payments: data.payments.length, payments: data.payments.length,
monthly_bill_state: data.monthly_bill_state.length, monthly_bill_state: data.monthly_bill_state.length,
monthly_starting_amounts: data.monthly_starting_amounts.length,
notes: data.notes.length, notes: data.notes.length,
}, },
summary, summary,
@ -327,6 +362,7 @@ function buildPreview(userId, data, originalFilename) {
bills: billPlan.slice(0, 100), bills: billPlan.slice(0, 100),
payments: paymentPlan.slice(0, 100), payments: paymentPlan.slice(0, 100),
monthly_bill_state: monthlyPlan.slice(0, 100), monthly_bill_state: monthlyPlan.slice(0, 100),
monthly_starting_amounts: monthlyStartingAmountsPlan.slice(0, 100),
}, },
warnings, warnings,
}; };
@ -470,6 +506,24 @@ function importMonthlyState(db, targetBillId, row, summary, details) {
details.monthly_bill_state.created++; details.monthly_bill_state.created++;
} }
function importMonthlyStartingAmounts(db, userId, row, summary, details) {
const duplicate = db.prepare(`
SELECT id FROM monthly_starting_amounts WHERE user_id = ? AND year = ? AND month = ?
`).get(userId, row.year, row.month);
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_starting_amounts.skipped++;
return;
}
db.prepare(`
INSERT INTO monthly_starting_amounts (user_id, year, month, first_amount, fifteenth_amount, other_amount, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, row.year, row.month, row.first_amount, row.fifteenth_amount, row.other_amount, row.notes);
summary.rows_created++;
details.monthly_starting_amounts.created++;
}
async function applyUserDbImport(userId, importSessionId, options = {}) { async function applyUserDbImport(userId, importSessionId, options = {}) {
if (options.overwrite) { if (options.overwrite) {
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED'); throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
@ -491,6 +545,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
bills: { created: 0, skipped: 0, errored: 0 }, bills: { created: 0, skipped: 0, errored: 0 },
payments: { created: 0, skipped: 0, errored: 0 }, payments: { created: 0, skipped: 0, errored: 0 },
monthly_bill_state: { created: 0, skipped: 0, errored: 0 }, monthly_bill_state: { created: 0, skipped: 0, errored: 0 },
monthly_starting_amounts: { created: 0, skipped: 0, errored: 0 },
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 }, notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
}; };
summary.rows_skipped += details.notes.skipped; summary.rows_skipped += details.notes.skipped;
@ -529,6 +584,10 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
importMonthlyState(db, targetBillId, row, summary, details); importMonthlyState(db, targetBillId, row, summary, details);
} }
for (const row of data.monthly_starting_amounts) {
importMonthlyStartingAmounts(db, userId, row, summary, details);
}
db.prepare(` db.prepare(`
INSERT INTO import_history INSERT INTO import_history
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed, (user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
@ -541,7 +600,7 @@ async function applyUserDbImport(userId, importSessionId, options = {}) {
session.source_filename || null, session.source_filename || null,
'sqlite', 'sqlite',
'User SQLite export', 'User SQLite export',
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + (data.notes?.length || 0), data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + data.monthly_starting_amounts.length + (data.notes?.length || 0),
summary.rows_created, summary.rows_created,
summary.rows_updated, summary.rows_updated,
summary.rows_skipped, summary.rows_skipped,