diff --git a/HISTORY.md b/HISTORY.md index 97d5000..a7c5bff 100644 --- a/HISTORY.md +++ b/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. --- diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx index 989c9f6..78186cf 100644 --- a/client/components/admin/BankSyncAdminCard.jsx +++ b/client/components/admin/BankSyncAdminCard.jsx @@ -161,12 +161,12 @@ export default function BankSyncAdminCard() {

Initial connect & backfill

- 44 days + 6 days

- 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 45-day hard limit and will return an error for any request beyond it. + strict 60-day hard limit and will return possible errors.

diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index e21a57e..a4d8349 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -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 ( - +
Loading… @@ -505,7 +505,7 @@ export default function BankSyncSection({ onConnectionChange }) { return ( <> - + {!enabled ? (
Bank sync is not enabled on this server. Ask your administrator to enable it in the Admin panel. diff --git a/client/components/data/DownloadMyDataSection.jsx b/client/components/data/DownloadMyDataSection.jsx index be71921..5dcb103 100644 --- a/client/components/data/DownloadMyDataSection.jsx +++ b/client/components/data/DownloadMyDataSection.jsx @@ -64,11 +64,12 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) { ); } -export default function DownloadMyDataSection() { +export default function DownloadMyDataSection({ cardProps = {} }) { return ( +
Loading…
); @@ -15,7 +15,7 @@ export default function ImportHistorySection({ history, loading, onRefresh }) { const rows = history ?? []; return ( - +

{rows.length === 0 ? 'No imports yet.' : `${rows.length} import${rows.length === 1 ? '' : 's'}`} diff --git a/client/components/data/ImportMyDataSection.jsx b/client/components/data/ImportMyDataSection.jsx index 6a94d31..5667d2b 100644 --- a/client/components/data/ImportMyDataSection.jsx +++ b/client/components/data/ImportMyDataSection.jsx @@ -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 ( <> + subtitle="Restore data from a SQLite export created by this app for your account." + {...cardProps}>

diff --git a/client/components/data/ImportSpreadsheetSection.jsx b/client/components/data/ImportSpreadsheetSection.jsx index f837f30..453b019 100644 --- a/client/components/data/ImportSpreadsheetSection.jsx +++ b/client/components/data/ImportSpreadsheetSection.jsx @@ -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 }) { {/* ── Upload panel ──────────────────────────────────────────────────────── */} diff --git a/client/components/data/ImportTransactionCsvSection.jsx b/client/components/data/ImportTransactionCsvSection.jsx index 9996ad0..b772410 100644 --- a/client/components/data/ImportTransactionCsvSection.jsx +++ b/client/components/data/ImportTransactionCsvSection.jsx @@ -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 }) {
diff --git a/client/components/data/SeedDemoDataSection.jsx b/client/components/data/SeedDemoDataSection.jsx index 80663f4..8a59ebe 100644 --- a/client/components/data/SeedDemoDataSection.jsx +++ b/client/components/data/SeedDemoDataSection.jsx @@ -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 ( - +
{statusLoading ? (

Loading…

diff --git a/client/components/data/TransactionMatchingSection.jsx b/client/components/data/TransactionMatchingSection.jsx index 22bc763..93151d1 100644 --- a/client/components/data/TransactionMatchingSection.jsx +++ b/client/components/data/TransactionMatchingSection.jsx @@ -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 } {simplefinConn && (
diff --git a/client/components/data/dataShared.jsx b/client/components/data/dataShared.jsx index d5c7932..142a2ef 100644 --- a/client/components/data/dataShared.jsx +++ b/client/components/data/dataShared.jsx @@ -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 + ? + : + )} +
+

{title}

+ {subtitle &&

{subtitle}

} + {collapsible && !open && summary && ( +

{summary}

+ )} +
+ {actions &&
{actions}
} + + ); + return (
-
-

{title}

- {subtitle &&

{subtitle}

} -
-
{children}
+ {collapsible ? ( + + ) : ( +
+ {headerContent} +
+ )} + {open &&
{children}
}
); } diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index 1ca8620..907496b 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -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 ( +
+
+

SimpleFIN

+

{syncStatus}

+
+
+

Last Sync

+

+ {simplefinConn?.last_sync_at ? new Date(simplefinConn.last_sync_at).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'β€”'} +

+
+
+

Import History

+

+ {historyLoading ? 'Loading…' : `${history?.length || 0} record${(history?.length || 0) === 1 ? '' : 's'}`} +

+
+
+ ); +} + 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() {
-
- - - - - + + +
+
+ {DATA_TABS.map(tab => { + const active = activeTab === tab.id; + return ( + + ); + })} +
- - - + + {activeTab === 'sync' && ( +
+ + + +
+ )} + + {activeTab === 'import' && ( +
+ + + +
+ )} + + {activeTab === 'export' && ( +
+ + +
+ )}
); } diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index fcd8311..b637ed9 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -292,6 +292,11 @@ function RecommendationCard({ recommendation, categoryId, onAccept, onDecline, o

{TYPE_LABELS[recommendation.subscription_type] || 'Other'} Β· {recommendation.occurrence_count} charges Β· last seen {fmtDate(recommendation.last_seen_date)}

+ {recommendation.accounts?.length > 0 && ( +

+ {recommendation.accounts.length > 1 ? 'Accounts' : 'Account'}: {recommendation.accounts.join(', ')} +

+ )}

{fmt(recommendation.expected_amount)}

diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 25bb07b..84d01f5 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -1540,6 +1540,14 @@ function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveCo AP )} + {row.is_subscription && ( + + S + + )}