feat: tracker bucket rollover logic, utils cleanup, HISTORY update

This commit is contained in:
null 2026-06-07 00:41:07 -05:00
parent f1817a520b
commit 1ebb2da50a
4 changed files with 232 additions and 17 deletions

View File

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

View File

@ -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 (
<TableHead
aria-sort={active ? (sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending') : 'none'}
className={cn('py-2.5', className)}
>
<button
type="button"
onClick={() => onSort?.(sortKey)}
className={cn(
'group inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-xs font-medium uppercase tracking-wider transition-all',
'hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60',
active ? 'bg-accent/70 text-foreground shadow-sm' : 'text-muted-foreground'
)}
>
<span>{children}</span>
<Icon
className={cn(
'h-3.5 w-3.5 transition-opacity',
active ? 'opacity-100' : 'opacity-0 group-hover:opacity-55'
)}
aria-hidden="true"
/>
</button>
</TableHead>
);
}
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
<Table className="min-w-[1120px]">
<TableHeader>
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
<TableHead className="w-[18%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">Bill</TableHead>
<TableHead className="w-[10%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">Due</TableHead>
<TableHead className="w-[10%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground text-right">Expected</TableHead>
<TableHead className="w-[10%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground/80 text-right">Last Month</TableHead>
<TableHead className="w-[10%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground text-right">Paid</TableHead>
<TableHead className="w-[10%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">Paid Date</TableHead>
<TableHead className="w-[9%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">Status</TableHead>
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
<SortableHead sortKey="expected" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-right">Expected</SortableHead>
<SortableHead sortKey="previous" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-right">Last Month</SortableHead>
<SortableHead sortKey="paid" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%] text-right">Paid</SortableHead>
<SortableHead sortKey="paid_date" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Paid Date</SortableHead>
<SortableHead sortKey="status" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[9%]">Status</SortableHead>
<TableHead className="w-[10%] py-2.5" />
<TableHead className="w-[23%] py-2.5 text-xs font-medium uppercase tracking-wider text-muted-foreground border-l border-border/80 pl-4">
Notes

View File

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

View File

@ -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() {
)}
<div className="rounded-xl border border-border/80 bg-card/95 p-4 shadow-sm shadow-black/15 space-y-3">
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_220px_180px_auto] lg:items-center">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[minmax(220px,1fr)_220px_180px_220px_auto_auto] xl:items-center">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@ -491,6 +529,27 @@ export default function TrackerPage() {
))}
</SelectContent>
</Select>
<Select value={sortKey} onValueChange={setSort}>
<SelectTrigger className="h-10">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
{TRACKER_SORT_OPTIONS.map(option => (
<SelectItem key={option.key} value={option.key}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
disabled={!hasSort}
onClick={toggleSortDirection}
className="h-10 justify-center gap-2 text-xs"
title={hasSort ? `Sort ${sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}` : 'Choose a sort first'}
>
{sortDir === TRACKER_SORT_ASC ? <ArrowUp className="h-3.5 w-3.5" /> : <ArrowDown className="h-3.5 w-3.5" />}
<span>{sortDir === TRACKER_SORT_ASC ? 'Asc' : 'Desc'}</span>
</Button>
<Button
type="button"
variant="ghost"
@ -511,6 +570,11 @@ export default function TrackerPage() {
<FilterChip active={filters.debt} onClick={() => toggleFilter('debt')}>Debt</FilterChip>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{filteredRows.length} of {rows.length} shown
{hasSort && (
<span className="ml-2 hidden sm:inline">
· sorted by {TRACKER_SORT_LABELS[sortKey]} {sortDir === TRACKER_SORT_ASC ? 'ascending' : 'descending'}
</span>
)}
</span>
</div>
</div>
@ -676,8 +740,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} 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)} />}
{first.length > 0 && <Bucket label="1st 14th" rows={first} year={year} month={month} refresh={refetch} onEditBill={handleOpenEditBill} loading={loading} reorderEnabled={reorderEnabled} movingBillId={movingBillId} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} 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} sortKey={sortKey} sortDir={sortDir} onSort={handleSortHeader} onReorderRows={(next) => handleReorderBucket('15th', next)} />}
</div>
)}