Add persistent bill reordering

This commit is contained in:
null 2026-05-30 16:13:37 -05:00
parent 8b0f33085c
commit 5449427b86
8 changed files with 426 additions and 22 deletions

View File

@ -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
---

View File

@ -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();

View File

@ -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 (
<button
@ -791,7 +798,7 @@ function NotesCell({ row, refresh }) {
}
// Table row
function Row({ row, year, month, refresh, index, onEditBill }) {
function Row({ row, year, month, refresh, index, onEditBill, moveControls, dragProps }) {
const amountRef = useRef(null);
const [editPayment, setEditPayment] = useState(null);
const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false);
@ -1008,16 +1015,56 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
return (
<>
<TableRow
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group border-border/65 transition-colors duration-150',
isSkipped ? 'opacity-40' : 'hover:bg-accent/50',
rowBg,
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
style={{ animationDelay: `${index * 40}ms` }}
>
{/* Bill name + category + monthly notes (if set) */}
<TableCell className="w-[18%] py-3">
<div className="flex items-center gap-2.5">
<div className="flex shrink-0 items-center gap-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="hidden flex-col sm:flex">
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill up"
aria-label={`Move ${row.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill down"
aria-label={`Move ${row.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div>
<div className="flex items-center gap-1">
{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,14 +1464,53 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
return (
<>
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
'space-y-3 transition-colors',
isSkipped ? 'opacity-55' : rowBg,
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
)}
style={{ animationDelay: `${index * 40}ms` }}
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 gap-2">
<div className="flex shrink-0 items-center gap-0.5 pt-0.5">
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls?.enabled && 'cursor-grab',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<button
type="button"
onClick={moveControls?.onMoveUp}
disabled={!moveControls?.enabled || !moveControls?.canMoveUp || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill up"
aria-label={`Move ${row.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls?.onMoveDown}
disabled={!moveControls?.enabled || !moveControls?.canMoveDown || moveControls?.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move bill down"
aria-label={`Move ${row.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
{row.website ? (
@ -1468,6 +1554,7 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill }) {
</p>
)}
</div>
</div>
<StatusBadge status={effectiveStatus} clickable={!isSkipped} onClick={handleTogglePaid} />
</div>
@ -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 (
<div className="rounded-xl border border-border/80 overflow-hidden bg-card/95 shadow-sm shadow-black/15">
@ -1685,6 +1824,9 @@ function Bucket({ label, rows, year, month, refresh, onEditBill, loading }) {
{allPaid && (
<span className="text-[11px] text-emerald-500">Done</span>
)}
{!reorderEnabled && rows.length > 1 && (
<span className="hidden text-[11px] text-muted-foreground/60 xl:inline">Clear filters to reorder</span>
)}
</div>
</div>
@ -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 (
<div className="space-y-5">
@ -2146,8 +2327,8 @@ export default function TrackerPage() {
)}
{!isError && (first.length > 0 || second.length > 0) && (
<div className="space-y-5">
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} />}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} onReorderRows={(next) => handleReorderBucket('1st', next)} />}
{second.length > 0 && <Bucket label="15th 31st" rows={second} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} onReorderRows={(next) => handleReorderBucket('15th', next)} />}
</div>
)}

View File

@ -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']

View File

@ -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);

View File

@ -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();

View File

@ -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',
};

133
tests/billReorder.test.js Normal file
View File

@ -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);
});