BillTracker/client/pages/SubscriptionsPage.jsx

1834 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import {
Bell,
CalendarDays,
CheckCircle2,
CheckCircle,
ChevronDown,
Cloud,
ArrowDown,
ArrowUp,
GripVertical,
AlertTriangle,
Info,
Link2,
Loader2,
MoreHorizontal,
Pause,
Plus,
RefreshCw,
Repeat,
Search,
Sparkles,
X,
} from 'lucide-react';
import { api } from '@/api';
import { cn, fmt, fmtDate, localDateString } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import SearchFilterPanel from '@/components/SearchFilterPanel';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import BillModal from '@/components/BillModal';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
import { getLinkImportPref } from '@/pages/SettingsPage';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { useVirtualizer } from '@tanstack/react-virtual';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = {
streaming: 'Streaming',
software: 'Software',
cloud: 'Cloud',
music: 'Music',
news: 'News',
fitness: 'Fitness',
gaming: 'Gaming',
utilities: 'Utilities',
insurance: 'Insurance',
food: 'Food',
education: 'Education',
shopping: 'Shopping',
security: 'Security',
other: 'Other',
};
const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode';
const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
const CADENCE_LABELS = {
weekly: 'Weekly',
biweekly: 'Biweekly',
monthly: 'Monthly',
quarterly: 'Quarterly',
annual: 'Yearly',
other: 'Other',
};
const SUBSCRIPTION_MONTHLY_FACTORS = {
weekly: 52 / 12,
biweekly: 26 / 12,
monthly: 1,
quarterly: 1 / 3,
annual: 1 / 12,
annually: 1 / 12,
};
function normalizedCadence(item) {
const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase();
if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
if (raw === 'biweekly') return 'biweekly';
if (raw.includes('week')) return 'weekly';
if (raw.includes('quarter')) return 'quarterly';
if (raw.includes('annual') || raw.includes('year')) return 'annual';
if (raw.includes('month') || !raw) return 'monthly';
return 'other';
}
function subscriptionMonthlyEquivalent(item) {
const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly'
? 'quarterly'
: String(item?.billing_cycle || '').toLowerCase() === 'annually'
? 'annual'
: key;
const factor = SUBSCRIPTION_MONTHLY_FACTORS[key] ?? SUBSCRIPTION_MONTHLY_FACTORS[fallback] ?? 1;
return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100;
}
function subscriptionNextDueDate(item, now = new Date()) {
const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31);
const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
let date = new Date(now.getFullYear(), now.getMonth(), dueDay);
if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay);
}
if (cycle === 'quarterly' || cycle === 'annual' || cycle === 'annually') {
const startMonth = Math.min(Math.max(Number(item?.cycle_day) || 1, 1), 12) - 1;
const step = cycle === 'quarterly' ? 3 : 12;
date = new Date(now.getFullYear(), startMonth, dueDay);
while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) {
date = new Date(date.getFullYear(), date.getMonth() + step, dueDay);
}
}
return localDateString(date);
}
function decorateSavedSubscriptionBill(bill, categories) {
const monthly = subscriptionMonthlyEquivalent(bill);
const category = categories.find(item => Number(item.id) === Number(bill.category_id));
return {
...bill,
active: !!bill.active,
is_subscription: !!bill.is_subscription,
category_name: bill.category_name || category?.name || null,
monthly_equivalent: monthly,
yearly_equivalent: Math.round(monthly * 12 * 100) / 100,
next_due_date: subscriptionNextDueDate(bill),
subscription_type: bill.subscription_type || 'other',
};
}
function subscriptionSummaryFromList(subscriptions) {
const active = subscriptions.filter(item => item.active);
const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0);
const typeTotals = new Map();
for (const item of active) {
const type = item.subscription_type || 'other';
typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
}
const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null;
return {
active_count: active.length,
paused_count: subscriptions.length - active.length,
monthly_total: Math.round(monthlyTotal * 100) / 100,
yearly_total: Math.round(monthlyTotal * 12 * 100) / 100,
top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null,
};
}
// Extended group order: monthly bills split by 1st/15th pay bucket
const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other'];
const GROUP_LABELS = {
'weekly': 'Weekly',
'biweekly': 'Biweekly',
'monthly-1st': '1st · Due days 114',
'monthly-15th': '15th · Due days 1531',
'quarterly': 'Quarterly',
'annual': 'Annual',
'other': 'Other',
};
function subscriptionGroup(item) {
const cadence = normalizedCadence(item);
if (cadence === 'monthly') {
return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th';
}
return cadence;
}
function groupIndex(item) {
const idx = GROUP_ORDER.indexOf(subscriptionGroup(item));
return idx >= 0 ? idx : GROUP_ORDER.length - 1;
}
function daysUntil(dateStr) {
if (!dateStr) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000);
}
function sortSubscriptionsByCadence(items) {
return [...items].sort((a, b) => (
groupIndex(a) - groupIndex(b)
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|| String(a.name || '').localeCompare(String(b.name || ''))
));
}
function SortModeButton({ active, children, onClick }) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'h-8 rounded-md px-3 text-xs font-semibold transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
aria-pressed={active}
>
{children}
</button>
);
}
function cycleLabel(item) {
return scheduleLabel(item);
}
function StatCard({ icon: Icon, label, value, hint }) {
return (
<div className="min-w-0 rounded-lg border border-border/70 bg-card/80 p-4">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-foreground/70">
<Icon className="h-4 w-4 text-primary" />
<span className="truncate">{label}</span>
</div>
<p className="tracker-number mt-2 truncate text-2xl font-bold tracking-tight text-foreground">{value}</p>
{hint && <p className="mt-1 text-xs font-medium text-muted-foreground">{hint}</p>}
</div>
);
}
function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 min-w-0 px-3"
onClick={() => onEdit(item)}
>
Edit
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 w-8 p-0"
disabled={busy}
aria-label={`More actions for ${item.name}`}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onSelect={() => onToggle(item)}>
{item.active ? <Pause className="h-4 w-4" /> : <CheckCircle2 className="h-4 w-4" />}
{item.active ? 'Pause' : 'Resume'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) {
return (
<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',
'md:grid-cols-[auto_minmax(0,1fr)_14rem_auto] md:items-center',
!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="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onEdit(item)}
className="min-w-0 max-w-full truncate text-left text-[15px] font-semibold text-foreground hover:text-primary"
>
{item.name}
</button>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default">
{TYPE_LABELS[item.subscription_type] || 'Other'}
</Badge>
</TooltipTrigger>
<TooltipContent>Service category</TooltipContent>
</Tooltip>
{!item.active && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-amber-500/25 bg-amber-500/10 text-amber-300 cursor-default">
Paused
</Badge>
</TooltipTrigger>
<TooltipContent>Paused not actively tracked</TooltipContent>
</Tooltip>
)}
{!!item.autopay_enabled && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 rounded border border-sky-500/25 bg-sky-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-sky-600 dark:text-sky-300 cursor-default">AP</span>
</TooltipTrigger>
<TooltipContent>Autopay enabled</TooltipContent>
</Tooltip>
)}
{!!item.has_2fa && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 rounded bg-violet-500/20 px-1.5 py-0.5 text-[11px] font-semibold text-violet-300 cursor-default">2FA</span>
</TooltipTrigger>
<TooltipContent>Two-factor authentication configured</TooltipContent>
</Tooltip>
)}
{(!!item.has_merchant_rule || !!item.has_linked_transactions) && (
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 cursor-default">L</span>
</TooltipTrigger>
<TooltipContent>Linked to bank transactions</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
<span>{item.category_name || 'Uncategorized'}</span>
{(() => {
const days = daysUntil(item.next_due_date);
const label = days === null ? null
: days < 0 ? `${Math.abs(days)}d overdue`
: days === 0 ? 'Due today'
: days === 1 ? 'Due tomorrow'
: `Due in ${days}d`;
const cls = days === null ? 'text-muted-foreground'
: days <= 1 ? 'text-rose-500'
: days <= 7 ? 'text-amber-500'
: 'text-emerald-600 dark:text-emerald-400';
return label ? (
<span className={cls}>{label} · {fmtDate(item.next_due_date)}</span>
) : null;
})()}
<span className="capitalize">{cycleLabel(item)}</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{item.reminder_days_before ?? 3}d reminder</span>
</TooltipTrigger>
<TooltipContent>Reminder sent {item.reminder_days_before ?? 3} days before the due date</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid grid-cols-2 gap-3 md:w-56">
<div>
<p className="text-xs text-muted-foreground">Per cycle</p>
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(item.expected_amount)}</p>
</div>
<div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-xs text-muted-foreground cursor-default w-fit">Monthly</p>
</TooltipTrigger>
<TooltipContent>Normalized monthly equivalent regardless of billing frequency</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="tracker-number text-sm font-semibold text-emerald-600 dark:text-emerald-300">{fmt(item.monthly_equivalent)}</p>
</div>
</div>
<SubscriptionRowActions item={item} onEdit={onEdit} onToggle={onToggle} busy={busy} />
</div>
);
}
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState(null);
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return (bills || []).filter(b => !q || b.name.toLowerCase().includes(q));
}, [bills, search]);
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Link to existing bill</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-medium text-foreground">{recommendation?.name}</span>
{recommendation && (
<span className="ml-2 text-xs">{recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · {fmt(recommendation.expected_amount)}</span>
)}
</p>
<p className="text-xs text-muted-foreground">
The matching transactions will be marked as paid under the selected bill.
</p>
</DialogHeader>
<Input
autoFocus
placeholder="Search bills…"
value={search}
onChange={e => setSearch(e.target.value)}
className="text-sm"
/>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 divide-y divide-border/40">
{filtered.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">No bills found.</p>
) : filtered.map(bill => (
<button
key={bill.id}
type="button"
onClick={() => setSelectedId(bill.id)}
className={cn(
'w-full flex items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/40',
selectedId === bill.id && 'bg-primary/10 text-primary',
)}
>
<span className="truncate font-medium">{bill.name}</span>
<span className="shrink-0 ml-3 text-xs text-muted-foreground tabular-nums">
${(bill.expected_amount ?? 0).toFixed(2)}/mo
</span>
</button>
))}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="button" className="gap-2" onClick={() => onConfirm(selectedId)} disabled={!selectedId || busy}>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
Link Bill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function TxResultRow({ tx, onTrack }) {
const dollars = (Math.abs(tx.amount) / 100).toFixed(2);
const label = tx.payee || tx.description || tx.memo || '—';
const account = tx.account_name || tx.data_source_name || null;
const isMatched = tx.match_status === 'matched';
const catalogMatch = tx.catalog_match;
return (
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium truncate max-w-[200px]">{label}</span>
<TooltipProvider delayDuration={300}>
{isMatched ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="shrink-0 border-emerald-400/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 gap-1 text-[10px] cursor-default">
<CheckCircle className="h-3 w-3" />
{tx.matched_bill_name || 'Matched'}
</Badge>
</TooltipTrigger>
<TooltipContent>Already linked to this bill</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground cursor-default">Unmatched</Badge>
</TooltipTrigger>
<TooltipContent>Not yet linked to any bill</TooltipContent>
</Tooltip>
)}
{catalogMatch && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="shrink-0 border-primary/25 bg-primary/10 text-primary text-[10px] cursor-default">
Known: {catalogMatch.name}
</Badge>
</TooltipTrigger>
<TooltipContent>Recognized in the subscription catalog as {catalogMatch.name}</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
<p className="text-[11px] text-muted-foreground mt-0.5">
{tx.posted_date}{account ? ` · ${account}` : ''}
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
</p>
</div>
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">${dollars}</span>
{!isMatched && (
<Button size="sm" variant="outline" onClick={() => onTrack(tx)}
className="h-8 shrink-0 gap-1.5 px-2.5 text-xs">
<Plus className="h-3 w-3" /> Track
</Button>
)}
</div>
);
}
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant={variant}
className={cn('h-9 w-9 shrink-0 p-0', className)}
disabled={disabled}
onClick={onClick}
aria-label={label}
>
<Icon className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="h-9 w-9 shrink-0 p-0"
disabled={busy}
aria-label="More recommendation actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{existingBill && (
<DropdownMenuItem onSelect={() => onAccept({ ...recommendation, category_id: categoryId })}>
<CheckCircle2 className="h-4 w-4" />
Track as new
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onMatch(recommendation)}>
<Link2 className="h-4 w-4" />
{existingBill ? 'Choose different bill' : 'Link existing bill'}
</DropdownMenuItem>
<DropdownMenuItem destructive onSelect={() => onDecline(recommendation)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
return (
<TooltipProvider delayDuration={180}>
<div className="rounded-lg border border-primary/20 bg-primary/[0.05] p-4">
<div className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{recommendation.name}</p>
<p className="mt-1 text-xs font-medium text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
</p>
{recommendation.catalog_match?.starting_monthly_usd && (
<p className="mt-1 text-xs font-medium text-muted-foreground">
Catalog starts at {fmt(recommendation.catalog_match.starting_monthly_usd)} / mo
{recommendation.catalog_match.starting_annual_usd
? ` or ${fmt(recommendation.catalog_match.starting_annual_usd)} / yr`
: ''}
</p>
)}
{recommendation.accounts?.length > 0 && (
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
</p>
)}
{existingBill && (
<p className="mt-1 truncate text-xs font-semibold text-primary">
Link to existing bill: {existingBill.name}
</p>
)}
</div>
<div className="shrink-0 text-right">
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
<p className="tracker-number text-xs font-semibold text-emerald-600 dark:text-emerald-300">
{fmt(recommendation.monthly_equivalent)} / mo
</p>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
{recommendation.confidence}% match
</Badge>
</TooltipTrigger>
<TooltipContent>Match confidence how likely this is a real recurring subscription</TooltipContent>
</Tooltip>
{identity?.label && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300 cursor-default">
{identity.label}
</Badge>
</TooltipTrigger>
<TooltipContent>Merchant identity evidence</TooltipContent>
</Tooltip>
)}
{amount?.label && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
'text-[11px] cursor-default',
amount.match === 'unusual'
? 'border-amber-500/25 bg-amber-500/10 text-amber-500'
: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300',
)}
>
{amount.match === 'unusual' ? 'Unusual amount' : 'Price checked'}
</Badge>
</TooltipTrigger>
<TooltipContent>{amount.label}</TooltipContent>
</Tooltip>
)}
{cadence?.recurring && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-300 cursor-default">
Recurring
</Badge>
</TooltipTrigger>
<TooltipContent>{cadence.label || 'Regular recurring payment pattern detected'}</TooltipContent>
</Tooltip>
)}
{ambiguity?.ambiguous && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
</TooltipTrigger>
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
</Tooltip>
)}
{existingBill && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
Existing bill
</Badge>
</TooltipTrigger>
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
</Tooltip>
)}
{amountRange && amountRange.min !== amountRange.max && (
<span className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
Range {fmt(amountRange.min)}-{fmt(amountRange.max)}
</span>
)}
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2">
<Button
type="button"
size="sm"
variant="default"
className="h-9 min-w-0 gap-2 px-3"
disabled={busy}
onClick={() => existingBill
? onQuickLink(recommendation, existingBill.id)
: onAccept({ ...recommendation, category_id: categoryId })}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : existingBill ? (
<Link2 className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
<span className="min-w-0 truncate">
{existingBill ? 'Link Existing Bill' : 'Track Subscription'}
</span>
</Button>
<RecommendationIconButton
label="View recommendation details"
icon={Info}
disabled={busy}
onClick={() => onDetails(recommendation)}
/>
<RecommendationMoreMenu
recommendation={recommendation}
existingBill={existingBill}
busy={busy}
onAccept={onAccept}
onDecline={onDecline}
onMatch={onMatch}
categoryId={categoryId}
/>
</div>
</div>
</div>
</TooltipProvider>
);
}
function EvidenceItem({ label, value, tone = 'default' }) {
if (!value) return null;
return (
<div className={cn(
'rounded-lg border px-3 py-2',
tone === 'warn'
? 'border-amber-500/25 bg-amber-500/10'
: 'border-border/60 bg-muted/20',
)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground">{value}</p>
</div>
);
}
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
if (!recommendation) return null;
const identity = recommendation.evidence?.identity;
const amount = recommendation.evidence?.amount;
const cadence = recommendation.evidence?.cadence;
const amountRange = recommendation.evidence?.amount_range;
const ambiguity = recommendation.evidence?.ambiguity;
const existingBill = recommendation.existing_bill_match;
const transactions = recommendation.transactions || [];
const handleAccept = async () => {
await onAccept({ ...recommendation, category_id: categoryId });
onClose();
};
const handleDecline = async () => {
await onDecline(recommendation);
onClose();
};
const handleMatch = () => {
onMatch(recommendation);
onClose();
};
const handleQuickLink = async () => {
if (!existingBill) return;
await onQuickLink(recommendation, existingBill.id);
onClose();
};
return (
<Dialog open={open} onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex flex-wrap items-center gap-2">
<span>{recommendation.name}</span>
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
{recommendation.confidence}% match
</Badge>
</TooltipTrigger>
<TooltipContent>Match confidence how likely this is a real recurring subscription</TooltipContent>
</Tooltip>
</TooltipProvider>
{ambiguity?.ambiguous && (
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="gap-1 border-amber-500/25 bg-amber-500/10 text-amber-600 dark:text-amber-300 cursor-default">
<AlertTriangle className="h-3 w-3" />
Review
</Badge>
</TooltipTrigger>
<TooltipContent>{ambiguity.label || 'Multiple patterns detected — verify before tracking'}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{existingBill && (
<TooltipProvider delayDuration={180}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="border-primary/25 bg-primary/10 text-primary cursor-default">
Existing bill
</Badge>
</TooltipTrigger>
<TooltipContent>May match your tracked bill: {existingBill.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</DialogTitle>
<p className="text-sm text-muted-foreground">
{TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
</p>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
<EvidenceItem label="Expected" value={`${fmt(recommendation.expected_amount)} ${recommendation.cycle_type || 'monthly'}`} />
<EvidenceItem label="Monthly" value={`${fmt(recommendation.monthly_equivalent)} / mo`} />
<EvidenceItem label="Identity" value={identity?.label} />
<EvidenceItem label="Price" value={amount?.label} tone={amount?.match === 'unusual' ? 'warn' : 'default'} />
<EvidenceItem label="Cadence" value={cadence?.label} />
<EvidenceItem
label="Catalog"
value={recommendation.catalog_match
? [
recommendation.catalog_match.name,
recommendation.catalog_match.starting_monthly_usd ? `from ${fmt(recommendation.catalog_match.starting_monthly_usd)} / mo` : null,
].filter(Boolean).join(' · ')
: null}
/>
</div>
{amountRange && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Amount Range</p>
<p className="mt-1 text-sm font-medium text-foreground">
{fmt(amountRange.min)}-{fmt(amountRange.max)} across matching transactions
</p>
</div>
)}
{ambiguity?.ambiguous && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2">
<p className="flex items-center gap-1.5 text-sm font-semibold text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-4 w-4" />
{ambiguity.label || 'Review before tracking'}
</p>
{ambiguity.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{ambiguity.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{existingBill && (
<div className="rounded-lg border border-primary/25 bg-primary/10 px-3 py-2">
<p className="text-sm font-semibold text-primary">Recommended action: link existing bill</p>
<p className="mt-1 text-sm text-muted-foreground">
{existingBill.name} · {existingBill.expected_amount ? fmt(existingBill.expected_amount) : 'No amount'} · due day {existingBill.due_day || 'not set'}
</p>
{existingBill.reasons?.length > 0 && (
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
{existingBill.reasons.map(reason => (
<p key={reason}>{reason}</p>
))}
</div>
)}
</div>
)}
{recommendation.reasons?.length > 0 && (
<div className="flex flex-wrap gap-2">
{recommendation.reasons.map(reason => (
<span key={reason} className="rounded-md border border-border/60 bg-background/60 px-2 py-1 text-[11px] font-medium text-muted-foreground">
{reason}
</span>
))}
</div>
)}
{transactions.length > 0 && (
<div className="overflow-hidden rounded-lg border border-border/60">
<div className="border-b border-border/50 bg-muted/20 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Bank Transactions
</p>
</div>
<div className="divide-y divide-border/40">
{transactions.map(tx => (
<div key={tx.id} className="grid gap-1 px-3 py-2.5 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{tx.payee || tx.description || tx.memo || 'Transaction'}
</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(tx.date)}{tx.account ? ` · ${tx.account}` : ''}
</p>
</div>
<p className="tracker-number text-sm font-semibold text-foreground">{fmt(tx.amount)}</p>
</div>
))}
</div>
</div>
)}
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button variant="ghost" onClick={handleDecline} disabled={busy} className="gap-1.5 text-muted-foreground hover:text-destructive">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
Dismiss
</Button>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
{existingBill && (
<Button variant="outline" onClick={handleAccept} disabled={busy} className="gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
Track New
</Button>
)}
<Button variant="outline" onClick={handleMatch} disabled={busy} className="gap-1.5">
<Link2 className="h-3.5 w-3.5" />
{existingBill ? 'Choose Bill' : 'Link Existing'}
</Button>
<Button variant="default" onClick={existingBill ? handleQuickLink : handleAccept} disabled={busy} className="gap-2">
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : existingBill ? (
<Link2 className="h-3.5 w-3.5" />
) : (
<CheckCircle2 className="h-3.5 w-3.5" />
)}
{existingBill ? 'Link Existing' : 'Track Subscription'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function SubscriptionsPage() {
const [data, setData] = useState({ summary: {}, subscriptions: [] });
const [recommendations, setRecommendations] = useState([]);
const [categories, setCategories] = useState([]);
const [bills, setBills] = useState([]);
const [loading, setLoading] = useState(true);
const [recommendationsLoading, setRecommendationsLoading] = useState(true);
const [busyId, setBusyId] = useState(null);
const [modal, setModal] = useState(null);
const [matchTarget, setMatchTarget] = useState(null);
const [detailsTarget, setDetailsTarget] = useState(null);
const [recSearch, setRecSearch] = useState('');
const [subSearch, setSubSearch] = useState('');
const [subscriptionSort, setSubscriptionSort] = useState(() => (
localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
));
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
const cardHeaderRef = useRef(null);
const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]);
const [txSearching, setTxSearching] = useState(false);
const [importDialog, setImportDialog] = useState(null); // { billId, billName }
const txDebounce = useRef(null);
const subscriptionCategoryId = useMemo(() => {
const match = categories.find(category => /subscrip/i.test(category.name));
return match?.id || null;
}, [categories]);
const load = useCallback(async () => {
setLoading(true);
try {
const [subscriptionData, categoryData] = await Promise.all([
api.subscriptions(),
api.categories(),
]);
setData(subscriptionData);
setCategories(categoryData || []);
} catch (err) {
toast.error(err.message || 'Failed to load subscriptions.');
} finally {
setLoading(false);
}
}, []);
const loadRecommendations = useCallback(async () => {
setRecommendationsLoading(true);
try {
const result = await api.subscriptionRecommendations();
setRecommendations(result.recommendations || []);
} catch (err) {
toast.error(err.message || 'Failed to scan subscription recommendations.');
} finally {
setRecommendationsLoading(false);
}
}, []);
useEffect(() => {
load();
loadRecommendations();
api.allBills()
.then(b => setBills(Array.isArray(b) ? b : []))
.catch(err => {
console.error('[SubscriptionsPage] failed to load bills', err);
toast.error(err.message || 'Bills could not be loaded for subscription linking.');
});
}, [load, loadRecommendations]);
useEffect(() => {
localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort);
}, [subscriptionSort]);
useEffect(() => {
const el = cardHeaderRef.current;
if (!el) return;
const obs = new ResizeObserver(() => setCardHeaderHeight(el.offsetHeight));
obs.observe(el);
return () => obs.disconnect();
}, []);
useEffect(() => {
clearTimeout(txDebounce.current);
const q = txQuery.trim();
if (!q) { setTxResults([]); return; }
txDebounce.current = setTimeout(async () => {
setTxSearching(true);
try {
const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
} catch (err) {
setTxResults([]);
toast.error(err.message || 'Transaction search failed.');
}
finally { setTxSearching(false); }
}, 300);
return () => clearTimeout(txDebounce.current);
}, [txQuery]);
async function refreshAll() {
await Promise.all([load(), loadRecommendations()]);
}
async function toggleSubscription(item) {
const newActive = !item.active;
addOptimisticSub({ id: item.id, active: newActive }); // instant — no spinner
try {
await api.updateSubscription(item.id, { active: newActive });
await load(); // reconciles optimistic state
} catch (err) {
toast.error(err.message || 'Subscription could not be updated.');
await load(); // revert via useOptimistic reconciliation
}
}
async function acceptRecommendation(recommendation) {
setBusyId(`rec-${recommendation.id}`);
try {
const created = await api.createSubscriptionFromRecommendation(recommendation);
toast.success(`${recommendation.name} is now tracked.`);
await refreshAll();
if (getLinkImportPref() && recommendation.merchant && created?.id) {
setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
}
} catch (err) {
toast.error(err.message || 'Could not create subscription.');
} finally {
setBusyId(null);
}
}
async function declineRecommendation(recommendation) {
if (!recommendation.decline_key) return;
setBusyId(`dec-${recommendation.id}`);
try {
await api.declineRecommendation(recommendation);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not dismiss recommendation.');
} finally {
setBusyId(null);
}
}
async function linkRecommendationToBill(recommendation, billId) {
if (!recommendation || !billId) return;
setBusyId(`match-${recommendation.id}`);
try {
const result = await api.matchRecommendationToBill(
recommendation.transaction_ids,
billId,
recommendation.merchant,
recommendation.catalog_match?.id,
recommendation.confidence,
);
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} finally {
setBusyId(null);
}
}
async function matchRecommendationToBill(billId) {
await linkRecommendationToBill(matchTarget, billId);
}
function applySavedBillToPage(savedBill) {
if (!savedBill?.id) return;
const decorated = decorateSavedSubscriptionBill(savedBill, categories);
setBills(prev => {
const current = Array.isArray(prev) ? prev : [];
const exists = current.some(item => Number(item.id) === Number(savedBill.id));
return exists
? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...savedBill } : item)
: [...current, savedBill];
});
setData(prev => {
const current = prev.subscriptions || [];
const exists = current.some(item => Number(item.id) === Number(savedBill.id));
const nextSubscriptions = decorated.is_subscription
? exists
? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...decorated } : item)
: [...current, decorated]
: current.filter(item => Number(item.id) !== Number(savedBill.id));
return {
...prev,
subscriptions: nextSubscriptions,
summary: subscriptionSummaryFromList(nextSubscriptions),
};
});
}
function handleBillModalSave(savedBill) {
if (!savedBill?.id) return;
applySavedBillToPage(savedBill);
}
function openManualSubscription() {
setModal({
bill: null,
initialBill: {
name: '',
category_id: subscriptionCategoryId,
due_day: new Date().getDate(),
expected_amount: '',
billing_cycle: 'monthly',
cycle_type: 'monthly',
cycle_day: String(new Date().getDate()),
is_subscription: 1,
subscription_type: 'other',
reminder_days_before: 3,
},
});
}
function openFromTransaction(tx) {
const catalogMatch = tx.catalog_match;
const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || '';
const dollars = Math.abs(tx.amount ?? 0) / 100;
const rawDay = tx.posted_date ? new Date(tx.posted_date + 'T12:00:00').getDate() : NaN;
const dueDay = Number.isInteger(rawDay) && rawDay >= 1 && rawDay <= 31 ? rawDay : new Date().getDate();
setModal({
bill: null,
initialBill: {
name: label,
category_id: subscriptionCategoryId,
due_day: dueDay,
expected_amount: dollars > 0 ? dollars.toFixed(2) : '',
cycle_type: 'monthly',
is_subscription: 1,
subscription_type: catalogMatch?.subscription_type || 'other',
website: catalogMatch?.website || undefined,
notes: catalogMatch
? `Matched known subscription catalog entry: ${catalogMatch.name}`
: undefined,
reminder_days_before: 3,
},
});
}
const summary = data.summary || {};
const subscriptions = data.subscriptions || [];
const [optimisticSubs, addOptimisticSub] = useOptimistic(
subscriptions,
useCallback((state, { id, active }) => state.map(s => s.id === id ? { ...s, active } : s), []),
);
const filteredSubscriptions = useMemo(() => {
const q = subSearch.trim().toLowerCase();
const base = q
? optimisticSubs.filter(item =>
String(item.name || '').toLowerCase().includes(q) ||
String(item.subscription_type || '').toLowerCase().includes(q) ||
String(item.category_name || '').toLowerCase().includes(q)
)
: optimisticSubs;
return base;
}, [optimisticSubs, subSearch]);
const active = filteredSubscriptions.filter(item => item.active);
const paused = filteredSubscriptions.filter(item => !item.active);
const sortedActive = useMemo(
() => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(active) : active,
[active, subscriptionSort],
);
const sortedPaused = useMemo(
() => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(paused) : paused,
[paused, subscriptionSort],
);
const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom';
// Flat item list for virtualizer — cadence mode only (custom mode needs DOM order for drag-reorder)
const flatVirtualItems = useMemo(() => {
if (subscriptionSort !== 'cadence') return null;
const items = [];
const buildGroup = (group, activeState) => {
GROUP_ORDER.forEach(groupKey => {
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
if (!groupItems.length) return;
const sectionKey = `${activeState ? 'active' : 'paused'}-${groupKey}`;
const monthlySum = groupItems.reduce((s, i) => s + (Number(i.monthly_equivalent) || 0), 0);
items.push({ type: 'group-header', sectionKey, groupKey, activeState, groupItems, monthlySum });
if (!collapsedGroups.has(sectionKey)) {
groupItems.forEach((item, i) => items.push({ type: 'row', item, groupItems, activeState, i }));
}
});
};
buildGroup(sortedActive, true);
if (sortedPaused.length > 0) {
items.push({ type: 'paused-divider', count: sortedPaused.length });
buildGroup(sortedPaused, false);
}
return items;
}, [sortedActive, sortedPaused, subscriptionSort, collapsedGroups]);
const listRef = useRef(null);
const virtualizer = useVirtualizer({
count: flatVirtualItems?.length ?? 0,
getScrollElement: () => listRef.current,
estimateSize: i => {
if (!flatVirtualItems) return 96;
const it = flatVirtualItems[i];
if (it?.type === 'group-header') return 40;
if (it?.type === 'paused-divider') return 32;
return 96;
},
overscan: 5,
enabled: !!flatVirtualItems,
});
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
const prevData = data;
const prevBills = bills;
setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
setBills(nextBills);
setMovingBillId(movedId);
try {
await api.reorderBills(reorderPayload(nextBills));
await load();
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
} catch (err) {
toast.error(err.message || 'Failed to save subscription order');
setData(prevData);
setBills(prevBills);
} 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 highConfidenceRecs = useMemo(
() => recommendations.filter(r => r.catalog_match && (r.confidence ?? 0) >= MIN_CONFIDENCE),
[recommendations],
);
const filteredRecs = useMemo(() => {
const q = recSearch.trim().toLowerCase();
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
}, [highConfidenceRecs, recSearch]);
function renderGroupHeader(sectionKey, groupKey, groupItems, monthlySum) {
const isCollapsed = collapsedGroups.has(sectionKey);
return (
<button
type="button"
onClick={() => setCollapsedGroups(prev => {
const next = new Set(prev);
next.has(sectionKey) ? next.delete(sectionKey) : next.add(sectionKey);
return next;
})}
style={{ top: cardHeaderHeight }}
className="sticky z-10 w-full border-b border-t border-border/40 bg-card/95 backdrop-blur-sm px-4 py-2 text-left transition-colors hover:bg-muted/30"
>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[groupKey]}
</p>
<div className="flex items-center gap-3">
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
{fmt(monthlySum)}/mo · {groupItems.length}
</span>
<ChevronDown className={cn('h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-150', isCollapsed && '-rotate-90')} />
</div>
</div>
</button>
);
}
// Custom sort: normal DOM rendering (drag-reorder needs DOM order)
function renderCustomRows(group, activeState) {
return group.map((item, index) => (
<SubscriptionRow
key={item.id}
item={item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
busy={false}
moveControls={moveControlsForGroup(group, activeState)(item, index)}
dragProps={dragPropsForGroup(group, activeState)(item, index)}
/>
));
}
// Cadence sort: virtualizer renders from flatVirtualItems
function renderVirtualItem(vRow) {
const it = flatVirtualItems[vRow.index];
if (!it) return null;
if (it.type === 'paused-divider') {
return (
<div className="border-t-2 border-border/60 bg-muted/20 px-4 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60">
Paused · {it.count}
</p>
</div>
);
}
if (it.type === 'group-header') {
return renderGroupHeader(it.sectionKey, it.groupKey, it.groupItems, it.monthlySum);
}
return (
<SubscriptionRow
item={it.item}
onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription}
busy={false}
moveControls={moveControlsForGroup(it.groupItems, it.activeState)(it.item, it.i)}
dragProps={dragPropsForGroup(it.groupItems, it.activeState)(it.item, it.i)}
/>
);
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.15em] text-muted-foreground">
Recurring Services
</p>
<h1 className="text-3xl font-semibold tracking-tight">Subscriptions</h1>
<p className="mt-1 text-sm text-muted-foreground">
Track manual subscriptions and review recurring SimpleFIN charges.
</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Button type="button" variant="outline" className="h-9 gap-2" onClick={refreshAll} disabled={loading || recommendationsLoading}>
<RefreshCw className={cn('h-4 w-4', (loading || recommendationsLoading) && 'animate-spin')} />
Refresh
</Button>
<Button type="button" className="h-9 gap-2" onClick={openManualSubscription}>
<Plus className="h-4 w-4" />
Add Subscription
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Repeat} label="Monthly" value={fmt(summary.monthly_total)} hint={`${summary.active_count || 0} active subscriptions`} />
<StatCard icon={CalendarDays} label="Yearly" value={fmt(summary.yearly_total)} hint="Normalized yearly impact" />
<StatCard icon={Pause} label="Paused" value={summary.paused_count || 0} hint="Kept but not active" />
<StatCard icon={Cloud} label="Top Type" value={summary.top_type ? TYPE_LABELS[summary.top_type.type] || summary.top_type.type : '—'} hint={summary.top_type ? `${fmt(summary.top_type.monthly_total)} / mo` : 'No active subscriptions'} />
</div>
<div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
<Card>
<CardHeader ref={cardHeaderRef} className="sticky top-0 z-20 rounded-t-xl border-b border-border/50 bg-card/95 px-4 pb-3 backdrop-blur-sm sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
<CardDescription>Subscriptions are bills with recurring-service metadata.</CardDescription>
</div>
<TooltipProvider delayDuration={300}>
<div className="grid grid-cols-2 rounded-lg border border-border/60 bg-muted/30 p-1">
<Tooltip>
<TooltipTrigger asChild>
<SortModeButton
active={subscriptionSort === 'custom'}
onClick={() => setSubscriptionSort('custom')}
>
Custom
</SortModeButton>
</TooltipTrigger>
<TooltipContent>Drag to reorder manually</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SortModeButton
active={subscriptionSort === 'cadence'}
onClick={() => setSubscriptionSort('cadence')}
>
Cadence
</SortModeButton>
</TooltipTrigger>
<TooltipContent>Sort by billing frequency: weekly biweekly monthly quarterly annual</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
<SearchFilterPanel
title="Search subscriptions"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={!!subSearch.trim()}
resultLabel={`${filteredSubscriptions.length} of ${subscriptions.length} shown`}
onClear={() => setSubSearch('')}
variant="embedded"
>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={subSearch}
onChange={e => setSubSearch(e.target.value)}
placeholder="Search subscriptions…"
className="h-8 pl-8 pr-8 text-sm"
/>
{subSearch && (
<button
type="button"
onClick={() => setSubSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</SearchFilterPanel>
</CardHeader>
<CardContent className={cn('p-0', subscriptionSort === 'cadence' && 'max-h-[70vh] overflow-y-auto')}>
{loading ? (
<div className="p-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="mb-2 h-20 animate-pulse rounded-lg bg-muted/40" />
))}
</div>
) : subscriptions.length === 0 ? (
<div className="px-4 py-14 text-center">
<Repeat className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No subscriptions tracked yet.</p>
<p className="mt-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p>
</div>
) : subscriptionSort === 'cadence' ? (
// Cadence mode: virtualised from flatVirtualItems
<div
ref={listRef}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map(vRow => (
<div
key={vRow.key}
data-index={vRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
}}
>
{renderVirtualItem(vRow)}
</div>
))}
</div>
) : (
// Custom mode: normal DOM rendering so drag-reorder works
<>
{renderCustomRows(sortedActive, true)}
{renderCustomRows(sortedPaused, false)}
</>
)}
</CardContent>
</Card>
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Recommendations</CardTitle>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<span className="ml-auto text-[11px] font-medium text-muted-foreground tabular-nums">
{filteredRecs.length} of {highConfidenceRecs.length}
</span>
)}
</div>
<CardDescription>Known subscription services found in your bank transactions with 90%+ confidence.</CardDescription>
{!recommendationsLoading && highConfidenceRecs.length > 0 && (
<SearchFilterPanel
title="Search recommendations"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={!!recSearch.trim()}
resultLabel={`${filteredRecs.length} of ${highConfidenceRecs.length} shown`}
onClear={() => setRecSearch('')}
variant="embedded"
>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search recommendations…"
value={recSearch}
onChange={e => setRecSearch(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
</SearchFilterPanel>
)}
</CardHeader>
<CardContent className="space-y-3">
{recommendationsLoading ? (
<div className="rounded-lg border border-border/60 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
Scanning transactions
</div>
) : highConfidenceRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-10 text-center">
<Bell className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No high-confidence recommendations.</p>
<p className="mt-1 text-sm text-muted-foreground">
{recommendations.length > 0
? 'More account activity or stronger descriptors will improve accuracy.'
: 'Sync your accounts after charges from known subscription services appear.'}
</p>
</div>
) : filteredRecs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/70 px-4 py-8 text-center">
<Search className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No matches for "{recSearch}"</p>
<button
type="button"
className="mt-1 text-xs text-primary hover:underline"
onClick={() => setRecSearch('')}
>
Clear search
</button>
</div>
) : (
filteredRecs.map(recommendation => (
<RecommendationCard
key={recommendation.id}
recommendation={recommendation}
categoryId={subscriptionCategoryId}
busy={busyId === `rec-${recommendation.id}` || busyId === `dec-${recommendation.id}` || busyId === `match-${recommendation.id}`}
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
onDetails={setDetailsTarget}
/>
))
)}
</CardContent>
</Card>
</div>
{/* Transaction search */}
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Search Bank Transactions</CardTitle>
</div>
<CardDescription>
Search all account charges matched and unmatched to find subscriptions the algorithm may have missed.
</CardDescription>
<SearchFilterPanel
title="Search transactions"
collapsed={searchPanelCollapsed}
onCollapsedChange={setSearchPanelCollapsed}
hasFilters={!!txQuery.trim()}
resultLabel={txQuery.trim() ? `${txResults.length} result${txResults.length === 1 ? '' : 's'}` : 'all bank transactions'}
onClear={() => setTxQuery('')}
variant="embedded"
>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="search"
placeholder="Search by merchant, description, or payee…"
value={txQuery}
onChange={e => setTxQuery(e.target.value)}
className="pl-8 h-9 text-sm"
autoComplete="off"
/>
</div>
</SearchFilterPanel>
</CardHeader>
{(txQuery.trim() || txSearching) && (
<CardContent className="p-0">
{txSearching ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Searching transactions
</div>
) : txResults.length === 0 ? (
<div className="px-4 py-8 text-center">
<Search className="mx-auto h-7 w-7 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No transactions found for "{txQuery}"</p>
<p className="mt-1 text-xs text-muted-foreground">Try a different merchant name or description.</p>
</div>
) : (
<div>
<div className="px-4 py-2 border-b border-border/40 bg-muted/20">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{txResults.length} result{txResults.length !== 1 ? 's' : ''}
{txResults.length === 50 ? ' (showing first 50)' : ''}
</p>
</div>
{txResults.map(tx => (
<TxResultRow key={tx.id} tx={tx} onTrack={openFromTransaction} />
))}
</div>
)}
</CardContent>
)}
</Card>
<Card className="overflow-hidden">
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<CardTitle className="text-base">Improve Matching</CardTitle>
</div>
<CardDescription>
Manage known services, catalog links, and custom bank descriptors on a dedicated page.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-sm text-muted-foreground">
Use the service catalog when a recommendation names the wrong service, a bill needs a catalog link, or your bank uses a custom descriptor.
</p>
<Button asChild type="button" variant="outline" className="h-9 shrink-0 gap-2">
<Link to="/subscriptions/catalog">
<Link2 className="h-4 w-4" />
Service Catalog
</Link>
</Button>
</CardContent>
</Card>
{modal && (
<BillModal
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
bill={modal.bill}
initialBill={modal.initialBill}
categories={categories}
onClose={() => setModal(null)}
onSave={handleBillModalSave}
/>
)}
<RecommendationDetailsDialog
open={!!detailsTarget}
recommendation={detailsTarget}
categoryId={subscriptionCategoryId}
onClose={() => setDetailsTarget(null)}
onAccept={acceptRecommendation}
onDecline={declineRecommendation}
onMatch={rec => setMatchTarget(rec)}
onQuickLink={linkRecommendationToBill}
busy={detailsTarget ? (
busyId === `rec-${detailsTarget.id}`
|| busyId === `dec-${detailsTarget.id}`
|| busyId === `match-${detailsTarget.id}`
) : false}
/>
<BillPickerDialog
open={!!matchTarget}
onClose={() => setMatchTarget(null)}
recommendation={matchTarget}
bills={bills}
onConfirm={matchRecommendationToBill}
busy={!!busyId?.startsWith('match-')}
/>
<BillHistoricalImportDialog
billId={importDialog?.billId}
billName={importDialog?.billName}
open={!!importDialog}
onClose={() => setImportDialog(null)}
onImported={() => { setImportDialog(null); refreshAll(); }}
/>
</div>
);
}