feat(data): two-pane shell + connection hero + deep-linking (Batch 1)

Rewrite the Data page shell into a settings-style two-pane layout (sticky goal
-nav on desktop, segmented on mobile) with:
- ConnectionHero — 5 states so a network blip is never mistaken for "not
  connected" and a server without SimpleFIN never shows a dead Connect button
  (loading / disabled / error+retry / not-connected / connected±needs-attention);
  Sync-now handles partial errors, 429, and failure with toasts.
- DataNav — <nav> landmark, aria-current, keyboard, responsive.
- ?section= deep-linking via useSearchParams (URL source of truth → localStorage
  → default; migrates the old 3-tab key), so refresh/back-button work.
- Goal-based regroup into 4 panes with plain-language titles/subtitles/icons
  passed via cardProps (every section component reused unchanged).
- Lazy panes: ImportSpreadsheet/ImportMyData code-split (own chunks) + only the
  active pane mounts; framer-motion cross-fade (reduced-motion aware);
  focus-to-heading on switch.
- Repoint BankTransactions "Open Data" → ?section=bank-sync; add /data to the
  authed axe sweep.

Build clean (heavy panes split into their own chunks); client suite 46 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 15:02:36 -05:00
parent 212117a61a
commit 6a1b2f62b2
5 changed files with 427 additions and 215 deletions

View File

@ -0,0 +1,179 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { Landmark, RefreshCw, Loader2, AlertTriangle, RotateCcw, Upload, Plus } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// Relative "2h ago" / "3d ago"; returns null for empty input.
function relativeTime(iso) {
if (!iso) return null;
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return null;
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 60) return 'just now';
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.round(hrs / 24);
return `${days}d ago`;
}
function HeroShell({ tone = 'default', icon: Icon, children }) {
const toneRing = {
default: 'border-border/60',
good: 'border-emerald-500/30',
warn: 'border-amber-500/40',
error: 'border-rose-500/30',
}[tone];
const toneChip = {
default: 'bg-muted/50 text-muted-foreground',
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
error: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
}[tone];
return (
<div className={cn('surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:gap-4', toneRing)}>
<span className={cn('grid h-11 w-11 shrink-0 place-items-center rounded-xl', toneChip)}>
<Icon className="h-5 w-5" />
</span>
{children}
</div>
);
}
/**
* The Data page's connection status hero. Five states, so a network blip is never
* mistaken for "not connected", and a server without bank sync never gets a dead
* Connect button:
* loading · disabled · error · not-connected · connected (± needs-attention)
*/
export default function ConnectionHero({
loading,
error, // truthy when the status/summary fetch failed
enabled, // status.enabled (server feature flag)
hasConnections, // status.has_connections
conn, // the simplefin data_source (name, last_sync_at, last_error) or null
onRetry,
onGoTo, // (sectionId) => void
onSynced, // () => void refresh after a successful sync
}) {
const [syncing, setSyncing] = useState(false);
async function handleSyncNow() {
setSyncing(true);
try {
const r = await api.syncAllSources();
const errs = Array.isArray(r?.errors) ? r.errors : [];
if (errs.length) {
toast.warning(`Synced, but ${errs.length} connection${errs.length === 1 ? '' : 's'} need attention.`);
} else {
const n = r?.transactions_new ?? 0;
toast.success(`Synced — ${n} new transaction${n === 1 ? '' : 's'}.`);
}
onSynced?.();
} catch (err) {
if (err?.status === 429) toast.error('Please wait a moment before syncing again.');
else toast.error(err?.message || 'Sync failed.');
} finally {
setSyncing(false);
}
}
// loading
if (loading) {
return (
<div className="surface flex items-center gap-4 p-5">
<span className="h-11 w-11 shrink-0 animate-pulse rounded-xl bg-muted/50" />
<div className="flex-1 space-y-2">
<div className="h-4 w-48 animate-pulse rounded bg-muted/50" />
<div className="h-3 w-32 animate-pulse rounded bg-muted/40" />
</div>
</div>
);
}
// disabled (server has no bank sync)
if (!enabled) {
return (
<HeroShell icon={Landmark}>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">Automatic bank sync isnt enabled</p>
<p className="mt-0.5 text-sm text-muted-foreground">
This server doesnt have SimpleFIN configured you can still import and export your data.
</p>
</div>
<Button variant="outline" className="shrink-0 gap-2" onClick={() => onGoTo?.('import')}>
<Upload className="h-4 w-4" /> Import data
</Button>
</HeroShell>
);
}
// error (couldn't check)
if (error) {
return (
<HeroShell tone="error" icon={AlertTriangle}>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">Couldnt check your bank connection</p>
<p className="mt-0.5 text-sm text-muted-foreground">Something went wrong loading your sync status.</p>
</div>
<Button variant="outline" className="shrink-0 gap-2" onClick={onRetry}>
<RotateCcw className="h-4 w-4" /> Retry
</Button>
</HeroShell>
);
}
// not connected
if (!hasConnections || !conn) {
return (
<HeroShell icon={Landmark}>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">Connect your bank to get started</p>
<p className="mt-0.5 text-sm text-muted-foreground">
Sync transactions automatically, or import your existing history.
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button className="gap-2" onClick={() => onGoTo?.('bank-sync')}>
<Plus className="h-4 w-4" /> Connect your bank
</Button>
<Button variant="outline" className="gap-2" onClick={() => onGoTo?.('import')}>
<Upload className="h-4 w-4" /> Import data
</Button>
</div>
</HeroShell>
);
}
// connected (± needs attention)
const needsAttention = Boolean(conn.last_error);
const synced = relativeTime(conn.last_sync_at);
return (
<HeroShell tone={needsAttention ? 'warn' : 'good'} icon={needsAttention ? AlertTriangle : Landmark}>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
{needsAttention ? 'Your bank connection needs attention' : 'Your bank is connected'}
</p>
<p className="mt-0.5 truncate text-sm text-muted-foreground">
{needsAttention
? conn.last_error
: <>{conn.name || 'SimpleFIN'}{synced ? <> · synced {synced}</> : null} · syncs automatically</>}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
{needsAttention && (
<Button variant="outline" className="gap-2" onClick={() => onGoTo?.('bank-sync')}>
<RotateCcw className="h-4 w-4" /> Reconnect
</Button>
)}
<Button className="gap-2" onClick={handleSyncNow} disabled={syncing}>
{syncing ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
{syncing ? 'Syncing…' : 'Sync now'}
</Button>
</div>
</HeroShell>
);
}

View File

@ -0,0 +1,64 @@
import { cn } from '@/lib/utils';
const DOT_TONES = {
green: 'bg-emerald-500',
amber: 'bg-amber-500',
red: 'bg-rose-500',
gray: 'bg-muted-foreground/40',
};
/**
* Goal-oriented navigation for the Data page. Desktop ( lg): a sticky vertical
* list. Mobile: a horizontally-scrollable segmented control. One <nav> landmark,
* aria-current on the active item, fully keyboard-operable (native buttons).
*
* sections: [{ id, label, description?, icon, dot?, badge? }]
*/
export default function DataNav({ sections, active, onSelect }) {
return (
<nav aria-label="Data sections" className="lg:sticky lg:top-20">
<ul className="flex gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
{sections.map((s) => {
const isActive = s.id === active;
const Icon = s.icon;
return (
<li key={s.id} className="shrink-0 lg:shrink">
<button
type="button"
onClick={() => onSelect(s.id)}
aria-current={isActive ? 'page' : undefined}
className={cn(
'group flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors',
'whitespace-nowrap lg:whitespace-normal',
isActive
? 'bg-primary/10 font-semibold text-foreground lg:shadow-sm'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
)}
>
{Icon && (
<Icon className={cn('h-4 w-4 shrink-0', isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground')} />
)}
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
{s.dot && <span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', DOT_TONES[s.dot] || DOT_TONES.gray)} />}
<span className="truncate">{s.label}</span>
</span>
{s.description && (
<span className="mt-0.5 hidden truncate text-xs font-normal text-muted-foreground lg:block">
{s.description}
</span>
)}
</span>
{s.badge != null && s.badge !== 0 && (
<span className="ml-auto shrink-0 rounded-full bg-amber-500/15 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-amber-600 dark:text-amber-400">
{s.badge}
</span>
)}
</button>
</li>
);
})}
</ul>
</nav>
);
}

View File

@ -695,7 +695,7 @@ export default function BankTransactionsPage() {
</div>
</div>
<Button asChild>
<Link to="/data">
<Link to="/data?section=bank-sync">
<WalletCards className="h-4 w-4" />
Open Data
</Link>

View File

@ -1,111 +1,103 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef, Suspense, lazy } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import {
Landmark, ArrowRightLeft, Upload, DatabaseBackup,
FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import ConnectionHero from '@/components/data/ConnectionHero';
import DataNav from '@/components/data/DataNav';
import BankSyncSection from '@/components/data/BankSyncSection';
import BillRulesManager from '@/components/BillRulesManager';
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
import ImportSpreadsheetSection from '@/components/data/ImportSpreadsheetSection';
import ImportMyDataSection from '@/components/data/ImportMyDataSection';
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.',
},
// Heavy panes (XLSX parsing / SQLite restore) code-split, loaded on demand.
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection'));
const SECTIONS = [
{ id: 'bank-sync', label: 'Bank sync', description: 'Connect & sync your bank', icon: Landmark },
{ id: 'transactions', label: 'Transactions', description: 'Review & match', icon: ArrowRightLeft },
{ id: 'import', label: 'Import', description: 'Bring in existing data', icon: Upload },
{ id: 'export', label: 'Export & backups', description: 'Download & restore', icon: DatabaseBackup },
];
const SECTION_IDS = SECTIONS.map(s => s.id);
const DEFAULT_SECTION = SECTIONS[0].id;
const SECTION_KEY = 'billtracker:data.section';
const LEGACY_KEY = 'billtracker:data.activeTab'; // old 3-tab key migrate
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 storedSection() {
if (typeof window === 'undefined') return null;
const s = window.localStorage.getItem(SECTION_KEY);
return SECTION_IDS.includes(s) ? s : null;
}
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';
function PaneSkeleton() {
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">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground cursor-default w-fit">SimpleFIN</p>
</TooltipTrigger>
<TooltipContent>Open standard for syncing bank transactions</TooltipContent>
</Tooltip>
{simplefinConn?.last_error ? (
<Tooltip>
<TooltipTrigger asChild>
<p className={cn('mt-1 truncate text-sm font-semibold cursor-default', syncTone)}>{syncStatus}</p>
</TooltipTrigger>
<TooltipContent>{simplefinConn.last_error}</TooltipContent>
</Tooltip>
) : (
<p className={cn('mt-1 truncate text-sm font-semibold', syncTone)}>{syncStatus}</p>
)}
</TooltipProvider>
</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 className="surface p-6">
<div className="h-5 w-40 animate-pulse rounded bg-muted/50" />
<div className="mt-3 h-24 animate-pulse rounded bg-muted/30" />
</div>
);
}
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 (
<BankSyncSection
onConnectionChange={handleConnectionChange}
cardProps={{ title: 'Connect your bank', subtitle: 'Securely sync transactions automatically (via SimpleFIN).', icon: Landmark }}
/>
);
case 'transactions':
return (
<div className="space-y-5">
<TransactionMatchingSection
refreshKey={transactionRefreshKey}
simplefinConn={simplefinConn}
cardProps={{ title: 'Review & match transactions', subtitle: 'Confirm which transactions paid which bills.', icon: ArrowRightLeft }}
/>
<BillRulesManager />
</div>
);
case 'import':
return (
<div className="space-y-5">
<Suspense fallback={<PaneSkeleton />}>
<ImportSpreadsheetSection
onHistoryRefresh={loadHistory}
cardProps={{ title: 'Import a spreadsheet', subtitle: 'Bring in bill & payment history from Excel.', icon: FileSpreadsheet, collapsible: true, defaultOpen: true, storageKey: 'billtracker:data.card.spreadsheet', summary: 'Import bill/payment history from an XLSX workbook.' }}
/>
</Suspense>
<ImportTransactionCsvSection
onHistoryRefresh={handleTransactionImportComplete}
cardProps={{ title: 'Import transactions', subtitle: 'Upload a bank or card CSV export.', icon: FileText, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.transactionCsv', summary: 'Upload bank or credit-card CSV transaction files.' }}
/>
<SeedDemoDataSection
onSeeded={loadHistory}
cardProps={{ title: 'Sample data', subtitle: 'Load or clear demo data to explore features.', icon: FlaskConical, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.demo', summary: 'Seed or clear demo data for testing.' }}
/>
</div>
);
case 'export':
return (
<div className="space-y-5">
<DownloadMyDataSection
cardProps={{ title: 'Download your data', subtitle: 'Export a full SQLite backup or an Excel file.', icon: Download }}
/>
<Suspense fallback={<PaneSkeleton />}>
<ImportMyDataSection
onHistoryRefresh={loadHistory}
cardProps={{ title: 'Restore from a backup', subtitle: 'Load a previous SQLite export of your data.', icon: RotateCcw, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.sqliteImport', summary: 'Restore data from a user SQLite export.' }}
/>
</Suspense>
<ImportHistorySection
history={history}
loading={historyLoading}
onRefresh={loadHistory}
cardProps={{ title: 'Recent activity', subtitle: 'A log of your past imports.', icon: History, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.history', summary: historyLoading ? 'Loading…' : `${history?.length || 0} import record${(history?.length || 0) === 1 ? '' : 's'}.` }}
/>
</div>
);
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 (
<div className="mx-auto w-full max-w-6xl space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Import, export, and review your user-owned bill tracker records.
<p className="mt-0.5 text-sm text-muted-foreground">
Manage how your bills, payments &amp; transactions get in and out.
</p>
</div>
<TooltipProvider delayDuration={300}>
@ -176,121 +239,27 @@ export default function DataPage() {
</TooltipProvider>
</div>
<DataStatusStrip history={history} historyLoading={historyLoading} simplefinConn={simplefinConn} syncLoading={syncLoading} />
<ConnectionHero
loading={syncLoading}
error={syncError}
enabled={syncEnabled}
hasConnections={hasConnections}
conn={simplefinConn}
onRetry={loadSimplefinSummary}
onGoTo={goTo}
onSynced={handleSynced}
/>
<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 className="grid gap-5 lg:grid-cols-[220px_1fr]">
<DataNav sections={SECTIONS} active={activeSection} onSelect={goTo} />
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
<AnimatePresence mode="wait">
<motion.div key={activeSection} {...motionProps}>
{renderPane()}
</motion.div>
</AnimatePresence>
</div>
</div>
{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.',
}}
/>
<BillRulesManager />
<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>
);
}

View File

@ -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 }) => {