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:
parent
212117a61a
commit
6a1b2f62b2
|
|
@ -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 isn’t enabled</p>
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
|
This server doesn’t 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">Couldn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -695,7 +695,7 @@ export default function BankTransactionsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to="/data">
|
<Link to="/data?section=bank-sync">
|
||||||
<WalletCards className="h-4 w-4" />
|
<WalletCards className="h-4 w-4" />
|
||||||
Open Data
|
Open Data
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 BankSyncSection from '@/components/data/BankSyncSection';
|
||||||
import BillRulesManager from '@/components/BillRulesManager';
|
import BillRulesManager from '@/components/BillRulesManager';
|
||||||
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection';
|
||||||
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection';
|
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 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';
|
// Heavy panes (XLSX parsing / SQLite restore) — code-split, loaded on demand.
|
||||||
const DATA_TABS = [
|
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
|
||||||
{
|
const ImportMyDataSection = lazy(() => import('@/components/data/ImportMyDataSection'));
|
||||||
id: 'sync',
|
|
||||||
label: 'Sync & Match',
|
const SECTIONS = [
|
||||||
description: 'Bank connections, synced transactions, and CSV transaction imports.',
|
{ 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: 'import',
|
{ id: 'export', label: 'Export & backups', description: 'Download & restore', icon: DatabaseBackup },
|
||||||
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.',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
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() {
|
function storedSection() {
|
||||||
const [activeTab, setActiveTabState] = useState(() => {
|
if (typeof window === 'undefined') return null;
|
||||||
if (typeof window === 'undefined') return DATA_TABS[0].id;
|
const s = window.localStorage.getItem(SECTION_KEY);
|
||||||
const stored = window.localStorage.getItem(DATA_TAB_STORAGE_KEY);
|
return SECTION_IDS.includes(s) ? s : null;
|
||||||
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 }) {
|
function PaneSkeleton() {
|
||||||
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 (
|
return (
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="surface p-6">
|
||||||
<div className="rounded-lg border border-border/70 bg-card/80 px-4 py-3">
|
<div className="h-5 w-40 animate-pulse rounded bg-muted/50" />
|
||||||
<TooltipProvider delayDuration={300}>
|
<div className="mt-3 h-24 animate-pulse rounded bg-muted/30" />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataPage() {
|
export default function DataPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
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 [syncEnabled, setSyncEnabled] = useState(true);
|
||||||
|
const [hasConnections, setHasConnections] = useState(false);
|
||||||
const [syncLoading, setSyncLoading] = useState(true);
|
const [syncLoading, setSyncLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useStoredTab();
|
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);
|
setHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const { history } = await api.importHistory();
|
const { history } = await api.importHistory();
|
||||||
|
|
@ -116,52 +108,123 @@ export default function DataPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setHistoryLoading(false);
|
setHistoryLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const loadSimplefinSummary = useCallback(async () => {
|
const loadSimplefinSummary = useCallback(async () => {
|
||||||
setSyncLoading(true);
|
setSyncLoading(true);
|
||||||
|
setSyncError(false);
|
||||||
try {
|
try {
|
||||||
const [status, sources] = await Promise.all([
|
const [status, sources] = await Promise.all([
|
||||||
api.simplefinStatus(),
|
api.simplefinStatus(),
|
||||||
api.dataSources({ type: 'provider_sync' }),
|
api.dataSources({ type: 'provider_sync' }),
|
||||||
]);
|
]);
|
||||||
if (!status.enabled) {
|
setSyncEnabled(Boolean(status.enabled));
|
||||||
setSimplefinConn(null);
|
setHasConnections(Boolean(status.has_connections));
|
||||||
return;
|
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
||||||
}
|
|
||||||
const conns = Array.isArray(sources) ? sources.filter(source => source.provider === 'simplefin') : [];
|
|
||||||
setSimplefinConn(conns[0] || null);
|
setSimplefinConn(conns[0] || null);
|
||||||
} catch {
|
} catch {
|
||||||
|
setSyncError(true);
|
||||||
setSimplefinConn(null);
|
setSimplefinConn(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSyncLoading(false);
|
setSyncLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadHistory(); loadSimplefinSummary(); }, [loadHistory, loadSimplefinSummary]);
|
||||||
loadHistory();
|
|
||||||
loadSimplefinSummary();
|
|
||||||
}, [loadSimplefinSummary]);
|
|
||||||
|
|
||||||
const handleTransactionImportComplete = () => {
|
const handleTransactionImportComplete = useCallback(() => {
|
||||||
loadHistory();
|
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) => {
|
const handleConnectionChange = useCallback((conn) => {
|
||||||
setSimplefinConn(conn || null);
|
setSimplefinConn(conn || null);
|
||||||
|
setHasConnections(Boolean(conn));
|
||||||
setSyncLoading(false);
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl space-y-5">
|
<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 className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||||
Import, export, and review your user-owned bill tracker records.
|
Manage how your bills, payments & transactions get in and out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
|
|
@ -176,121 +239,27 @@ export default function DataPage() {
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</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-5 lg:grid-cols-[220px_1fr]">
|
||||||
<div className="grid gap-1 md:grid-cols-3">
|
<DataNav sections={SECTIONS} active={activeSection} onSelect={goTo} />
|
||||||
{DATA_TABS.map(tab => {
|
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
|
||||||
const active = activeTab === tab.id;
|
<AnimatePresence mode="wait">
|
||||||
return (
|
<motion.div key={activeSection} {...motionProps}>
|
||||||
<button
|
{renderPane()}
|
||||||
key={tab.id}
|
</motion.div>
|
||||||
type="button"
|
</AnimatePresence>
|
||||||
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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const { STORAGE_STATE } = require('./constants');
|
||||||
|
|
||||||
test.use({ storageState: STORAGE_STATE });
|
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) {
|
for (const path of PAGES) {
|
||||||
test(`no critical/serious a11y violations on ${path}`, async ({ page }) => {
|
test(`no critical/serious a11y violations on ${path}`, async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue