feat(spending): category groups, YNAB-style spending page overhaul, 3-month averages, cover overspending (batch 0.41.0)

This commit is contained in:
null 2026-06-14 19:21:34 -05:00
parent 81ddcb5fc1
commit 35e5d185de
12 changed files with 1156 additions and 128 deletions

View File

@ -1,3 +1,55 @@
# Errors Logged During Phase 1 Verification # Errors Logged During Phase 1 Verification
No errors encountered during Build-Verify Phase 1. No errors encountered during Build-Verify Phase 1.
---
## v0.40.0 Documentation Update
No errors encountered during release notes and API reference update.
**Date**: 2026-06-14
**Task**: Documentation update for v0.40.0
**Status**: Complete
---
## Release Notes and API Documentation Update v0.40
### Errors Encountered
1. **Initial edit failed - exact match required**
- Attempted to add new version sections to release-notes/index.md using `edit` tool
- Error: `Could not find edits[2] in ... The oldText must match exactly including all whitespace and newlines.`
- Resolution: Read the current file to understand its structure, then made targeted edits for version header changes
2. **API Reference nav placement verification**
- Checked if api-v040.md was already in mkdocs.yml nav
- Found already present with correct alphabetical ordering
- No action needed
### Non-Obvious Findings
1. **Release notes already had v0.38.x sections**
- File already contained v0.38.0 through v0.38.4 subsections
- Only needed to update version header from 0.37.0 to 0.40.0
- The file was already partially updated
2. **mkdocs.yml already has api-v040.md entry**
- `API Reference (v0.40): technical/api-v040.md` was already present
- Ordered alphabetically correctly (v0.40 before v0.37)
- No action needed
3. **New bank transactions endpoint path**
- Endpoint is `/api/transactions/bank-ledger`, not `/api/bank-transactions`
- Confirmed by `grep -E "router\\.(get|post|patch|put|delete)" routes/transactions.js`
4. **cents-migration-plan.md location**
- File exists at `./docs/cents-migration-plan.md` (not in technical/ subdirectory)
- Correct relative link from api-v040.md is `../cents-migration-plan.md`
---
**Date**: 2026-06-14
**Task**: Documentation update for v0.38-v0.40 release notes and API reference

View File

@ -48,7 +48,62 @@ The business logic extraction is complete and working correctly. All validation
--- ---
## Release Notes and API Documentation Update v0.40
### What Was Done
1. **Updated Release Notes** (`mkdocs/docs/release-notes/index.md`)
- Current Release version updated from `0.37.0` to `0.40.0`
- Added v0.38.x section with sub-sections for v0.38.0, v0.38.1, v0.38.2, v0.38.3, v0.38.4
- Added v0.39.0 section documenting settings auto-save, safe-to-spend toggle, and notifications UI move
- Added v0.40.0 section documenting bank transactions ledger page, merchant/store matching service, and transaction matching refactor
2. **Created API Reference v0.40** (`mkdocs/docs/technical/api-v040.md`)
- Documented new endpoints added in v0.38-v0.40
- Settings endpoints (`PUT /api/settings`)
- Transactions endpoints (`GET /api/transactions/bank-ledger`, `POST /api/transactions/auto-categorize`, `POST /api/transactions/:id/apply-merchant-match`, etc.)
- Bank Transactions ledger page endpoints
- Matching service endpoints
- Included worked curl examples for high-impact endpoints
3. **Verified mkdocs.yml**
- `API Reference (v0.40): technical/api-v040.md` already present under Reference section
- Alphabetically ordered correctly (v0.40 before v0.37)
4. **Internal Link Verification**
- All user-guide links point to existing files
- All technical doc links point to existing files
- `cents-migration-plan.md` link corrected from `cents-migration-plan.md` to `../cents-migration-plan.md`
### Files Modified
- `mkdocs/docs/release-notes/index.md` — Updated release notes with v0.38.x, v0.39.0, v0.40.0 sections
- `mkdocs/docs/technical/api-v040.md` — Created new API reference file
### No Errors Found
All documentation updates completed successfully with proper internal linking.
### Test Notes
- No mkdocs build attempted in sandbox (mkdocs not installable)
- Manual verification of nav structure and internal links performed
- Git commit avoided per instructions (Ripley owns git)
### Files Created
- `mkdocs/docs/technical/api-v040.md` — New API reference for v0.38-v0.40
### Learnings
- The release notes structure needed granular v0.38.x sub-sections per version (v0.38.0 through v0.38.4)
- The new bank transactions endpoint is at `/api/transactions/bank-ledger`, not `/api/bank-transactions`
- The settings auto-save endpoint is `PUT /api/settings` with partial update support
- The merchant/store matching service is invoked via `/api/transactions/auto-categorize` and `/api/transactions/:id/apply-merchant-match`
---
**Verified By**: Bishop (subagent) **Verified By**: Bishop (subagent)
**Date**: 2026-05-11 **Date**: 2026-06-14
**Phase**: 1/4 — Build-Verify **Version**: v0.40.0
**Version**: v0.24.4 **Task**: Documentation update for v0.38-v0.40 release notes and API reference

View File

@ -314,6 +314,12 @@ export const api = {
deleteCategory: (id) => del(`/categories/${id}`), deleteCategory: (id) => del(`/categories/${id}`),
restoreCategory: (id) => post(`/categories/${id}/restore`), restoreCategory: (id) => post(`/categories/${id}/restore`),
// Category groups
categoryGroups: () => get('/categories/groups'),
createCategoryGroup: (data) => post('/categories/groups', data),
updateCategoryGroup: (id, data) => put(`/categories/groups/${id}`, data),
deleteCategoryGroup: (id) => del(`/categories/groups/${id}`),
// Settings // Settings
settings: () => get('/settings'), settings: () => get('/settings'),
saveSettings: (data) => put('/settings', data), saveSettings: (data) => put('/settings', data),

View File

@ -3,7 +3,7 @@ import { Tag } from 'lucide-react';
// Category picker dropdown // Category picker dropdown
export function CategoryPicker({ categories, current, onSelect }) { export function CategoryPicker({ categories, current, currentLabel, onSelect }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
@ -24,7 +24,7 @@ export function CategoryPicker({ categories, current, onSelect }) {
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors" className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
> >
<Tag className="h-3 w-3 shrink-0" /> <Tag className="h-3 w-3 shrink-0" />
<span className="max-w-[100px] truncate">{currentCat?.name ?? 'Uncategorized'}</span> <span className="max-w-[100px] truncate">{currentCat?.name ?? currentLabel ?? 'Uncategorized'}</span>
</button> </button>
{open && ( {open && (

View File

@ -1,9 +1,18 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, Trash2, BookmarkPlus, Settings2, Copy, DollarSign } from 'lucide-react'; import {
ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X,
Trash2, BookmarkPlus, Settings2, Copy, AlertCircle, RefreshCw, ArrowRightLeft,
Layers, Wallet,
} from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/Skeleton';
import {
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { CategoryPicker } from '@/components/transactions/CategoryPicker';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
@ -12,13 +21,25 @@ function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
} }
function pctBar(amount, budget) { function settingEnabled(value, fallback = true) {
if (!budget) return null; if (value === undefined || value === null || value === '') return fallback;
const pct = Math.min(100, Math.round((amount / budget) * 100)); return value === true || value === 'true';
const over = amount > budget;
return { pct, over };
} }
// pctBar() returns a progress-bar percent plus a YNAB-style status level:
// - 'over': spending has exceeded the budget (Available < 0)
// - 'warn': spending has crossed ~80% of the budget
// - 'ok': on track
function pctBar(amount, budget) {
if (budget == null) return null;
const pct = Math.min(100, Math.round((amount / budget) * 100));
const level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok';
return { pct, level, over: level === 'over' };
}
const LEVEL_BAR_CLASS = { over: 'bg-destructive', warn: 'bg-amber-500', ok: 'bg-primary' };
const LEVEL_TEXT_CLASS = { over: 'text-destructive', warn: 'text-amber-500', ok: 'text-emerald-500' };
// Transaction row // Transaction row
function TxRow({ tx, categories, onCategorize }) { function TxRow({ tx, categories, onCategorize }) {
@ -73,7 +94,7 @@ function TxRow({ tx, categories, onCategorize }) {
</span> </span>
{saving {saving
? <span className="text-xs text-muted-foreground w-28 text-center">Saving</span> ? <span className="text-xs text-muted-foreground w-28 text-center">Saving</span>
: <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={handleSelect} /> : <CategoryPicker categories={categories} current={tx.spending_category_id} currentLabel={tx.spending_category_name} onSelect={handleSelect} />
} }
</div> </div>
{rememberPrompt && ( {rememberPrompt && (
@ -332,6 +353,260 @@ function RulesManager({ categories }) {
); );
} }
// Spending settings menu
function SpendingSettingsMenu({ settings, onToggle, saving }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="outline" className="h-8 gap-1.5 px-2.5 text-xs" disabled={saving}>
<Settings2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">View</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuLabel>Spending page options</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={settingEnabled(settings.spending_show_ready_to_assign)}
onSelect={e => e.preventDefault()}
onCheckedChange={checked => onToggle('spending_show_ready_to_assign', checked)}
>
Ready to Assign
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={settingEnabled(settings.spending_show_avg)}
onSelect={e => e.preventDefault()}
onCheckedChange={checked => onToggle('spending_show_avg', checked)}
>
3-month averages
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={settingEnabled(settings.spending_show_cover_overspend)}
onSelect={e => e.preventDefault()}
onCheckedChange={checked => onToggle('spending_show_cover_overspend', checked)}
>
Cover overspending
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={settingEnabled(settings.spending_group_categories, false)}
onSelect={e => e.preventDefault()}
onCheckedChange={checked => onToggle('spending_group_categories', checked)}
>
Group categories
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
// Cover overspending
function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }) {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const catBudget = budgets[cat.category_id] ?? cat.budget ?? 0;
const overspend = Math.max(0, cat.amount - catBudget);
if (overspend <= 0) return null;
const options = siblings
.filter(s => s.category_id !== cat.category_id)
.map(s => ({ ...s, available: (budgets[s.category_id] ?? s.budget ?? 0) - s.amount }))
.filter(s => s.available > 0);
const cover = async (source) => {
const sourceBudget = budgets[source.category_id] ?? source.budget ?? 0;
const amount = Math.min(overspend, source.available);
setSaving(true);
try {
const newSourceBudget = sourceBudget - amount;
const newCatBudget = catBudget + amount;
await api.setSpendingBudget({ category_id: source.category_id, year, month, amount: newSourceBudget });
await api.setSpendingBudget({ category_id: cat.category_id, year, month, amount: newCatBudget });
onCovered({ [source.category_id]: newSourceBudget, [cat.category_id]: newCatBudget });
toast.success(`Covered ${fmt(amount)} from ${source.category_name}.`);
setOpen(false);
} catch (err) {
toast.error(err.message || 'Failed to cover overspending');
} finally {
setSaving(false);
}
};
if (options.length === 0) {
return <span className="text-[11px] text-destructive/70">No category has budget left to cover this</span>;
}
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(v => !v)}
disabled={saving}
className="flex items-center gap-1 text-[11px] font-medium text-destructive hover:text-destructive/80 transition-colors disabled:opacity-50"
>
<ArrowRightLeft className="h-3 w-3" /> Cover from
</button>
{open && (
<div className="absolute z-10 right-0 mt-1 w-52 rounded-md border border-border/60 bg-popover shadow-lg py-1">
{options.map(s => (
<button
key={s.category_id}
type="button"
onClick={() => cover(s)}
className="w-full flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-left hover:bg-muted/40 transition-colors"
>
<span className="truncate">{s.category_name}</span>
<span className="text-muted-foreground font-mono shrink-0">{fmt(s.available)} avail</span>
</button>
))}
</div>
)}
</div>
);
}
// Category groups manager
function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }) {
const [open, setOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState('');
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const addGroup = async (e) => {
e.preventDefault();
if (!newGroupName.trim()) return;
setAdding(true);
try {
await api.createCategoryGroup({ name: newGroupName.trim() });
setNewGroupName('');
await onGroupsChanged();
toast.success('Group created.');
} catch (err) {
toast.error(err.message || 'Failed to create group');
} finally {
setAdding(false);
}
};
const renameGroup = async (id) => {
if (!editName.trim()) return;
try {
await api.updateCategoryGroup(id, { name: editName.trim() });
setEditingId(null);
await onGroupsChanged();
} catch (err) {
toast.error(err.message || 'Failed to rename group');
}
};
const deleteGroup = async (id) => {
try {
await api.deleteCategoryGroup(id);
await onGroupsChanged();
toast.success('Group deleted.');
} catch (err) {
toast.error(err.message || 'Failed to delete group');
}
};
const setCategoryGroup = async (cat, groupId) => {
try {
await api.updateCategory(cat.id, { name: cat.name, spending_enabled: true, group_id: groupId === '' ? null : Number(groupId) });
await onCategoriesChanged();
} catch (err) {
toast.error(err.message || 'Failed to update category group');
}
};
return (
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
<button
type="button"
onClick={() => setOpen(v => !v)}
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
>
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium flex-1">Category Groups</span>
{groups.length > 0 && !open && (
<Badge variant="secondary" className="text-xs">{groups.length}</Badge>
)}
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="border-t border-border/40">
<form onSubmit={addGroup} className="flex items-center gap-2 px-4 py-3 border-b border-border/30">
<input
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="New group name (e.g. Bills, Everyday)"
className="flex-1 min-w-0 rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
<Button type="submit" size="sm" disabled={adding || !newGroupName.trim()}>
{adding ? 'Adding…' : 'Add group'}
</Button>
</form>
{groups.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No groups yet. Create one above, then assign categories to it below.
</p>
) : (
<div className="divide-y divide-border/30">
{groups.map(g => (
<div key={g.id} className="flex items-center gap-2 px-4 py-2">
{editingId === g.id ? (
<>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
autoFocus
onKeyDown={e => { if (e.key === 'Enter') renameGroup(g.id); if (e.key === 'Escape') setEditingId(null); }}
className="flex-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button type="button" onClick={() => renameGroup(g.id)} className="text-emerald-500 hover:text-emerald-400"><Check className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => setEditingId(null)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
</>
) : (
<>
<span className="text-sm font-medium flex-1 truncate">{g.name}</span>
<button type="button" onClick={() => { setEditingId(g.id); setEditName(g.name); }} className="text-muted-foreground hover:text-foreground"><Pencil className="h-3.5 w-3.5" /></button>
<button type="button" onClick={() => deleteGroup(g.id)} className="text-muted-foreground hover:text-destructive"><Trash2 className="h-3.5 w-3.5" /></button>
</>
)}
</div>
))}
</div>
)}
{categories.length > 0 && (
<div className="border-t border-border/30 px-4 py-3 space-y-2">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Assign categories</p>
{categories.map(c => (
<div key={c.id} className="flex items-center justify-between gap-2">
<span className="text-sm truncate">{c.name}</span>
<select
value={c.group_id ?? ''}
onChange={e => setCategoryGroup(c, e.target.value)}
className="rounded-md border border-border/60 bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">No group</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// Main page // Main page
export default function SpendingPage() { export default function SpendingPage() {
@ -345,26 +620,42 @@ export default function SpendingPage() {
const [txPage, setTxPage] = useState(1); const [txPage, setTxPage] = useState(1);
const [txPages, setTxPages] = useState(1); const [txPages, setTxPages] = useState(1);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [categoryGroups, setCategoryGroups] = useState([]);
const [activeCat, setActiveCat] = useState(undefined); // undefined = all const [activeCat, setActiveCat] = useState(undefined); // undefined = all
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [txLoading, setTxLoading] = useState(false); const [txLoading, setTxLoading] = useState(false);
const [budgets, setBudgets] = useState({}); // categoryId amount const [budgets, setBudgets] = useState({}); // categoryId amount
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
const [spendingSettings, setSpendingSettings] = useState({});
const [savingSpendingSetting, setSavingSpendingSetting] = useState(false);
const [summaryError, setSummaryError] = useState(null);
const [txError, setTxError] = useState(null);
const [catError, setCatError] = useState(null);
// loadCategories is stable categories don't vary by month // loadCategories is stable categories don't vary by month
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
setCatError(null);
try { try {
const d = await api.categories(); const d = await api.categories();
// Only show spending-enabled categories in the spending UI // Only show spending-enabled categories in the spending UI
setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled)); setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled));
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load categories'); setCatError(err.message || 'Failed to load categories');
}
}, []);
const loadCategoryGroups = useCallback(async () => {
try {
setCategoryGroups((await api.categoryGroups()) || []);
} catch {
// non-fatal group headers simply won't render
} }
}, []); }, []);
// loadTransactions is exposed so pagination buttons can call it with a page arg // loadTransactions is exposed so pagination buttons can call it with a page arg
const loadTransactions = useCallback(async (page = 1) => { const loadTransactions = useCallback(async (page = 1) => {
setTxLoading(true); setTxLoading(true);
setTxError(null);
try { try {
const params = { year, month, page, limit: 50 }; const params = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null'; if (activeCat === null) params.category_id = 'null';
@ -375,41 +666,56 @@ export default function SpendingPage() {
setTxPages(d.pages || 1); setTxPages(d.pages || 1);
setTxPage(page); setTxPage(page);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load transactions'); setTxError(err.message || 'Failed to load transactions');
} finally { } finally {
setTxLoading(false); setTxLoading(false);
} }
}, [year, month, activeCat]); }, [year, month, activeCat]);
// Load categories once on mount const loadSummary = useCallback(async () => {
useEffect(() => { loadCategories(); }, [loadCategories]);
// Load summary and transactions whenever month/category filter changes.
// Depends on primitive values directly avoids the double-fetch that
// happened when useCallback references were used as deps (both effects
// would fire whenever year/month changed).
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true); setLoading(true);
setSummaryError(null);
try { try {
const d = await api.spendingSummary({ year, month }); const d = await api.spendingSummary({ year, month });
if (cancelled) return;
setSummary(d); setSummary(d);
const bmap = {}; const bmap = {};
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap); setBudgets(bmap);
if (d.category_groups) setCategoryGroups(d.category_groups);
} catch (err) { } catch (err) {
if (!cancelled) toast.error(err.message || 'Failed to load spending summary'); setSummaryError(err.message || 'Failed to load spending summary');
} finally { } finally {
if (!cancelled) setLoading(false); setLoading(false);
} }
}; }, [year, month]);
run();
// Load categories, groups, and settings once on mount
useEffect(() => { loadCategories(); loadCategoryGroups(); }, [loadCategories, loadCategoryGroups]);
useEffect(() => {
api.settings().then(setSpendingSettings).catch(() => {});
}, []);
// Load summary and transactions whenever month/category filter changes.
useEffect(() => {
loadSummary();
loadTransactions(1); loadTransactions(1);
return () => { cancelled = true; };
}, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps }, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps
async function saveSpendingSetting(patch, successMessage) {
setSavingSpendingSetting(true);
setSpendingSettings(prev => ({ ...prev, ...patch }));
try {
const next = await api.saveSettings(patch);
setSpendingSettings(prev => ({ ...prev, ...next }));
if (successMessage) toast.success(successMessage);
} catch (err) {
toast.error(err.message || 'Failed to update spending setting');
api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...s }))).catch(() => {});
} finally {
setSavingSpendingSetting(false);
}
}
const handleCopyBudgets = async () => { const handleCopyBudgets = async () => {
setCopying(true); setCopying(true);
try { try {
@ -465,21 +771,168 @@ export default function SpendingPage() {
}); });
}; };
const handleBudgetsCovered = (updates) => {
setBudgets(prev => ({ ...prev, ...updates }));
setSummary(prev => {
if (!prev) return prev;
return {
...prev,
by_category: prev.by_category.map(c =>
c.category_id in updates ? { ...c, budget: updates[c.category_id] } : c
),
};
});
};
const refreshAfterGroupChange = async () => {
await loadCategoryGroups();
await loadSummary();
};
const refreshAfterCategoryChange = async () => {
await loadCategories();
await loadSummary();
};
const selectCat = (catId) => { const selectCat = (catId) => {
setActiveCat(prev => prev === catId ? undefined : catId); setActiveCat(prev => prev === catId ? undefined : catId);
setTxPage(1); setTxPage(1);
}; };
if (loading) { if (loading && !summary) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground text-sm"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16">
Loading spending <div className="mx-auto max-w-4xl px-4 py-6 sm:px-6 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="space-y-2">
<Skeleton variant="line" className="h-6 w-28" />
<Skeleton variant="line" className="h-4 w-56" />
</div>
<Skeleton variant="button" className="w-32" />
</div>
<Skeleton variant="card" className="h-28" />
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} variant="card" className="h-20" />)}
</div>
<Skeleton variant="card" className="h-72" />
<Skeleton variant="card" className="h-64" />
</div>
</div> </div>
); );
} }
const uncatEntry = summary?.by_category?.find(c => !c.category_id); const realCatEntries = summary?.by_category?.filter(c => typeof c.category_id === 'number') || [];
const catEntries = summary?.by_category?.filter(c => !!c.category_id) || []; const uncatEntry = summary?.by_category?.find(c => c.category_id === null) || null;
const otherEntry = summary?.by_category?.find(c => c.category_id === 'other') || null;
const totalBudgeted = Object.values(budgets).reduce((sum, b) => sum + (Number(b) || 0), 0);
const totalSpending = summary?.total_spending || 0;
const income = summary?.income || 0;
const readyToAssign = income - totalBudgeted;
const remaining = totalBudgeted - totalSpending;
const showReadyToAssign = settingEnabled(spendingSettings.spending_show_ready_to_assign);
const showAvg = settingEnabled(spendingSettings.spending_show_avg);
const showCover = settingEnabled(spendingSettings.spending_show_cover_overspend);
const showGroups = settingEnabled(spendingSettings.spending_group_categories, false);
const groupedEntries = (() => {
if (!showGroups || categoryGroups.length === 0) return null;
const byGroup = new Map(categoryGroups.map(g => [g.id, { group: g, entries: [] }]));
const ungrouped = [];
realCatEntries.forEach(c => {
if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id).entries.push(c);
else ungrouped.push(c);
});
const groups = [...byGroup.values()].filter(g => g.entries.length > 0);
if (ungrouped.length > 0) groups.push({ group: { id: 'ungrouped', name: 'Ungrouped' }, entries: ungrouped });
return groups;
})();
const renderCategoryRow = (cat) => {
const budget = budgets[cat.category_id] ?? cat.budget ?? null;
const bar = pctBar(cat.amount, budget);
const isActive = activeCat === cat.category_id;
const available = budget != null ? budget - cat.amount : null;
const level = bar?.level ?? 'ok';
return (
<div key={cat.category_id} className={`transition-colors ${isActive ? 'bg-primary/8' : 'hover:bg-muted/30'}`}>
<button type="button" onClick={() => selectCat(cat.category_id)} className="w-full text-left px-4 pt-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{cat.category_name}</span>
<span className="text-sm font-mono font-semibold shrink-0">{fmt(cat.amount)}</span>
</div>
{bar && (
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted/50">
<div className={`h-full rounded-full transition-all ${LEVEL_BAR_CLASS[level]}`} style={{ width: `${bar.pct}%` }} />
</div>
)}
<div className="flex items-center justify-between mt-1 gap-2">
<span className="text-[11px] text-muted-foreground">
{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}
{showAvg && cat.avg_3mo > 0 && (
<span className="ml-2 text-muted-foreground/70">avg {fmt(cat.avg_3mo)}/mo</span>
)}
</span>
{available != null && (
<span className={`text-[11px] font-medium ${LEVEL_TEXT_CLASS[level]}`}>
{available < 0 ? `${fmt(Math.abs(available))} over` : `${fmt(available)} left`}
</span>
)}
</div>
</button>
<div className="flex items-center justify-between gap-2 px-4 pb-3 pt-1.5">
<BudgetEditor
categoryId={cat.category_id}
year={year} month={month}
initial={budgets[cat.category_id] ?? null}
onSaved={handleBudgetSaved}
/>
{showCover && level === 'over' && (
<CoverOverspendPicker cat={cat} siblings={realCatEntries} budgets={budgets} year={year} month={month} onCovered={handleBudgetsCovered} />
)}
</div>
</div>
);
};
const renderUncatRow = () => (
<button
type="button"
onClick={() => selectCat(null)}
className={`w-full text-left px-4 py-3 transition-colors ${activeCat === null ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Tag className="h-3.5 w-3.5" /> Uncategorized
</span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry.amount)}</span>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}</p>
</button>
);
const renderOtherRow = () => (
<div
title="Categorized outside Spending — enable Spending on this category to manage it here."
className="px-4 py-3"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Layers className="h-3.5 w-3.5" /> Other categories
</span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(otherEntry.amount)}</span>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}</p>
</div>
);
const activeCatLabel = activeCat === null
? 'Uncategorized'
: activeCat !== undefined
? realCatEntries.find(c => c.category_id === activeCat)?.category_name
: null;
return ( return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16"> <div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16">
@ -501,26 +954,59 @@ export default function SpendingPage() {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
<SpendingSettingsMenu settings={spendingSettings} onToggle={(key, checked) => saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
</div> </div>
</div> </div>
{/* Summary load error */}
{summaryError && (
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive flex-1">{summaryError}</span>
<Button variant="outline" size="sm" onClick={loadSummary} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3.5 w-3.5" /> Retry
</Button>
</div>
)}
{/* Ready to Assign */}
{showReadyToAssign && (
<div className={`rounded-xl border px-4 py-4 ${readyToAssign >= 0 ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-amber-500/25 bg-amber-500/10'}`}>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Ready to Assign</p>
<p className={`text-3xl font-bold mt-1 ${readyToAssign >= 0 ? 'text-emerald-500' : 'text-amber-500'}`}>
{fmt(Math.abs(readyToAssign))}
</p>
<p className="text-xs text-muted-foreground mt-1">
{readyToAssign >= 0 ? 'left to budget this month' : 'over-assigned — budgets exceed income'}
</p>
</div>
)}
{/* Overview strip */} {/* Overview strip */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3"> <div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Total Spending</p> <p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Budgeted</p>
<p className="text-2xl font-bold mt-1">{fmt(summary?.total_spending)}</p> <p className="text-2xl font-bold mt-1">{fmt(totalBudgeted)}</p>
</div> </div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3"> <div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Spent</p>
<p className="text-2xl font-bold mt-1">{fmt(totalSpending)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Remaining</p>
<p className={`text-2xl font-bold mt-1 ${remaining < 0 ? 'text-destructive' : 'text-emerald-500'}`}>{fmt(remaining)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Income</p>
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(income)}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Uncategorized</p> <p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Uncategorized</p>
<p className="text-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p> <p className="text-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p>
{summary?.uncategorized_count > 0 && ( {summary?.uncategorized_count > 0 && (
<p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</p> <p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</p>
)} )}
</div> </div>
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Income Received</p>
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(summary?.income)}</p>
</div>
</div> </div>
{/* No spending categories notice */} {/* No spending categories notice */}
@ -534,6 +1020,15 @@ export default function SpendingPage() {
</div> </div>
</div> </div>
)} )}
{catError && (
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive flex-1">{catError}</span>
<Button variant="outline" size="sm" onClick={loadCategories} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3.5 w-3.5" /> Retry
</Button>
</div>
)}
{/* Category breakdown */} {/* Category breakdown */}
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden"> <div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
@ -560,68 +1055,42 @@ export default function SpendingPage() {
</div> </div>
</div> </div>
{catEntries.length === 0 && !uncatEntry ? ( {realCatEntries.length === 0 && !uncatEntry && !otherEntry ? (
<p className="text-sm text-muted-foreground text-center py-8">No spending transactions found for this month.</p> <div className="flex flex-col items-center gap-2 py-10 text-center">
) : ( <Wallet className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No spending found for this month yet.</p>
<p className="text-xs text-muted-foreground/70">Set a budget or categorize transactions below to see them here.</p>
</div>
) : groupedEntries ? (
<div className="divide-y divide-border/30"> <div className="divide-y divide-border/30">
{catEntries.map(cat => { {groupedEntries.map(({ group, entries }) => {
const bar = pctBar(cat.amount, cat.budget ?? budgets[cat.category_id]); const subtotalAmount = entries.reduce((s, c) => s + c.amount, 0);
const isActive = activeCat === cat.category_id; const subtotalBudget = entries.reduce((s, c) => s + (budgets[c.category_id] ?? c.budget ?? 0), 0);
return ( return (
<button <div key={group.id}>
key={cat.category_id} <div className="px-4 py-2 bg-muted/20 flex items-center justify-between">
type="button" <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.name}</span>
onClick={() => selectCat(cat.category_id)} <span className="text-xs font-mono text-muted-foreground">{fmt(subtotalAmount)} / {fmt(subtotalBudget)}</span>
className={`w-full text-left px-4 py-3 transition-colors ${isActive ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">{cat.category_name}</span>
<span className="text-sm font-mono font-semibold shrink-0">{fmt(cat.amount)}</span>
</div> </div>
{bar && ( <div className="divide-y divide-border/30">
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted/50"> {entries.map(renderCategoryRow)}
<div
className={`h-full rounded-full transition-all ${bar.over ? 'bg-destructive' : 'bg-primary'}`}
style={{ width: `${bar.pct}%` }}
/>
</div>
)}
<div className="flex items-center justify-between mt-1">
<span className="text-[11px] text-muted-foreground">{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}</span>
<span onClick={e => e.stopPropagation()}>
<BudgetEditor
categoryId={cat.category_id}
year={year} month={month}
initial={budgets[cat.category_id] ?? null}
onSaved={handleBudgetSaved}
/>
</span>
</div> </div>
</div> </div>
</div>
</button>
); );
})} })}
{(uncatEntry || otherEntry) && (
{/* Uncategorized row */} <div className="divide-y divide-border/30">
{uncatEntry && ( {uncatEntry && renderUncatRow()}
<button {otherEntry && renderOtherRow()}
type="button"
onClick={() => selectCat(null)}
className={`w-full text-left px-4 py-3 transition-colors ${activeCat === null ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Tag className="h-3.5 w-3.5" /> Uncategorized
</span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry.amount)}</span>
</div> </div>
<p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}</p>
</button>
)} )}
</div> </div>
) : (
<div className="divide-y divide-border/30">
{realCatEntries.map(renderCategoryRow)}
{uncatEntry && renderUncatRow()}
{otherEntry && renderOtherRow()}
</div>
)} )}
</div> </div>
@ -631,19 +1100,41 @@ export default function SpendingPage() {
<ReceiptText className="h-4 w-4 text-muted-foreground" /> <ReceiptText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Transactions Transactions
{activeCat !== undefined && ( {activeCatLabel && (
<span className="ml-1.5 text-muted-foreground font-normal"> <span className="ml-1.5 text-muted-foreground font-normal">
{activeCat === null ? 'Uncategorized' : catEntries.find(c => c.category_id === activeCat)?.category_name} {activeCatLabel}
</span> </span>
)} )}
</span> </span>
<Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge> <Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge>
</div> </div>
{txLoading ? ( {txError ? (
<div className="py-10 text-center text-sm text-muted-foreground">Loading</div> <div className="flex items-center gap-3 px-4 py-6">
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive flex-1">{txError}</span>
<Button variant="outline" size="sm" onClick={() => loadTransactions(txPage)} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3.5 w-3.5" /> Retry
</Button>
</div>
) : txLoading ? (
<div className="divide-y divide-border/30">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton variant="line" className="h-3.5 w-2/3" />
<Skeleton variant="line" className="h-3 w-1/4" />
</div>
<Skeleton variant="line" className="h-3.5 w-16" />
<Skeleton variant="line" className="h-7 w-28" />
</div>
))}
</div>
) : transactions.length === 0 ? ( ) : transactions.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">No transactions found.</div> <div className="flex flex-col items-center gap-2 py-10 text-center">
<ReceiptText className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No transactions found{activeCatLabel ? ` for ${activeCatLabel}` : ''}.</p>
</div>
) : ( ) : (
<> <>
<div> <div>
@ -674,6 +1165,16 @@ export default function SpendingPage() {
{/* Income & deposits */} {/* Income & deposits */}
<IncomeSection year={year} month={month} totalIncome={summary?.income} /> <IncomeSection year={year} month={month} totalIncome={summary?.income} />
{/* Category groups management */}
{showGroups && (
<CategoryGroupManager
categories={categories}
groups={categoryGroups}
onGroupsChanged={refreshAfterGroupChange}
onCategoriesChanged={refreshAfterCategoryChange}
/>
)}
{/* Merchant rules manager */} {/* Merchant rules manager */}
<RulesManager categories={categories} /> <RulesManager categories={categories} />

View File

@ -3473,6 +3473,30 @@ function runMigrations() {
runMerchantStoreMatchMigration(db); runMerchantStoreMatchMigration(db);
} }
}, },
{
version: 'v1.06',
description: 'category_groups: organize spending categories into named groups',
dependsOn: ['v1.05'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS category_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, name)
)
`);
const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name);
if (!cols.includes('group_id'))
db.exec('ALTER TABLE categories ADD COLUMN group_id INTEGER REFERENCES category_groups(id) ON DELETE SET NULL');
console.log('[v1.06] category_groups table + categories.group_id added');
}
},
]; ];
// ── users: notification columns ─────────────────────────────────────────── // ── users: notification columns ───────────────────────────────────────────

View File

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

View File

@ -11,7 +11,7 @@ router.get('/', (req, res) => {
ensureUserDefaultCategories(req.user.id); ensureUserDefaultCategories(req.user.id);
const categories = db.prepare(` const categories = db.prepare(`
SELECT id, user_id, name, sort_order, spending_enabled, created_at, updated_at SELECT id, user_id, name, sort_order, spending_enabled, group_id, created_at, updated_at
FROM categories FROM categories
WHERE user_id = ? WHERE user_id = ?
AND deleted_at IS NULL AND deleted_at IS NULL
@ -134,19 +134,25 @@ router.post('/', (req, res) => {
// PUT /api/categories/:id // PUT /api/categories/:id
router.put('/:id', (req, res) => { router.put('/:id', (req, res) => {
const db = getDb(); const db = getDb();
const { name, spending_enabled } = req.body; const { name, spending_enabled, group_id } = req.body;
if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name')); if (!name) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id); const cat = db.prepare('SELECT id FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id')); if (!cat) return res.status(404).json(standardizeError('Category not found', 'NOT_FOUND', 'id'));
if (group_id !== undefined && group_id !== null) {
const group = db.prepare('SELECT id FROM category_groups WHERE id = ? AND user_id = ?').get(group_id, req.user.id);
if (!group) return res.status(404).json(standardizeError('Category group not found', 'NOT_FOUND', 'group_id'));
}
try { try {
const fields = ["name = ?", "updated_at = datetime('now')"]; const fields = ["name = ?", "updated_at = datetime('now')"];
const values = [name.trim()]; const values = [name.trim()];
if (spending_enabled !== undefined) { fields.push('spending_enabled = ?'); values.push(spending_enabled ? 1 : 0); } if (spending_enabled !== undefined) { fields.push('spending_enabled = ?'); values.push(spending_enabled ? 1 : 0); }
if (group_id !== undefined) { fields.push('group_id = ?'); values.push(group_id); }
db.prepare(`UPDATE categories SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`) db.prepare(`UPDATE categories SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`)
.run(...values, req.params.id, req.user.id); .run(...values, req.params.id, req.user.id);
const updated = db.prepare('SELECT id, name, sort_order, spending_enabled, created_at, updated_at FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id); const updated = db.prepare('SELECT id, name, sort_order, spending_enabled, group_id, created_at, updated_at FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id);
res.json({ ...updated, spending_enabled: !!updated.spending_enabled }); res.json({ ...updated, spending_enabled: !!updated.spending_enabled });
} catch (e) { } catch (e) {
if (e.message?.includes('UNIQUE')) { if (e.message?.includes('UNIQUE')) {
@ -157,6 +163,80 @@ router.put('/:id', (req, res) => {
} }
}); });
// ── Category groups ──────────────────────────────────────────────────────────
// GET /api/category-groups
router.get('/groups', (req, res) => {
const db = getDb();
const groups = db.prepare(`
SELECT id, name, sort_order, created_at, updated_at
FROM category_groups
WHERE user_id = ?
ORDER BY sort_order ASC, name COLLATE NOCASE ASC
`).all(req.user.id);
res.json(groups);
});
// POST /api/category-groups
router.post('/groups', (req, res) => {
const db = getDb();
const { name } = req.body;
if (!name?.trim()) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
try {
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_groups WHERE user_id = ?').get(req.user.id).m;
const result = db.prepare('INSERT INTO category_groups (user_id, name, sort_order) VALUES (?, ?, ?)')
.run(req.user.id, name.trim(), maxOrder + 1);
const created = db.prepare('SELECT id, name, sort_order, created_at, updated_at FROM category_groups WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(created);
} catch (e) {
if (e.message?.includes('UNIQUE')) {
return res.status(409).json(standardizeError('Category group already exists', 'CONFLICT', 'name'));
}
console.error('[category-groups POST]', e.message);
res.status(500).json({ error: 'Failed to create category group' });
}
});
// PUT /api/category-groups/:id
router.put('/groups/:id', (req, res) => {
const db = getDb();
const { name, sort_order } = req.body;
const group = db.prepare('SELECT id FROM category_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json(standardizeError('Category group not found', 'NOT_FOUND', 'id'));
try {
const fields = ["updated_at = datetime('now')"];
const values = [];
if (name !== undefined) {
if (!name.trim()) return res.status(400).json(standardizeError('name is required', 'VALIDATION_ERROR', 'name'));
fields.push('name = ?'); values.push(name.trim());
}
if (sort_order !== undefined) { fields.push('sort_order = ?'); values.push(Number(sort_order)); }
db.prepare(`UPDATE category_groups SET ${fields.join(', ')} WHERE id = ? AND user_id = ?`)
.run(...values, req.params.id, req.user.id);
const updated = db.prepare('SELECT id, name, sort_order, created_at, updated_at FROM category_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
res.json(updated);
} catch (e) {
if (e.message?.includes('UNIQUE')) {
return res.status(409).json(standardizeError('Category group already exists', 'CONFLICT', 'name'));
}
console.error('[category-groups PUT]', e.message);
res.status(500).json({ error: 'Failed to update category group' });
}
});
// DELETE /api/category-groups/:id
router.delete('/groups/:id', (req, res) => {
const db = getDb();
const group = db.prepare('SELECT id FROM category_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!group) return res.status(404).json(standardizeError('Category group not found', 'NOT_FOUND', 'id'));
db.prepare('DELETE FROM category_groups WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
});
// PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name // PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name
router.patch('/:id/spending', (req, res) => { router.patch('/:id/spending', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -19,6 +19,13 @@ function monthRange(year, month) {
return { start, end }; return { start, end };
} }
function shiftMonth(year, month, delta) {
let m = month + delta, y = year;
while (m < 1) { m += 12; y--; }
while (m > 12) { m -= 12; y++; }
return { year: y, month: m };
}
function cents(raw) { function cents(raw) {
return Math.abs(Number(raw)) / 100; return Math.abs(Number(raw)) / 100;
} }
@ -27,21 +34,46 @@ function cents(raw) {
function getSpendingSummary(db, userId, year, month) { function getSpendingSummary(db, userId, year, month) {
const { start, end } = monthRange(year, month); const { start, end } = monthRange(year, month);
const dateRangeParams = [start, end, start + 'T00:00:00', end + 'T23:59:59'];
const DATE_RANGE_SQL = '(t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))';
// Spending by category // Every spending-enabled category, even with $0 activity this month (YNAB-style: budgeted
const rows = db.prepare(` // categories are always visible, not just ones with transactions).
const categoryRows = db.prepare(`
SELECT SELECT
c.id AS category_id, c.id AS category_id,
c.name AS category_name, c.name AS category_name,
SUM(ABS(t.amount)) AS total_cents, c.group_id AS group_id,
cg.name AS group_name,
COALESCE(SUM(ABS(t.amount)), 0) AS total_cents,
COUNT(t.id) AS tx_count COUNT(t.id) AS tx_count
FROM transactions t FROM categories c
LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL LEFT JOIN category_groups cg ON cg.id = c.group_id
WHERE ${SPENDING_WHERE} LEFT JOIN transactions t ON t.spending_category_id = c.id
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) AND ${SPENDING_WHERE} AND ${DATE_RANGE_SQL}
WHERE c.user_id = ? AND c.deleted_at IS NULL AND c.spending_enabled = 1
GROUP BY c.id GROUP BY c.id
ORDER BY total_cents DESC ORDER BY CASE WHEN c.sort_order IS NULL THEN 1 ELSE 0 END, c.sort_order ASC, c.name COLLATE NOCASE ASC
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59'); `).all(userId, ...dateRangeParams, userId);
// Uncategorized outflows
const uncatRow = db.prepare(`
SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count
FROM transactions t
WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NULL AND ${DATE_RANGE_SQL}
`).get(userId, ...dateRangeParams);
// Outflows categorized to a category that isn't (or is no longer) spending-enabled —
// bundled into a single "Other categories" row so totals stay accurate without polluting
// the spending-enabled category list.
const otherRow = db.prepare(`
SELECT COALESCE(SUM(ABS(t.amount)), 0) AS total_cents, COUNT(t.id) AS tx_count
FROM transactions t
LEFT JOIN categories c ON c.id = t.spending_category_id
WHERE ${SPENDING_WHERE} AND ${DATE_RANGE_SQL}
AND t.spending_category_id IS NOT NULL
AND (c.id IS NULL OR c.deleted_at IS NOT NULL OR c.spending_enabled = 0)
`).get(userId, ...dateRangeParams);
const budgets = db.prepare(` const budgets = db.prepare(`
SELECT category_id, amount FROM spending_budgets SELECT category_id, amount FROM spending_budgets
@ -49,26 +81,56 @@ function getSpendingSummary(db, userId, year, month) {
`).all(userId, year, month); `).all(userId, year, month);
const budgetMap = new Map(budgets.map(b => [b.category_id, b.amount])); const budgetMap = new Map(budgets.map(b => [b.category_id, b.amount]));
// 3-month spending average per category (the 3 calendar months before the requested one)
const prev1 = monthRange(shiftMonth(year, month, -1).year, shiftMonth(year, month, -1).month);
const prev3 = monthRange(shiftMonth(year, month, -3).year, shiftMonth(year, month, -3).month);
const avgRows = db.prepare(`
SELECT t.spending_category_id AS category_id, COALESCE(SUM(ABS(t.amount)), 0) AS total_cents
FROM transactions t
WHERE ${SPENDING_WHERE} AND t.spending_category_id IS NOT NULL
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
GROUP BY t.spending_category_id
`).all(userId, prev3.start, prev1.end, prev3.start + 'T00:00:00', prev1.end + 'T23:59:59');
const avgMap = new Map(avgRows.map(r => [r.category_id, r.total_cents / 3]));
let totalCents = 0; let totalCents = 0;
const byCategory = rows.map(r => { const byCategory = categoryRows.map(r => {
totalCents += r.total_cents; totalCents += r.total_cents;
return { return {
category_id: r.category_id, category_id: r.category_id,
category_name: r.category_name ?? '(Uncategorized)', category_name: r.category_name,
group_id: r.group_id ?? null,
group_name: r.group_name ?? null,
amount: r.total_cents / 100, amount: r.total_cents / 100,
tx_count: r.tx_count, tx_count: r.tx_count,
budget: r.category_id ? fromCents(budgetMap.get(r.category_id) ?? null) : null, budget: fromCents(budgetMap.get(r.category_id) ?? null),
avg_3mo: fromCents(avgMap.get(r.category_id) ?? 0),
}; };
}); });
if (uncatRow.tx_count > 0) {
totalCents += uncatRow.total_cents;
byCategory.push({
category_id: null, category_name: '(Uncategorized)', group_id: null, group_name: null,
amount: uncatRow.total_cents / 100, tx_count: uncatRow.tx_count, budget: null, avg_3mo: null,
});
}
if (otherRow.tx_count > 0) {
totalCents += otherRow.total_cents;
byCategory.push({
category_id: 'other', category_name: 'Other categories', group_id: null, group_name: null,
amount: otherRow.total_cents / 100, tx_count: otherRow.tx_count, budget: null, avg_3mo: null,
});
}
// Attach pct_of_total // Attach pct_of_total
byCategory.forEach(c => { byCategory.forEach(c => {
c.pct_of_total = totalCents > 0 ? Math.round(c.amount / (totalCents / 100) * 100) / 100 : 0; c.pct_of_total = totalCents > 0 ? Math.round(c.amount / (totalCents / 100) * 100) / 100 : 0;
}); });
const uncatRow = byCategory.find(c => !c.category_id); const uncategorized_amount = uncatRow.total_cents / 100;
const uncategorized_amount = uncatRow?.amount ?? 0; const uncategorized_count = uncatRow.tx_count;
const uncategorized_count = uncatRow?.tx_count ?? 0;
// Income (positive unmatched transactions this month) // Income (positive unmatched transactions this month)
const incomeRow = db.prepare(` const incomeRow = db.prepare(`
@ -78,12 +140,18 @@ function getSpendingSummary(db, userId, year, month) {
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?)) AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
`).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59'); `).get(userId, start, end, start + 'T00:00:00', end + 'T23:59:59');
const categoryGroups = db.prepare(`
SELECT id, name, sort_order FROM category_groups
WHERE user_id = ? ORDER BY sort_order ASC, name COLLATE NOCASE ASC
`).all(userId);
return { return {
year, month, year, month,
total_spending: totalCents / 100, total_spending: totalCents / 100,
uncategorized_amount, uncategorized_amount,
uncategorized_count, uncategorized_count,
by_category: byCategory, by_category: byCategory,
category_groups: categoryGroups,
income: (incomeRow?.total ?? 0) / 100, income: (incomeRow?.total ?? 0) / 100,
}; };
} }

View File

@ -22,6 +22,10 @@ const USER_SETTING_KEYS = [
'tracker_show_drift_insights', 'tracker_show_drift_insights',
'tracker_table_columns', 'tracker_table_columns',
'bank_auto_categorize_merchants', 'bank_auto_categorize_merchants',
'spending_show_ready_to_assign',
'spending_show_avg',
'spending_show_cover_overspend',
'spending_group_categories',
]; ];
const USER_SETTING_DEFAULTS = { const USER_SETTING_DEFAULTS = {
@ -35,6 +39,10 @@ const USER_SETTING_DEFAULTS = {
tracker_show_overdue_command_center: 'true', tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true', tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]', tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
spending_show_ready_to_assign: 'true',
spending_show_avg: 'true',
spending_show_cover_overspend: 'true',
spending_group_categories: 'false',
}; };
function defaultUserSettings() { function defaultUserSettings() {

View File

@ -0,0 +1,129 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-category-groups-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
function createUser(db, suffix) {
return db.prepare(`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
`).run(`category-groups-${suffix}`, `category-groups-${suffix}@local`).lastInsertRowid;
}
function createCategory(db, userId, name) {
return db.prepare(`
INSERT INTO categories (user_id, name) VALUES (?, ?)
`).run(userId, name).lastInsertRowid;
}
function callCategoriesRoute(routePath, method, { userId, params = {}, body = {} }) {
const categoriesRouter = require('../routes/categories');
const layer = categoriesRouter.stack.find(item => item.route?.path === routePath && item.route.methods[method]);
assert.ok(layer, `route ${method.toUpperCase()} ${routePath} should exist`);
const handler = layer.route.stack[0].handle;
return new Promise((resolve, reject) => {
const req = {
body,
params,
user: { id: userId, role: 'user' },
};
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
json(data) {
resolve({ status: this.statusCode, data });
},
};
try {
handler(req, res);
} catch (err) {
reject(err);
}
});
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('category groups can be created, listed, renamed, and deleted', async () => {
const db = getDb();
const userId = createUser(db, 'crud');
const created = await callCategoriesRoute('/groups', 'post', { userId, body: { name: 'Bills' } });
assert.equal(created.status, 201);
assert.equal(created.data.name, 'Bills');
const groupId = created.data.id;
const listed = await callCategoriesRoute('/groups', 'get', { userId });
assert.equal(listed.status, 200);
assert.deepEqual(listed.data.map(g => g.name), ['Bills']);
const renamed = await callCategoriesRoute('/groups/:id', 'put', { userId, params: { id: groupId }, body: { name: 'Fixed Costs' } });
assert.equal(renamed.status, 200);
assert.equal(renamed.data.name, 'Fixed Costs');
const deleted = await callCategoriesRoute('/groups/:id', 'delete', { userId, params: { id: groupId } });
assert.equal(deleted.status, 200);
assert.equal(deleted.data.success, true);
const listedAfter = await callCategoriesRoute('/groups', 'get', { userId });
assert.deepEqual(listedAfter.data, []);
});
test('creating a duplicate group name for the same user is rejected', async () => {
const db = getDb();
const userId = createUser(db, 'dup');
await callCategoriesRoute('/groups', 'post', { userId, body: { name: 'Everyday' } });
const dup = await callCategoriesRoute('/groups', 'post', { userId, body: { name: 'Everyday' } });
assert.equal(dup.status, 409);
});
test('assigning a category to a group requires the group to belong to the user', async () => {
const db = getDb();
const ownerId = createUser(db, 'group-owner');
const otherId = createUser(db, 'group-other');
const category = createCategory(db, ownerId, 'Groceries');
const otherGroup = await callCategoriesRoute('/groups', 'post', { userId: otherId, body: { name: 'Not Mine' } });
const result = await callCategoriesRoute('/:id', 'put', {
userId: ownerId,
params: { id: category },
body: { name: 'Groceries', group_id: otherGroup.data.id },
});
assert.equal(result.status, 404);
});
test('assigning a category to an owned group succeeds and is reflected on read', async () => {
const db = getDb();
const userId = createUser(db, 'group-assign');
const category = createCategory(db, userId, 'Dining');
const group = await callCategoriesRoute('/groups', 'post', { userId, body: { name: 'Everyday' } });
const updated = await callCategoriesRoute('/:id', 'put', {
userId,
params: { id: category },
body: { name: 'Dining', group_id: group.data.id },
});
assert.equal(updated.status, 200);
assert.equal(updated.data.group_id, group.data.id);
});

View File

@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const dbPath = path.join(os.tmpdir(), `bill-tracker-spending-summary-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { getSpendingSummary, setSpendingBudget } = require('../services/spendingService');
function createUser(db, suffix) {
return db.prepare(`
INSERT INTO users (username, password_hash, role, active, email, created_at, updated_at)
VALUES (?, 'x', 'user', 1, ?, datetime('now'), datetime('now'))
`).run(`spending-summary-${suffix}`, `spending-summary-${suffix}@local`).lastInsertRowid;
}
function createCategory(db, userId, name, { spendingEnabled = 1, groupId = null } = {}) {
return db.prepare(`
INSERT INTO categories (user_id, name, spending_enabled, group_id)
VALUES (?, ?, ?, ?)
`).run(userId, name, spendingEnabled, groupId).lastInsertRowid;
}
function createTransaction(db, userId, { amountCents, postedDate, categoryId = null, matchStatus = 'unmatched' }) {
return db.prepare(`
INSERT INTO transactions (user_id, source_type, posted_date, amount, payee, match_status, ignored, spending_category_id)
VALUES (?, 'manual', ?, ?, 'Test Payee', ?, 0, ?)
`).run(userId, postedDate, amountCents, matchStatus, categoryId).lastInsertRowid;
}
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
fs.rmSync(`${dbPath}${suffix}`, { force: true });
}
});
test('a budgeted category with no activity still appears with its budget', () => {
const db = getDb();
const userId = createUser(db, 'zero-activity');
const groceries = createCategory(db, userId, 'Groceries');
setSpendingBudget(db, userId, groceries, 2026, 1, 300);
const summary = getSpendingSummary(db, userId, 2026, 1);
const row = summary.by_category.find(c => c.category_id === groceries);
assert.ok(row, 'budgeted category should appear even with $0 activity');
assert.equal(row.amount, 0);
assert.equal(row.budget, 300);
assert.equal(row.tx_count, 0);
});
test('outflows assigned to a non-spending category are bundled into "Other categories"', () => {
const db = getDb();
const userId = createUser(db, 'other-bucket');
const nonSpending = createCategory(db, userId, 'Transfers', { spendingEnabled: 0 });
createTransaction(db, userId, { amountCents: -1500, postedDate: '2026-02-05', categoryId: nonSpending });
const summary = getSpendingSummary(db, userId, 2026, 2);
const other = summary.by_category.find(c => c.category_id === 'other');
assert.ok(other, '"Other categories" row should appear');
assert.equal(other.amount, 15);
assert.equal(other.tx_count, 1);
assert.equal(summary.total_spending, 15);
});
test('avg_3mo reflects the average spend over the prior 3 months', () => {
const db = getDb();
const userId = createUser(db, 'avg-3mo');
const dining = createCategory(db, userId, 'Dining');
createTransaction(db, userId, { amountCents: -1000, postedDate: '2026-03-10', categoryId: dining });
createTransaction(db, userId, { amountCents: -2000, postedDate: '2026-04-10', categoryId: dining });
createTransaction(db, userId, { amountCents: -3000, postedDate: '2026-05-10', categoryId: dining });
const summary = getSpendingSummary(db, userId, 2026, 6);
const row = summary.by_category.find(c => c.category_id === dining);
assert.ok(row);
assert.equal(row.avg_3mo, 20); // (10 + 20 + 30) / 3
});
test('category_groups are returned and group_id/group_name are attached to categories', () => {
const db = getDb();
const userId = createUser(db, 'groups');
const groupRow = db.prepare(`
INSERT INTO category_groups (user_id, name, sort_order) VALUES (?, 'Bills', 0)
`).run(userId);
const groupId = groupRow.lastInsertRowid;
const rent = createCategory(db, userId, 'Rent', { groupId });
createTransaction(db, userId, { amountCents: -120000, postedDate: '2026-07-01', categoryId: rent });
const summary = getSpendingSummary(db, userId, 2026, 7);
const row = summary.by_category.find(c => c.category_id === rent);
assert.deepEqual(summary.category_groups, [{ id: groupId, name: 'Bills', sort_order: 0 }]);
assert.equal(row.group_id, groupId);
assert.equal(row.group_name, 'Bills');
});