From 35e5d185deaaaadbe05e9c753247a6d401f0b95c Mon Sep 17 00:00:00 2001 From: null Date: Sun, 14 Jun 2026 19:21:34 -0500 Subject: [PATCH] feat(spending): category groups, YNAB-style spending page overhaul, 3-month averages, cover overspending (batch 0.41.0) --- .learnings/bishop/ERRORS.md | 52 ++ .learnings/bishop/LEARNINGS.md | 61 +- client/api.js | 6 + .../transactions/CategoryPicker.jsx | 4 +- client/pages/SpendingPage.jsx | 707 +++++++++++++++--- db/database.js | 24 + package.json | 2 +- routes/categories.js | 86 ++- services/spendingService.js | 100 ++- services/userSettings.js | 8 + tests/categoryGroups.test.js | 129 ++++ tests/spendingSummary.test.js | 105 +++ 12 files changed, 1156 insertions(+), 128 deletions(-) create mode 100644 tests/categoryGroups.test.js create mode 100644 tests/spendingSummary.test.js diff --git a/.learnings/bishop/ERRORS.md b/.learnings/bishop/ERRORS.md index f84b72f..6225533 100644 --- a/.learnings/bishop/ERRORS.md +++ b/.learnings/bishop/ERRORS.md @@ -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 diff --git a/.learnings/bishop/LEARNINGS.md b/.learnings/bishop/LEARNINGS.md index 23069b1..b96c1da 100644 --- a/.learnings/bishop/LEARNINGS.md +++ b/.learnings/bishop/LEARNINGS.md @@ -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 diff --git a/client/api.js b/client/api.js index baa3117..81b2be2 100644 --- a/client/api.js +++ b/client/api.js @@ -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), diff --git a/client/components/transactions/CategoryPicker.jsx b/client/components/transactions/CategoryPicker.jsx index fc95c9c..f0fe486 100644 --- a/client/components/transactions/CategoryPicker.jsx +++ b/client/components/transactions/CategoryPicker.jsx @@ -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" > - {currentCat?.name ?? 'Uncategorized'} + {currentCat?.name ?? currentLabel ?? 'Uncategorized'} {open && ( diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.jsx index 3a382c6..08b1f57 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.jsx @@ -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 }) { {saving ? Saving… - : + : } {rememberPrompt && ( @@ -332,6 +353,260 @@ function RulesManager({ categories }) { ); } +// ── Spending settings menu ─────────────────────────────────────────────────── + +function SpendingSettingsMenu({ settings, onToggle, saving }) { + return ( + + + + + + Spending page options + + e.preventDefault()} + onCheckedChange={checked => onToggle('spending_show_ready_to_assign', checked)} + > + Ready to Assign + + e.preventDefault()} + onCheckedChange={checked => onToggle('spending_show_avg', checked)} + > + 3-month averages + + e.preventDefault()} + onCheckedChange={checked => onToggle('spending_show_cover_overspend', checked)} + > + Cover overspending + + e.preventDefault()} + onCheckedChange={checked => onToggle('spending_group_categories', checked)} + > + Group categories + + + + ); +} + +// ── 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 No category has budget left to cover this; + } + + return ( +
+ + {open && ( +
+ {options.map(s => ( + + ))} +
+ )} +
+ ); +} + +// ── 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 ( +
+ + + {open && ( +
+
+ 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" + /> + +
+ + {groups.length === 0 ? ( +

+ No groups yet. Create one above, then assign categories to it below. +

+ ) : ( +
+ {groups.map(g => ( +
+ {editingId === g.id ? ( + <> + 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" + /> + + + + ) : ( + <> + {g.name} + + + + )} +
+ ))} +
+ )} + + {categories.length > 0 && ( +
+

Assign categories

+ {categories.map(c => ( +
+ {c.name} + +
+ ))} +
+ )} +
+ )} +
+ ); +} + // ── 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 ( -
- Loading spending… +
+
+
+
+ + +
+ +
+ +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ + +
); } - 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 ( +
+ +
+ + {showCover && level === 'over' && ( + + )} +
+
+ ); + }; + + const renderUncatRow = () => ( + + ); + + const renderOtherRow = () => ( +
+
+ + Other categories + + {fmt(otherEntry.amount)} +
+

{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}

+
+ ); + + const activeCatLabel = activeCat === null + ? 'Uncategorized' + : activeCat !== undefined + ? realCatEntries.find(c => c.category_id === activeCat)?.category_name + : null; return (
@@ -501,26 +954,59 @@ export default function SpendingPage() { + saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
+ {/* Summary load error */} + {summaryError && ( +
+ + {summaryError} + +
+ )} + + {/* Ready to Assign */} + {showReadyToAssign && ( +
= 0 ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-amber-500/25 bg-amber-500/10'}`}> +

Ready to Assign

+

= 0 ? 'text-emerald-500' : 'text-amber-500'}`}> + {fmt(Math.abs(readyToAssign))} +

+

+ {readyToAssign >= 0 ? 'left to budget this month' : 'over-assigned — budgets exceed income'} +

+
+ )} + {/* Overview strip */}
-

Total Spending

-

{fmt(summary?.total_spending)}

+

Budgeted

+

{fmt(totalBudgeted)}

+

Spent

+

{fmt(totalSpending)}

+
+
+

Remaining

+

{fmt(remaining)}

+
+
+

Income

+

{fmt(income)}

+
+

Uncategorized

{fmt(summary?.uncategorized_amount)}

{summary?.uncategorized_count > 0 && (

{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}

)}
-
-

Income Received

-

{fmt(summary?.income)}

-
{/* No spending categories notice */} @@ -534,6 +1020,15 @@ export default function SpendingPage() { )} + {catError && ( +
+ + {catError} + +
+ )} {/* Category breakdown */}
@@ -560,68 +1055,42 @@ export default function SpendingPage() {
- {catEntries.length === 0 && !uncatEntry ? ( -

No spending transactions found for this month.

- ) : ( + {realCatEntries.length === 0 && !uncatEntry && !otherEntry ? ( +
+ +

No spending found for this month yet.

+

Set a budget or categorize transactions below to see them here.

+
+ ) : groupedEntries ? (
- {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 ( - +
+ {entries.map(renderCategoryRow)} +
+
); })} - - {/* Uncategorized row */} - {uncatEntry && ( - + {(uncatEntry || otherEntry) && ( +
+ {uncatEntry && renderUncatRow()} + {otherEntry && renderOtherRow()} +
)} + ) : ( +
+ {realCatEntries.map(renderCategoryRow)} + {uncatEntry && renderUncatRow()} + {otherEntry && renderOtherRow()} +
)} @@ -631,19 +1100,41 @@ export default function SpendingPage() { Transactions - {activeCat !== undefined && ( + {activeCatLabel && ( - — {activeCat === null ? 'Uncategorized' : catEntries.find(c => c.category_id === activeCat)?.category_name} + — {activeCatLabel} )} {txTotal} - {txLoading ? ( -
Loading…
+ {txError ? ( +
+ + {txError} + +
+ ) : txLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
) : transactions.length === 0 ? ( -
No transactions found.
+
+ +

No transactions found{activeCatLabel ? ` for ${activeCatLabel}` : ''}.

+
) : ( <>
@@ -674,6 +1165,16 @@ export default function SpendingPage() { {/* Income & deposits */} + {/* Category groups management */} + {showGroups && ( + + )} + {/* Merchant rules manager */} diff --git a/db/database.js b/db/database.js index c773255..3a8c664 100644 --- a/db/database.js +++ b/db/database.js @@ -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 ─────────────────────────────────────────── diff --git a/package.json b/package.json index e10310d..07b60f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.39.0", + "version": "0.40.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/categories.js b/routes/categories.js index 5632cca..6f0f3c1 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -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(); diff --git a/services/spendingService.js b/services/spendingService.js index 45b48d0..4ae4bb0 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -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, }; } diff --git a/services/userSettings.js b/services/userSettings.js index 0a47a54..1ccd0bb 100644 --- a/services/userSettings.js +++ b/services/userSettings.js @@ -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() { diff --git a/tests/categoryGroups.test.js b/tests/categoryGroups.test.js new file mode 100644 index 0000000..4db3f24 --- /dev/null +++ b/tests/categoryGroups.test.js @@ -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); +}); diff --git a/tests/spendingSummary.test.js b/tests/spendingSummary.test.js new file mode 100644 index 0000000..396d14d --- /dev/null +++ b/tests/spendingSummary.test.js @@ -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'); +});