From 743379fc9413f911319b7679c37540530e4d0990 Mon Sep 17 00:00:00 2001 From: null Date: Thu, 4 Jun 2026 20:01:51 -0500 Subject: [PATCH] feat: spending toggle per category, empty state, income query, auto-enable on rule creation --- client/api.js | 1 + client/pages/CategoriesPage.jsx | 27 +++++++++++++++++- client/pages/SpendingPage.jsx | 23 ++++++++++++++-- db/database.js | 49 +++++++++++++++++++++++++++++++++ routes/categories.js | 42 +++++++++++++++++++++++----- services/spendingService.js | 9 +++++- 6 files changed, 139 insertions(+), 12 deletions(-) diff --git a/client/api.js b/client/api.js index ef9eb51..c64915e 100644 --- a/client/api.js +++ b/client/api.js @@ -256,6 +256,7 @@ export const api = { createCategory: (data) => post('/categories', data), reorderCategories: (order) => put('/categories/reorder', order), updateCategory: (id, data) => put(`/categories/${id}`, data), + toggleCategorySpending: (id, val) => patch(`/categories/${id}/spending`, { spending_enabled: val }), deleteCategory: (id) => del(`/categories/${id}`), restoreCategory: (id) => post(`/categories/${id}/restore`), diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index a2a42b4..660dd71 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { - ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, + ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart, } from 'lucide-react'; import { api } from '@/api.js'; import { Button, buttonVariants } from '@/components/ui/button'; @@ -641,6 +641,31 @@ export default function CategoriesPage() {
+ + + + + + {cat.spending_enabled ? 'Shown in Spending page — click to disable' : 'Enable for Spending page'} + + - {categories.map(cat => ( + {categories.length === 0 ? ( +

+ No spending categories. Enable some in Categories. +

+ ) : categories.map(cat => (
+ {/* No spending categories notice */} + {categories.length === 0 && ( +
+ +
+ No spending categories are enabled yet. Go to{' '} + Categories + {' '}and enable "Spending" on the categories you want to use here. +
+
+ )} + {/* Category breakdown */}
diff --git a/db/database.js b/db/database.js index c7e398c..a4ed9e9 100644 --- a/db/database.js +++ b/db/database.js @@ -2835,6 +2835,55 @@ function runMigrations() { console.log('[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets'); } + }, + { + version: 'v0.88', + description: 'categories: spending_enabled flag to separate bill vs spending categories', + dependsOn: ['v0.87'], + run: function() { + const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); + if (!cols.includes('spending_enabled')) + db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0'); + + // Mark the v0.87-seeded defaults as spending-enabled + const SPENDING_DEFAULTS = ['Groceries','Dining','Fuel & Transport','Shopping','Entertainment','Health','Travel','Other']; + const placeholder = SPENDING_DEFAULTS.map(() => '?').join(','); + db.exec(`UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map(n => `'${n.replace("'", "''")}'`).join(',')})`); + + // Mark any category already linked to a spending rule as spending-enabled + try { + db.exec(` + UPDATE categories SET spending_enabled=1 + WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules) + `); + } catch { /* spending_category_rules may not exist on legacy paths */ } + + console.log('[v0.88] categories.spending_enabled added, seeded defaults marked'); + } + }, + { + version: 'v0.89', + description: 'categories: seed spending defaults for users who had existing categories before v0.87', + dependsOn: ['v0.88'], + run: function() { + const SPENDING_DEFAULTS = [ + 'Groceries','Dining','Fuel & Transport','Shopping', + 'Entertainment','Health','Travel','Other' + ]; + const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all(); + const insert = db.prepare(` + INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled) + VALUES (?, ?, ?, 1, 1) + `); + let seeded = 0; + for (const user of users) { + const hasSpending = db.prepare('SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1').get(user.id); + if (!hasSpending) { + SPENDING_DEFAULTS.forEach((name, i) => { insert.run(user.id, name, 200 + i); seeded++; }); + } + } + console.log(`[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`); + } } ]; diff --git a/routes/categories.js b/routes/categories.js index a0d4dd0..d10b1a0 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -5,11 +5,12 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database'); // GET /api/categories router.get('/', (req, res) => { + try { const db = getDb(); ensureUserDefaultCategories(req.user.id); const categories = db.prepare(` - SELECT id, user_id, name, sort_order, created_at, updated_at + SELECT id, user_id, name, sort_order, spending_enabled, created_at, updated_at FROM categories WHERE user_id = ? AND deleted_at IS NULL @@ -55,6 +56,7 @@ router.get('/', (req, res) => { return { ...category, + spending_enabled: !!category.spending_enabled, bill_count: activeBillCount, active_bill_count: activeBillCount, inactive_bill_count: inactiveBillCount, @@ -65,6 +67,10 @@ router.get('/', (req, res) => { }); res.json(shaped); + } catch (err) { + console.error('[categories GET]', err.message); + res.status(500).json({ error: 'Failed to load categories' }); + } }); // PUT /api/categories/reorder @@ -126,21 +132,43 @@ router.post('/', (req, res) => { // PUT /api/categories/:id router.put('/:id', (req, res) => { const db = getDb(); - const { name } = req.body; + const { name, spending_enabled } = 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')); try { - db.prepare("UPDATE categories SET name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?") - .run(name.trim(), req.params.id, req.user.id); - res.json(db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(req.params.id, req.user.id)); + 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); } + 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); + res.json({ ...updated, spending_enabled: !!updated.spending_enabled }); } catch (e) { - if (e.message.includes('UNIQUE')) { + if (e.message?.includes('UNIQUE')) { return res.status(409).json(standardizeError('Category already exists', 'CONFLICT', 'name')); } - throw e; + console.error('[categories PUT]', e.message); + res.status(500).json({ error: 'Failed to update category' }); + } +}); + +// PATCH /api/categories/:id/spending — toggle spending_enabled without requiring name +router.patch('/:id/spending', (req, res) => { + const db = getDb(); + const cat = db.prepare('SELECT id, spending_enabled 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')); + + const enabled = req.body?.spending_enabled !== undefined ? !!req.body.spending_enabled : !cat.spending_enabled; + try { + db.prepare("UPDATE categories SET spending_enabled=?, updated_at=datetime('now') WHERE id=? AND user_id=?") + .run(enabled ? 1 : 0, req.params.id, req.user.id); + res.json({ id: cat.id, spending_enabled: enabled }); + } catch (err) { + console.error('[categories PATCH spending]', err.message); + res.status(500).json({ error: 'Failed to update category' }); } }); diff --git a/services/spendingService.js b/services/spendingService.js index 3c66122..2bdc4eb 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -163,7 +163,9 @@ function categorizeTransaction(db, userId, txId, categoryId, saveMerchantRule = VALUES (?, ?, ?) ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id `).run(userId, categoryId, merchant); - // Apply this rule to all existing matching transactions + // Auto-mark this category as spending-enabled + db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?") + .run(categoryId, userId); applySpendingCategoryRules(db, userId, merchant); } catch { /* safe to ignore */ } } @@ -257,6 +259,11 @@ function addSpendingCategoryRule(db, userId, categoryId, merchant) { VALUES (?, ?, ?) ON CONFLICT(user_id, merchant) DO UPDATE SET category_id=excluded.category_id `).run(userId, categoryId, normalized); + // Auto-mark this category as spending-enabled so it shows in the picker + try { + db.prepare("UPDATE categories SET spending_enabled=1, updated_at=datetime('now') WHERE id=? AND user_id=?") + .run(categoryId, userId); + } catch { /* non-critical */ } applySpendingCategoryRules(db, userId, normalized); }