feat(spending): category groups, YNAB-style spending page overhaul, 3-month averages, cover overspending (batch 0.41.0)
This commit is contained in:
parent
81ddcb5fc1
commit
35e5d185de
|
|
@ -1,3 +1,55 @@
|
|||
# Errors Logged During Phase 1 Verification
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
**Date**: 2026-05-11
|
||||
**Phase**: 1/4 — Build-Verify
|
||||
**Version**: v0.24.4
|
||||
**Date**: 2026-06-14
|
||||
**Version**: v0.40.0
|
||||
**Task**: Documentation update for v0.38-v0.40 release notes and API reference
|
||||
|
|
|
|||
|
|
@ -314,6 +314,12 @@ export const api = {
|
|||
deleteCategory: (id) => del(`/categories/${id}`),
|
||||
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: () => get('/settings'),
|
||||
saveSettings: (data) => put('/settings', data),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Tag } from 'lucide-react';
|
|||
|
||||
// ── Category picker dropdown ─────────────────────────────────────────────────
|
||||
|
||||
export function CategoryPicker({ categories, current, onSelect }) {
|
||||
export function CategoryPicker({ categories, current, currentLabel, onSelect }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{open && (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
function pctBar(amount, budget) {
|
||||
if (!budget) return null;
|
||||
const pct = Math.min(100, Math.round((amount / budget) * 100));
|
||||
const over = amount > budget;
|
||||
return { pct, over };
|
||||
function settingEnabled(value, fallback = true) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
return value === true || value === 'true';
|
||||
}
|
||||
|
||||
// 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
function TxRow({ tx, categories, onCategorize }) {
|
||||
|
|
@ -73,7 +94,7 @@ function TxRow({ tx, categories, onCategorize }) {
|
|||
</span>
|
||||
{saving
|
||||
? <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>
|
||||
{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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SpendingPage() {
|
||||
|
|
@ -345,26 +620,42 @@ export default function SpendingPage() {
|
|||
const [txPage, setTxPage] = useState(1);
|
||||
const [txPages, setTxPages] = useState(1);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [categoryGroups, setCategoryGroups] = useState([]);
|
||||
const [activeCat, setActiveCat] = useState(undefined); // undefined = all
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [txLoading, setTxLoading] = useState(false);
|
||||
const [budgets, setBudgets] = useState({}); // categoryId → amount
|
||||
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
|
||||
const loadCategories = useCallback(async () => {
|
||||
setCatError(null);
|
||||
try {
|
||||
const d = await api.categories();
|
||||
// Only show spending-enabled categories in the spending UI
|
||||
setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled));
|
||||
} 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
|
||||
const loadTransactions = useCallback(async (page = 1) => {
|
||||
setTxLoading(true);
|
||||
setTxError(null);
|
||||
try {
|
||||
const params = { year, month, page, limit: 50 };
|
||||
if (activeCat === null) params.category_id = 'null';
|
||||
|
|
@ -375,41 +666,56 @@ export default function SpendingPage() {
|
|||
setTxPages(d.pages || 1);
|
||||
setTxPage(page);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load transactions');
|
||||
setTxError(err.message || 'Failed to load transactions');
|
||||
} finally {
|
||||
setTxLoading(false);
|
||||
}
|
||||
}, [year, month, activeCat]);
|
||||
|
||||
// Load categories once on mount
|
||||
useEffect(() => { loadCategories(); }, [loadCategories]);
|
||||
const loadSummary = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSummaryError(null);
|
||||
try {
|
||||
const d = await api.spendingSummary({ year, month });
|
||||
setSummary(d);
|
||||
const bmap = {};
|
||||
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
|
||||
setBudgets(bmap);
|
||||
if (d.category_groups) setCategoryGroups(d.category_groups);
|
||||
} catch (err) {
|
||||
setSummaryError(err.message || 'Failed to load spending summary');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [year, month]);
|
||||
|
||||
// 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.
|
||||
// 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);
|
||||
try {
|
||||
const d = await api.spendingSummary({ year, month });
|
||||
if (cancelled) return;
|
||||
setSummary(d);
|
||||
const bmap = {};
|
||||
(d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; });
|
||||
setBudgets(bmap);
|
||||
} catch (err) {
|
||||
if (!cancelled) toast.error(err.message || 'Failed to load spending summary');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
run();
|
||||
loadSummary();
|
||||
loadTransactions(1);
|
||||
return () => { cancelled = true; };
|
||||
}, [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 () => {
|
||||
setCopying(true);
|
||||
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) => {
|
||||
setActiveCat(prev => prev === catId ? undefined : catId);
|
||||
setTxPage(1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground text-sm">
|
||||
Loading spending…
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const uncatEntry = summary?.by_category?.find(c => !c.category_id);
|
||||
const catEntries = summary?.by_category?.filter(c => !!c.category_id) || [];
|
||||
const realCatEntries = summary?.by_category?.filter(c => typeof c.category_id === 'number') || [];
|
||||
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 (
|
||||
<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)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<SpendingSettingsMenu settings={spendingSettings} onToggle={(key, checked) => saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
|
||||
</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 */}
|
||||
<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">
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Total Spending</p>
|
||||
<p className="text-2xl font-bold mt-1">{fmt(summary?.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(totalBudgeted)}</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">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-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p>
|
||||
{summary?.uncategorized_count > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</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">Income Received</p>
|
||||
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(summary?.income)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No spending categories notice */}
|
||||
|
|
@ -534,6 +1020,15 @@ export default function SpendingPage() {
|
|||
</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 */}
|
||||
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
||||
|
|
@ -560,68 +1055,42 @@ export default function SpendingPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{catEntries.length === 0 && !uncatEntry ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No spending transactions found for this month.</p>
|
||||
) : (
|
||||
{realCatEntries.length === 0 && !uncatEntry && !otherEntry ? (
|
||||
<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">
|
||||
{catEntries.map(cat => {
|
||||
const bar = pctBar(cat.amount, cat.budget ?? budgets[cat.category_id]);
|
||||
const isActive = activeCat === cat.category_id;
|
||||
{groupedEntries.map(({ group, entries }) => {
|
||||
const subtotalAmount = entries.reduce((s, c) => s + c.amount, 0);
|
||||
const subtotalBudget = entries.reduce((s, c) => s + (budgets[c.category_id] ?? c.budget ?? 0), 0);
|
||||
return (
|
||||
<button
|
||||
key={cat.category_id}
|
||||
type="button"
|
||||
onClick={() => selectCat(cat.category_id)}
|
||||
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>
|
||||
{bar && (
|
||||
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted/50">
|
||||
<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 key={group.id}>
|
||||
<div className="px-4 py-2 bg-muted/20 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.name}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{fmt(subtotalAmount)} / {fmt(subtotalBudget)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="divide-y divide-border/30">
|
||||
{entries.map(renderCategoryRow)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Uncategorized row */}
|
||||
{uncatEntry && (
|
||||
<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>
|
||||
{(uncatEntry || otherEntry) && (
|
||||
<div className="divide-y divide-border/30">
|
||||
{uncatEntry && renderUncatRow()}
|
||||
{otherEntry && renderOtherRow()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{realCatEntries.map(renderCategoryRow)}
|
||||
{uncatEntry && renderUncatRow()}
|
||||
{otherEntry && renderOtherRow()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -631,19 +1100,41 @@ export default function SpendingPage() {
|
|||
<ReceiptText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
Transactions
|
||||
{activeCat !== undefined && (
|
||||
{activeCatLabel && (
|
||||
<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>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge>
|
||||
</div>
|
||||
|
||||
{txLoading ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
{txError ? (
|
||||
<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 ? (
|
||||
<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>
|
||||
|
|
@ -674,6 +1165,16 @@ export default function SpendingPage() {
|
|||
{/* Income & deposits */}
|
||||
<IncomeSection year={year} month={month} totalIncome={summary?.income} />
|
||||
|
||||
{/* Category groups management */}
|
||||
{showGroups && (
|
||||
<CategoryGroupManager
|
||||
categories={categories}
|
||||
groups={categoryGroups}
|
||||
onGroupsChanged={refreshAfterGroupChange}
|
||||
onCategoriesChanged={refreshAfterCategoryChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Merchant rules manager */}
|
||||
<RulesManager categories={categories} />
|
||||
|
||||
|
|
|
|||
|
|
@ -3473,6 +3473,30 @@ function runMigrations() {
|
|||
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 ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.39.0",
|
||||
"version": "0.40.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ router.get('/', (req, res) => {
|
|||
ensureUserDefaultCategories(req.user.id);
|
||||
|
||||
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
|
||||
WHERE user_id = ?
|
||||
AND deleted_at IS NULL
|
||||
|
|
@ -134,19 +134,25 @@ router.post('/', (req, res) => {
|
|||
// PUT /api/categories/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
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'));
|
||||
|
||||
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 (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 {
|
||||
const fields = ["name = ?", "updated_at = datetime('now')"];
|
||||
const values = [name.trim()];
|
||||
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 = ?`)
|
||||
.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 });
|
||||
} catch (e) {
|
||||
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
|
||||
router.patch('/:id/spending', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ function monthRange(year, month) {
|
|||
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) {
|
||||
return Math.abs(Number(raw)) / 100;
|
||||
}
|
||||
|
|
@ -27,21 +34,46 @@ function cents(raw) {
|
|||
|
||||
function getSpendingSummary(db, userId, 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
|
||||
const rows = db.prepare(`
|
||||
// Every spending-enabled category, even with $0 activity this month (YNAB-style: budgeted
|
||||
// categories are always visible, not just ones with transactions).
|
||||
const categoryRows = db.prepare(`
|
||||
SELECT
|
||||
c.id AS category_id,
|
||||
c.name AS category_name,
|
||||
SUM(ABS(t.amount)) AS total_cents,
|
||||
COUNT(t.id) AS tx_count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON c.id = t.spending_category_id AND c.deleted_at IS NULL
|
||||
WHERE ${SPENDING_WHERE}
|
||||
AND (t.posted_date BETWEEN ? AND ? OR (t.posted_date IS NULL AND t.transacted_at BETWEEN ? AND ?))
|
||||
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
|
||||
FROM categories c
|
||||
LEFT JOIN category_groups cg ON cg.id = c.group_id
|
||||
LEFT JOIN transactions t ON t.spending_category_id = c.id
|
||||
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
|
||||
ORDER BY total_cents DESC
|
||||
`).all(userId, start, end, start + 'T00:00:00', end + 'T23:59:59');
|
||||
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, ...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(`
|
||||
SELECT category_id, amount FROM spending_budgets
|
||||
|
|
@ -49,26 +81,56 @@ function getSpendingSummary(db, userId, year, month) {
|
|||
`).all(userId, year, month);
|
||||
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;
|
||||
const byCategory = rows.map(r => {
|
||||
const byCategory = categoryRows.map(r => {
|
||||
totalCents += r.total_cents;
|
||||
return {
|
||||
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,
|
||||
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
|
||||
byCategory.forEach(c => {
|
||||
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?.amount ?? 0;
|
||||
const uncategorized_count = uncatRow?.tx_count ?? 0;
|
||||
const uncategorized_amount = uncatRow.total_cents / 100;
|
||||
const uncategorized_count = uncatRow.tx_count;
|
||||
|
||||
// Income (positive unmatched transactions this month)
|
||||
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 ?))
|
||||
`).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 {
|
||||
year, month,
|
||||
total_spending: totalCents / 100,
|
||||
uncategorized_amount,
|
||||
uncategorized_count,
|
||||
by_category: byCategory,
|
||||
category_groups: categoryGroups,
|
||||
income: (incomeRow?.total ?? 0) / 100,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const USER_SETTING_KEYS = [
|
|||
'tracker_show_drift_insights',
|
||||
'tracker_table_columns',
|
||||
'bank_auto_categorize_merchants',
|
||||
'spending_show_ready_to_assign',
|
||||
'spending_show_avg',
|
||||
'spending_show_cover_overspend',
|
||||
'spending_group_categories',
|
||||
];
|
||||
|
||||
const USER_SETTING_DEFAULTS = {
|
||||
|
|
@ -35,6 +39,10 @@ const USER_SETTING_DEFAULTS = {
|
|||
tracker_show_overdue_command_center: 'true',
|
||||
tracker_show_drift_insights: 'true',
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue