chore: bump to v0.34.2, subscription badge fix on Tracker rows

This commit is contained in:
null 2026-05-30 21:52:02 -05:00
parent 90cfed035b
commit c6cd81e33a
17 changed files with 329 additions and 39 deletions

View File

@ -1,5 +1,15 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.34.2
### 🔧 Changed
- **Bump**`0.34.1.3``0.34.2`
- **Subscription badge on Tracker** — The "S" subscription badge now appears consistently on all bill rows in the Tracker page. The reorder-mode row variant was missing the badge even though it showed the "AP" autopay badge — both badges now render in all row contexts.
---
## v0.34.1.3 ## v0.34.1.3
### 🚀 Features ### 🚀 Features
@ -16,6 +26,7 @@
- **Summary bill ordering** — Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries. - **Summary bill ordering** — Summary expenses now use the persisted bill order and support tracker-style drag/up/down reordering, while hiding reorder controls from printed/PDF summaries.
- **Unified bill schedule editing** — Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls. - **Unified bill schedule editing** — Edit Bill now uses one canonical "Billing Schedule" field instead of separate Billing Cycle and Cycle Type controls.
- **Data page workflow tabs** — Data now groups tools into Sync & Match, Import Data, and Export & History tabs, with remembered collapsible cards and a compact status strip.
### 🔧 Changed ### 🔧 Changed
@ -35,6 +46,7 @@
- **Scheduled backup retention** — The database backup scheduler now starts with the server only when enabled in Admin settings and prunes only scheduled backups, keeping the configured default of 2 without deleting manual, imported, or pre-restore backups. - **Scheduled backup retention** — The database backup scheduler now starts with the server only when enabled in Admin settings and prunes only scheduled backups, keeping the configured default of 2 without deleting manual, imported, or pre-restore backups.
- **Billing schedule migration** — Added migration v0.76 to backfill legacy billing-cycle values into `cycle_type`, normalize `cycle_day`, and derive the legacy `billing_cycle` value from the canonical schedule. - **Billing schedule migration** — Added migration v0.76 to backfill legacy billing-cycle values into `cycle_type`, normalize `cycle_day`, and derive the legacy `billing_cycle` value from the canonical schedule.
- **Subscription recommendations** — Possible subscription matches now list the bank account that produced the matching transaction activity.
--- ---

View File

@ -161,12 +161,12 @@ export default function BankSyncAdminCard() {
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
Initial connect &amp; backfill Initial connect &amp; backfill
</p> </p>
<span className="font-mono text-sm font-bold">44 days</span> <span className="font-mono text-sm font-bold">6 days</span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The first sync (and any manual backfill) always fetches the maximum 44 days of history The first sync (and any manual backfill) always fetches the maximum 60 days of history
to build a complete transaction picture. This is fixed SimpleFIN Bridge enforces a to build a complete transaction picture. This is fixed SimpleFIN Bridge enforces a
strict <strong>45-day hard limit</strong> and will return an error for any request beyond it. strict <strong>60-day hard limit</strong> and will return possible errors.
</p> </p>
</div> </div>

View File

@ -285,7 +285,7 @@ function AccountRow({ account, sourceId, expanded, onToggleExpand, onToggleMonit
); );
} }
export default function BankSyncSection({ onConnectionChange }) { export default function BankSyncSection({ onConnectionChange, cardProps = {} }) {
const [enabled, setEnabled] = useState(null); const [enabled, setEnabled] = useState(null);
const [syncDays, setSyncDays] = useState(90); const [syncDays, setSyncDays] = useState(90);
const [connections, setConnections] = useState([]); const [connections, setConnections] = useState([]);
@ -494,7 +494,7 @@ export default function BankSyncSection({ onConnectionChange }) {
if (enabled === null) { if (enabled === null) {
return ( return (
<SectionCard title="Bank Sync"> <SectionCard title="Bank Sync" {...cardProps}>
<div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground"> <div className="px-6 py-6 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading Loading
@ -505,7 +505,7 @@ export default function BankSyncSection({ onConnectionChange }) {
return ( return (
<> <>
<SectionCard title="Bank Sync" subtitle="Connect your SimpleFIN Bridge to sync read-only bank transactions."> <SectionCard title="Bank Sync" subtitle="Connect your SimpleFIN Bridge to sync read-only bank transactions." {...cardProps}>
{!enabled ? ( {!enabled ? (
<div className="px-6 py-5 text-sm text-muted-foreground"> <div className="px-6 py-5 text-sm text-muted-foreground">
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel. Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel.

View File

@ -64,11 +64,12 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
); );
} }
export default function DownloadMyDataSection() { export default function DownloadMyDataSection({ cardProps = {} }) {
return ( return (
<SectionCard <SectionCard
title="Download My Data" title="Download My Data"
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup." subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
{...cardProps}
> >
<ExportCard icon={Database} title="SQLite Data Export" <ExportCard icon={Database} title="SQLite Data Export"
description="Download a portable SQLite database containing your bill tracker data." description="Download a portable SQLite database containing your bill tracker data."

View File

@ -3,10 +3,10 @@ import { RefreshCw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { SectionCard, fmt } from './dataShared'; import { SectionCard, fmt } from './dataShared';
export default function ImportHistorySection({ history, loading, onRefresh }) { export default function ImportHistorySection({ history, loading, onRefresh, cardProps = {} }) {
if (loading) { if (loading) {
return ( return (
<SectionCard title="Import History"> <SectionCard title="Import History" {...cardProps}>
<div className="px-6 py-6 text-sm text-muted-foreground">Loading</div> <div className="px-6 py-6 text-sm text-muted-foreground">Loading</div>
</SectionCard> </SectionCard>
); );
@ -15,7 +15,7 @@ export default function ImportHistorySection({ history, loading, onRefresh }) {
const rows = history ?? []; const rows = history ?? [];
return ( return (
<SectionCard title="Import History"> <SectionCard title="Import History" {...cardProps}>
<div className="px-6 py-4 flex items-center justify-between"> <div className="px-6 py-4 flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`} {rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}

View File

@ -10,7 +10,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared'; import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
export default function ImportMyDataSection({ onHistoryRefresh }) { export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }) {
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
@ -69,7 +69,8 @@ export default function ImportMyDataSection({ onHistoryRefresh }) {
return ( return (
<> <>
<SectionCard title="Import My Data Export" <SectionCard title="Import My Data Export"
subtitle="Restore data from a SQLite export created by this app for your account."> subtitle="Restore data from a SQLite export created by this app for your account."
{...cardProps}>
<div className="px-6 py-5"> <div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4"> <div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">

View File

@ -869,7 +869,7 @@ function BillHistoryView({ previewRows, allBills, importingBillId, billImportRes
// //
export default function ImportSpreadsheetSection({ onHistoryRefresh }) { export default function ImportSpreadsheetSection({ onHistoryRefresh, cardProps = {} }) {
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [options, setOptions] = useState(INITIAL_OPTIONS); const [options, setOptions] = useState(INITIAL_OPTIONS);
@ -1190,6 +1190,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh }) {
<SectionCard <SectionCard
title="Import Spreadsheet History" title="Import Spreadsheet History"
subtitle='Export your Google Sheet as .xlsx, upload it here, review the matches, then apply only the rows you approve.' subtitle='Export your Google Sheet as .xlsx, upload it here, review the matches, then apply only the rows you approve.'
{...cardProps}
> >
{/* ── Upload panel ──────────────────────────────────────────────────────── */} {/* ── Upload panel ──────────────────────────────────────────────────────── */}

View File

@ -287,7 +287,7 @@ function formatCsvRowDetail(detail) {
return `${field}${detail.message || detail.value || JSON.stringify(detail)}`; return `${field}${detail.message || detail.value || JSON.stringify(detail)}`;
} }
export default function ImportTransactionCsvSection({ onHistoryRefresh }) { export default function ImportTransactionCsvSection({ onHistoryRefresh, cardProps = {} }) {
const fileRef = useRef(null); const fileRef = useRef(null);
const [file, setFile] = useState(null); const [file, setFile] = useState(null);
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null }); const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
@ -377,6 +377,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh }) {
<SectionCard <SectionCard
title="Import Transaction CSV" title="Import Transaction CSV"
subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later." subtitle="Upload a bank or credit-card CSV, map its columns, then save normalized transactions for matching later."
{...cardProps}
> >
<div className="px-6 py-5 space-y-5"> <div className="px-6 py-5 space-y-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4"> <div className="rounded-lg border border-border/60 bg-muted/25 p-4">

View File

@ -10,7 +10,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { SectionCard } from './dataShared'; import { SectionCard } from './dataShared';
export default function SeedDemoDataSection({ onSeeded }) { export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [seeded, setSeeded] = useState(false); const [seeded, setSeeded] = useState(false);
const [counts, setCounts] = useState({ bills: 0, categories: 0 }); const [counts, setCounts] = useState({ bills: 0, categories: 0 });
@ -62,7 +62,7 @@ export default function SeedDemoDataSection({ onSeeded }) {
}; };
return ( return (
<SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing"> <SectionCard title="Demo Data" subtitle="Seed your database with demo data for testing" {...cardProps}>
<div className="rounded-lg border border-border/60 bg-background/50 p-4"> <div className="rounded-lg border border-border/60 bg-background/50 p-4">
{statusLoading ? ( {statusLoading ? (
<p className="text-sm text-muted-foreground">Loading</p> <p className="text-sm text-muted-foreground">Loading</p>

View File

@ -372,7 +372,7 @@ function timeAgo(iso) {
return `${Math.floor(secs / 86400)}d ago`; return `${Math.floor(secs / 86400)}d ago`;
} }
export default function TransactionMatchingSection({ refreshKey, simplefinConn }) { export default function TransactionMatchingSection({ refreshKey, simplefinConn, cardProps = {} }) {
const [transactions, setTransactions] = useState([]); const [transactions, setTransactions] = useState([]);
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [bills, setBills] = useState([]); const [bills, setBills] = useState([]);
@ -558,6 +558,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
<SectionCard <SectionCard
title="Transactions" title="Transactions"
subtitle="Review imported or manual transactions and confirm matches to bills." subtitle="Review imported or manual transactions and confirm matches to bills."
{...cardProps}
> >
{simplefinConn && ( {simplefinConn && (
<div className="px-6 py-2 flex items-center justify-between gap-3 border-b border-border/50 bg-muted/20 text-xs text-muted-foreground"> <div className="px-6 py-2 flex items-center justify-between gap-3 border-b border-border/50 bg-muted/20 text-xs text-muted-foreground">

View File

@ -1,7 +1,6 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronRight } from 'lucide-react';
import { RefreshCw } from 'lucide-react';
export function fmt(isoStr) { export function fmt(isoStr) {
if (!isoStr) return '—'; if (!isoStr) return '—';
@ -21,14 +20,63 @@ export function importErrorState(err, fallback) {
}; };
} }
export function SectionCard({ title, subtitle, children, className }) { export function SectionCard({
title,
subtitle,
children,
className,
collapsible = false,
defaultOpen = true,
storageKey,
summary,
actions,
}) {
const [open, setOpen] = useState(() => {
if (!collapsible || !storageKey || typeof window === 'undefined') return defaultOpen;
const stored = window.localStorage.getItem(storageKey);
return stored === null ? defaultOpen : stored === 'true';
});
useEffect(() => {
if (!collapsible || !storageKey || typeof window === 'undefined') return;
window.localStorage.setItem(storageKey, String(open));
}, [collapsible, open, storageKey]);
const headerContent = (
<>
{collapsible && (
open
? <ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
: <ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
{collapsible && !open && summary && (
<p className="mt-1 truncate text-xs font-medium text-muted-foreground/80">{summary}</p>
)}
</div>
{actions && <div className="shrink-0">{actions}</div>}
</>
);
return ( return (
<div className={cn('table-surface mb-6', className)}> <div className={cn('table-surface mb-6', className)}>
<div className="px-6 py-4 border-b border-border/50"> {collapsible ? (
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2> <button
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>} type="button"
onClick={() => setOpen(value => !value)}
className="flex w-full items-start gap-2 border-b border-border/50 px-6 py-4 text-left transition-colors hover:bg-muted/20"
aria-expanded={open}
>
{headerContent}
</button>
) : (
<div className="flex items-start gap-2 border-b border-border/50 px-6 py-4">
{headerContent}
</div> </div>
<div className="divide-y divide-border/50">{children}</div> )}
{open && <div className="divide-y divide-border/50">{children}</div>}
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils';
import BankSyncSection from '@/components/data/BankSyncSection'; import BankSyncSection from '@/components/data/BankSyncSection';
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection'; import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection'; import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
@ -10,11 +11,81 @@ import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection'; import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
import ImportHistorySection from '@/components/data/ImportHistorySection'; import ImportHistorySection from '@/components/data/ImportHistorySection';
const DATA_TAB_STORAGE_KEY = 'billtracker:data.activeTab';
const DATA_TABS = [
{
id: 'sync',
label: 'Sync & Match',
description: 'Bank connections, synced transactions, and CSV transaction imports.',
},
{
id: 'import',
label: 'Import Data',
description: 'Bring in spreadsheet history, restore an app export, or seed demo data.',
},
{
id: 'export',
label: 'Export & History',
description: 'Download your data and review previous import activity.',
},
];
function useStoredTab() {
const [activeTab, setActiveTabState] = useState(() => {
if (typeof window === 'undefined') return DATA_TABS[0].id;
const stored = window.localStorage.getItem(DATA_TAB_STORAGE_KEY);
return DATA_TABS.some(tab => tab.id === stored) ? stored : DATA_TABS[0].id;
});
const setActiveTab = useCallback((tab) => {
setActiveTabState(tab);
if (typeof window !== 'undefined') {
window.localStorage.setItem(DATA_TAB_STORAGE_KEY, tab);
}
}, []);
return [activeTab, setActiveTab];
}
function DataStatusStrip({ history, historyLoading, simplefinConn, syncLoading }) {
const syncStatus = simplefinConn
? (simplefinConn.last_error ? 'Needs attention' : 'Connected')
: syncLoading ? 'Loading…' : 'Not connected';
const syncTone = simplefinConn?.last_error
? 'text-amber-600 dark:text-amber-300'
: simplefinConn
? 'text-emerald-600 dark:text-emerald-300'
: 'text-muted-foreground';
return (
<div className="grid gap-2 sm:grid-cols-3">
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">SimpleFIN</p>
<p className={cn('mt-1 truncate text-sm font-semibold', syncTone)}>{syncStatus}</p>
</div>
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Sync</p>
<p className="mt-1 truncate text-sm font-semibold text-foreground">
{simplefinConn?.last_sync_at ? new Date(simplefinConn.last_sync_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
</p>
</div>
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Import History</p>
<p className="mt-1 truncate text-sm font-semibold text-foreground">
{historyLoading ? 'Loading…' : `${history?.length || 0} record${(history?.length || 0) === 1 ? '' : 's'}`}
</p>
</div>
</div>
);
}
export default function DataPage() { export default function DataPage() {
const [history, setHistory] = useState(null); const [history, setHistory] = useState(null);
const [historyLoading, setHistoryLoading] = useState(true); const [historyLoading, setHistoryLoading] = useState(true);
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0); const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
const [simplefinConn, setSimplefinConn] = useState(null); const [simplefinConn, setSimplefinConn] = useState(null);
const [syncLoading, setSyncLoading] = useState(true);
const [activeTab, setActiveTab] = useStoredTab();
const loadHistory = async () => { const loadHistory = async () => {
setHistoryLoading(true); setHistoryLoading(true);
@ -29,7 +100,30 @@ export default function DataPage() {
} }
}; };
useEffect(() => { loadHistory(); }, []); const loadSimplefinSummary = useCallback(async () => {
setSyncLoading(true);
try {
const [status, sources] = await Promise.all([
api.simplefinStatus(),
api.dataSources({ type: 'provider_sync' }),
]);
if (!status.enabled) {
setSimplefinConn(null);
return;
}
const conns = Array.isArray(sources) ? sources.filter(source => source.provider === 'simplefin') : [];
setSimplefinConn(conns[0] || null);
} catch {
setSimplefinConn(null);
} finally {
setSyncLoading(false);
}
}, []);
useEffect(() => {
loadHistory();
loadSimplefinSummary();
}, [loadSimplefinSummary]);
const handleTransactionImportComplete = () => { const handleTransactionImportComplete = () => {
loadHistory(); loadHistory();
@ -39,6 +133,7 @@ export default function DataPage() {
// Called by BankSyncSection when connection state changes (connect/sync/disconnect) // Called by BankSyncSection when connection state changes (connect/sync/disconnect)
const handleConnectionChange = useCallback((conn) => { const handleConnectionChange = useCallback((conn) => {
setSimplefinConn(conn || null); setSimplefinConn(conn || null);
setSyncLoading(false);
setTransactionRefreshKey(key => key + 1); setTransactionRefreshKey(key => key + 1);
}, []); }, []);
@ -56,16 +151,120 @@ export default function DataPage() {
</div> </div>
</div> </div>
<div className="space-y-5"> <DataStatusStrip history={history} historyLoading={historyLoading} simplefinConn={simplefinConn} syncLoading={syncLoading} />
<BankSyncSection onConnectionChange={handleConnectionChange} />
<ImportTransactionCsvSection onHistoryRefresh={handleTransactionImportComplete} /> <div className="rounded-lg border border-border/70 bg-card/70 p-1">
<TransactionMatchingSection refreshKey={transactionRefreshKey} simplefinConn={simplefinConn} /> <div className="grid gap-1 md:grid-cols-3">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} /> {DATA_TABS.map(tab => {
<ImportMyDataSection onHistoryRefresh={loadHistory} /> const active = activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'rounded-md px-4 py-3 text-left transition-colors',
active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
)}
>
<span className="block text-sm font-semibold">{tab.label}</span>
<span className={cn('mt-0.5 block text-xs', active ? 'text-primary-foreground/75' : 'text-muted-foreground')}>
{tab.description}
</span>
</button>
);
})}
</div> </div>
<SeedDemoDataSection onSeeded={loadHistory} /> </div>
<DownloadMyDataSection />
<ImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} /> {activeTab === 'sync' && (
<div className="space-y-5">
<BankSyncSection
onConnectionChange={handleConnectionChange}
cardProps={{
collapsible: true,
defaultOpen: true,
storageKey: 'billtracker:data.card.bankSync',
summary: simplefinConn ? `Connected as ${simplefinConn.name || 'SimpleFIN'}` : 'Connect or manage SimpleFIN bank sync.',
}}
/>
<TransactionMatchingSection
refreshKey={transactionRefreshKey}
simplefinConn={simplefinConn}
cardProps={{
collapsible: true,
defaultOpen: true,
storageKey: 'billtracker:data.card.transactions',
summary: 'Review transaction matches and create bill payments.',
}}
/>
<ImportTransactionCsvSection
onHistoryRefresh={handleTransactionImportComplete}
cardProps={{
collapsible: true,
defaultOpen: false,
storageKey: 'billtracker:data.card.transactionCsv',
summary: 'Upload bank or credit-card CSV transaction files.',
}}
/>
</div>
)}
{activeTab === 'import' && (
<div className="space-y-5">
<ImportSpreadsheetSection
onHistoryRefresh={loadHistory}
cardProps={{
collapsible: true,
defaultOpen: false,
storageKey: 'billtracker:data.card.spreadsheet',
summary: 'Import bill/payment history from an XLSX workbook.',
}}
/>
<ImportMyDataSection
onHistoryRefresh={loadHistory}
cardProps={{
collapsible: true,
defaultOpen: false,
storageKey: 'billtracker:data.card.sqliteImport',
summary: 'Restore data from a user SQLite export.',
}}
/>
<SeedDemoDataSection
onSeeded={loadHistory}
cardProps={{
collapsible: true,
defaultOpen: false,
storageKey: 'billtracker:data.card.demo',
summary: 'Seed or clear demo data for testing.',
}}
/>
</div>
)}
{activeTab === 'export' && (
<div className="space-y-5">
<DownloadMyDataSection
cardProps={{
collapsible: true,
defaultOpen: true,
storageKey: 'billtracker:data.card.download',
summary: 'Download SQLite or Excel exports of your records.',
}}
/>
<ImportHistorySection
history={history}
loading={historyLoading}
onRefresh={loadHistory}
cardProps={{
collapsible: true,
defaultOpen: false,
storageKey: 'billtracker:data.card.history',
summary: historyLoading ? 'Loading import activity…' : `${history?.length || 0} import record${(history?.length || 0) === 1 ? '' : 's'}.`,
}}
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -292,6 +292,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
<p className="mt-1 text-xs font-medium text-muted-foreground"> <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)} {TYPE_LABELS[recommendation.subscription_type] || 'Other'} · {recommendation.occurrence_count} charges · last seen {fmtDate(recommendation.last_seen_date)}
</p> </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>
)}
</div> </div>
<div className="shrink-0 text-right"> <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-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>

View File

@ -1540,6 +1540,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveCo
AP AP
</span> </span>
)} )}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
S
</span>
)}
<Button <Button
size="icon" variant="ghost" size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100" className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.34.1.3", "version": "0.34.2",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {

View File

@ -297,7 +297,9 @@ function getSubscriptionRecommendations(db, userId) {
t.id, t.amount, t.currency, t.description, t.payee, t.memo, t.category, t.id, t.amount, t.currency, t.description, t.payee, t.memo, t.category,
COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) AS tx_date, COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) AS tx_date,
ds.provider AS data_source_provider, ds.provider AS data_source_provider,
ds.name AS data_source_name ds.name AS data_source_name,
fa.name AS account_name,
fa.org_name AS account_org_name
FROM transactions t FROM transactions t
LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id LEFT JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
@ -410,6 +412,14 @@ function getSubscriptionRecommendations(db, userId) {
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap }) { function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap }) {
const name = catalogEntry ? catalogEntry.name : titleCase(merchant); const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap); const subscriptionType = inferType(merchant, catalogEntry, catalogTypeMap);
const accounts = Array.from(new Set(sorted
.map(item => {
const accountName = item.account_name || '';
const orgName = item.account_org_name || '';
if (accountName && orgName && accountName !== orgName) return `${orgName} · ${accountName}`;
return accountName || orgName || item.data_source_name || '';
})
.filter(Boolean)));
const reasons = []; const reasons = [];
if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`); if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`);
@ -435,6 +445,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
merchant, merchant,
decline_key: declineKey, decline_key: declineKey,
source: last.data_source_name || 'Transaction history', source: last.data_source_name || 'Transaction history',
accounts,
reasons, reasons,
}; };
} }

View File

@ -58,7 +58,8 @@ test.after(() => {
test('known catalog services appear as high-confidence subscription recommendations', () => { test('known catalog services appear as high-confidence subscription recommendations', () => {
const db = getDb(); const db = getDb();
const userId = createUser(db, 'recommendation'); const userId = createUser(db, 'recommendation');
createTransaction(db, userId); const accountId = createAccount(db, userId, true);
createTransaction(db, userId, { account_id: accountId });
const recommendations = getSubscriptionRecommendations(db, userId); const recommendations = getSubscriptionRecommendations(db, userId);
const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix'); const netflix = recommendations.find(item => item.catalog_match?.name === 'Netflix');
@ -66,6 +67,7 @@ test('known catalog services appear as high-confidence subscription recommendati
assert.ok(netflix, 'Netflix catalog match should be recommended from one known charge'); assert.ok(netflix, 'Netflix catalog match should be recommended from one known charge');
assert.equal(netflix.subscription_type, 'streaming'); assert.equal(netflix.subscription_type, 'streaming');
assert.equal(netflix.confidence >= 90, true); assert.equal(netflix.confidence >= 90, true);
assert.deepEqual(netflix.accounts, ['Checking']);
assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/); assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/);
}); });