feat: reordering across management pages (Bills, Subscriptions, Categories, Snowball) — batch v0.34.1.2

This commit is contained in:
null 2026-05-30 20:04:50 -05:00
parent 6edb23cd66
commit c23cae1107
16 changed files with 723 additions and 45 deletions

View File

@ -1,15 +1,18 @@
# Bill Tracker — Changelog # 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 ### 🔧 Changed
- **Bump**`0.34.1``0.34.1.1` - **Bump**`0.34.1.1``0.34.1.2`
- **Claude.ai catalog seed** — Updated subscription catalog to match Claude Pro transaction descriptions.
--- ---
## v0.34.1 ## v0.34.1.1
### 🚀 Features ### 🚀 Features

0
bills.db Normal file
View File

View File

@ -224,6 +224,7 @@ export const api = {
// Categories // Categories
categories: () => get('/categories'), categories: () => get('/categories'),
createCategory: (data) => post('/categories', data), createCategory: (data) => post('/categories', data),
reorderCategories: (order) => put('/categories/reorder', order),
updateCategory: (id, data) => put(`/categories/${id}`, data), updateCategory: (id, data) => put(`/categories/${id}`, data),
deleteCategory: (id) => del(`/categories/${id}`), deleteCategory: (id) => del(`/categories/${id}`),
restoreCategory: (id) => post(`/categories/${id}/restore`), restoreCategory: (id) => post(`/categories/${id}/restore`),

View File

@ -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 { cn } from '@/lib/utils';
import { MobileBillRow } from '@/components/MobileBillRow'; import { MobileBillRow } from '@/components/MobileBillRow';
@ -31,17 +31,59 @@ const ALL_ON = {
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, 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 isDebt = bill.current_balance != null || bill.minimum_payment != null;
const hasHistory = hasHistoricalVisibility(bill); const hasHistory = hasHistoricalVisibility(bill);
return ( return (
<div className={cn( <div
'flex items-center gap-3 px-5 py-3.5 transition-colors', draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
'hover:bg-accent/20', 'hover:bg-accent/20',
!bill.active && 'opacity-60', !bill.active && 'opacity-60',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}> )}>
<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 ${bill.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 ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
{/* Main info */} {/* Main info */}
<div className="flex-1 min-w-0 space-y-1.5"> <div className="flex-1 min-w-0 space-y-1.5">
@ -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 ( return (
<> <>
<div className="hidden divide-y divide-border/30 sm:block"> <div className="hidden divide-y divide-border/30 sm:block">
{bills.map(bill => ( {bills.map((bill, index) => (
<BillCard <BillCard
key={bill.id} key={bill.id}
bill={bill} bill={bill}
@ -200,11 +242,13 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
onDelete={onDelete} onDelete={onDelete}
onHistory={onHistory} onHistory={onHistory}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
moveControls={moveControlsFor?.(bill, index)}
dragProps={dragPropsFor?.(bill, index)}
/> />
))} ))}
</div> </div>
<div className="space-y-3 p-3 sm:hidden"> <div className="space-y-3 p-3 sm:hidden">
{bills.map(bill => ( {bills.map((bill, index) => (
<MobileBillRow <MobileBillRow
key={bill.id} key={bill.id}
bill={bill} bill={bill}
@ -213,6 +257,8 @@ export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggl
onDelete={onDelete} onDelete={onDelete}
onHistory={onHistory} onHistory={onHistory}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
moveControls={moveControlsFor?.(bill, index)}
dragProps={dragPropsFor?.(bill, index)}
/> />
))} ))}
</div> </div>

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; 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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -8,7 +8,7 @@ function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (visibility && visibility !== 'default'); 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 hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
const statusClass = useMemo(() => { const statusClass = useMemo(() => {
@ -37,9 +37,54 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
}, [bill.active]); }, [bill.active]);
return ( return (
<div className="rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10"> <div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-primary/40',
)}
>
<div className="flex min-w-0 items-start justify-between gap-3"> <div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0"> <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',
!moveControls?.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="flex flex-col">
<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 ${bill.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 ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<button <button
type="button" type="button"

19
client/lib/reorder.js Normal file
View File

@ -0,0 +1,19 @@
export function moveInArray(items, fromIndex, toIndex) {
if (!Array.isArray(items)) return [];
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length) {
return items;
}
const next = [...items];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
return next;
}
export function reorderPayload(items) {
return Object.fromEntries((items || []).map((item, index) => [item.id, index]));
}
export function movedItemId(before, after) {
const moved = (after || []).find((item, index) => item.id !== before?.[index]?.id);
return moved?.id || after?.[0]?.id || null;
}

View File

@ -18,6 +18,7 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
import BillsTableInner from '@/components/BillsTableInner'; import BillsTableInner from '@/components/BillsTableInner';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
@ -555,6 +556,9 @@ export default function BillsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
category: FILTER_ALL, category: FILTER_ALL,
cycle: FILTER_ALL, cycle: FILTER_ALL,
@ -759,6 +763,78 @@ export default function BillsPage() {
const inactive = filteredBills.filter(b => !b.active); const inactive = filteredBills.filter(b => !b.active);
const totalActive = bills.filter(b => b.active).length; const totalActive = bills.filter(b => b.active).length;
const totalInactive = bills.filter(b => !b.active).length; const totalInactive = bills.filter(b => !b.active).length;
const reorderEnabled = !hasFilters && !loading;
async function persistBillOrder(nextBills, movedId) {
setBills(nextBills);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextBills));
toast.success('Bill order saved');
load();
} catch (err) {
toast.error(err.message || 'Failed to save bill order');
load();
} finally {
setMovingBillId(null);
}
}
function reorderBillGroup(activeState, orderedGroup) {
const sourceGroup = bills.filter(bill => !!bill.active === activeState);
const replacements = [...orderedGroup];
const nextBills = bills.map(bill => (
!!bill.active === activeState ? replacements.shift() : bill
));
persistBillOrder(nextBills, movedItemId(sourceGroup, orderedGroup));
}
function moveControlsForGroup(group, activeState) {
return (bill, index) => ({
enabled: reorderEnabled,
moving: movingBillId === bill.id,
canMoveUp: index > 0,
canMoveDown: index < group.length - 1,
onMoveUp: () => reorderBillGroup(activeState, moveInArray(group, index, index - 1)),
onMoveDown: () => reorderBillGroup(activeState, moveInArray(group, index, index + 1)),
});
}
function dragPropsForGroup(group, activeState) {
return (bill, index) => {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === bill.id,
isDropTarget: dropTargetId === bill.id && draggingId !== bill.id,
onDragStart: (event) => {
setDraggingId(bill.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(bill.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== bill.id) setDropTargetId(bill.id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = group.findIndex(item => item.id === sourceId);
if (fromIndex >= 0) reorderBillGroup(activeState, moveInArray(group, fromIndex, index));
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
};
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -873,7 +949,10 @@ export default function BillsPage() {
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Active Active
</span> </span>
<span className="text-xs tabular-nums text-muted-foreground">{active.length}</span> <span className="text-xs tabular-nums text-muted-foreground">
{active.length}
{!reorderEnabled && active.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
</span>
</div> </div>
{loading ? ( {loading ? (
@ -906,6 +985,8 @@ export default function BillsPage() {
onToggle={handleToggle} onToggle={handleToggle}
onDelete={handleDeleteRequest} onDelete={handleDeleteRequest}
onDuplicate={handleDuplicateBill} onDuplicate={handleDuplicateBill}
moveControlsFor={moveControlsForGroup(active, true)}
dragPropsFor={dragPropsForGroup(active, true)}
/> />
)} )}
</div> </div>
@ -934,7 +1015,10 @@ export default function BillsPage() {
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Inactive Inactive
</span> </span>
<span className="text-xs tabular-nums text-muted-foreground">{inactive.length}</span> <span className="text-xs tabular-nums text-muted-foreground">
{inactive.length}
{!reorderEnabled && inactive.length > 1 && <span className="ml-2 hidden sm:inline">Clear filters to reorder</span>}
</span>
</div> </div>
<BillsTableInner <BillsTableInner
bills={inactive} bills={inactive}
@ -944,6 +1028,8 @@ export default function BillsPage() {
onDelete={handleDeleteRequest} onDelete={handleDeleteRequest}
onHistory={setHistoryTarget} onHistory={setHistoryTarget}
onDuplicate={handleDuplicateBill} onDuplicate={handleDuplicateBill}
moveControlsFor={moveControlsForGroup(inactive, false)}
dragPropsFor={dragPropsForGroup(inactive, false)}
/> />
</div> </div>
)} )}

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
ChevronDown, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api.js'; import { api } from '@/api.js';
import { Button, buttonVariants } from '@/components/ui/button'; import { Button, buttonVariants } from '@/components/ui/button';
@ -16,6 +16,7 @@ import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate } from '@/lib/utils';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
function plural(count, label) { function plural(count, label) {
return `${count} ${label}${count === 1 ? '' : 's'}`; return `${count} ${label}${count === 1 ? '' : 's'}`;
@ -236,6 +237,9 @@ export default function CategoriesPage() {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [expanded, setExpanded] = useState(() => new Set()); const [expanded, setExpanded] = useState(() => new Set());
const [showEmptyCategories, setShowEmptyCategories] = useState(false); 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 addInputRef = useRef(null);
const [renameTarget, setRenameTarget] = useState(null); const [renameTarget, setRenameTarget] = useState(null);
@ -361,6 +365,84 @@ export default function CategoriesPage() {
const visibleCategories = showEmptyCategories const visibleCategories = showEmptyCategories
? categories ? categories
: categories.filter(cat => categoryBillCount(cat) > 0); : 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 ( return (
<TooltipProvider delayDuration={180}> <TooltipProvider delayDuration={180}>
@ -463,11 +545,26 @@ export default function CategoriesPage() {
</Button> </Button>
</div> </div>
)} )}
{visibleCategories.map((cat) => { {visibleCategories.map((cat, index) => {
const isExpanded = expanded.has(cat.id); const isExpanded = expanded.has(cat.id);
const preview = billPreview(cat.bill_names); const preview = billPreview(cat.bill_names);
const moveControls = categoryMoveControls(cat, index);
const dragProps = categoryDragProps(cat, index);
return ( return (
<section key={cat.id} className="bg-card/35"> <section
key={cat.id}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'bg-card/35',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
@ -482,6 +579,42 @@ export default function CategoriesPage() {
)} )}
> >
<div className="flex min-w-0 flex-1 items-start gap-3"> <div className="flex min-w-0 flex-1 items-start gap-3">
<div
className="flex shrink-0 items-center gap-0.5"
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<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="flex flex-col">
<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 category up"
aria-label={`Move ${cat.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 category down"
aria-label={`Move ${cat.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<ChevronDown <ChevronDown
className={cn( className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform', 'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react'; import { ArrowDown, ArrowUp, GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff, CheckCircle2, Circle, AlertCircle, RefreshCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/Skeleton'; import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { moveInArray } from '@/lib/reorder';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import PlanStatusBanner from '@/components/snowball/PlanStatusBanner'; import PlanStatusBanner from '@/components/snowball/PlanStatusBanner';
@ -494,6 +495,12 @@ export default function SnowballPage() {
toast.success('Arranged smallest-to-largest balance'); toast.success('Arranged smallest-to-largest balance');
}; };
const moveDebt = (fromIndex, toIndex) => {
if (ramseyMode || saving || fromIndex === toIndex) return;
setBills(prev => moveInArray(prev, fromIndex, toIndex));
setDirty(true);
};
// save order // save order
const handleSaveOrder = async () => { const handleSaveOrder = async () => {
setSaving(true); setSaving(true);
@ -929,18 +936,42 @@ export default function SnowballPage() {
<div className="flex items-stretch"> <div className="flex items-stretch">
{/* Grip */} {/* Grip */}
<div <div className="flex items-center gap-0.5 px-3 transition-colors touch-none shrink-0">
data-grip <div
onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }} data-grip
className={cn( onPointerDown={e => { if (!ramseyMode) onPointerDown(e, index); }}
'flex items-center px-3 transition-colors touch-none shrink-0', className={cn(
ramseyMode 'transition-colors',
? 'text-muted-foreground/10 cursor-not-allowed' ramseyMode
: 'text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing', ? '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'} )}
> aria-label={ramseyMode ? 'Ramsey Mode controls order' : 'Drag to reorder'}
<GripVertical className="h-5 w-5" /> >
<GripVertical className="h-5 w-5" />
</div>
<div className="flex flex-col">
<button
type="button"
onClick={() => moveDebt(index, index - 1)}
disabled={ramseyMode || saving || index === 0}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt up"
aria-label={`Move ${bill.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => moveDebt(index, index + 1)}
disabled={ramseyMode || saving || index === bills.length - 1}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move debt down"
aria-label={`Move ${bill.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div> </div>
{/* Main content */} {/* Main content */}

View File

@ -6,6 +6,9 @@ import {
CheckCircle2, CheckCircle2,
CheckCircle, CheckCircle,
Cloud, Cloud,
ArrowDown,
ArrowUp,
GripVertical,
Link2, Link2,
Loader2, Loader2,
Pause, Pause,
@ -26,6 +29,7 @@ import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = { const TYPE_LABELS = {
streaming: 'Streaming', 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 ( return (
<div className={cn( <div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'group',
'grid min-w-0 gap-3 border-b border-border/40 px-4 py-4 last:border-b-0', 'grid min-w-0 gap-3 border-b border-border/40 px-4 py-4 last:border-b-0',
'md:grid-cols-[minmax(0,1fr)_14rem_auto] md:items-center', 'md:grid-cols-[auto_minmax(0,1fr)_14rem_auto] md:items-center',
!item.active && 'opacity-60', !item.active && 'opacity-60',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}> )}>
<div className="flex shrink-0 items-center gap-0.5 self-start md:self-center">
<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="flex flex-col">
<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 subscription up"
aria-label={`Move ${item.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 subscription down"
aria-label={`Move ${item.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
@ -316,6 +362,9 @@ export default function SubscriptionsPage() {
const [modal, setModal] = useState(null); const [modal, setModal] = useState(null);
const [matchTarget, setMatchTarget] = useState(null); const [matchTarget, setMatchTarget] = useState(null);
const [recSearch, setRecSearch] = useState(''); const [recSearch, setRecSearch] = useState('');
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [txQuery, setTxQuery] = useState(''); const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]); const [txResults, setTxResults] = useState([]);
@ -358,7 +407,7 @@ export default function SubscriptionsPage() {
useEffect(() => { useEffect(() => {
load(); load();
loadRecommendations(); loadRecommendations();
api.bills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {}); api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
}, [load, loadRecommendations]); }, [load, loadRecommendations]);
useEffect(() => { useEffect(() => {
@ -481,6 +530,88 @@ export default function SubscriptionsPage() {
const subscriptions = data.subscriptions || []; const subscriptions = data.subscriptions || [];
const active = subscriptions.filter(item => item.active); const active = subscriptions.filter(item => item.active);
const paused = subscriptions.filter(item => !item.active); const paused = subscriptions.filter(item => !item.active);
const reorderEnabled = !loading && bills.length > 0;
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
setBills(nextBills);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextBills));
toast.success('Subscription order saved');
await load();
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
} catch (err) {
toast.error(err.message || 'Failed to save subscription order');
await load();
} finally {
setMovingBillId(null);
}
}
function reorderSubscriptionGroup(activeState, orderedGroup) {
const sourceGroup = subscriptions.filter(item => !!item.active === activeState);
const replacements = [...orderedGroup];
const nextSubscriptions = subscriptions.map(item => (
!!item.active === activeState ? replacements.shift() : item
));
const sourceBills = bills.length ? bills : subscriptions;
const affectedIds = new Set(sourceGroup.map(item => item.id));
const billById = new Map(sourceBills.map(item => [item.id, item]));
const orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item }));
const nextBills = sourceBills.map(bill => (
affectedIds.has(bill.id) ? orderedBills.shift() : bill
));
persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup));
}
function moveControlsForGroup(group, activeState) {
return (item, index) => ({
enabled: reorderEnabled,
moving: movingBillId === item.id,
canMoveUp: index > 0,
canMoveDown: index < group.length - 1,
onMoveUp: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index - 1)),
onMoveDown: () => reorderSubscriptionGroup(activeState, moveInArray(group, index, index + 1)),
});
}
function dragPropsForGroup(group, activeState) {
return (item, index) => {
if (!reorderEnabled) return { draggable: false };
return {
draggable: true,
isDragging: draggingId === item.id,
isDropTarget: dropTargetId === item.id && draggingId !== item.id,
onDragStart: (event) => {
setDraggingId(item.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(item.id));
},
onDragEnter: () => {
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
},
onDragOver: (event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
if (draggingId && draggingId !== item.id) setDropTargetId(item.id);
},
onDrop: (event) => {
event.preventDefault();
const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId);
const fromIndex = group.findIndex(row => row.id === sourceId);
if (fromIndex >= 0) reorderSubscriptionGroup(activeState, moveInArray(group, fromIndex, index));
setDraggingId(null);
setDropTargetId(null);
},
onDragEnd: () => {
setDraggingId(null);
setDropTargetId(null);
},
};
};
}
const MIN_CONFIDENCE = 90; const MIN_CONFIDENCE = 90;
const highConfidenceRecs = useMemo( const highConfidenceRecs = useMemo(
@ -544,11 +675,25 @@ export default function SubscriptionsPage() {
</div> </div>
) : ( ) : (
<> <>
{active.map(item => ( {active.map((item, index) => (
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} /> <SubscriptionRow
key={item.id}
item={item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
moveControls={moveControlsForGroup(active, true)(item, index)}
dragProps={dragPropsForGroup(active, true)(item, index)}
/>
))} ))}
{paused.map(item => ( {paused.map((item, index) => (
<SubscriptionRow key={item.id} item={item} onEdit={bill => setModal({ bill })} onToggle={toggleSubscription} /> <SubscriptionRow
key={item.id}
item={item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
moveControls={moveControlsForGroup(paused, false)(item, index)}
dragProps={dragPropsForGroup(paused, false)(item, index)}
/>
))} ))}
</> </>
)} )}

View File

@ -50,6 +50,8 @@ const COLUMN_WHITELIST = new Set([
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include', 'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
'sort_order', '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', 'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until',
// categories table columns
'sort_order',
// sessions table columns // sessions table columns
'created_at', 'created_at',
// financial_accounts table columns // financial_accounts table columns
@ -2498,6 +2500,16 @@ function runMigrations() {
) )
`).run(); `).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', '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': { 'v0.51': {
description: 'bills: snowball_exempt column', description: 'bills: snowball_exempt column',
sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt'] sql: ['ALTER TABLE bills DROP COLUMN snowball_exempt']

View File

@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
sort_order INTEGER,
deleted_at TEXT, deleted_at TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')) updated_at TEXT DEFAULT (datetime('now'))

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.34.1.1", "version": "0.34.1.2",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -9,11 +9,13 @@ router.get('/', (req, res) => {
ensureUserDefaultCategories(req.user.id); ensureUserDefaultCategories(req.user.id);
const categories = db.prepare(` 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 FROM categories
WHERE user_id = ? WHERE user_id = ?
AND deleted_at IS NULL 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); `).all(req.user.id);
const billsByCategory = db.prepare(` const billsByCategory = db.prepare(`
@ -65,6 +67,44 @@ router.get('/', (req, res) => {
res.json(shaped); 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 // POST /api/categories
router.post('/', (req, res) => { router.post('/', (req, res) => {
const db = getDb(); const db = getDb();

View File

@ -228,7 +228,11 @@ function getSubscriptions(db, userId) {
WHERE b.user_id = ? WHERE b.user_id = ?
AND b.deleted_at IS NULL AND b.deleted_at IS NULL
AND b.is_subscription = 1 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); `).all(userId).map(decorateSubscription);
} }

View File

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