chore: bump to v0.34.2, subscription badge fix on Tracker rows
This commit is contained in:
parent
90cfed035b
commit
c6cd81e33a
12
HISTORY.md
12
HISTORY.md
|
|
@ -1,5 +1,15 @@
|
|||
# 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
|
||||
|
||||
### 🚀 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.
|
||||
- **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
|
||||
|
||||
|
|
@ -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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -161,12 +161,12 @@ export default function BankSyncAdminCard() {
|
|||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Initial connect & backfill
|
||||
</p>
|
||||
<span className="font-mono text-sm font-bold">44 days</span>
|
||||
<span className="font-mono text-sm font-bold">6 days</span>
|
||||
</div>
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [syncDays, setSyncDays] = useState(90);
|
||||
const [connections, setConnections] = useState([]);
|
||||
|
|
@ -494,7 +494,7 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
|
||||
if (enabled === null) {
|
||||
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">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
|
|
@ -505,7 +505,7 @@ export default function BankSyncSection({ onConnectionChange }) {
|
|||
|
||||
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 ? (
|
||||
<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.
|
||||
|
|
|
|||
|
|
@ -64,11 +64,12 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function DownloadMyDataSection() {
|
||||
export default function DownloadMyDataSection({ cardProps = {} }) {
|
||||
return (
|
||||
<SectionCard
|
||||
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."
|
||||
{...cardProps}
|
||||
>
|
||||
<ExportCard icon={Database} title="SQLite Data Export"
|
||||
description="Download a portable SQLite database containing your bill tracker data."
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { RefreshCw, Clock } from 'lucide-react';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { SectionCard, fmt } from './dataShared';
|
||||
|
||||
export default function ImportHistorySection({ history, loading, onRefresh }) {
|
||||
export default function ImportHistorySection({ history, loading, onRefresh, cardProps = {} }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<SectionCard title="Import History">
|
||||
<SectionCard title="Import History" {...cardProps}>
|
||||
<div className="px-6 py-6 text-sm text-muted-foreground">Loading…</div>
|
||||
</SectionCard>
|
||||
);
|
||||
|
|
@ -15,7 +15,7 @@ export default function ImportHistorySection({ history, loading, onRefresh }) {
|
|||
const rows = history ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard title="Import History">
|
||||
<SectionCard title="Import History" {...cardProps}>
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { SectionCard, CountPill, fmt, importErrorState } from './dataShared';
|
||||
|
||||
export default function ImportMyDataSection({ onHistoryRefresh }) {
|
||||
export default function ImportMyDataSection({ onHistoryRefresh, cardProps = {} }) {
|
||||
const fileRef = useRef(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
||||
|
|
@ -69,7 +69,8 @@ export default function ImportMyDataSection({ onHistoryRefresh }) {
|
|||
return (
|
||||
<>
|
||||
<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="rounded-lg border border-border/60 bg-muted/25 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
|
|
|
|||
|
|
@ -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 [file, setFile] = useState(null);
|
||||
const [options, setOptions] = useState(INITIAL_OPTIONS);
|
||||
|
|
@ -1190,6 +1190,7 @@ export default function ImportSpreadsheetSection({ onHistoryRefresh }) {
|
|||
<SectionCard
|
||||
title="Import Spreadsheet History"
|
||||
subtitle='Export your Google Sheet as .xlsx, upload it here, review the matches, then apply only the rows you approve.'
|
||||
{...cardProps}
|
||||
>
|
||||
|
||||
{/* ── Upload panel ──────────────────────────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ function formatCsvRowDetail(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 [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState({ status: 'idle', data: null, error: null });
|
||||
|
|
@ -377,6 +377,7 @@ export default function ImportTransactionCsvSection({ onHistoryRefresh }) {
|
|||
<SectionCard
|
||||
title="Import Transaction CSV"
|
||||
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="rounded-lg border border-border/60 bg-muted/25 p-4">
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
} from '@/components/ui/alert-dialog';
|
||||
import { SectionCard } from './dataShared';
|
||||
|
||||
export default function SeedDemoDataSection({ onSeeded }) {
|
||||
export default function SeedDemoDataSection({ onSeeded, cardProps = {} }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [counts, setCounts] = useState({ bills: 0, categories: 0 });
|
||||
|
|
@ -62,7 +62,7 @@ export default function SeedDemoDataSection({ onSeeded }) {
|
|||
};
|
||||
|
||||
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">
|
||||
{statusLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ function timeAgo(iso) {
|
|||
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 [suggestions, setSuggestions] = useState([]);
|
||||
const [bills, setBills] = useState([]);
|
||||
|
|
@ -558,6 +558,7 @@ export default function TransactionMatchingSection({ refreshKey, simplefinConn }
|
|||
<SectionCard
|
||||
title="Transactions"
|
||||
subtitle="Review imported or manual transactions and confirm matches to bills."
|
||||
{...cardProps}
|
||||
>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
export function fmt(isoStr) {
|
||||
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 (
|
||||
<div className={cn('table-surface mb-6', className)}>
|
||||
<div className="px-6 py-4 border-b border-border/50">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">{children}</div>
|
||||
{collapsible ? (
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
{open && <div className="divide-y divide-border/50">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import BankSyncSection from '@/components/data/BankSyncSection';
|
||||
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
||||
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
||||
|
|
@ -10,11 +11,81 @@ import SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
|
|||
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
|
||||
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() {
|
||||
const [history, setHistory] = useState(null);
|
||||
const [historyLoading, setHistoryLoading] = useState(true);
|
||||
const [transactionRefreshKey, setTransactionRefreshKey] = useState(0);
|
||||
const [simplefinConn, setSimplefinConn] = useState(null);
|
||||
const [syncLoading, setSyncLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useStoredTab();
|
||||
|
||||
const loadHistory = async () => {
|
||||
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 = () => {
|
||||
loadHistory();
|
||||
|
|
@ -39,6 +133,7 @@ export default function DataPage() {
|
|||
// Called by BankSyncSection when connection state changes (connect/sync/disconnect)
|
||||
const handleConnectionChange = useCallback((conn) => {
|
||||
setSimplefinConn(conn || null);
|
||||
setSyncLoading(false);
|
||||
setTransactionRefreshKey(key => key + 1);
|
||||
}, []);
|
||||
|
||||
|
|
@ -56,16 +151,120 @@ export default function DataPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<BankSyncSection onConnectionChange={handleConnectionChange} />
|
||||
<ImportTransactionCsvSection onHistoryRefresh={handleTransactionImportComplete} />
|
||||
<TransactionMatchingSection refreshKey={transactionRefreshKey} simplefinConn={simplefinConn} />
|
||||
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
||||
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
||||
<DataStatusStrip history={history} historyLoading={historyLoading} simplefinConn={simplefinConn} syncLoading={syncLoading} />
|
||||
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-1">
|
||||
<div className="grid gap-1 md:grid-cols-3">
|
||||
{DATA_TABS.map(tab => {
|
||||
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} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,6 +292,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o
|
|||
<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.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 className="shrink-0 text-right">
|
||||
<p className="tracker-number text-base font-semibold text-foreground">{fmt(recommendation.expected_amount)}</p>
|
||||
|
|
|
|||
|
|
@ -1540,6 +1540,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveCo
|
|||
AP
|
||||
</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
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.1.3",
|
||||
"version": "0.34.2",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -297,7 +297,9 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
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,
|
||||
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
|
||||
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
|
||||
|
|
@ -410,6 +412,14 @@ function getSubscriptionRecommendations(db, userId) {
|
|||
function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, maxDelta, last, cycleType, avgGap, confidence, tier, declineKey, catalogTypeMap }) {
|
||||
const name = catalogEntry ? catalogEntry.name : titleCase(merchant);
|
||||
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 = [];
|
||||
if (catalogEntry) reasons.push(`Matches known service: ${catalogEntry.name}`);
|
||||
|
|
@ -435,6 +445,7 @@ function buildRecommendation({ merchant, catalogEntry, sorted, averageAmount, ma
|
|||
merchant,
|
||||
decline_key: declineKey,
|
||||
source: last.data_source_name || 'Transaction history',
|
||||
accounts,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ test.after(() => {
|
|||
test('known catalog services appear as high-confidence subscription recommendations', () => {
|
||||
const db = getDb();
|
||||
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 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.equal(netflix.subscription_type, 'streaming');
|
||||
assert.equal(netflix.confidence >= 90, true);
|
||||
assert.deepEqual(netflix.accounts, ['Checking']);
|
||||
assert.match(netflix.reasons.join(' '), /Matches known service: Netflix/);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue