-
-
-
-
- SimpleFIN
-
- Open standard for syncing bank transactions
-
- {simplefinConn?.last_error ? (
-
-
- {syncStatus}
-
- {simplefinConn.last_error}
-
- ) : (
- {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 [searchParams, setSearchParams] = useSearchParams();
+ 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 [simplefinConn, setSimplefinConn] = useState(null);
+ const [syncEnabled, setSyncEnabled] = useState(true);
+ const [hasConnections, setHasConnections] = useState(false);
+ const [syncLoading, setSyncLoading] = useState(true);
+ const [syncError, setSyncError] = useState(false);
+ const reduceMotion = useReducedMotion();
+ const paneRef = useRef(null);
+ const firstRender = useRef(true);
- const loadHistory = async () => {
+ // Active section: URL ?section= is source of truth → localStorage → default.
+ const urlSection = searchParams.get('section');
+ const activeSection = SECTION_IDS.includes(urlSection) ? urlSection : (storedSection() || DEFAULT_SECTION);
+
+ const goTo = useCallback((id) => {
+ if (!SECTION_IDS.includes(id)) return;
+ window.localStorage.setItem(SECTION_KEY, id);
+ setSearchParams(prev => {
+ const p = new URLSearchParams(prev);
+ p.set('section', id);
+ return p;
+ });
+ }, [setSearchParams]);
+
+ // Reflect the resolved section into the URL once, so refresh/back-button work
+ // (and migrate the old 3-tab key). Runs only when the URL lacks a valid section.
+ useEffect(() => {
+ if (SECTION_IDS.includes(urlSection)) return;
+ const legacy = window.localStorage.getItem(LEGACY_KEY);
+ const migrated = { sync: 'bank-sync', import: 'import', export: 'export' }[legacy];
+ const target = storedSection() || migrated || DEFAULT_SECTION;
+ setSearchParams(prev => {
+ const p = new URLSearchParams(prev);
+ p.set('section', target);
+ return p;
+ }, { replace: true });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Move focus to the pane on section change (keyboard/SR), but not on first load.
+ useEffect(() => {
+ if (firstRender.current) { firstRender.current = false; return; }
+ paneRef.current?.focus?.();
+ }, [activeSection]);
+
+ const loadHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const { history } = await api.importHistory();
@@ -116,52 +108,123 @@ export default function DataPage() {
} finally {
setHistoryLoading(false);
}
- };
+ }, []);
const loadSimplefinSummary = useCallback(async () => {
setSyncLoading(true);
+ setSyncError(false);
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') : [];
+ setSyncEnabled(Boolean(status.enabled));
+ setHasConnections(Boolean(status.has_connections));
+ const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setSimplefinConn(conns[0] || null);
} catch {
+ setSyncError(true);
setSimplefinConn(null);
} finally {
setSyncLoading(false);
}
}, []);
- useEffect(() => {
- loadHistory();
- loadSimplefinSummary();
- }, [loadSimplefinSummary]);
+ useEffect(() => { loadHistory(); loadSimplefinSummary(); }, [loadHistory, loadSimplefinSummary]);
- const handleTransactionImportComplete = () => {
+ const handleTransactionImportComplete = useCallback(() => {
loadHistory();
- setTransactionRefreshKey(key => key + 1);
- };
+ setTransactionRefreshKey(k => k + 1);
+ }, [loadHistory]);
- // Called by BankSyncSection when connection state changes (connect/sync/disconnect)
+ // BankSyncSection reports connect/sync/disconnect changes.
const handleConnectionChange = useCallback((conn) => {
setSimplefinConn(conn || null);
+ setHasConnections(Boolean(conn));
setSyncLoading(false);
- setTransactionRefreshKey(key => key + 1);
+ setTransactionRefreshKey(k => k + 1);
}, []);
+ const handleSynced = useCallback(() => {
+ loadSimplefinSummary();
+ setTransactionRefreshKey(k => k + 1);
+ }, [loadSimplefinSummary]);
+
+ const renderPane = () => {
+ switch (activeSection) {
+ case 'bank-sync':
+ return (
+
+ );
+ case 'transactions':
+ return (
+
+
+
+
+ );
+ case 'import':
+ return (
+
+ }>
+
+
+
+
+
+ );
+ case 'export':
+ return (
+
+
+ }>
+
+
+
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ const motionProps = reduceMotion
+ ? {}
+ : { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 } };
+
return (
Data
-
- Import, export, and review your user-owned bill tracker records.
+
+ Manage how your bills, payments & transactions get in and out.
@@ -176,121 +239,27 @@ export default function DataPage() {
-
+
-
-
- {DATA_TABS.map(tab => {
- const active = activeTab === tab.id;
- return (
-
- );
- })}
+
-
- {activeTab === 'sync' && (
-
-
-
-
-
-
- )}
-
- {activeTab === 'import' && (
-
-
-
-
-
- )}
-
- {activeTab === 'export' && (
-
-
-
-
- )}
);
}
diff --git a/e2e/a11y.authed.spec.js b/e2e/a11y.authed.spec.js
index 1b31515..68b4eed 100644
--- a/e2e/a11y.authed.spec.js
+++ b/e2e/a11y.authed.spec.js
@@ -8,7 +8,7 @@ const { STORAGE_STATE } = require('./constants');
test.use({ storageState: STORAGE_STATE });
-const PAGES = ['/', '/bills', '/summary', '/spending', '/analytics', '/categories', '/snowball'];
+const PAGES = ['/', '/bills', '/summary', '/spending', '/analytics', '/categories', '/snowball', '/data'];
for (const path of PAGES) {
test(`no critical/serious a11y violations on ${path}`, async ({ page }) => {