diff --git a/client/components/data/ConnectionHero.jsx b/client/components/data/ConnectionHero.jsx new file mode 100644 index 0000000..4c0745d --- /dev/null +++ b/client/components/data/ConnectionHero.jsx @@ -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 ( +
+ + + + {children} +
+ ); +} + +/** + * 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 ( +
+ +
+
+
+
+
+ ); + } + + // ── disabled (server has no bank sync) ── + if (!enabled) { + return ( + +
+

Automatic bank sync isn’t enabled

+

+ This server doesn’t have SimpleFIN configured — you can still import and export your data. +

+
+ +
+ ); + } + + // ── error (couldn't check) ── + if (error) { + return ( + +
+

Couldn’t check your bank connection

+

Something went wrong loading your sync status.

+
+ +
+ ); + } + + // ── not connected ── + if (!hasConnections || !conn) { + return ( + +
+

Connect your bank to get started

+

+ Sync transactions automatically, or import your existing history. +

+
+
+ + +
+
+ ); + } + + // ── connected (± needs attention) ── + const needsAttention = Boolean(conn.last_error); + const synced = relativeTime(conn.last_sync_at); + return ( + +
+

+ {needsAttention ? 'Your bank connection needs attention' : 'Your bank is connected'} +

+

+ {needsAttention + ? conn.last_error + : <>{conn.name || 'SimpleFIN'}{synced ? <> · synced {synced} : null} · syncs automatically} +

+
+
+ {needsAttention && ( + + )} + +
+
+ ); +} diff --git a/client/components/data/DataNav.jsx b/client/components/data/DataNav.jsx new file mode 100644 index 0000000..762bf6c --- /dev/null +++ b/client/components/data/DataNav.jsx @@ -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
- ); - })} +
+ +
+ + + {renderPane()} + +
- - {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 }) => {