refactor(ts): SubscriptionsPage → TypeScript (final .jsx page)
The last .jsx page — 0 remain under client/. Recurring-services view typed: Subscription/SubSummary/Recommendation/CatalogMatch/SubTx local types, useOptimistic<Subscription[]> typed reducer, virtualizer FlatItem discriminated union, drag/move-controls via DragProps/ MoveControls, setQueryData<T> optimistic caches, local branded fmt. Fixed daysUntil date subtraction (getTime), guarded optional .length chains, cast api.* results at boundaries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aa9c357fdf
commit
8229d21ab8
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, type ComponentType, type ComponentProps, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSubscriptions, useSubscriptionRecommendations, useCategories, useBills } from '@/hooks/useQueries';
|
||||
|
|
@ -27,7 +27,8 @@ import {
|
|||
X,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn, fmt, fmtDate, localDateString } from '@/lib/utils';
|
||||
import { cn, fmtDate, localDateString, errMessage } from '@/lib/utils';
|
||||
import { formatUSD, asDollars } from '@/lib/money';
|
||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
|
@ -52,8 +53,14 @@ import { getLinkImportPref } from '@/pages/SettingsPage';
|
|||
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||
import type { Bill, Category } from '@/types';
|
||||
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||
|
||||
const TYPE_LABELS = {
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return formatUSD(asDollars(Number(v) || 0));
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
streaming: 'Streaming',
|
||||
software: 'Software',
|
||||
cloud: 'Cloud',
|
||||
|
|
@ -70,9 +77,142 @@ const TYPE_LABELS = {
|
|||
other: 'Other',
|
||||
};
|
||||
|
||||
function typeLabel(t: string | null | undefined): string {
|
||||
return (t ? TYPE_LABELS[t] : undefined) || 'Other';
|
||||
}
|
||||
|
||||
// ── Domain types ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CadenceLike {
|
||||
cycle_type?: string | null;
|
||||
billing_cycle?: string | null;
|
||||
due_day?: number | null;
|
||||
cycle_day?: number | string | null;
|
||||
expected_amount?: number | null;
|
||||
name?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Subscription {
|
||||
id: number;
|
||||
name: string;
|
||||
active?: boolean | number;
|
||||
is_subscription?: boolean | number;
|
||||
category_id?: number | null;
|
||||
category_name?: string | null;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
yearly_equivalent?: number | null;
|
||||
next_due_date?: string | null;
|
||||
subscription_type?: string;
|
||||
due_day?: number | null;
|
||||
cycle_type?: string | null;
|
||||
billing_cycle?: string | null;
|
||||
cycle_day?: number | string | null;
|
||||
autopay_enabled?: unknown;
|
||||
has_2fa?: unknown;
|
||||
has_merchant_rule?: unknown;
|
||||
has_linked_transactions?: unknown;
|
||||
reminder_days_before?: number | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TopType { type: string; monthly_total: number }
|
||||
interface SubSummary {
|
||||
active_count?: number;
|
||||
paused_count?: number;
|
||||
monthly_total?: number;
|
||||
yearly_total?: number;
|
||||
top_type?: TopType | null;
|
||||
}
|
||||
interface SubData { summary?: SubSummary; subscriptions?: Subscription[] }
|
||||
|
||||
interface CatalogMatch {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
website?: string;
|
||||
starting_monthly_usd?: number | null;
|
||||
starting_annual_usd?: number | null;
|
||||
}
|
||||
interface ExistingBillMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
expected_amount?: number | null;
|
||||
due_day?: number | null;
|
||||
reasons?: string[];
|
||||
}
|
||||
interface RecEvidence {
|
||||
identity?: { label?: string };
|
||||
amount?: { label?: string; match?: string };
|
||||
cadence?: { label?: string; recurring?: boolean };
|
||||
amount_range?: { min: number; max: number };
|
||||
ambiguity?: { ambiguous?: boolean; label?: string; reasons?: string[] };
|
||||
}
|
||||
interface RecTransaction { id: number; payee?: string; description?: string; memo?: string; date?: string; account?: string; amount?: number }
|
||||
interface Recommendation {
|
||||
id: number | string;
|
||||
name: string;
|
||||
subscription_type?: string;
|
||||
occurrence_count?: number;
|
||||
last_seen_date?: string;
|
||||
expected_amount?: number | null;
|
||||
monthly_equivalent?: number | null;
|
||||
confidence?: number;
|
||||
cycle_type?: string;
|
||||
merchant?: string;
|
||||
decline_key?: string;
|
||||
transaction_ids?: (number | string)[];
|
||||
accounts?: string[];
|
||||
reasons?: string[];
|
||||
evidence?: RecEvidence;
|
||||
existing_bill_match?: ExistingBillMatch | null;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
transactions?: RecTransaction[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SubTx {
|
||||
id: number;
|
||||
amount: number;
|
||||
payee?: string;
|
||||
description?: string;
|
||||
memo?: string;
|
||||
account_name?: string;
|
||||
data_source_name?: string;
|
||||
account?: string;
|
||||
match_status?: string;
|
||||
matched_bill_name?: string;
|
||||
catalog_match?: CatalogMatch | null;
|
||||
posted_date?: string;
|
||||
date?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SubDraft {
|
||||
name?: string;
|
||||
category_id?: number | null;
|
||||
due_day?: number;
|
||||
expected_amount?: string;
|
||||
billing_cycle?: string;
|
||||
cycle_type?: string;
|
||||
cycle_day?: string;
|
||||
is_subscription?: number;
|
||||
subscription_type?: string;
|
||||
website?: string;
|
||||
notes?: string;
|
||||
reminder_days_before?: number;
|
||||
}
|
||||
interface ModalState { bill: Subscription | null; initialBill?: SubDraft }
|
||||
|
||||
type RecAction = (rec: Recommendation) => void | Promise<void>;
|
||||
type QuickLink = (rec: Recommendation, billId: number) => void | Promise<void>;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode';
|
||||
const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other'];
|
||||
const CADENCE_LABELS = {
|
||||
const CADENCE_LABELS: Record<string, string> = {
|
||||
weekly: 'Weekly',
|
||||
biweekly: 'Biweekly',
|
||||
monthly: 'Monthly',
|
||||
|
|
@ -81,7 +221,7 @@ const CADENCE_LABELS = {
|
|||
other: 'Other',
|
||||
};
|
||||
|
||||
const SUBSCRIPTION_MONTHLY_FACTORS = {
|
||||
const SUBSCRIPTION_MONTHLY_FACTORS: Record<string, number> = {
|
||||
weekly: 52 / 12,
|
||||
biweekly: 26 / 12,
|
||||
monthly: 1,
|
||||
|
|
@ -90,7 +230,7 @@ const SUBSCRIPTION_MONTHLY_FACTORS = {
|
|||
annually: 1 / 12,
|
||||
};
|
||||
|
||||
function normalizedCadence(item) {
|
||||
function normalizedCadence(item: CadenceLike): string {
|
||||
const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase();
|
||||
if (raw.includes('week') && raw.includes('bi')) return 'biweekly';
|
||||
if (raw === 'biweekly') return 'biweekly';
|
||||
|
|
@ -101,7 +241,7 @@ function normalizedCadence(item) {
|
|||
return 'other';
|
||||
}
|
||||
|
||||
function subscriptionMonthlyEquivalent(item) {
|
||||
function subscriptionMonthlyEquivalent(item: CadenceLike): number {
|
||||
const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase();
|
||||
const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly'
|
||||
? 'quarterly'
|
||||
|
|
@ -112,7 +252,7 @@ function subscriptionMonthlyEquivalent(item) {
|
|||
return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100;
|
||||
}
|
||||
|
||||
function subscriptionNextDueDate(item, now = new Date()) {
|
||||
function subscriptionNextDueDate(item: CadenceLike, now = new Date()): string {
|
||||
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);
|
||||
|
|
@ -130,7 +270,7 @@ function subscriptionNextDueDate(item, now = new Date()) {
|
|||
return localDateString(date);
|
||||
}
|
||||
|
||||
function decorateSavedSubscriptionBill(bill, categories) {
|
||||
function decorateSavedSubscriptionBill(bill: Bill, categories: Category[]): Subscription {
|
||||
const monthly = subscriptionMonthlyEquivalent(bill);
|
||||
const category = categories.find(item => Number(item.id) === Number(bill.category_id));
|
||||
return {
|
||||
|
|
@ -141,14 +281,14 @@ function decorateSavedSubscriptionBill(bill, categories) {
|
|||
monthly_equivalent: monthly,
|
||||
yearly_equivalent: Math.round(monthly * 12 * 100) / 100,
|
||||
next_due_date: subscriptionNextDueDate(bill),
|
||||
subscription_type: bill.subscription_type || 'other',
|
||||
subscription_type: (bill.subscription_type as string | undefined) || 'other',
|
||||
};
|
||||
}
|
||||
|
||||
function subscriptionSummaryFromList(subscriptions) {
|
||||
function subscriptionSummaryFromList(subscriptions: Subscription[]): SubSummary {
|
||||
const active = subscriptions.filter(item => item.active);
|
||||
const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0);
|
||||
const typeTotals = new Map();
|
||||
const typeTotals = new Map<string, number>();
|
||||
for (const item of active) {
|
||||
const type = item.subscription_type || 'other';
|
||||
typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0));
|
||||
|
|
@ -165,7 +305,7 @@ function subscriptionSummaryFromList(subscriptions) {
|
|||
|
||||
// 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 = {
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
'weekly': 'Weekly',
|
||||
'biweekly': 'Biweekly',
|
||||
'monthly-1st': '1st · Due days 1–14',
|
||||
|
|
@ -175,7 +315,7 @@ const GROUP_LABELS = {
|
|||
'other': 'Other',
|
||||
};
|
||||
|
||||
function subscriptionGroup(item) {
|
||||
function subscriptionGroup(item: Subscription): string {
|
||||
const cadence = normalizedCadence(item);
|
||||
if (cadence === 'monthly') {
|
||||
return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th';
|
||||
|
|
@ -183,19 +323,19 @@ function subscriptionGroup(item) {
|
|||
return cadence;
|
||||
}
|
||||
|
||||
function groupIndex(item) {
|
||||
function groupIndex(item: Subscription): number {
|
||||
const idx = GROUP_ORDER.indexOf(subscriptionGroup(item));
|
||||
return idx >= 0 ? idx : GROUP_ORDER.length - 1;
|
||||
}
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
function daysUntil(dateStr: string | null | undefined): number | null {
|
||||
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);
|
||||
return Math.round((new Date(dateStr + 'T00:00:00').getTime() - today.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function sortSubscriptionsByCadence(items) {
|
||||
function sortSubscriptionsByCadence(items: Subscription[]): Subscription[] {
|
||||
return [...items].sort((a, b) => (
|
||||
groupIndex(a) - groupIndex(b)
|
||||
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|
||||
|
|
@ -203,7 +343,7 @@ function sortSubscriptionsByCadence(items) {
|
|||
));
|
||||
}
|
||||
|
||||
function SortModeButton({ active, children, onClick }) {
|
||||
function SortModeButton({ active, children, onClick }: { active: boolean; children: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -221,11 +361,16 @@ function SortModeButton({ active, children, onClick }) {
|
|||
);
|
||||
}
|
||||
|
||||
function cycleLabel(item) {
|
||||
function cycleLabel(item: Subscription): string {
|
||||
return scheduleLabel(item);
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, hint }) {
|
||||
function StatCard({ icon: Icon, label, value, hint }: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
hint?: ReactNode;
|
||||
}) {
|
||||
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">
|
||||
|
|
@ -238,7 +383,12 @@ function StatCard({ icon: Icon, label, value, hint }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
|
||||
function SubscriptionRowActions({ item, onEdit, onToggle, busy }: {
|
||||
item: Subscription;
|
||||
onEdit: (item: Subscription) => void;
|
||||
onToggle: (item: Subscription) => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 md:flex md:justify-end">
|
||||
<Button
|
||||
|
|
@ -274,7 +424,14 @@ function SubscriptionRowActions({ item, onEdit, onToggle, busy }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }) {
|
||||
function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy }: {
|
||||
item: Subscription;
|
||||
onEdit: (item: Subscription) => void;
|
||||
onToggle: (item: Subscription) => void;
|
||||
moveControls?: MoveControls;
|
||||
dragProps?: DragProps;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
|
|
@ -336,7 +493,7 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
|
|||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="capitalize border-primary/25 bg-primary/10 text-primary cursor-default">
|
||||
{TYPE_LABELS[item.subscription_type] || 'Other'}
|
||||
{typeLabel(item.subscription_type)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Service category</TooltipContent>
|
||||
|
|
@ -429,9 +586,16 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
|
|||
);
|
||||
}
|
||||
|
||||
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }) {
|
||||
function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, busy }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
recommendation: Recommendation | null;
|
||||
bills: Bill[];
|
||||
onConfirm: (billId: number | null) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => { if (open) { setSearch(''); setSelectedId(null); } }, [open]);
|
||||
|
||||
|
|
@ -499,7 +663,7 @@ function BillPickerDialog({ open, onClose, recommendation, bills, onConfirm, bus
|
|||
);
|
||||
}
|
||||
|
||||
function TxResultRow({ tx, onTrack }) {
|
||||
function TxResultRow({ tx, onTrack }: { tx: SubTx; onTrack: (tx: SubTx) => void }) {
|
||||
const dollars = fmt(Math.abs(tx.amount) / 100);
|
||||
const label = tx.payee || tx.description || tx.memo || '—';
|
||||
const account = tx.account_name || tx.data_source_name || null;
|
||||
|
|
@ -544,7 +708,7 @@ function TxResultRow({ tx, onTrack }) {
|
|||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{tx.posted_date}{account ? ` · ${account}` : ''}
|
||||
{catalogMatch ? ` · ${TYPE_LABELS[catalogMatch.subscription_type] || 'Other'}` : ''}
|
||||
{catalogMatch ? ` · ${typeLabel(catalogMatch.subscription_type)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-sm font-semibold tabular-nums shrink-0">{dollars}</span>
|
||||
|
|
@ -558,7 +722,14 @@ function TxResultRow({ tx, onTrack }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }) {
|
||||
function RecommendationIconButton({ label, icon: Icon, onClick, disabled, className, variant = 'outline' }: {
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
variant?: ComponentProps<typeof Button>['variant'];
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -579,7 +750,15 @@ function RecommendationIconButton({ label, icon: Icon, onClick, disabled, classN
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }) {
|
||||
function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept, onDecline, onMatch, categoryId }: {
|
||||
recommendation: Recommendation;
|
||||
existingBill?: ExistingBillMatch | null;
|
||||
busy?: boolean;
|
||||
onAccept: RecAction;
|
||||
onDecline: RecAction;
|
||||
onMatch: RecAction;
|
||||
categoryId: number | null;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -614,7 +793,16 @@ function RecommendationMoreMenu({ recommendation, existingBill, busy, onAccept,
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }) {
|
||||
function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, onMatch, onQuickLink, onDetails, busy }: {
|
||||
recommendation: Recommendation;
|
||||
categoryId: number | null;
|
||||
onAccept: RecAction;
|
||||
onDecline: RecAction;
|
||||
onMatch: RecAction;
|
||||
onQuickLink: QuickLink;
|
||||
onDetails: (rec: Recommendation) => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const identity = recommendation.evidence?.identity;
|
||||
const amount = recommendation.evidence?.amount;
|
||||
const cadence = recommendation.evidence?.cadence;
|
||||
|
|
@ -630,19 +818,19 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
<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)}
|
||||
{typeLabel(recommendation.subscription_type)} · {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`
|
||||
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 && (
|
||||
{!!recommendation.accounts?.length && (
|
||||
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
|
||||
{recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')}
|
||||
{(recommendation.accounts?.length ?? 0) > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts?.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{existingBill && (
|
||||
|
|
@ -778,7 +966,7 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
);
|
||||
}
|
||||
|
||||
function EvidenceItem({ label, value, tone = 'default' }) {
|
||||
function EvidenceItem({ label, value, tone = 'default' }: { label: string; value?: ReactNode; tone?: string }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className={cn(
|
||||
|
|
@ -793,7 +981,17 @@ function EvidenceItem({ label, value, tone = 'default' }) {
|
|||
);
|
||||
}
|
||||
|
||||
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }) {
|
||||
function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose, onAccept, onDecline, onMatch, onQuickLink, busy }: {
|
||||
open: boolean;
|
||||
recommendation: Recommendation | null;
|
||||
categoryId: number | null;
|
||||
onClose: () => void;
|
||||
onAccept: RecAction;
|
||||
onDecline: RecAction;
|
||||
onMatch: RecAction;
|
||||
onQuickLink: QuickLink;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
if (!recommendation) return null;
|
||||
|
||||
const identity = recommendation.evidence?.identity;
|
||||
|
|
@ -864,7 +1062,7 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
)}
|
||||
</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)}
|
||||
{typeLabel(recommendation.subscription_type)} · {recommendation.occurrence_count} charge{recommendation.occurrence_count !== 1 ? 's' : ''} · last seen {fmtDate(recommendation.last_seen_date)}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -900,9 +1098,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
<AlertTriangle className="h-4 w-4" />
|
||||
{ambiguity.label || 'Review before tracking'}
|
||||
</p>
|
||||
{ambiguity.reasons?.length > 0 && (
|
||||
{!!ambiguity.reasons?.length && (
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
{ambiguity.reasons.map(reason => (
|
||||
{ambiguity.reasons?.map(reason => (
|
||||
<p key={reason}>{reason}</p>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -916,9 +1114,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
<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 && (
|
||||
{!!existingBill.reasons?.length && (
|
||||
<div className="mt-2 space-y-1 text-sm text-muted-foreground">
|
||||
{existingBill.reasons.map(reason => (
|
||||
{existingBill.reasons?.map(reason => (
|
||||
<p key={reason}>{reason}</p>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -926,9 +1124,9 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
</div>
|
||||
)}
|
||||
|
||||
{recommendation.reasons?.length > 0 && (
|
||||
{!!recommendation.reasons?.length && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendation.reasons.map(reason => (
|
||||
{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>
|
||||
|
|
@ -994,43 +1192,49 @@ function RecommendationDetailsDialog({ open, recommendation, categoryId, onClose
|
|||
);
|
||||
}
|
||||
|
||||
type FlatItem =
|
||||
| { type: 'group-header'; sectionKey: string; groupKey: string; activeState: boolean; groupItems: Subscription[]; monthlySum: number }
|
||||
| { type: 'row'; item: Subscription; groupItems: Subscription[]; activeState: boolean; i: number }
|
||||
| { type: 'paused-divider'; count: number };
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: subData, isPending: loading } = useSubscriptions();
|
||||
const data = subData || { summary: {}, subscriptions: [] };
|
||||
const { data: recommendations = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
|
||||
const data: SubData = (subData as SubData | undefined) || { summary: {}, subscriptions: [] };
|
||||
const { data: recommendationsRaw = [], isPending: recommendationsLoading } = useSubscriptionRecommendations();
|
||||
const recommendations = recommendationsRaw as Recommendation[];
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: bills = [] } = useBills();
|
||||
// Optimistic updates write through the query cache so every existing call site
|
||||
// (setData(prev => …), setBills(…), setRecommendations(…)) works unchanged.
|
||||
const setData = useCallback((u) => queryClient.setQueryData(['subscriptions'],
|
||||
const setData = useCallback((u: SubData | ((prev: SubData) => SubData)) => queryClient.setQueryData<SubData>(['subscriptions'],
|
||||
prev => (typeof u === 'function' ? u(prev || { summary: {}, subscriptions: [] }) : u)), [queryClient]);
|
||||
const setBills = useCallback((u) => queryClient.setQueryData(['bills'],
|
||||
const setBills = useCallback((u: Bill[] | ((prev: Bill[]) => Bill[])) => queryClient.setQueryData<Bill[]>(['bills'],
|
||||
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
|
||||
const setRecommendations = useCallback((u) => queryClient.setQueryData(['subscription-recommendations'],
|
||||
const setRecommendations = useCallback((u: Recommendation[] | ((prev: Recommendation[]) => Recommendation[])) => queryClient.setQueryData<Recommendation[]>(['subscription-recommendations'],
|
||||
prev => (typeof u === 'function' ? u(prev || []) : u)), [queryClient]);
|
||||
const [busyId, setBusyId] = useState(null);
|
||||
const [modal, setModal] = useState(null);
|
||||
const [matchTarget, setMatchTarget] = useState(null);
|
||||
const [detailsTarget, setDetailsTarget] = useState(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [modal, setModal] = useState<ModalState | null>(null);
|
||||
const [matchTarget, setMatchTarget] = useState<Recommendation | null>(null);
|
||||
const [detailsTarget, setDetailsTarget] = useState<Recommendation | null>(null);
|
||||
const [recSearch, setRecSearch] = useState('');
|
||||
const [subSearch, setSubSearch] = useState('');
|
||||
const [subscriptionSort, setSubscriptionSort] = useState(() => (
|
||||
const [subscriptionSort, setSubscriptionSort] = useState<'custom' | 'cadence'>(() => (
|
||||
localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom'
|
||||
));
|
||||
const [draggingId, setDraggingId] = useState(null);
|
||||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [dropTargetId, setDropTargetId] = useState<number | null>(null);
|
||||
const [movingBillId, setMovingBillId] = useState<number | string | null>(null);
|
||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
|
||||
const cardHeaderRef = useRef(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const cardHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
|
||||
|
||||
const [txQuery, setTxQuery] = useState('');
|
||||
const [txResults, setTxResults] = useState([]);
|
||||
const [txResults, setTxResults] = useState<SubTx[]>([]);
|
||||
const [txSearching, setTxSearching] = useState(false);
|
||||
const [importDialog, setImportDialog] = useState(null); // { billId, billName }
|
||||
const txDebounce = useRef(null);
|
||||
const [importDialog, setImportDialog] = useState<{ billId: number; billName: string } | null>(null);
|
||||
const txDebounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const subscriptionCategoryId = useMemo(() => {
|
||||
const match = categories.find(category => /subscrip/i.test(category.name));
|
||||
|
|
@ -1066,11 +1270,11 @@ export default function SubscriptionsPage() {
|
|||
txDebounce.current = setTimeout(async () => {
|
||||
setTxSearching(true);
|
||||
try {
|
||||
const result = await api.subscriptionTransactionMatches({ q, limit: 50 });
|
||||
const result = await api.subscriptionTransactionMatches({ q, limit: 50 }) as SubTx[] | { transactions?: SubTx[] };
|
||||
setTxResults(Array.isArray(result) ? result : (result?.transactions ?? []));
|
||||
} catch (err) {
|
||||
setTxResults([]);
|
||||
toast.error(err.message || 'Transaction search failed.');
|
||||
toast.error(errMessage(err, 'Transaction search failed.'));
|
||||
}
|
||||
finally { setTxSearching(false); }
|
||||
}, 300);
|
||||
|
|
@ -1081,48 +1285,48 @@ export default function SubscriptionsPage() {
|
|||
await Promise.all([load(), loadRecommendations()]);
|
||||
}
|
||||
|
||||
async function toggleSubscription(item) {
|
||||
async function toggleSubscription(item: Subscription) {
|
||||
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.');
|
||||
toast.error(errMessage(err, 'Subscription could not be updated.'));
|
||||
await load(); // revert via useOptimistic reconciliation
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptRecommendation(recommendation) {
|
||||
async function acceptRecommendation(recommendation: Recommendation) {
|
||||
setBusyId(`rec-${recommendation.id}`);
|
||||
try {
|
||||
const created = await api.createSubscriptionFromRecommendation(recommendation);
|
||||
const created = await api.createSubscriptionFromRecommendation(recommendation) as { id?: number; name?: string } | null;
|
||||
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.');
|
||||
toast.error(errMessage(err, 'Could not create subscription.'));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function declineRecommendation(recommendation) {
|
||||
async function declineRecommendation(recommendation: 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.');
|
||||
toast.error(errMessage(err, 'Could not dismiss recommendation.'));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkRecommendationToBill(recommendation, billId) {
|
||||
async function linkRecommendationToBill(recommendation: Recommendation | null, billId: number | null) {
|
||||
if (!recommendation || !billId) return;
|
||||
setBusyId(`match-${recommendation.id}`);
|
||||
try {
|
||||
|
|
@ -1132,22 +1336,22 @@ export default function SubscriptionsPage() {
|
|||
recommendation.merchant,
|
||||
recommendation.catalog_match?.id,
|
||||
recommendation.confidence,
|
||||
);
|
||||
) as { matched_count?: number; bill_name?: string };
|
||||
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.');
|
||||
toast.error(errMessage(err, 'Could not link recommendation to bill.'));
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function matchRecommendationToBill(billId) {
|
||||
async function matchRecommendationToBill(billId: number | null) {
|
||||
await linkRecommendationToBill(matchTarget, billId);
|
||||
}
|
||||
|
||||
function applySavedBillToPage(savedBill) {
|
||||
function applySavedBillToPage(savedBill: Bill) {
|
||||
if (!savedBill?.id) return;
|
||||
const decorated = decorateSavedSubscriptionBill(savedBill, categories);
|
||||
|
||||
|
|
@ -1176,7 +1380,7 @@ export default function SubscriptionsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
function handleBillModalSave(savedBill) {
|
||||
function handleBillModalSave(savedBill?: Bill) {
|
||||
if (!savedBill?.id) return;
|
||||
applySavedBillToPage(savedBill);
|
||||
}
|
||||
|
|
@ -1199,7 +1403,7 @@ export default function SubscriptionsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
function openFromTransaction(tx) {
|
||||
function openFromTransaction(tx: SubTx) {
|
||||
const catalogMatch = tx.catalog_match;
|
||||
const label = catalogMatch?.name || tx.payee || tx.description || tx.memo || '';
|
||||
const dollars = Math.abs(tx.amount ?? 0) / 100;
|
||||
|
|
@ -1229,7 +1433,7 @@ export default function SubscriptionsPage() {
|
|||
|
||||
const [optimisticSubs, addOptimisticSub] = useOptimistic(
|
||||
subscriptions,
|
||||
useCallback((state, { id, active }) => state.map(s => s.id === id ? { ...s, active } : s), []),
|
||||
useCallback((state: Subscription[], { id, active }: { id: number; active: boolean }) => state.map(s => s.id === id ? { ...s, active } : s), []),
|
||||
);
|
||||
|
||||
const filteredSubscriptions = useMemo(() => {
|
||||
|
|
@ -1256,10 +1460,10 @@ export default function SubscriptionsPage() {
|
|||
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(() => {
|
||||
const flatVirtualItems = useMemo<FlatItem[] | null>(() => {
|
||||
if (subscriptionSort !== 'cadence') return null;
|
||||
const items = [];
|
||||
const buildGroup = (group, activeState) => {
|
||||
const items: FlatItem[] = [];
|
||||
const buildGroup = (group: Subscription[], activeState: boolean) => {
|
||||
GROUP_ORDER.forEach(groupKey => {
|
||||
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
|
||||
if (!groupItems.length) return;
|
||||
|
|
@ -1279,7 +1483,7 @@ export default function SubscriptionsPage() {
|
|||
return items;
|
||||
}, [sortedActive, sortedPaused, subscriptionSort, collapsedGroups]);
|
||||
|
||||
const listRef = useRef(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: flatVirtualItems?.length ?? 0,
|
||||
getScrollElement: () => listRef.current,
|
||||
|
|
@ -1294,7 +1498,7 @@ export default function SubscriptionsPage() {
|
|||
enabled: !!flatVirtualItems,
|
||||
});
|
||||
|
||||
async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
|
||||
async function persistSubscriptionOrder(nextSubscriptions: Subscription[], nextBills: Bill[], movedId: string | number | null) {
|
||||
const prevData = data;
|
||||
const prevBills = bills;
|
||||
setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
|
||||
|
|
@ -1305,7 +1509,7 @@ export default function SubscriptionsPage() {
|
|||
await load();
|
||||
api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {});
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to save subscription order');
|
||||
toast.error(errMessage(err, 'Failed to save subscription order'));
|
||||
setData(prevData);
|
||||
setBills(prevBills);
|
||||
} finally {
|
||||
|
|
@ -1313,24 +1517,24 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
function reorderSubscriptionGroup(activeState, orderedGroup) {
|
||||
function reorderSubscriptionGroup(activeState: boolean, orderedGroup: Subscription[]) {
|
||||
const sourceGroup = subscriptions.filter(item => !!item.active === activeState);
|
||||
const replacements = [...orderedGroup];
|
||||
const nextSubscriptions = subscriptions.map(item => (
|
||||
!!item.active === activeState ? replacements.shift() : item
|
||||
!!item.active === activeState ? replacements.shift()! : item
|
||||
));
|
||||
|
||||
const sourceBills = bills.length ? bills : subscriptions;
|
||||
const sourceBills: Bill[] = bills.length ? bills : (subscriptions as unknown as Bill[]);
|
||||
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 orderedBills = orderedGroup.map(item => ({ ...(billById.get(item.id) || item), ...item }) as unknown as Bill);
|
||||
const nextBills = sourceBills.map(bill => (
|
||||
affectedIds.has(bill.id) ? orderedBills.shift() : bill
|
||||
affectedIds.has(bill.id) ? orderedBills.shift()! : bill
|
||||
));
|
||||
persistSubscriptionOrder(nextSubscriptions, nextBills, movedItemId(sourceGroup, orderedGroup));
|
||||
}
|
||||
|
||||
function moveControlsForGroup(group, activeState) {
|
||||
function moveControlsForGroup(group: Subscription[], activeState: boolean): (item: Subscription, index: number) => MoveControls {
|
||||
return (item, index) => ({
|
||||
enabled: reorderEnabled,
|
||||
moving: movingBillId === item.id,
|
||||
|
|
@ -1341,7 +1545,7 @@ export default function SubscriptionsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
function dragPropsForGroup(group, activeState) {
|
||||
function dragPropsForGroup(group: Subscription[], activeState: boolean): (item: Subscription, index: number) => DragProps {
|
||||
return (item, index) => {
|
||||
if (!reorderEnabled) return { draggable: false };
|
||||
return {
|
||||
|
|
@ -1387,14 +1591,14 @@ export default function SubscriptionsPage() {
|
|||
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
|
||||
}, [highConfidenceRecs, recSearch]);
|
||||
|
||||
function renderGroupHeader(sectionKey, groupKey, groupItems, monthlySum) {
|
||||
function renderGroupHeader(sectionKey: string, groupKey: string, groupItems: Subscription[], monthlySum: number) {
|
||||
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);
|
||||
if (next.has(sectionKey)) next.delete(sectionKey); else next.add(sectionKey);
|
||||
return next;
|
||||
})}
|
||||
style={{ top: cardHeaderHeight }}
|
||||
|
|
@ -1416,7 +1620,7 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
|
||||
// Custom sort: normal DOM rendering (drag-reorder needs DOM order)
|
||||
function renderCustomRows(group, activeState) {
|
||||
function renderCustomRows(group: Subscription[], activeState: boolean) {
|
||||
return group.map((item, index) => (
|
||||
<SubscriptionRow
|
||||
key={item.id}
|
||||
|
|
@ -1431,8 +1635,8 @@ export default function SubscriptionsPage() {
|
|||
}
|
||||
|
||||
// Cadence sort: virtualizer renders from flatVirtualItems
|
||||
function renderVirtualItem(vRow) {
|
||||
const it = flatVirtualItems[vRow.index];
|
||||
function renderVirtualItem(vRow: { index: number }) {
|
||||
const it = flatVirtualItems?.[vRow.index];
|
||||
if (!it) return null;
|
||||
if (it.type === 'paused-divider') {
|
||||
return (
|
||||
|
|
@ -1769,8 +1973,8 @@ export default function SubscriptionsPage() {
|
|||
{modal && (
|
||||
<BillModal
|
||||
key={modal.bill?.id ? `subscription-edit-${modal.bill.id}` : 'subscription-new'}
|
||||
bill={modal.bill}
|
||||
initialBill={modal.initialBill}
|
||||
bill={modal.bill as Bill | null}
|
||||
initialBill={modal.initialBill as unknown as Partial<Bill> | undefined}
|
||||
categories={categories}
|
||||
onClose={() => setModal(null)}
|
||||
onSave={handleBillModalSave}
|
||||
|
|
@ -1803,8 +2007,8 @@ export default function SubscriptionsPage() {
|
|||
/>
|
||||
|
||||
<BillHistoricalImportDialog
|
||||
billId={importDialog?.billId}
|
||||
billName={importDialog?.billName}
|
||||
billId={importDialog?.billId ?? ''}
|
||||
billName={importDialog?.billName ?? ''}
|
||||
open={!!importDialog}
|
||||
onClose={() => setImportDialog(null)}
|
||||
onImported={() => { setImportDialog(null); refreshAll(); }}
|
||||
Loading…
Reference in New Issue