feat(data): actionable badges, health dots, at-a-glance stats, palette links (Batch 2)

- Transactions nav shows a live "N to review" badge (unmatched count from the
  bank-ledger summary, limit:1 so it's cheap; refreshes on sync/import).
- Bank sync nav shows a green/amber/grey health dot (connected / needs-attention
  / off), mirroring the hero tone.
- Connection hero connected line now shows the transaction count at a glance
  ("SimpleFIN · 1,159 transactions · synced 2h ago · syncs automatically").
- Command palette gains Data section deep-links (Bank sync / Transactions /
  Import / Export) via ?section=.
- Count/stat fetch is non-blocking (.catch → 0), never blocks the page.

Build clean; client suite 46 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 15:05:25 -05:00
parent 6a1b2f62b2
commit c7b110cd68
3 changed files with 38 additions and 2 deletions

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { import {
BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus, BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus,
Receipt, Search, Settings, Snowflake, Tag, Upload, User, X, Receipt, Search, Settings, Snowflake, Tag, Upload, User, X,
Landmark, ArrowRightLeft, Download,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
@ -28,6 +29,10 @@ const NAV_COMMANDS = [
{ id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' }, { id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' },
{ id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' }, { id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' },
{ id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' }, { id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' },
{ id: 'nav-data-bank', label: 'Data: Bank sync', icon: Landmark, path: '/data?section=bank-sync', group: 'Navigate' },
{ id: 'nav-data-tx', label: 'Data: Transactions', icon: ArrowRightLeft, path: '/data?section=transactions', group: 'Navigate' },
{ id: 'nav-data-import', label: 'Data: Import', icon: Upload, path: '/data?section=import', group: 'Navigate' },
{ id: 'nav-data-export', label: 'Data: Export & backups', icon: Download, path: '/data?section=export', group: 'Navigate' },
{ id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' }, { id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' },
{ id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' }, { id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' },
{ id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' }, { id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' },

View File

@ -55,6 +55,7 @@ export default function ConnectionHero({
enabled, // status.enabled (server feature flag) enabled, // status.enabled (server feature flag)
hasConnections, // status.has_connections hasConnections, // status.has_connections
conn, // the simplefin data_source (name, last_sync_at, last_error) or null conn, // the simplefin data_source (name, last_sync_at, last_error) or null
txnTotal, // total synced transactions (at-a-glance), or null
onRetry, onRetry,
onGoTo, // (sectionId) => void onGoTo, // (sectionId) => void
onSynced, // () => void refresh after a successful sync onSynced, // () => void refresh after a successful sync
@ -160,7 +161,14 @@ export default function ConnectionHero({
<p className="mt-0.5 truncate text-sm text-muted-foreground"> <p className="mt-0.5 truncate text-sm text-muted-foreground">
{needsAttention {needsAttention
? conn.last_error ? conn.last_error
: <>{conn.name || 'SimpleFIN'}{synced ? <> · synced {synced}</> : null} · syncs automatically</>} : (
<>
{conn.name || 'SimpleFIN'}
{Number(txnTotal) > 0 ? <> · {Number(txnTotal).toLocaleString('en-US')} transactions</> : null}
{synced ? <> · synced {synced}</> : null}
{' · syncs automatically'}
</>
)}
</p> </p>
</div> </div>
<div className="flex shrink-0 flex-wrap gap-2"> <div className="flex shrink-0 flex-wrap gap-2">

View File

@ -58,6 +58,8 @@ export default function DataPage() {
const [hasConnections, setHasConnections] = useState(false); const [hasConnections, setHasConnections] = useState(false);
const [syncLoading, setSyncLoading] = useState(true); const [syncLoading, setSyncLoading] = useState(true);
const [syncError, setSyncError] = useState(false); const [syncError, setSyncError] = useState(false);
const [unmatchedCount, setUnmatchedCount] = useState(0);
const [txnTotal, setTxnTotal] = useState(null);
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const paneRef = useRef(null); const paneRef = useRef(null);
const firstRender = useRef(true); const firstRender = useRef(true);
@ -130,7 +132,19 @@ export default function DataPage() {
} }
}, []); }, []);
// Cheap "N to review" + transaction count (summary is aggregate, so limit:1 is enough).
const loadTxnStats = useCallback(async () => {
try {
const data = await api.bankTransactionsLedger({ limit: 1 });
setUnmatchedCount(data?.summary?.unmatched || 0);
setTxnTotal(data?.summary?.total ?? null);
} catch {
setUnmatchedCount(0);
}
}, []);
useEffect(() => { loadHistory(); loadSimplefinSummary(); }, [loadHistory, loadSimplefinSummary]); useEffect(() => { loadHistory(); loadSimplefinSummary(); }, [loadHistory, loadSimplefinSummary]);
useEffect(() => { loadTxnStats(); }, [loadTxnStats, transactionRefreshKey]);
const handleTransactionImportComplete = useCallback(() => { const handleTransactionImportComplete = useCallback(() => {
loadHistory(); loadHistory();
@ -218,6 +232,14 @@ export default function DataPage() {
? {} ? {}
: { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 } }; : { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 } };
// Health dot for Bank sync + "N to review" badge for Transactions.
const bankDot = (syncError || !syncEnabled || !hasConnections) ? 'gray' : simplefinConn?.last_error ? 'amber' : 'green';
const navSections = SECTIONS.map(s =>
s.id === 'bank-sync' ? { ...s, dot: bankDot }
: s.id === 'transactions' ? { ...s, badge: unmatchedCount || undefined }
: s,
);
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">
@ -245,13 +267,14 @@ export default function DataPage() {
enabled={syncEnabled} enabled={syncEnabled}
hasConnections={hasConnections} hasConnections={hasConnections}
conn={simplefinConn} conn={simplefinConn}
txnTotal={txnTotal}
onRetry={loadSimplefinSummary} onRetry={loadSimplefinSummary}
onGoTo={goTo} onGoTo={goTo}
onSynced={handleSynced} onSynced={handleSynced}
/> />
<div className="grid gap-5 lg:grid-cols-[220px_1fr]"> <div className="grid gap-5 lg:grid-cols-[220px_1fr]">
<DataNav sections={SECTIONS} active={activeSection} onSelect={goTo} /> <DataNav sections={navSections} active={activeSection} onSelect={goTo} />
<div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none"> <div ref={paneRef} tabIndex={-1} className="min-w-0 outline-none">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div key={activeSection} {...motionProps}> <motion.div key={activeSection} {...motionProps}>