diff --git a/HISTORY.md b/HISTORY.md index 03865bb..0757777 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,15 +1,18 @@ # Bill Tracker — Changelog -## v0.34.1.1 +## v0.34.1.2 + +### 🚀 Features + +- **Reordering across management pages** — Bills, Subscriptions, Categories, and Snowball now expose tracker-style drag/up/down controls. Bill-backed pages persist through `sort_order`; Categories adds its own persisted `sort_order` API. ### 🔧 Changed -- **Bump** — `0.34.1` → `0.34.1.1` -- **Claude.ai catalog seed** — Updated subscription catalog to match Claude Pro transaction descriptions. +- **Bump** — `0.34.1.1` → `0.34.1.2` --- -## v0.34.1 +## v0.34.1.1 ### 🚀 Features diff --git a/bills.db b/bills.db new file mode 100644 index 0000000..e69de29 diff --git a/client/api.js b/client/api.js index 3e57038..83ee902 100644 --- a/client/api.js +++ b/client/api.js @@ -224,6 +224,7 @@ export const api = { // Categories categories: () => get('/categories'), createCategory: (data) => post('/categories', data), + reorderCategories: (order) => put('/categories/reorder', order), updateCategory: (id, data) => put(`/categories/${id}`, data), deleteCategory: (id) => del(`/categories/${id}`), restoreCategory: (id) => post(`/categories/${id}/restore`), diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index bfaa9d4..1f0c671 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -1,4 +1,4 @@ -import { Copy, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { MobileBillRow } from '@/components/MobileBillRow'; @@ -31,17 +31,59 @@ const ALL_ON = { showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, }; -function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) { +function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { const isDebt = bill.current_balance != null || bill.minimum_payment != null; const hasHistory = hasHistoricalVisibility(bill); return ( -
+
+
+ {/* Main info */}
@@ -186,11 +228,11 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, ); } -export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate }) { +export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) { return ( <>
- {bills.map(bill => ( + {bills.map((bill, index) => ( ))}
- {bills.map(bill => ( + {bills.map((bill, index) => ( ))}
diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.jsx index 4be6c44..96e8dc8 100644 --- a/client/components/MobileBillRow.jsx +++ b/client/components/MobileBillRow.jsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { Copy, History } from 'lucide-react'; +import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -8,7 +8,7 @@ function hasHistoricalVisibility(bill) { return !!bill.has_history_ranges || (visibility && visibility !== 'default'); } -export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate }) { +export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]); const statusClass = useMemo(() => { @@ -37,9 +37,54 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o }, [bill.active]); return ( -
+
-
+
+
+ +
@@ -934,7 +1015,10 @@ export default function BillsPage() { Inactive - {inactive.length} + + {inactive.length} + {!reorderEnabled && inactive.length > 1 && Clear filters to reorder} +
)} diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.jsx index bfdd246..a2a42b4 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 { - ChevronDown, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, + ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, } from 'lucide-react'; import { api } from '@/api.js'; import { Button, buttonVariants } from '@/components/ui/button'; @@ -16,6 +16,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { cn, fmt, fmtDate } from '@/lib/utils'; +import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; function plural(count, label) { return `${count} ${label}${count === 1 ? '' : 's'}`; @@ -236,6 +237,9 @@ export default function CategoriesPage() { const [adding, setAdding] = useState(false); const [expanded, setExpanded] = useState(() => new Set()); const [showEmptyCategories, setShowEmptyCategories] = useState(false); + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [movingCategoryId, setMovingCategoryId] = useState(null); const addInputRef = useRef(null); const [renameTarget, setRenameTarget] = useState(null); @@ -361,6 +365,84 @@ export default function CategoriesPage() { const visibleCategories = showEmptyCategories ? categories : categories.filter(cat => categoryBillCount(cat) > 0); + const reorderEnabled = !loading && !loadError; + + async function persistCategoryOrder(nextCategories, movedId) { + setCategories(nextCategories); + setMovingCategoryId(movedId); + try { + await api.reorderCategories(reorderPayload(nextCategories)); + toast.success('Category order saved'); + load(); + } catch (err) { + toast.error(err.message || 'Failed to save category order'); + load(); + } finally { + setMovingCategoryId(null); + } + } + + function reorderVisibleCategories(orderedVisible) { + if (!reorderEnabled) return; + const visibleIds = new Set(visibleCategories.map(cat => cat.id)); + const replacements = [...orderedVisible]; + const nextCategories = categories.map(cat => ( + visibleIds.has(cat.id) ? replacements.shift() : cat + )); + persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible)); + } + + function categoryMoveControls(cat, index) { + return { + enabled: reorderEnabled, + moving: movingCategoryId === cat.id, + canMoveUp: index > 0, + canMoveDown: index < visibleCategories.length - 1, + onMoveUp: (event) => { + event.stopPropagation(); + reorderVisibleCategories(moveInArray(visibleCategories, index, index - 1)); + }, + onMoveDown: (event) => { + event.stopPropagation(); + reorderVisibleCategories(moveInArray(visibleCategories, index, index + 1)); + }, + }; + } + + function categoryDragProps(cat, index) { + if (!reorderEnabled) return { draggable: false }; + return { + draggable: true, + isDragging: draggingId === cat.id, + isDropTarget: dropTargetId === cat.id && draggingId !== cat.id, + onDragStart: (event) => { + setDraggingId(cat.id); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(cat.id)); + }, + onDragEnter: () => { + if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id); + }, + onDragOver: (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id); + }, + onDrop: (event) => { + event.preventDefault(); + event.stopPropagation(); + const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); + const fromIndex = visibleCategories.findIndex(item => item.id === sourceId); + if (fromIndex >= 0) reorderVisibleCategories(moveInArray(visibleCategories, fromIndex, index)); + setDraggingId(null); + setDropTargetId(null); + }, + onDragEnd: () => { + setDraggingId(null); + setDropTargetId(null); + }, + }; + } return ( @@ -463,11 +545,26 @@ export default function CategoriesPage() {
)} - {visibleCategories.map((cat) => { + {visibleCategories.map((cat, index) => { const isExpanded = expanded.has(cat.id); const preview = billPreview(cat.bill_names); + const moveControls = categoryMoveControls(cat, index); + const dragProps = categoryDragProps(cat, index); return ( -
+
+
event.stopPropagation()} + onKeyDown={event => event.stopPropagation()} + > +
{ + if (ramseyMode || saving || fromIndex === toIndex) return; + setBills(prev => moveInArray(prev, fromIndex, toIndex)); + setDirty(true); + }; + // ── save order ──────────────────────────────────────────────────────────── const handleSaveOrder = async () => { setSaving(true); @@ -929,18 +936,42 @@ export default function SnowballPage() {
{/* Grip */} -
{ if (!ramseyMode) onPointerDown(e, index); }} - className={cn( - 'flex items-center px-3 transition-colors touch-none shrink-0', - ramseyMode - ? 'text-muted-foreground/10 cursor-not-allowed' - : 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing', - )} - aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'} - > - +
+
{ if (!ramseyMode) onPointerDown(e, index); }} + className={cn( + 'transition-colors', + ramseyMode + ? 'text-muted-foreground/10 cursor-not-allowed' + : 'text-muted-foreground/35 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing', + )} + aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'} + > + +
+
+ + +
{/* Main content */} diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index c634736..4241e08 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -6,6 +6,9 @@ import { CheckCircle2, CheckCircle, Cloud, + ArrowDown, + ArrowUp, + GripVertical, Link2, Loader2, Pause, @@ -26,6 +29,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import BillModal from '@/components/BillModal'; +import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; const TYPE_LABELS = { streaming: 'Streaming', @@ -62,13 +66,55 @@ function StatCard({ icon: Icon, label, value, hint }) { ); } -function SubscriptionRow({ item, onEdit, onToggle }) { +function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps }) { return ( -
+
+
) : ( <> - {active.map(item => ( - setModal({ bill })} onToggle={toggleSubscription} /> + {active.map((item, index) => ( + setModal({ bill })} + onToggle={toggleSubscription} + moveControls={moveControlsForGroup(active, true)(item, index)} + dragProps={dragPropsForGroup(active, true)(item, index)} + /> ))} - {paused.map(item => ( - setModal({ bill })} onToggle={toggleSubscription} /> + {paused.map((item, index) => ( + setModal({ bill })} + onToggle={toggleSubscription} + moveControls={moveControlsForGroup(paused, false)(item, index)} + dragProps={dragPropsForGroup(paused, false)(item, index)} + /> ))} )} diff --git a/db/database.js b/db/database.js index 5d590f7..fa22780 100644 --- a/db/database.js +++ b/db/database.js @@ -50,6 +50,8 @@ const COLUMN_WHITELIST = new Set([ 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', 'sort_order', 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until', + // categories table columns + 'sort_order', // sessions table columns 'created_at', // financial_accounts table columns @@ -2498,6 +2500,16 @@ function runMigrations() { ) `).run(); } + }, + { + version: 'v0.75', + description: 'categories: persistent sort order', + dependsOn: ['v0.74'], + run: function() { + const cols = db.prepare('PRAGMA table_info(categories)').all().map(c => c.name); + if (!cols.includes('sort_order')) db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER'); + db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)'); + } } ]; @@ -2937,6 +2949,13 @@ const ROLLBACK_SQL_MAP = { 'ALTER TABLE bills DROP COLUMN sort_order', ] }, + 'v0.75': { + description: 'categories: persistent sort order', + sql: [ + 'DROP INDEX IF EXISTS idx_categories_user_sort', + 'ALTER TABLE categories DROP COLUMN sort_order', + ] + }, 'v0.51': { description: 'bills: snowball_exempt column', sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] diff --git a/db/schema.sql b/db/schema.sql index ba1f222..0ab1fcd 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, + sort_order INTEGER, deleted_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) diff --git a/package.json b/package.json index b91ac9d..3a90019 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.34.1.1", + "version": "0.34.1.2", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/categories.js b/routes/categories.js index 3d4bc87..a0d4dd0 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -9,11 +9,13 @@ router.get('/', (req, res) => { ensureUserDefaultCategories(req.user.id); const categories = db.prepare(` - SELECT id, user_id, name, created_at, updated_at + SELECT id, user_id, name, sort_order, created_at, updated_at FROM categories WHERE user_id = ? AND deleted_at IS NULL - ORDER BY name COLLATE NOCASE ASC + ORDER BY CASE WHEN sort_order IS NULL THEN 1 ELSE 0 END, + sort_order ASC, + name COLLATE NOCASE ASC `).all(req.user.id); const billsByCategory = db.prepare(` @@ -65,6 +67,44 @@ router.get('/', (req, res) => { res.json(shaped); }); +// PUT /api/categories/reorder +router.put('/reorder', (req, res) => { + const db = getDb(); + const entries = Object.entries(req.body || {}).map(([categoryId, sortOrder]) => ({ + categoryId: Number(categoryId), + sortOrder: Number(sortOrder), + })); + + if (entries.length === 0) { + return res.status(400).json(standardizeError('At least one category order is required', 'VALIDATION_ERROR', 'reorder')); + } + + const invalid = entries.find(({ categoryId, sortOrder }) => ( + !Number.isInteger(categoryId) || categoryId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0 + )); + if (invalid) { + return res.status(400).json(standardizeError('Reorder payload must map category ids to non-negative integer positions', 'VALIDATION_ERROR', 'reorder')); + } + + const ids = entries.map(item => item.categoryId); + const placeholders = ids.map(() => '?').join(','); + const owned = db.prepare(` + SELECT id + FROM categories + WHERE user_id = ? AND deleted_at IS NULL AND id IN (${placeholders}) + `).all(req.user.id, ...ids); + if (owned.length !== ids.length) { + return res.status(404).json(standardizeError('One or more categories were not found', 'NOT_FOUND', 'category_id')); + } + + const update = db.prepare("UPDATE categories SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?"); + db.transaction((items) => { + for (const item of items) update.run(item.sortOrder, item.categoryId, req.user.id); + })(entries); + + res.json({ success: true }); +}); + // POST /api/categories router.post('/', (req, res) => { const db = getDb(); diff --git a/services/subscriptionService.js b/services/subscriptionService.js index 2ed452a..39f0acc 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -228,7 +228,11 @@ function getSubscriptions(db, userId) { WHERE b.user_id = ? AND b.deleted_at IS NULL AND b.is_subscription = 1 - ORDER BY b.active DESC, b.due_day ASC, b.name COLLATE NOCASE ASC + ORDER BY b.active DESC, + CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, + b.sort_order ASC, + b.due_day ASC, + b.name COLLATE NOCASE ASC `).all(userId).map(decorateSubscription); } diff --git a/tests/categoryReorder.test.js b/tests/categoryReorder.test.js new file mode 100644 index 0000000..7871922 --- /dev/null +++ b/tests/categoryReorder.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-category-reorder-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-reorder-${suffix}`, `category-reorder-${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 reorder endpoint persists category order for the current user', async () => { + const db = getDb(); + const userId = createUser(db, 'owner'); + const food = createCategory(db, userId, 'Food'); + const loans = createCategory(db, userId, 'Loans'); + const utilities = createCategory(db, userId, 'Utilities'); + + const response = await callCategoriesRoute('/reorder', 'put', { + userId, + body: { + [utilities]: 0, + [food]: 1, + [loans]: 2, + }, + }); + + assert.equal(response.status, 200); + assert.equal(response.data.success, true); + + const ordered = await callCategoriesRoute('/', 'get', { userId }); + assert.deepEqual( + ordered.data.filter(cat => [food, loans, utilities].includes(cat.id)).map(cat => cat.id), + [utilities, food, loans], + ); +}); + +test('category reorder rejects categories outside the current user scope', async () => { + const db = getDb(); + const ownerId = createUser(db, 'scoped-owner'); + const otherId = createUser(db, 'scoped-other'); + const ownerCategory = createCategory(db, ownerId, 'Owner Category'); + const otherCategory = createCategory(db, otherId, 'Other Category'); + + const response = await callCategoriesRoute('/reorder', 'put', { + userId: ownerId, + body: { + [ownerCategory]: 0, + [otherCategory]: 1, + }, + }); + + assert.equal(response.status, 404); +});