diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.tsx similarity index 83% rename from client/pages/SubscriptionsPage.jsx rename to client/pages/SubscriptionsPage.tsx index 474ee92..8838f77 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.tsx @@ -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 = { 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; +type QuickLink = (rec: Recommendation, billId: number) => void | Promise; + +// ── Helpers ────────────────────────────────────────────────────────────────── + const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode'; const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other']; -const CADENCE_LABELS = { +const CADENCE_LABELS: Record = { weekly: 'Weekly', biweekly: 'Biweekly', monthly: 'Monthly', @@ -81,7 +221,7 @@ const CADENCE_LABELS = { other: 'Other', }; -const SUBSCRIPTION_MONTHLY_FACTORS = { +const SUBSCRIPTION_MONTHLY_FACTORS: Record = { 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(); 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 = { '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 (