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
|
# 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 & backfill
|
Initial connect & 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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'}`}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────── */}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue