diff --git a/HISTORY.md b/HISTORY.md index 1685d5a..3146be8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,8 @@ ### ✨ Added +- **Tracker table sorting** — The Tracker page now supports URL-backed sorting by bill name, due date, expected amount, last-month paid, paid amount, remaining amount, paid date, and status. Desktop table headers are clickable with active direction indicators, while the filter bar provides a compact sort selector for mobile and tablet. Status sorting uses the tracker lifecycle order (missed, late, due soon, upcoming, autodraft, paid, skipped) instead of plain alphabetical labels, and manual bill reordering is paused while a sorted view is active. + - **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction. - **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account. diff --git a/client/components/tracker/TrackerBucket.jsx b/client/components/tracker/TrackerBucket.jsx index 87d9764..bb8e4c9 100644 --- a/client/components/tracker/TrackerBucket.jsx +++ b/client/components/tracker/TrackerBucket.jsx @@ -1,13 +1,44 @@ import { useState } from 'react'; +import { ArrowDown, ArrowUp } from 'lucide-react'; import { cn, fmt } from '@/lib/utils'; import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell, } from '@/components/ui/table'; -import { moveInArray } from '@/lib/trackerUtils'; +import { TRACKER_SORT_ASC, TRACKER_SORT_DEFAULT, moveInArray } from '@/lib/trackerUtils'; import { TrackerRow as Row } from '@/components/tracker/TrackerRow'; import { MobileTrackerRow } from '@/components/tracker/MobileTrackerRow'; -export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId }) { +function SortableHead({ sortKey, activeSortKey, sortDir, onSort, children, className }) { + const active = activeSortKey === sortKey; + const Icon = sortDir === TRACKER_SORT_ASC ? ArrowUp : ArrowDown; + return ( + + + + ); +} + +export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, loading, onReorderRows, reorderEnabled, movingBillId, sortKey = TRACKER_SORT_DEFAULT, sortDir = TRACKER_SORT_ASC, onSort }) { 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 @@ -178,13 +209,13 @@ export function TrackerBucket({ label, rows, year, month, refresh, onEditBill, l - Bill - Due - Expected - Last Month - Paid - Paid Date - Status + Bill + Due + Expected + Last Month + Paid + Paid Date + Status Notes diff --git a/client/lib/trackerUtils.js b/client/lib/trackerUtils.js index 85761b5..01c43d2 100644 --- a/client/lib/trackerUtils.js +++ b/client/lib/trackerUtils.js @@ -8,6 +8,30 @@ export const FILTER_ALL = 'all'; // Sentinel for the "no method" select option — empty string crashes Radix Select export const METHOD_NONE = 'none'; +export const TRACKER_SORT_DEFAULT = 'manual'; +export const TRACKER_SORT_ASC = 'asc'; +export const TRACKER_SORT_DESC = 'desc'; + +export const TRACKER_SORT_OPTIONS = [ + { key: TRACKER_SORT_DEFAULT, label: 'Custom order', defaultDir: TRACKER_SORT_ASC }, + { key: 'name', label: 'Bill name', defaultDir: TRACKER_SORT_ASC }, + { key: 'due', label: 'Due date', defaultDir: TRACKER_SORT_ASC }, + { key: 'expected', label: 'Expected amount', defaultDir: TRACKER_SORT_DESC }, + { key: 'previous', label: 'Last month paid', defaultDir: TRACKER_SORT_DESC }, + { key: 'paid', label: 'Paid amount', defaultDir: TRACKER_SORT_DESC }, + { key: 'remaining', label: 'Remaining amount', defaultDir: TRACKER_SORT_DESC }, + { key: 'paid_date', label: 'Paid date', defaultDir: TRACKER_SORT_DESC }, + { key: 'status', label: 'Status', defaultDir: TRACKER_SORT_ASC }, +]; + +export const TRACKER_SORT_LABELS = Object.fromEntries( + TRACKER_SORT_OPTIONS.map(option => [option.key, option.label]) +); + +export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries( + TRACKER_SORT_OPTIONS.map(option => [option.key, option.defaultDir]) +); + export const ROW_STATUS_CLS = { paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]', autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]', @@ -75,6 +99,100 @@ export function rowIsDebt(row) { || ['credit card', 'credit cards', 'loan', 'loans', 'debt', 'mortgage'].some(token => category.includes(token)); } +const STATUS_SORT_ORDER = { + missed: 0, + late: 1, + due_soon: 2, + upcoming: 3, + autodraft: 4, + paid: 5, + skipped: 6, +}; + +function parseDateSortValue(value) { + if (!value) return null; + const parsed = Date.parse(`${value}T00:00:00`); + return Number.isFinite(parsed) ? parsed : null; +} + +function numericSortValue(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function trackerSortValue(row, key) { + switch (key) { + case 'name': + return String(row.name || '').toLowerCase(); + case 'due': + return parseDateSortValue(row.due_date) ?? numericSortValue(row.due_day); + case 'expected': + return numericSortValue(rowThreshold(row)); + case 'previous': + return numericSortValue(row.previous_month_paid); + case 'paid': + return numericSortValue(row.total_paid); + case 'remaining': + return paymentSummary(row, rowThreshold(row)).remaining; + case 'paid_date': + return parseDateSortValue(row.last_paid_date); + case 'status': + return STATUS_SORT_ORDER[rowEffectiveStatus(row)] ?? 99; + default: + return null; + } +} + +function compareSortValues(a, b, dir) { + const aMissing = a === null || a === undefined || a === ''; + const bMissing = b === null || b === undefined || b === ''; + if (aMissing && bMissing) return 0; + if (aMissing) return 1; + if (bMissing) return -1; + + let result = 0; + if (typeof a === 'string' || typeof b === 'string') { + result = String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true }); + } else { + result = a === b ? 0 : (a > b ? 1 : -1); + } + return dir === TRACKER_SORT_DESC ? -result : result; +} + +export function normalizeTrackerSortKey(key) { + return TRACKER_SORT_LABELS[key] ? key : TRACKER_SORT_DEFAULT; +} + +export function normalizeTrackerSortDir(dir) { + return dir === TRACKER_SORT_DESC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC; +} + +export function sortTrackerRows(rows, sortKey, sortDir) { + const key = normalizeTrackerSortKey(sortKey); + if (key === TRACKER_SORT_DEFAULT) return rows; + + const dir = normalizeTrackerSortDir(sortDir); + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + const primary = compareSortValues( + trackerSortValue(a.row, key), + trackerSortValue(b.row, key), + dir + ); + if (primary !== 0) return primary; + + const due = compareSortValues(trackerSortValue(a.row, 'due'), trackerSortValue(b.row, 'due'), TRACKER_SORT_ASC); + if (due !== 0) return due; + + const name = compareSortValues(trackerSortValue(a.row, 'name'), trackerSortValue(b.row, 'name'), TRACKER_SORT_ASC); + if (name !== 0) return name; + + return a.index - b.index; + }) + .map(item => item.row); +} + export function moveInArray(items, fromIndex, toIndex) { const next = [...items]; const [moved] = next.splice(fromIndex, 1); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 14655b3..f4100a0 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark, ArrowUpToLine } from 'lucide-react'; +import { ChevronLeft, ChevronRight, AlertCircle, CheckCircle2, Plus, Search, X, RefreshCw, Landmark, ArrowUpToLine, ArrowUp, ArrowDown } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api.js'; import { useTracker, useDriftReport } from '@/hooks/useQueries'; @@ -24,6 +24,9 @@ import DriftInsightPanel from '@/components/tracker/DriftInsightPanel'; import { MONTHS, FILTER_ALL, paymentDateForTrackerMonth, amountSearchText, rowEffectiveStatus, rowIsPaid, rowIsDebt, + TRACKER_SORT_DEFAULT, TRACKER_SORT_ASC, TRACKER_SORT_DESC, TRACKER_SORT_OPTIONS, + TRACKER_SORT_DEFAULT_DIRS, TRACKER_SORT_LABELS, + normalizeTrackerSortKey, normalizeTrackerSortDir, sortTrackerRows, } from '@/lib/trackerUtils'; import { FilterChip } from '@/components/tracker/FilterChip'; import { SummaryCard, TrendCard } from '@/components/tracker/SummaryCards'; @@ -91,6 +94,11 @@ export default function TrackerPage() { overdue: searchParams.get('ov') === '1', debt: searchParams.get('de') === '1', }; + const sortKey = normalizeTrackerSortKey(searchParams.get('sort') || TRACKER_SORT_DEFAULT); + const hasSort = sortKey !== TRACKER_SORT_DEFAULT; + const sortDir = hasSort + ? normalizeTrackerSortDir(searchParams.get('dir') || TRACKER_SORT_DEFAULT_DIRS[sortKey] || TRACKER_SORT_ASC) + : TRACKER_SORT_ASC; // replace: true keeps history clean for rapid navigation (e.g. search keystrokes) const updateParams = useCallback((patch) => { @@ -241,6 +249,33 @@ export default function TrackerPage() { const paramMap = { category: 'fc', cycle: 'cy' }; updateParams({ [paramMap[key]]: value === FILTER_ALL ? null : value }); }; + const setSort = (key) => { + const normalizedKey = normalizeTrackerSortKey(key); + if (normalizedKey === TRACKER_SORT_DEFAULT) { + updateParams({ sort: null, dir: null }); + return; + } + updateParams({ + sort: normalizedKey, + dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, + }); + }; + const toggleSortDirection = () => { + if (!hasSort) return; + updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); + }; + const handleSortHeader = (key) => { + const normalizedKey = normalizeTrackerSortKey(key); + if (normalizedKey === TRACKER_SORT_DEFAULT) return; + if (sortKey === normalizedKey) { + updateParams({ dir: sortDir === TRACKER_SORT_ASC ? TRACKER_SORT_DESC : TRACKER_SORT_ASC }); + return; + } + updateParams({ + sort: normalizedKey, + dir: TRACKER_SORT_DEFAULT_DIRS[normalizedKey] || TRACKER_SORT_ASC, + }); + }; const hasFilters = !!( search.trim() || filters.category !== FILTER_ALL @@ -251,9 +286,10 @@ export default function TrackerPage() { || filters.unpaid || filters.overdue || filters.debt + || hasSort ); const resetFilters = () => { - updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null }); + updateParams({ q: null, fc: null, cy: null, ap: null, b1: null, b2: null, un: null, ov: null, de: null, sort: null, dir: null }); }; const categoryOptions = useMemo(() => { const map = new Map(); @@ -296,14 +332,16 @@ export default function TrackerPage() { // When pin-upcoming is on, sort by urgency so overdue/due-soon bills surface // at the top of each bucket. Bucket split runs after so each bucket is sorted independently. const URGENCY_ORDER = { missed: 0, late: 1, due_soon: 2, upcoming: 3 }; - const sortedRows = pinUpcoming - ? [...filteredRows].sort((a, b) => { + const sortedRows = hasSort + ? sortTrackerRows(filteredRows, sortKey, sortDir) + : pinUpcoming + ? [...filteredRows].sort((a, b) => { const ua = URGENCY_ORDER[a.status] ?? 99; const ub = URGENCY_ORDER[b.status] ?? 99; if (ua !== ub) return ua - ub; return (a.due_day ?? 99) - (b.due_day ?? 99); }) - : filteredRows; + : filteredRows; const first = sortedRows.filter(r => r.bucket === '1st'); const second = sortedRows.filter(r => r.bucket === '15th'); @@ -459,7 +497,7 @@ export default function TrackerPage() { )}
-
+
@@ -676,8 +740,8 @@ export default function TrackerPage() { )} {!isError && (first.length > 0 || second.length > 0) && (
- {first.length > 0 && handleReorderBucket('1st', next)} />} - {second.length > 0 && handleReorderBucket('15th', next)} />} + {first.length > 0 && handleReorderBucket('1st', next)} />} + {second.length > 0 && handleReorderBucket('15th', next)} />}
)}