Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Supabase configuration
# Copy this file to .env.local and fill in the values from your Supabase project
# dashboard (Project Settings -> API).
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-public-key
132 changes: 132 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"date-fns": "^4.1.0",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
Expand Down
128 changes: 117 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,109 @@
import { Routes, Route, NavLink, useLocation } from 'react-router-dom'
import { useEffect, useState, type ReactNode } from 'react'
import { Routes, Route, NavLink, useLocation, Navigate } from 'react-router-dom'
import ClientsPage from './pages/ClientsPage'
import SessionPage from './pages/SessionPage'
import DataPage from './pages/DataPage'
import ClientFormPage from './pages/ClientFormPage'
import SelectClientPage from './pages/SelectClientPage'
import SessionDetailPage from './pages/SessionDetailPage'
import LoginPage from './pages/LoginPage'
import { useAuth } from './lib/useAuth'
import { migrateLocalToCloud, pullAll, flushQueue } from './lib/sync'
import { isSupabaseConfigured } from './lib/supabase'

function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth()
const location = useLocation()

if (!isSupabaseConfigured) {
// Fall back to local-only mode when Supabase is not configured so
// developers can still work on unrelated UI without env vars.
return <>{children}</>
}

if (loading) {
return <div style={{ padding: 24 }}>Loading…</div>
}

if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}

return <>{children}</>
}

function App() {
const location = useLocation()
const hideNav = location.pathname.includes('/session/') && !location.pathname.includes('/session/select')
const { user, signOut } = useAuth()
const hideNav =
location.pathname === '/login' ||
(location.pathname.includes('/session/') && !location.pathname.includes('/session/select'))

const [syncing, setSyncing] = useState(false)

useEffect(() => {
if (!user || !isSupabaseConfigured) return

let cancelled = false
const run = async () => {
setSyncing(true)
try {
await migrateLocalToCloud(user.id)
await flushQueue()
await pullAll(user.id)
} finally {
if (!cancelled) setSyncing(false)
}
}
run()

const onOnline = () => {
flushQueue().then(() => pullAll(user.id))
}
window.addEventListener('online', onOnline)

return () => {
cancelled = true
window.removeEventListener('online', onOnline)
}
}, [user])

return (
<>
{syncing && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
background: '#2563eb',
color: 'white',
padding: '4px 12px',
fontSize: 12,
textAlign: 'center',
zIndex: 1000,
}}
>
Syncing…
</div>
)}

<main className="main-content">
<Routes>
<Route path="/" element={<ClientsPage />} />
<Route path="/clients" element={<ClientsPage />} />
<Route path="/clients/new" element={<ClientFormPage />} />
<Route path="/clients/:id/edit" element={<ClientFormPage />} />
<Route path="/session/select" element={<SelectClientPage />} />
<Route path="/session/:clientId" element={<SessionPage />} />
<Route path="/data" element={<DataPage />} />
<Route path="/data/:clientId" element={<DataPage />} />
<Route path="/data/:clientId/session/:sessionId" element={<SessionDetailPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><ClientsPage /></RequireAuth>} />
<Route path="/clients" element={<RequireAuth><ClientsPage /></RequireAuth>} />
<Route path="/clients/new" element={<RequireAuth><ClientFormPage /></RequireAuth>} />
<Route path="/clients/:id/edit" element={<RequireAuth><ClientFormPage /></RequireAuth>} />
<Route path="/session/select" element={<RequireAuth><SelectClientPage /></RequireAuth>} />
<Route path="/session/:clientId" element={<RequireAuth><SessionPage /></RequireAuth>} />
<Route path="/data" element={<RequireAuth><DataPage /></RequireAuth>} />
<Route path="/data/:clientId" element={<RequireAuth><DataPage /></RequireAuth>} />
<Route
path="/data/:clientId/session/:sessionId"
element={<RequireAuth><SessionDetailPage /></RequireAuth>}
/>
</Routes>
</main>

Expand Down Expand Up @@ -52,6 +133,31 @@ function App() {
</svg>
Data
</NavLink>
{user && isSupabaseConfigured && (
<button
type="button"
onClick={() => { signOut() }}
className="sign-out-btn"
style={{
background: 'none',
border: 'none',
color: 'inherit',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
cursor: 'pointer',
padding: 0,
font: 'inherit',
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Sign out
</button>
)}
</nav>
)}
</>
Expand Down
25 changes: 25 additions & 0 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,27 @@ export interface Session {
updatedAt: string;
}

export type SyncEntity = 'client' | 'session';
export type SyncOpType = 'upsert' | 'delete';

export interface SyncQueueItem {
id?: number;
entity: SyncEntity;
opType: SyncOpType;
// For upserts we store the full row so it can be replayed offline; for
// deletes we store just the id.
payload: unknown;
createdAt: string;
}

const db = new Dexie('ABADataApp') as Dexie & {
clients: EntityTable<Client, 'id'>;
sessions: EntityTable<Session, 'id'>;
treatmentPlans: EntityTable<TreatmentPlan, 'id'>;
treatmentGoals: EntityTable<TreatmentGoal, 'id'>;
behaviorDefinitions: EntityTable<BehaviorDefinition, 'id'>;
parentTrainingPrograms: EntityTable<ParentTrainingProgram, 'id'>;
syncQueue: EntityTable<SyncQueueItem, 'id'>;
};

// Version 2: Added behavior category field
Expand Down Expand Up @@ -131,6 +145,17 @@ db.version(3).stores({
parentTrainingPrograms: 'id, clientId, programId, status, createdAt'
});

// Version 4: Added syncQueue table for offline-first Supabase sync
db.version(4).stores({
clients: 'id, name, createdAt, updatedAt',
sessions: 'id, clientId, startTime, createdAt',
treatmentPlans: 'id, clientId, status, createdAt, updatedAt',
treatmentGoals: 'id, clientId, goalId, category, status, createdAt',
behaviorDefinitions: 'id, clientId, behaviorName, behaviorType, createdAt',
parentTrainingPrograms: 'id, clientId, programId, status, createdAt',
syncQueue: '++id, entity, opType, createdAt'
});

export { db };

// Re-export types for convenience
Expand Down
Loading