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 && ( + + )} )} 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. +

+ )} + +
+ + + + {error &&

{error}

} + {info &&

{info}

} + + +
+ +

+ {mode === 'signin' ? ( + <> + No account?{' '} + + + ) : ( + <> + Already have an account?{' '} + + + )} +

+
+ ) +} 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);