diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..857db07
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/package-lock.json b/package-lock.json
index dc8a50f..ab37d2c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "aba-data-app",
"version": "0.0.0",
"dependencies": {
+ "@supabase/supabase-js": "^2.45.0",
"date-fns": "^4.1.0",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
@@ -2782,6 +2783,92 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
+ "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
+ "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/phoenix": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
+ "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
+ "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
+ "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/phoenix": "^0.4.0",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
+ "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.103.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
+ "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.103.0",
+ "@supabase/functions-js": "2.103.0",
+ "@supabase/postgrest-js": "2.103.0",
+ "@supabase/realtime-js": "2.103.0",
+ "@supabase/storage-js": "2.103.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -2992,6 +3079,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.50.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz",
@@ -5199,6 +5295,15 @@
"node": ">=8.0.0"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -7550,6 +7655,12 @@
"typescript": ">=4.8.4"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -8592,6 +8703,27 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
diff --git a/package.json b/package.json
index cd07f6b..c515ccc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.tsx b/src/App.tsx
index 7a9c566..24fd0e7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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
Loading…
+ }
+
+ if (!user) {
+ return
+ }
+
+ 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 && (
+
+ Syncing…
+
+ )}
+
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
@@ -52,6 +133,31 @@ function App() {
Data
+ {user && isSupabaseConfigured && (
+ { signOut() }}
+ className="sign-out-btn"
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'inherit',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ cursor: 'pointer',
+ padding: 0,
+ font: 'inherit',
+ }}
+ >
+
+
+
+
+
+ Sign out
+
+ )}
)}
>
diff --git a/src/db/database.ts b/src/db/database.ts
index 133d23c..94e66e0 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -97,6 +97,19 @@ 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;
sessions: EntityTable;
@@ -104,6 +117,7 @@ const db = new Dexie('ABADataApp') as Dexie & {
treatmentGoals: EntityTable;
behaviorDefinitions: EntityTable;
parentTrainingPrograms: EntityTable;
+ syncQueue: EntityTable;
};
// Version 2: Added behavior category field
@@ -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
diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx
new file mode 100644
index 0000000..bc7a214
--- /dev/null
+++ b/src/lib/auth.tsx
@@ -0,0 +1,56 @@
+import { useEffect, useState, type ReactNode } from 'react'
+import type { Session } from '@supabase/supabase-js'
+import { supabase } from './supabase'
+import { AuthContext, type AuthContextValue } from './authContext'
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [session, setSession] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let mounted = true
+
+ supabase.auth.getSession().then(({ data }) => {
+ if (!mounted) return
+ setSession(data.session)
+ setLoading(false)
+ }).catch(() => {
+ if (!mounted) return
+ setLoading(false)
+ })
+
+ const { data: listener } = supabase.auth.onAuthStateChange((_event, newSession) => {
+ setSession(newSession)
+ })
+
+ return () => {
+ mounted = false
+ listener.subscription.unsubscribe()
+ }
+ }, [])
+
+ const signIn = async (email: string, password: string) => {
+ const { error } = await supabase.auth.signInWithPassword({ email, password })
+ return { error: error?.message ?? null }
+ }
+
+ const signUp = async (email: string, password: string) => {
+ const { error } = await supabase.auth.signUp({ email, password })
+ return { error: error?.message ?? null }
+ }
+
+ const signOut = async () => {
+ await supabase.auth.signOut()
+ }
+
+ const value: AuthContextValue = {
+ user: session?.user ?? null,
+ session,
+ loading,
+ signIn,
+ signUp,
+ signOut,
+ }
+
+ return {children}
+}
diff --git a/src/lib/authContext.ts b/src/lib/authContext.ts
new file mode 100644
index 0000000..7352816
--- /dev/null
+++ b/src/lib/authContext.ts
@@ -0,0 +1,13 @@
+import { createContext } from 'react'
+import type { Session, User } from '@supabase/supabase-js'
+
+export interface AuthContextValue {
+ user: User | null
+ session: Session | null
+ loading: boolean
+ signIn: (email: string, password: string) => Promise<{ error: string | null }>
+ signUp: (email: string, password: string) => Promise<{ error: string | null }>
+ signOut: () => Promise
+}
+
+export const AuthContext = createContext(undefined)
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
new file mode 100644
index 0000000..b1c6f04
--- /dev/null
+++ b/src/lib/supabase.ts
@@ -0,0 +1,27 @@
+import { createClient } from '@supabase/supabase-js'
+
+const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string | undefined
+const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined
+
+if (!supabaseUrl || !supabaseAnonKey) {
+ // Log once at startup so the developer knows why auth/sync calls fail.
+ // The app still boots in offline-only mode using Dexie.
+ console.warn(
+ '[supabase] VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY is missing. ' +
+ 'Copy .env.example to .env.local and fill in your Supabase project values.'
+ )
+}
+
+export const supabase = createClient(
+ supabaseUrl ?? 'http://localhost',
+ supabaseAnonKey ?? 'public-anon-key',
+ {
+ auth: {
+ persistSession: true,
+ autoRefreshToken: true,
+ detectSessionInUrl: true,
+ },
+ }
+)
+
+export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseAnonKey)
diff --git a/src/lib/sync.ts b/src/lib/sync.ts
new file mode 100644
index 0000000..e87bc06
--- /dev/null
+++ b/src/lib/sync.ts
@@ -0,0 +1,314 @@
+import { supabase, isSupabaseConfigured } from './supabase'
+import { db, type Client, type Session, type SyncQueueItem } from '../db/database'
+
+// ---------------- row mapping ----------------
+// Dexie uses camelCase; Supabase columns are snake_case.
+
+interface ClientRow {
+ id: string
+ user_id: string
+ name: string
+ date_of_birth: string | null
+ phone: string | null
+ address: string | null
+ location: string | null
+ target_behaviors: Client['targetBehaviors']
+ created_at: string
+ updated_at: string
+}
+
+interface SessionRow {
+ id: string
+ user_id: string
+ client_id: string
+ client_name: string
+ start_time: string
+ end_time: string | null
+ duration_ms: number | null
+ behavior_data: Session['behaviorData']
+ notes: string
+ created_at: string
+ updated_at: string
+}
+
+function clientToRow(client: Client, userId: string): ClientRow {
+ return {
+ id: client.id,
+ user_id: userId,
+ name: client.name,
+ date_of_birth: client.dateOfBirth ?? null,
+ phone: client.phone ?? null,
+ address: client.address ?? null,
+ location: client.location ?? null,
+ target_behaviors: client.targetBehaviors,
+ created_at: client.createdAt,
+ updated_at: client.updatedAt,
+ }
+}
+
+function rowToClient(row: ClientRow): Client {
+ return {
+ id: row.id,
+ name: row.name,
+ dateOfBirth: row.date_of_birth ?? undefined,
+ phone: row.phone ?? undefined,
+ address: row.address ?? undefined,
+ location: row.location ?? undefined,
+ targetBehaviors: row.target_behaviors ?? [],
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ }
+}
+
+function sessionToRow(session: Session, userId: string): SessionRow {
+ return {
+ id: session.id,
+ user_id: userId,
+ client_id: session.clientId,
+ client_name: session.clientName,
+ start_time: session.startTime,
+ end_time: session.endTime ?? null,
+ duration_ms: session.durationMs ?? null,
+ behavior_data: session.behaviorData,
+ notes: session.notes ?? '',
+ created_at: session.createdAt,
+ updated_at: session.updatedAt,
+ }
+}
+
+function rowToSession(row: SessionRow): Session {
+ return {
+ id: row.id,
+ clientId: row.client_id,
+ clientName: row.client_name,
+ startTime: row.start_time,
+ endTime: row.end_time ?? undefined,
+ durationMs: row.duration_ms ?? undefined,
+ behaviorData: row.behavior_data ?? [],
+ notes: row.notes ?? '',
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ }
+}
+
+// ---------------- current user helper ----------------
+
+async function currentUserId(): Promise {
+ if (!isSupabaseConfigured) return null
+ const { data } = await supabase.auth.getSession()
+ return data.session?.user.id ?? null
+}
+
+// ---------------- sync queue ----------------
+
+async function enqueue(item: Omit) {
+ await db.syncQueue.add({
+ ...item,
+ createdAt: new Date().toISOString(),
+ })
+}
+
+export async function flushQueue(): Promise {
+ if (!isSupabaseConfigured) return
+ const userId = await currentUserId()
+ if (!userId) return
+
+ const items = await db.syncQueue.orderBy('createdAt').toArray()
+ for (const item of items) {
+ try {
+ if (item.entity === 'client' && item.opType === 'upsert') {
+ const row = clientToRow(item.payload as Client, userId)
+ const { error } = await supabase.from('clients').upsert(row)
+ if (error) throw error
+ } else if (item.entity === 'client' && item.opType === 'delete') {
+ const { error } = await supabase
+ .from('clients')
+ .delete()
+ .eq('id', item.payload as string)
+ if (error) throw error
+ } else if (item.entity === 'session' && item.opType === 'upsert') {
+ const row = sessionToRow(item.payload as Session, userId)
+ const { error } = await supabase.from('sessions').upsert(row)
+ if (error) throw error
+ } else if (item.entity === 'session' && item.opType === 'delete') {
+ const { error } = await supabase
+ .from('sessions')
+ .delete()
+ .eq('id', item.payload as string)
+ if (error) throw error
+ }
+ if (item.id !== undefined) {
+ await db.syncQueue.delete(item.id)
+ }
+ } catch (err) {
+ // Leave this item (and everything after it) in the queue for the next
+ // flush attempt. Stop here so ordering is preserved.
+ console.warn('[sync] flushQueue stopped on error', err)
+ return
+ }
+ }
+}
+
+// ---------------- push (write path) ----------------
+
+export async function pushClient(client: Client): Promise {
+ if (!isSupabaseConfigured) return
+ const userId = await currentUserId()
+ if (!userId) return
+
+ if (!navigator.onLine) {
+ await enqueue({ entity: 'client', opType: 'upsert', payload: client })
+ return
+ }
+
+ try {
+ const { error } = await supabase.from('clients').upsert(clientToRow(client, userId))
+ if (error) throw error
+ } catch (err) {
+ console.warn('[sync] pushClient failed, queuing', err)
+ await enqueue({ entity: 'client', opType: 'upsert', payload: client })
+ }
+}
+
+export async function deleteClientRemote(clientId: string): Promise {
+ if (!isSupabaseConfigured) return
+ const userId = await currentUserId()
+ if (!userId) return
+
+ if (!navigator.onLine) {
+ await enqueue({ entity: 'client', opType: 'delete', payload: clientId })
+ return
+ }
+
+ try {
+ const { error } = await supabase.from('clients').delete().eq('id', clientId)
+ if (error) throw error
+ } catch (err) {
+ console.warn('[sync] deleteClientRemote failed, queuing', err)
+ await enqueue({ entity: 'client', opType: 'delete', payload: clientId })
+ }
+}
+
+export async function pushSession(session: Session): Promise {
+ if (!isSupabaseConfigured) return
+ const userId = await currentUserId()
+ if (!userId) return
+
+ if (!navigator.onLine) {
+ await enqueue({ entity: 'session', opType: 'upsert', payload: session })
+ return
+ }
+
+ try {
+ const { error } = await supabase.from('sessions').upsert(sessionToRow(session, userId))
+ if (error) throw error
+ } catch (err) {
+ // Never let a failing push block an in-progress session auto-save.
+ console.warn('[sync] pushSession failed, queuing', err)
+ await enqueue({ entity: 'session', opType: 'upsert', payload: session })
+ }
+}
+
+export async function deleteSessionRemote(sessionId: string): Promise {
+ if (!isSupabaseConfigured) return
+ const userId = await currentUserId()
+ if (!userId) return
+
+ if (!navigator.onLine) {
+ await enqueue({ entity: 'session', opType: 'delete', payload: sessionId })
+ return
+ }
+
+ try {
+ const { error } = await supabase.from('sessions').delete().eq('id', sessionId)
+ if (error) throw error
+ } catch (err) {
+ console.warn('[sync] deleteSessionRemote failed, queuing', err)
+ await enqueue({ entity: 'session', opType: 'delete', payload: sessionId })
+ }
+}
+
+// ---------------- pull (read path) ----------------
+
+export async function pullAll(userId: string): Promise {
+ if (!isSupabaseConfigured) return
+ if (!navigator.onLine) return
+
+ try {
+ const [clientsRes, sessionsRes] = await Promise.all([
+ supabase.from('clients').select('*').eq('user_id', userId),
+ supabase.from('sessions').select('*').eq('user_id', userId),
+ ])
+
+ if (clientsRes.error) throw clientsRes.error
+ if (sessionsRes.error) throw sessionsRes.error
+
+ const remoteClients = (clientsRes.data ?? []).map((row) => rowToClient(row as ClientRow))
+ const remoteSessions = (sessionsRes.data ?? []).map((row) => rowToSession(row as SessionRow))
+
+ // Merge: remote wins when its updated_at is newer than the local copy,
+ // so we don't clobber a session currently being auto-saved.
+ const localClients = await db.clients.toArray()
+ const localClientById = new Map(localClients.map((c) => [c.id, c]))
+ const clientsToWrite: Client[] = []
+ for (const remote of remoteClients) {
+ const local = localClientById.get(remote.id)
+ if (!local || new Date(remote.updatedAt).getTime() >= new Date(local.updatedAt).getTime()) {
+ clientsToWrite.push(remote)
+ }
+ }
+ if (clientsToWrite.length > 0) {
+ await db.clients.bulkPut(clientsToWrite)
+ }
+
+ const localSessions = await db.sessions.toArray()
+ const localSessionById = new Map(localSessions.map((s) => [s.id, s]))
+ const sessionsToWrite: Session[] = []
+ for (const remote of remoteSessions) {
+ const local = localSessionById.get(remote.id)
+ if (!local || new Date(remote.updatedAt).getTime() >= new Date(local.updatedAt).getTime()) {
+ sessionsToWrite.push(remote)
+ }
+ }
+ if (sessionsToWrite.length > 0) {
+ await db.sessions.bulkPut(sessionsToWrite)
+ }
+ } catch (err) {
+ console.warn('[sync] pullAll failed', err)
+ }
+}
+
+// ---------------- first-login migration ----------------
+
+const migrationFlagKey = (userId: string) => `aba.migratedUserId.${userId}`
+
+export async function migrateLocalToCloud(userId: string): Promise {
+ if (!isSupabaseConfigured) return
+ if (typeof localStorage !== 'undefined' && localStorage.getItem(migrationFlagKey(userId))) {
+ return
+ }
+ if (!navigator.onLine) return
+
+ try {
+ const localClients = await db.clients.toArray()
+ const localSessions = await db.sessions.toArray()
+
+ if (localClients.length > 0) {
+ const rows = localClients.map((c) => clientToRow(c, userId))
+ const { error } = await supabase.from('clients').upsert(rows)
+ if (error) throw error
+ }
+
+ if (localSessions.length > 0) {
+ const rows = localSessions.map((s) => sessionToRow(s, userId))
+ const { error } = await supabase.from('sessions').upsert(rows)
+ if (error) throw error
+ }
+
+ if (typeof localStorage !== 'undefined') {
+ localStorage.setItem(migrationFlagKey(userId), 'true')
+ }
+ } catch (err) {
+ console.warn('[sync] migrateLocalToCloud failed', err)
+ }
+}
diff --git a/src/lib/useAuth.ts b/src/lib/useAuth.ts
new file mode 100644
index 0000000..ae49ad4
--- /dev/null
+++ b/src/lib/useAuth.ts
@@ -0,0 +1,10 @@
+import { useContext } from 'react'
+import { AuthContext, type AuthContextValue } from './authContext'
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext)
+ if (!ctx) {
+ throw new Error('useAuth must be used inside an ')
+ }
+ return ctx
+}
diff --git a/src/main.tsx b/src/main.tsx
index ade9d64..6986a98 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
+import { AuthProvider } from './lib/auth'
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
)
diff --git a/src/pages/ClientFormPage.tsx b/src/pages/ClientFormPage.tsx
index fb2a9c7..3529564 100644
--- a/src/pages/ClientFormPage.tsx
+++ b/src/pages/ClientFormPage.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { v4 as uuidv4 } from 'uuid'
import { db, type Client, type TargetBehavior, type DataType, type BehaviorCategory } from '../db/database'
+import { pushClient } from '../lib/sync'
import Modal from '../components/Modal'
import ConfirmDialog from '../components/ConfirmDialog'
@@ -128,7 +129,8 @@ export default function ClientFormPage() {
if (isEditing && id) {
const existing = await db.clients.get(id)
if (existing) {
- await db.clients.update(id, {
+ const updated: Client = {
+ ...existing,
name: name.trim(),
dateOfBirth: dateOfBirth || undefined,
phone: phone || undefined,
@@ -136,7 +138,9 @@ export default function ClientFormPage() {
location: location || undefined,
targetBehaviors,
updatedAt: now
- })
+ }
+ await db.clients.put(updated)
+ void pushClient(updated)
}
} else {
const client: Client = {
@@ -151,6 +155,7 @@ export default function ClientFormPage() {
updatedAt: now
}
await db.clients.add(client)
+ void pushClient(client)
}
navigate('/clients')
diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx
index 5502c7f..42d74f3 100644
--- a/src/pages/ClientsPage.tsx
+++ b/src/pages/ClientsPage.tsx
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useLiveQuery } from 'dexie-react-hooks'
import { v4 as uuidv4 } from 'uuid'
import { db, type Client, type TargetBehavior, type DataType, type BehaviorCategory } from '../db/database'
+import { pushClient, deleteClientRemote, deleteSessionRemote } from '../lib/sync'
import ConfirmDialog from '../components/ConfirmDialog'
import Modal from '../components/Modal'
@@ -81,8 +82,14 @@ export default function ClientsPage() {
const handleDelete = async () => {
if (deleteClient) {
+ const clientSessions = await db.sessions.where('clientId').equals(deleteClient.id).toArray()
await db.sessions.where('clientId').equals(deleteClient.id).delete()
await db.clients.delete(deleteClient.id)
+ // Propagate to Supabase (queues if offline).
+ for (const session of clientSessions) {
+ void deleteSessionRemote(session.id)
+ }
+ void deleteClientRemote(deleteClient.id)
if (selectedClient?.id === deleteClient.id) {
setSelectedClient(null)
}
@@ -160,17 +167,7 @@ export default function ClientsPage() {
}))
if (selectedClient) {
- await db.clients.update(selectedClient.id, {
- name: name.trim(),
- dateOfBirth: dateOfBirth || undefined,
- phone: phone || undefined,
- address: address || undefined,
- location: location || undefined,
- targetBehaviors,
- updatedAt: now
- })
- // Update local state
- setSelectedClient({
+ const updated: Client = {
...selectedClient,
name: name.trim(),
dateOfBirth: dateOfBirth || undefined,
@@ -179,7 +176,10 @@ export default function ClientsPage() {
location: location || undefined,
targetBehaviors,
updatedAt: now
- })
+ }
+ await db.clients.put(updated)
+ setSelectedClient(updated)
+ void pushClient(updated)
} else {
const newClient: Client = {
id: uuidv4(),
@@ -194,6 +194,7 @@ export default function ClientsPage() {
}
await db.clients.add(newClient)
setSelectedClient(newClient)
+ void pushClient(newClient)
}
}
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..ceb8799
--- /dev/null
+++ b/src/pages/LoginPage.tsx
@@ -0,0 +1,118 @@
+import { useState, type FormEvent } from 'react'
+import { useNavigate, useLocation } from 'react-router-dom'
+import { useAuth } from '../lib/useAuth'
+import { isSupabaseConfigured } from '../lib/supabase'
+
+type Mode = 'signin' | 'signup'
+
+export default function LoginPage() {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const { signIn, signUp } = useAuth()
+
+ const [mode, setMode] = useState('signin')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [info, setInfo] = useState(null)
+ const [submitting, setSubmitting] = useState(false)
+
+ const from = (location.state as { from?: string } | null)?.from ?? '/clients'
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault()
+ setError(null)
+ setInfo(null)
+ setSubmitting(true)
+
+ try {
+ const result = mode === 'signin'
+ ? await signIn(email.trim(), password)
+ : await signUp(email.trim(), password)
+
+ if (result.error) {
+ setError(result.error)
+ } else if (mode === 'signup') {
+ setInfo('Account created. Check your email if confirmation is required, then sign in.')
+ setMode('signin')
+ } else {
+ navigate(from, { replace: true })
+ }
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
{mode === 'signin' ? 'Sign in' : 'Create account'}
+
+ {!isSupabaseConfigured && (
+
+ Supabase is not configured. Copy .env.example to .env.local and
+ set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY, then restart
+ the dev server.
+
+ )}
+
+
+
+
+ {mode === 'signin' ? (
+ <>
+ No account?{' '}
+ { setMode('signup'); setError(null); setInfo(null) }}>
+ Create one
+
+ >
+ ) : (
+ <>
+ Already have an account?{' '}
+ { setMode('signin'); setError(null); setInfo(null) }}>
+ Sign in
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/pages/SessionPage.tsx b/src/pages/SessionPage.tsx
index c663f58..ce2d57a 100644
--- a/src/pages/SessionPage.tsx
+++ b/src/pages/SessionPage.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { v4 as uuidv4 } from 'uuid'
import { db, type Client, type Session, type BehaviorData, type ABCRecord, type BehaviorCategory, type DataType } from '../db/database'
+import { pushSession } from '../lib/sync'
import { formatDuration } from '../utils/time'
import ConfirmDialog from '../components/ConfirmDialog'
import Modal from '../components/Modal'
@@ -130,6 +131,9 @@ export default function SessionPage() {
}
await db.sessions.put(session)
+ // Fire-and-forget: never block the live session on a network hiccup.
+ // pushSession handles its own errors (queues to syncQueue on failure).
+ void pushSession(session)
}, [client, behaviorStates, notes, sessionId, sessionStartTime])
useEffect(() => {
diff --git a/supabase/migrations/0001_init.sql b/supabase/migrations/0001_init.sql
new file mode 100644
index 0000000..8c8a43f
--- /dev/null
+++ b/supabase/migrations/0001_init.sql
@@ -0,0 +1,156 @@
+-- ABA Data Collection App — initial Supabase schema
+--
+-- Run this once in the Supabase SQL editor (or via the Supabase CLI) to create
+-- the tables and row-level security policies used by the app.
+--
+-- Tables:
+-- clients -- one row per client, owned by an auth.users row
+-- sessions -- one row per session; behaviorData stored as JSONB
+-- treatment_plans -- optional treatment plan metadata
+-- treatment_goals -- optional treatment goals
+-- behavior_definitions -- optional FBA-style behavior definitions
+-- parent_training_programs -- optional parent training programs
+--
+-- Every row is scoped to auth.uid() via a user_id column and RLS.
+
+-- ---------- clients ----------
+create table if not exists public.clients (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ name text not null,
+ date_of_birth text,
+ phone text,
+ address text,
+ location text,
+ target_behaviors jsonb not null default '[]'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists clients_user_id_idx on public.clients(user_id);
+
+alter table public.clients enable row level security;
+
+create policy "clients select own" on public.clients
+ for select using (auth.uid() = user_id);
+create policy "clients insert own" on public.clients
+ for insert with check (auth.uid() = user_id);
+create policy "clients update own" on public.clients
+ for update using (auth.uid() = user_id);
+create policy "clients delete own" on public.clients
+ for delete using (auth.uid() = user_id);
+
+-- ---------- sessions ----------
+create table if not exists public.sessions (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ client_id uuid not null references public.clients(id) on delete cascade,
+ client_name text not null,
+ start_time timestamptz not null,
+ end_time timestamptz,
+ duration_ms bigint,
+ behavior_data jsonb not null default '[]'::jsonb,
+ notes text not null default '',
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists sessions_user_id_idx on public.sessions(user_id);
+create index if not exists sessions_client_id_idx on public.sessions(client_id);
+create index if not exists sessions_start_time_idx on public.sessions(start_time desc);
+
+alter table public.sessions enable row level security;
+
+create policy "sessions select own" on public.sessions
+ for select using (auth.uid() = user_id);
+create policy "sessions insert own" on public.sessions
+ for insert with check (auth.uid() = user_id);
+create policy "sessions update own" on public.sessions
+ for update using (auth.uid() = user_id);
+create policy "sessions delete own" on public.sessions
+ for delete using (auth.uid() = user_id);
+
+-- ---------- treatment_plans ----------
+create table if not exists public.treatment_plans (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ client_id uuid not null references public.clients(id) on delete cascade,
+ data jsonb not null default '{}'::jsonb,
+ status text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists treatment_plans_user_id_idx on public.treatment_plans(user_id);
+create index if not exists treatment_plans_client_id_idx on public.treatment_plans(client_id);
+alter table public.treatment_plans enable row level security;
+create policy "treatment_plans select own" on public.treatment_plans
+ for select using (auth.uid() = user_id);
+create policy "treatment_plans insert own" on public.treatment_plans
+ for insert with check (auth.uid() = user_id);
+create policy "treatment_plans update own" on public.treatment_plans
+ for update using (auth.uid() = user_id);
+create policy "treatment_plans delete own" on public.treatment_plans
+ for delete using (auth.uid() = user_id);
+
+-- ---------- treatment_goals ----------
+create table if not exists public.treatment_goals (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ client_id uuid not null references public.clients(id) on delete cascade,
+ data jsonb not null default '{}'::jsonb,
+ status text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists treatment_goals_user_id_idx on public.treatment_goals(user_id);
+create index if not exists treatment_goals_client_id_idx on public.treatment_goals(client_id);
+alter table public.treatment_goals enable row level security;
+create policy "treatment_goals select own" on public.treatment_goals
+ for select using (auth.uid() = user_id);
+create policy "treatment_goals insert own" on public.treatment_goals
+ for insert with check (auth.uid() = user_id);
+create policy "treatment_goals update own" on public.treatment_goals
+ for update using (auth.uid() = user_id);
+create policy "treatment_goals delete own" on public.treatment_goals
+ for delete using (auth.uid() = user_id);
+
+-- ---------- behavior_definitions ----------
+create table if not exists public.behavior_definitions (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ client_id uuid not null references public.clients(id) on delete cascade,
+ data jsonb not null default '{}'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists behavior_definitions_user_id_idx on public.behavior_definitions(user_id);
+create index if not exists behavior_definitions_client_id_idx on public.behavior_definitions(client_id);
+alter table public.behavior_definitions enable row level security;
+create policy "behavior_definitions select own" on public.behavior_definitions
+ for select using (auth.uid() = user_id);
+create policy "behavior_definitions insert own" on public.behavior_definitions
+ for insert with check (auth.uid() = user_id);
+create policy "behavior_definitions update own" on public.behavior_definitions
+ for update using (auth.uid() = user_id);
+create policy "behavior_definitions delete own" on public.behavior_definitions
+ for delete using (auth.uid() = user_id);
+
+-- ---------- parent_training_programs ----------
+create table if not exists public.parent_training_programs (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ client_id uuid not null references public.clients(id) on delete cascade,
+ data jsonb not null default '{}'::jsonb,
+ status text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+create index if not exists parent_training_programs_user_id_idx on public.parent_training_programs(user_id);
+create index if not exists parent_training_programs_client_id_idx on public.parent_training_programs(client_id);
+alter table public.parent_training_programs enable row level security;
+create policy "parent_training_programs select own" on public.parent_training_programs
+ for select using (auth.uid() = user_id);
+create policy "parent_training_programs insert own" on public.parent_training_programs
+ for insert with check (auth.uid() = user_id);
+create policy "parent_training_programs update own" on public.parent_training_programs
+ for update using (auth.uid() = user_id);
+create policy "parent_training_programs delete own" on public.parent_training_programs
+ for delete using (auth.uid() = user_id);