2026-07-03 15:02:36 -05:00
import React , { useState , useEffect , useCallback , useRef , Suspense , lazy } from 'react' ;
import { useSearchParams } from 'react-router-dom' ;
2026-05-29 01:06:20 -05:00
import { toast } from 'sonner' ;
2026-07-03 15:02:36 -05:00
import { motion , AnimatePresence , useReducedMotion } from 'framer-motion' ;
import {
Landmark , ArrowRightLeft , Upload , DatabaseBackup ,
FileSpreadsheet , FileText , FlaskConical , Download , RotateCcw , History ,
} from 'lucide-react' ;
2026-05-03 19:51:57 -05:00
import { api } from '@/api' ;
2026-06-06 23:53:53 -05:00
import { Tooltip , TooltipContent , TooltipProvider , TooltipTrigger } from '@/components/ui/tooltip' ;
2026-07-03 15:02:36 -05:00
import ConnectionHero from '@/components/data/ConnectionHero' ;
import DataNav from '@/components/data/DataNav' ;
2026-05-28 22:06:15 -05:00
import BankSyncSection from '@/components/data/BankSyncSection' ;
2026-06-04 20:45:11 -05:00
import BillRulesManager from '@/components/BillRulesManager' ;
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
import ImportTransactionCsvSection from '@/components/data/ImportTransactionCsvSection' ;
feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
import ImportOfxSection from '@/components/data/ImportOfxSection' ;
refactor: component splits, PWA support, CommandPalette
Component Splits:
- AdminPage.jsx: 1,906 -> 82 lines (logic moved to client/components/admin/ — 9 files)
- DataPage.jsx: 3,132 -> 60 lines (logic moved to client/components/data/ — 8 files)
- TrackerPage.jsx: 2,566 -> 2,132 lines (MonthlyStateDialog, StartingAmountsEditDialog, PaymentModal)
PWA:
- vite-plugin-pwa installed with NetworkFirst caching for API routes
- Square PWA icons (192x192, 512x512, apple-touch-icon)
- theme-color, apple meta tags, touch icon in index.html
- Build generates dist/sw.js + Workbox runtime
CommandPalette:
- Navigation commands, Add bill action, month jumps
- Grouped results with empty/filtered states
2026-05-28 20:53:22 -05:00
import TransactionMatchingSection from '@/components/data/TransactionMatchingSection' ;
import SeedDemoDataSection from '@/components/data/SeedDemoDataSection' ;
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection' ;
import ImportHistorySection from '@/components/data/ImportHistorySection' ;
2026-05-09 13:03:36 -05:00
2026-07-03 15:02:36 -05:00
// 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' ) ) ;
2026-05-30 21:52:02 -05:00
2026-07-03 15:02:36 -05:00
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
2026-05-30 21:52:02 -05:00
2026-07-03 15:02:36 -05:00
function storedSection ( ) {
if ( typeof window === 'undefined' ) return null ;
const s = window . localStorage . getItem ( SECTION _KEY ) ;
return SECTION _IDS . includes ( s ) ? s : null ;
2026-05-30 21:52:02 -05:00
}
2026-07-03 15:02:36 -05:00
function PaneSkeleton ( ) {
2026-05-30 21:52:02 -05:00
return (
2026-07-03 15:02:36 -05:00
< 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" / >
2026-05-30 21:52:02 -05:00
< / div >
) ;
}
2026-05-03 19:51:57 -05:00
export default function DataPage ( ) {
2026-07-03 15:02:36 -05:00
const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
const [ history , setHistory ] = useState ( null ) ;
2026-05-04 23:34:24 -05:00
const [ historyLoading , setHistoryLoading ] = useState ( true ) ;
2026-05-16 21:36:04 -05:00
const [ transactionRefreshKey , setTransactionRefreshKey ] = useState ( 0 ) ;
2026-07-03 15:02:36 -05:00
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 ) ;
2026-07-03 15:05:25 -05:00
const [ unmatchedCount , setUnmatchedCount ] = useState ( 0 ) ;
const [ txnTotal , setTxnTotal ] = useState ( null ) ;
2026-07-03 15:02:36 -05:00
const reduceMotion = useReducedMotion ( ) ;
const paneRef = useRef ( null ) ;
const firstRender = useRef ( true ) ;
// 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 ] ) ;
2026-05-04 23:34:24 -05:00
2026-07-03 15:02:36 -05:00
const loadHistory = useCallback ( async ( ) => {
2026-05-04 23:34:24 -05:00
setHistoryLoading ( true ) ;
try {
const { history } = await api . importHistory ( ) ;
setHistory ( history ) ;
2026-05-29 01:06:20 -05:00
} catch ( err ) {
2026-05-04 23:34:24 -05:00
setHistory ( [ ] ) ;
2026-05-29 01:06:20 -05:00
toast . error ( err . message || 'Failed to load import history.' ) ;
2026-05-04 23:34:24 -05:00
} finally {
setHistoryLoading ( false ) ;
}
2026-07-03 15:02:36 -05:00
} , [ ] ) ;
2026-05-04 23:34:24 -05:00
2026-05-30 21:52:02 -05:00
const loadSimplefinSummary = useCallback ( async ( ) => {
setSyncLoading ( true ) ;
2026-07-03 15:02:36 -05:00
setSyncError ( false ) ;
2026-05-30 21:52:02 -05:00
try {
const [ status , sources ] = await Promise . all ( [
api . simplefinStatus ( ) ,
api . dataSources ( { type : 'provider_sync' } ) ,
] ) ;
2026-07-03 15:02:36 -05:00
setSyncEnabled ( Boolean ( status . enabled ) ) ;
setHasConnections ( Boolean ( status . has _connections ) ) ;
const conns = Array . isArray ( sources ) ? sources . filter ( s => s . provider === 'simplefin' ) : [ ] ;
2026-05-30 21:52:02 -05:00
setSimplefinConn ( conns [ 0 ] || null ) ;
} catch {
2026-07-03 15:02:36 -05:00
setSyncError ( true ) ;
2026-05-30 21:52:02 -05:00
setSimplefinConn ( null ) ;
} finally {
setSyncLoading ( false ) ;
}
} , [ ] ) ;
2026-07-03 15:05:25 -05:00
// 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 ) ;
}
} , [ ] ) ;
2026-07-03 15:02:36 -05:00
useEffect ( ( ) => { loadHistory ( ) ; loadSimplefinSummary ( ) ; } , [ loadHistory , loadSimplefinSummary ] ) ;
2026-07-03 15:05:25 -05:00
useEffect ( ( ) => { loadTxnStats ( ) ; } , [ loadTxnStats , transactionRefreshKey ] ) ;
2026-05-04 23:34:24 -05:00
2026-07-03 15:02:36 -05:00
const handleTransactionImportComplete = useCallback ( ( ) => {
2026-05-16 21:36:04 -05:00
loadHistory ( ) ;
2026-07-03 15:02:36 -05:00
setTransactionRefreshKey ( k => k + 1 ) ;
} , [ loadHistory ] ) ;
2026-05-16 21:36:04 -05:00
2026-07-03 15:02:36 -05:00
// BankSyncSection reports connect/sync/disconnect changes.
2026-05-28 22:06:15 -05:00
const handleConnectionChange = useCallback ( ( conn ) => {
setSimplefinConn ( conn || null ) ;
2026-07-03 15:02:36 -05:00
setHasConnections ( Boolean ( conn ) ) ;
2026-05-30 21:52:02 -05:00
setSyncLoading ( false ) ;
2026-07-03 15:02:36 -05:00
setTransactionRefreshKey ( k => k + 1 ) ;
2026-05-28 22:06:15 -05:00
} , [ ] ) ;
2026-07-03 15:02:36 -05:00
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 }
feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
cardProps = { { title : 'Import transactions (CSV)' , 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.' } }
/ >
< ImportOfxSection
onHistoryRefresh = { handleTransactionImportComplete }
cardProps = { { title : 'Import transactions (OFX/QFX)' , subtitle : 'Upload a bank OFX or QFX export — no column mapping needed.' , icon : FileText , collapsible : true , defaultOpen : false , storageKey : 'billtracker:data.card.ofx' , summary : 'Upload an OFX/QFX transaction file.' } }
2026-07-03 15:02:36 -05:00
/ >
< 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 } } ;
2026-07-03 15:05:25 -05:00
// 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 ,
) ;
2026-05-04 23:34:24 -05:00
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 >
2026-07-03 15:02:36 -05:00
< p className = "mt-0.5 text-sm text-muted-foreground" >
Manage how your bills , payments & amp ; transactions get in and out .
2026-05-04 23:34:24 -05:00
< / p >
< / div >
2026-06-06 23:53:53 -05:00
< TooltipProvider delayDuration = { 300 } >
< Tooltip >
< TooltipTrigger asChild >
< div className = "rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground cursor-default" >
User data only
< / div >
< / TooltipTrigger >
< TooltipContent > This page only manages your own records — other users ' data is not accessible here < / TooltipContent >
< / Tooltip >
< / TooltipProvider >
2026-05-04 23:34:24 -05:00
< / div >
2026-07-03 15:02:36 -05:00
< ConnectionHero
loading = { syncLoading }
error = { syncError }
enabled = { syncEnabled }
hasConnections = { hasConnections }
conn = { simplefinConn }
2026-07-03 15:05:25 -05:00
txnTotal = { txnTotal }
2026-07-03 15:02:36 -05:00
onRetry = { loadSimplefinSummary }
onGoTo = { goTo }
onSynced = { handleSynced }
/ >
2026-05-30 21:52:02 -05:00
2026-07-03 15:02:36 -05:00
< div className = "grid gap-5 lg:grid-cols-[220px_1fr]" >
2026-07-03 15:05:25 -05:00
< DataNav sections = { navSections } active = { activeSection } onSelect = { goTo } / >
2026-07-03 15:02:36 -05:00
< div ref = { paneRef } tabIndex = { - 1 } className = "min-w-0 outline-none" >
< AnimatePresence mode = "wait" >
< motion.div key = { activeSection } { ...motionProps } >
{ renderPane ( ) }
< / motion.div >
< / AnimatePresence >
2026-05-30 21:52:02 -05:00
< / div >
2026-05-04 23:34:24 -05:00
< / div >
< / div >
) ;
2026-05-03 19:51:57 -05:00
}