From 5449427b86a4bae8f7cdd055f775bef0b0cf062f Mon Sep 17 00:00:00 2001 From: null Date: Sat, 30 May 2026 16:13:37 -0500 Subject: [PATCH] Add persistent bill reordering --- FUTURE.md | 15 +-- client/api.js | 2 + client/pages/TrackerPage.jsx | 199 +++++++++++++++++++++++++++++++++-- db/database.js | 28 ++++- db/schema.sql | 2 + routes/bills.js | 67 +++++++++++- services/trackerService.js | 2 +- tests/billReorder.test.js | 133 +++++++++++++++++++++++ 8 files changed, 426 insertions(+), 22 deletions(-) create mode 100644 tests/billReorder.test.js diff --git a/FUTURE.md b/FUTURE.md index a44ef38..af087cb 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -178,17 +178,18 @@ Currently no unit tests exist for components or hooks. The only testing is funct --- -### 🔵 Missing Bill Grouping and Reorganization API -**Added:** 2026-05-08 by Neo +### 🔵 Custom Bill Grouping Criteria +**Added:** 2026-05-30 by Codex +**Origin:** Split from "Missing Bill Grouping and Reorganization API" after persistent bill ordering was implemented. **Description:** -No way to reorder bills, drag-and-drop, or group by custom criteria. +Bills can now be reordered and remembered on the tracker page, but users still cannot define custom tracker groupings beyond the existing due-date buckets. **Implementation Notes:** -- `sort_order` column to bills table (default NULL, ordered first by sort_order then due_day) -- `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}` -- `PUT /api/bills/:id/archived` to soft-archive -- Estimated effort: 6 hours +- Add user-defined grouping settings for tracker sections +- Decide whether grouping is global or per-user/per-view +- Preserve manual `sort_order` inside each custom group +- Estimated effort: 3-5 hours --- diff --git a/client/api.js b/client/api.js index bcdde8b..c3876d6 100644 --- a/client/api.js +++ b/client/api.js @@ -156,6 +156,8 @@ export const api = { bill: (id) => get(`/bills/${id}`), createBill: (data) => post('/bills', data), updateBill: (id, data) => put(`/bills/${id}`, data), + reorderBills: (order) => put('/bills/reorder', order), + archiveBill: (id, archived = true) => put(`/bills/${id}/archived`, { archived }), updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), billAmortization: (id, opts = {}) => { const params = new URLSearchParams(); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index d3fe7ee..573a8be 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, useMemo, useTransition } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react'; +import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, GripVertical, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { useTracker, useDriftReport } from '@/hooks/useQueries'; @@ -106,6 +106,13 @@ function rowIsDebt(row) { || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } +function moveInArray(items, fromIndex, toIndex) { + const next = [...items]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + return next; +} + function FilterChip({ active, children, onClick }) { return ( + + +
{row.website ? ( @@ -1314,7 +1361,7 @@ function Row({ row, year, month, refresh, index, onEditBill }) { ); } -function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { +function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) { const amountRef = useRef(null); const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); @@ -1417,15 +1464,54 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { return ( <>
-
+
+
+
+
{row.website ? ( )} +
@@ -1623,7 +1710,9 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) { } // ── Bucket ───────────────────────────────────────────────────────────────── -function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { +function Bucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) { + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); // Use actual_amount (if set) as the per-row threshold; exclude skipped rows from totals const activeRows = rows.filter(r => !r.is_skipped); const totalThreshold = activeRows.reduce((s, r) => s + (r.actual_amount ?? r.expected_amount ?? 0), 0); @@ -1639,6 +1728,56 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { const pct = totalThreshold > 0 ? Math.min((totalPaidTowardDue / totalThreshold) * 100, 100) : 0; const allPaid = pct >= 100; + function reorderByIndex(fromIndex, toIndex) { + if (!reorderEnabled || fromIndex === toIndex || fromIndex < 0 || toIndex < 0) return; + onReorderRows?.(moveInArray(rows, fromIndex, toIndex)); + } + + function dragPropsFor(row, index) { + if (!reorderEnabled) return { draggable: false }; + return { + draggable: true, + isDragging: draggingId === row.id, + isDropTarget: dropTargetId === row.id && draggingId !== row.id, + onDragStart: (event) => { + setDraggingId(row.id); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', String(row.id)); + }, + onDragEnter: () => { + if (draggingId && draggingId !== row.id) setDropTargetId(row.id); + }, + onDragOver: (event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (draggingId && draggingId !== row.id) setDropTargetId(row.id); + }, + onDrop: (event) => { + event.preventDefault(); + const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); + const fromIndex = rows.findIndex(item => item.id === sourceId); + reorderByIndex(fromIndex, index); + setDraggingId(null); + setDropTargetId(null); + }, + onDragEnd: () => { + setDraggingId(null); + setDropTargetId(null); + }, + }; + } + + function moveControlsFor(row, index) { + return { + enabled: !!reorderEnabled, + moving: movingBillId === row.id, + canMoveUp: index > 0, + canMoveDown: index < rows.length - 1, + onMoveUp: () => reorderByIndex(index, index - 1), + onMoveDown: () => reorderByIndex(index, index + 1), + }; + } + return (
@@ -1685,6 +1824,9 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { {allPaid && ( Done )} + {!reorderEnabled && rows.length > 1 && ( + Clear filters to reorder + )}
@@ -1727,6 +1869,8 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { refresh={refresh} index={i} onEditBill={onEditBill} + moveControls={moveControlsFor(r, i)} + dragProps={dragPropsFor(r, i)} /> )) )} @@ -1793,6 +1937,8 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) { refresh={refresh} index={i} onEditBill={onEditBill} + moveControls={moveControlsFor(r, i)} + dragProps={dragPropsFor(r, i)} /> )) )} @@ -1815,6 +1961,8 @@ export default function TrackerPage() { // Edit Starting Amounts modal: true when open, false when closed const [editStartingOpen, setEditStartingOpen] = useState(false); const [search, setSearch] = useState(''); + const [orderedRows, setOrderedRows] = useState(null); + const [movingBillId, setMovingBillId] = useState(null); const [filters, setFilters] = useState({ category: FILTER_ALL, cycle: FILTER_ALL, @@ -1830,9 +1978,14 @@ export default function TrackerPage() { const [commandCenterPayRow, setCommandCenterPayRow] = useState(null); // Use React Query for data fetching - const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month); + const { data, isLoading: loading, isError, error, refetch, dataUpdatedAt } = useTracker(year, month); const { data: driftData, refetch: refetchDrift } = useDriftReport(); + useEffect(() => { + setOrderedRows(null); + setMovingBillId(null); + }, [dataUpdatedAt, year, month]); + useEffect(() => { const querySearch = searchParams.get('search') || ''; if (querySearch) setSearch(querySearch); @@ -1866,7 +2019,7 @@ export default function TrackerPage() { } - const rows = data?.rows || []; + const rows = orderedRows || data?.rows || []; const summary = data?.summary || {}; const toggleFilter = (key) => setFilters(prev => ({ ...prev, [key]: !prev[key] })); const setFilterValue = (key, value) => setFilters(prev => ({ ...prev, [key]: value })); @@ -1933,6 +2086,34 @@ export default function TrackerPage() { }, [filters, rows, search]); const first = filteredRows.filter(r => r.bucket === '1st'); const second = filteredRows.filter(r => r.bucket === '15th'); + const reorderEnabled = !hasFilters && !loading && !isError; + + async function persistTrackerOrder(nextRows, movedBillId) { + const payload = Object.fromEntries(nextRows.map((row, index) => [row.id, index])); + setOrderedRows(nextRows); + setMovingBillId(movedBillId); + try { + await api.reorderBills(payload); + toast.success('Bill order saved'); + refetch(); + } catch (err) { + setOrderedRows(null); + toast.error(err.message || 'Failed to save bill order'); + } finally { + setMovingBillId(null); + } + } + + function handleReorderBucket(bucket, orderedBucketRows) { + const sourceRows = rows; + const nextRows = [...sourceRows]; + const replacement = [...orderedBucketRows]; + for (let i = 0; i < nextRows.length; i += 1) { + if (nextRows[i].bucket === bucket) nextRows[i] = replacement.shift(); + } + const moved = orderedBucketRows.find((row, index) => row.id !== (sourceRows.filter(item => item.bucket === bucket)[index]?.id)); + persistTrackerOrder(nextRows, moved?.id || orderedBucketRows[0]?.id); + } return (
@@ -2146,8 +2327,8 @@ export default function TrackerPage() { )} {!isError && (first.length > 0 || second.length > 0) && (
- {first.length > 0 && } - {second.length > 0 && } + {first.length > 0 && handleReorderBucket('1st', next)} />} + {second.length > 0 && handleReorderBucket('15th', next)} />}
)} diff --git a/db/database.js b/db/database.js index 34d8d08..990fea2 100644 --- a/db/database.js +++ b/db/database.js @@ -48,7 +48,7 @@ const COLUMN_WHITELIST = new Set([ // bills table columns 'history_visibility', 'interest_rate', 'user_id', 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', - 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before', + 'sort_order', 'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before', 'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until', // sessions table columns 'created_at', @@ -2423,7 +2423,8 @@ function runMigrations() { description: 'monthly_bill_state: add snoozed_until for overdue command center', dependsOn: ['v0.69'], run: function() { - db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT'); + const cols = db.prepare('PRAGMA table_info(monthly_bill_state)').all().map(c => c.name); + if (!cols.includes('snoozed_until')) db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT'); } }, { @@ -2431,8 +2432,20 @@ function runMigrations() { description: 'bills: add drift_snoozed_until; users: add notify_amount_change', dependsOn: ['v0.70'], run: function() { - db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT'); - db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1'); + const billCols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); + if (!billCols.includes('drift_snoozed_until')) db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT'); + if (!userCols.includes('notify_amount_change')) db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1'); + } + }, + { + version: 'v0.72', + description: 'bills: persistent tracker sort order', + dependsOn: ['v0.71'], + run: function() { + const cols = db.prepare('PRAGMA table_info(bills)').all().map(c => c.name); + if (!cols.includes('sort_order')) db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER'); + db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)'); } } ]; @@ -2866,6 +2879,13 @@ const ROLLBACK_SQL_MAP = { 'ALTER TABLE bills DROP COLUMN is_subscription', ] }, + 'v0.72': { + description: 'bills: persistent tracker sort order', + sql: [ + 'DROP INDEX IF EXISTS idx_bills_user_sort', + 'ALTER TABLE bills 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 8440757..6423327 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS bills ( current_balance REAL, minimum_payment REAL, snowball_order INTEGER, + sort_order INTEGER, snowball_include INTEGER NOT NULL DEFAULT 0, snowball_exempt INTEGER NOT NULL DEFAULT 0, is_subscription INTEGER NOT NULL DEFAULT 0, @@ -184,6 +185,7 @@ CREATE TABLE IF NOT EXISTS notifications ( CREATE INDEX IF NOT EXISTS idx_notifications_lookup ON notifications(bill_id, user_id, year, month); CREATE INDEX IF NOT EXISTS idx_bills_active ON bills(active); +CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day); CREATE INDEX IF NOT EXISTS idx_payments_bill_id ON payments(bill_id); CREATE INDEX IF NOT EXISTS idx_payments_paid_date ON payments(paid_date); CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); diff --git a/routes/bills.js b/routes/bills.js index 575ddbe..99af8ab 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -31,11 +31,58 @@ router.get('/', (req, res) => { WHERE b.user_id = ? AND b.deleted_at IS NULL ${includeInactive ? '' : 'AND b.active = 1'} - ORDER BY b.due_day ASC, b.name ASC + ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC `).all(req.user.id); res.json(bills); }); +// ── PUT /api/bills/reorder ─────────────────────────────────────────────────── +router.put('/reorder', (req, res) => { + const db = getDb(); + const entries = Object.entries(req.body || {}).map(([billId, sortOrder]) => ({ + billId: Number(billId), + sortOrder: Number(sortOrder), + })); + + if (entries.length === 0) { + return res.status(400).json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder')); + } + + const invalid = entries.find(({ billId, sortOrder }) => ( + !Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0 + )); + if (invalid) { + return res.status(400).json(standardizeError('Reorder payload must map bill ids to non-negative integer positions', 'VALIDATION_ERROR', 'reorder')); + } + + const ids = entries.map(item => item.billId); + const placeholders = ids.map(() => '?').join(','); + const owned = db.prepare(` + SELECT id + FROM bills + 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 bills were not found', 'NOT_FOUND', 'bill_id')); + } + + const update = db.prepare("UPDATE bills SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?"); + const applyOrder = db.transaction((items) => { + for (const item of items) update.run(item.sortOrder, item.billId, req.user.id); + }); + applyOrder(entries); + + const bills = db.prepare(` + SELECT b.*, c.name AS category_name + FROM bills b + LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL + WHERE b.user_id = ? AND b.deleted_at IS NULL AND b.active = 1 + ORDER BY CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC + `).all(req.user.id); + + res.json({ success: true, bills }); +}); + // ── GET /api/bills/audit?inactive=true ─────────────────────────────────────── router.get('/audit', (req, res) => { const db = getDb(); @@ -381,6 +428,24 @@ router.put('/:id', (req, res) => { res.json(updated); }); +// ── PUT /api/bills/:id/archived ────────────────────────────────────────────── +router.put('/:id/archived', (req, res) => { + const db = getDb(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id')); + } + const bill = db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id); + if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + + const archived = !!req.body?.archived; + db.prepare("UPDATE bills SET active = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?") + .run(archived ? 0 : 1, id, req.user.id); + + const updated = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(id, req.user.id); + res.json({ ...updated, archived: !updated.active }); +}); + // ── DELETE /api/bills/:id — soft delete for 30-day recovery ─────────────────── router.delete('/:id', (req, res) => { const db = getDb(); diff --git a/services/trackerService.js b/services/trackerService.js index a6642e9..aff97dc 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -35,7 +35,7 @@ function monthOffset(year, month, offset) { } const FETCH_BILLS_ORDER = { - due_day: 'b.due_day ASC, b.name ASC', + due_day: 'CASE WHEN b.sort_order IS NULL THEN 1 ELSE 0 END, b.sort_order ASC, b.due_day ASC, b.name ASC', id: 'b.id ASC', }; diff --git a/tests/billReorder.test.js b/tests/billReorder.test.js new file mode 100644 index 0000000..6ddf1b8 --- /dev/null +++ b/tests/billReorder.test.js @@ -0,0 +1,133 @@ +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-reorder-test-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { getTracker } = require('../services/trackerService'); + +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(`reorder-user-${suffix}`, `reorder-user-${suffix}@local`).lastInsertRowid; +} + +function createBill(db, userId, name, dueDay) { + return db.prepare(` + INSERT INTO bills (user_id, name, due_day, expected_amount) + VALUES (?, ?, ?, 25) + `).run(userId, name, dueDay).lastInsertRowid; +} + +function callBillsRoute(routePath, method, { userId, params = {}, query = {}, body = {} }) { + const billsRouter = require('../routes/bills'); + const layer = billsRouter.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, + query, + 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('bill reorder endpoint persists tracker order for the current user', async () => { + const db = getDb(); + const userId = createUser(db, 'owner'); + const water = createBill(db, userId, 'Water', 10); + const power = createBill(db, userId, 'Power', 5); + const rent = createBill(db, userId, 'Rent', 20); + + const initial = getTracker(userId, { year: 2026, month: 5 }, new Date('2026-05-01T12:00:00Z')); + assert.deepEqual(initial.rows.map(row => row.id), [power, water, rent]); + + const response = await callBillsRoute('/reorder', 'put', { + userId, + body: { + [rent]: 0, + [water]: 1, + [power]: 2, + }, + }); + + assert.equal(response.status, 200); + assert.equal(response.data.success, true); + + const tracker = getTracker(userId, { year: 2026, month: 5 }, new Date('2026-05-01T12:00:00Z')); + assert.deepEqual(tracker.rows.map(row => row.id), [rent, water, power]); +}); + +test('bill reorder rejects bills outside the current user scope', async () => { + const db = getDb(); + const ownerId = createUser(db, 'scoped-owner'); + const otherId = createUser(db, 'scoped-other'); + const ownerBill = createBill(db, ownerId, 'Internet', 7); + const otherBill = createBill(db, otherId, 'Other Internet', 7); + + const response = await callBillsRoute('/reorder', 'put', { + userId: ownerId, + body: { + [ownerBill]: 0, + [otherBill]: 1, + }, + }); + + assert.equal(response.status, 404); +}); + +test('bill archived endpoint toggles tracker visibility without deleting the bill', async () => { + const db = getDb(); + const userId = createUser(db, 'archive'); + const billId = createBill(db, userId, 'Streaming', 12); + + const archived = await callBillsRoute('/:id/archived', 'put', { + userId, + params: { id: String(billId) }, + body: { archived: true }, + }); + + assert.equal(archived.status, 200); + assert.equal(archived.data.archived, true); + assert.equal(getTracker(userId, { year: 2026, month: 5 }, new Date('2026-05-01T12:00:00Z')).rows.length, 0); + + const restored = await callBillsRoute('/:id/archived', 'put', { + userId, + params: { id: String(billId) }, + body: { archived: false }, + }); + + assert.equal(restored.status, 200); + assert.equal(restored.data.archived, false); + assert.equal(getTracker(userId, { year: 2026, month: 5 }, new Date('2026-05-01T12:00:00Z')).rows.length, 1); +});