diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8d56d34 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Supabase cloud sync (optional). If unset, the app runs local-only. +# See supabase/README.md for setup instructions. +VITE_SUPABASE_URL= +VITE_SUPABASE_ANON_KEY= diff --git a/package-lock.json b/package-lock.json index dc8a50f..49ac5b8 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.104.1", "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.104.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.104.1.tgz", + "integrity": "sha512-pqFnDKekq1isqlqnzqzyJ3mzmho+o+FjfVTqhKY3PFlwj2anx3OPznO1kbo1ZEwD8zg1r4EAFf/7pplLyX0ocQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.104.1.tgz", + "integrity": "sha512-JjAH4JN9rZzxh4plQnILPrQZXAG6ccoRS6z9hQAGmXpRSwJA+7CWbsDV2R82I8MROlGDsjqj1Ot/cWpTfdf6xg==", + "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.104.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.104.1.tgz", + "integrity": "sha512-RqlLpvgXsjcc27fLyHNGm3zN0KDWXbkdTdaFtaEdX83RsTEqH7BAmshH7zoUMml5lL04naUeRjS3B81O6jZcJw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.104.1.tgz", + "integrity": "sha512-dVJHhFB2ErBd0/2qE9G8CedCrGoAtBfL9Q4zbSMXO7b1Cpld916ljSiX21mURUqijPf1WoPQG4Bp/averUzk/g==", + "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.104.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.104.1.tgz", + "integrity": "sha512-2bQaLbkRshctkUVuqamwYZDEd+0cGSc9DY9sjh92DcA5hu1F/1AP8p6gxGr76sgdK9Ngi0rh+2Kdh+uC4hcnGA==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.104.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.104.1.tgz", + "integrity": "sha512-E0H/CtVmaGjiAy+ieZ5ZB/1EqxXcGdaFaAc23AE5zaYfz6NtCNDcmaEdoGPYMPFH5pE6drGG6e3ljPmkFoGVxQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.104.1", + "@supabase/functions-js": "2.104.1", + "@supabase/postgrest-js": "2.104.1", + "@supabase/realtime-js": "2.104.1", + "@supabase/storage-js": "2.104.1" + }, + "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..5f4d98c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@supabase/supabase-js": "^2.104.1", "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 57d1d4f..be3aec8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,12 @@ import DataPage from './pages/DataPage' import ClientFormPage from './pages/ClientFormPage' import SelectClientPage from './pages/SelectClientPage' import SessionDetailPage from './pages/SessionDetailPage' +import SyncPage from './pages/SyncPage' import { useTheme } from './hooks/useTheme' import { useToast } from './components/Toast' +import { useAuth } from './hooks/useAuth' +import { useSyncStatus } from './hooks/useSyncStatus' +import { startSync } from './services/sync' import { isBackupDue } from './utils/backup' function App() { @@ -15,6 +19,12 @@ function App() { const hideNav = location.pathname.includes('/session/') && !location.pathname.includes('/session/select') const { theme, toggle } = useTheme() const toast = useToast() + const { user, configured } = useAuth() + const sync = useSyncStatus() + + useEffect(() => { + if (user) void startSync(user.id) + }, [user]) useEffect(() => { if (!isBackupDue()) return @@ -38,6 +48,7 @@ function App() { } /> } /> } /> + } /> @@ -67,6 +78,51 @@ function App() { Data + {configured && ( + isActive ? 'active' : ''}> + + + + + + + {user && sync.pending > 0 && ( + + {sync.pending} + + )} + {user && sync.pending === 0 && !sync.error && ( + + )} + + Sync + + )} +

Cloud Sync

+
+ + +
+ {!configured && ( +
+

Supabase not configured

+

+ Cloud sync is optional. To enable it, set + {' '}VITE_SUPABASE_URL and{' '} + VITE_SUPABASE_ANON_KEY in .env.local, + then restart the dev server. +

+

+ See supabase/README.md for full setup instructions. +

+
+ )} + + {configured && loading && ( +
+

Loading…

+
+ )} + + {configured && !loading && !user && ( +
+

+ {mode === 'signin' ? 'Sign in to sync' : 'Create an account'} +

+

+ All of your existing local data — including the test clients — + will upload automatically once you sign in. +

+
+ + setEmail(e.target.value)} + autoComplete="email" + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete={mode === 'signin' ? 'current-password' : 'new-password'} + minLength={6} + required + /> +
+ + +
+ )} + + {configured && user && ( + <> +
+

Sync status

+

Account: {user.email}

+

+ Network: {status.online ? 'online' : 'offline'} +

+

+ Pending uploads: {status.pending} +

+

+ Last sync: {lastSyncLabel} +

+ {status.error && ( +

+ Error: {status.error} +

+ )} + +
+ + + + )} +
+ + ) +} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..99ea172 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,44 @@ +import type { Session, User } from '@supabase/supabase-js' +import { supabase, isSupabaseConfigured } from './supabase' + +export type AuthChangeListener = (user: User | null) => void + +export async function getCurrentSession(): Promise { + if (!supabase) return null + const { data } = await supabase.auth.getSession() + return data.session +} + +export async function getCurrentUser(): Promise { + const session = await getCurrentSession() + return session?.user ?? null +} + +export function onAuthChange(listener: AuthChangeListener): () => void { + if (!supabase) return () => {} + const { data } = supabase.auth.onAuthStateChange((_event, session) => { + listener(session?.user ?? null) + }) + return () => data.subscription.unsubscribe() +} + +export async function signIn(email: string, password: string) { + if (!supabase) throw new Error('Supabase is not configured') + const { data, error } = await supabase.auth.signInWithPassword({ email, password }) + if (error) throw error + return data +} + +export async function signUp(email: string, password: string) { + if (!supabase) throw new Error('Supabase is not configured') + const { data, error } = await supabase.auth.signUp({ email, password }) + if (error) throw error + return data +} + +export async function signOut() { + if (!supabase) return + await supabase.auth.signOut() +} + +export { isSupabaseConfigured } diff --git a/src/services/supabase.ts b/src/services/supabase.ts new file mode 100644 index 0000000..8a3406d --- /dev/null +++ b/src/services/supabase.ts @@ -0,0 +1,16 @@ +import { createClient, type SupabaseClient } from '@supabase/supabase-js' + +const url = import.meta.env.VITE_SUPABASE_URL as string | undefined +const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined + +export const isSupabaseConfigured = Boolean(url && anonKey) + +export const supabase: SupabaseClient | null = isSupabaseConfigured + ? createClient(url as string, anonKey as string, { + auth: { + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: false, + }, + }) + : null diff --git a/src/services/sync.ts b/src/services/sync.ts new file mode 100644 index 0000000..0b2f459 --- /dev/null +++ b/src/services/sync.ts @@ -0,0 +1,539 @@ +// Offline-first sync engine for Supabase. +// +// Writes always go to Dexie first (so the UI is fast and works offline). +// Dexie hooks (in src/db/database.ts) automatically tag every write with +// _dirty=1 and ownerId. This service: +// - flushes dirty rows to Supabase ("push") +// - pulls newer remote rows back ("pull") +// - re-runs both on a timer / when the network reconnects +// +// Pages don't need to know any of this beyond using `softDelete()` instead +// of `db..delete()`. + +import { + db, + suspendSyncHooks, + setCurrentOwnerId, + SYNCED_TABLES, + type Client, + type Session, +} from '../db/database' +import type { + TreatmentPlan, + TreatmentGoal, + BehaviorDefinition, + ParentTrainingProgram, +} from '../types/treatmentPlan' +import { supabase, isSupabaseConfigured } from './supabase' + +type SyncedTableName = (typeof SYNCED_TABLES)[number] + +const REMOTE_TABLE: Record = { + clients: 'clients', + sessions: 'sessions', + treatmentPlans: 'treatment_plans', + treatmentGoals: 'treatment_goals', + behaviorDefinitions: 'behavior_definitions', + parentTrainingPrograms: 'parent_training_programs', +} + +// ---------------------------------------------------------------------------- +// Sync status (subscribable for UI) +// ---------------------------------------------------------------------------- + +export interface SyncStatus { + enabled: boolean + online: boolean + syncing: boolean + pending: number + lastSyncAt: string | null + error: string | null +} + +let status: SyncStatus = { + enabled: false, + online: typeof navigator !== 'undefined' ? navigator.onLine : true, + syncing: false, + pending: 0, + lastSyncAt: null, + error: null, +} + +const listeners = new Set<(s: SyncStatus) => void>() + +function emit(patch: Partial) { + status = { ...status, ...patch } + listeners.forEach(l => l(status)) +} + +export function getSyncStatus(): SyncStatus { + return status +} + +export function subscribeSyncStatus(listener: (s: SyncStatus) => void): () => void { + listeners.add(listener) + listener(status) + return () => listeners.delete(listener) +} + +// ---------------------------------------------------------------------------- +// Last sync timestamp persistence (per user) +// ---------------------------------------------------------------------------- + +function lastSyncKey(userId: string, table: SyncedTableName) { + return `aba.lastSyncAt.${userId}.${table}` +} + +function getLastSyncAt(userId: string, table: SyncedTableName): string | null { + return localStorage.getItem(lastSyncKey(userId, table)) +} + +function setLastSyncAt(userId: string, table: SyncedTableName, iso: string) { + localStorage.setItem(lastSyncKey(userId, table), iso) +} + +// ---------------------------------------------------------------------------- +// Local <-> Remote row converters +// ---------------------------------------------------------------------------- + +type SyncedRow = { + id: string + ownerId?: string + createdAt: string + updatedAt: string + _dirty?: number + _deleted?: number + _syncedAt?: string +} + +function tombstoneTimestamp(row: { _deleted?: number; updatedAt: string }) { + return row._deleted ? row.updatedAt : null +} + +function stripSyncMeta>(row: T): T { + const copy = { ...row } + delete (copy as Record).ownerId + delete (copy as Record)._dirty + delete (copy as Record)._deleted + delete (copy as Record)._syncedAt + return copy +} + +// Clients +function clientToRemote(c: Client) { + return { + id: c.id, + owner_id: c.ownerId, + name: c.name, + date_of_birth: c.dateOfBirth ?? null, + phone: c.phone ?? null, + address: c.address ?? null, + location: c.location ?? null, + target_behaviors: c.targetBehaviors ?? [], + created_at: c.createdAt, + updated_at: c.updatedAt, + deleted_at: tombstoneTimestamp(c), + } +} + +interface RemoteClient { + id: string + owner_id: string + name: string + date_of_birth: string | null + phone: string | null + address: string | null + location: string | null + target_behaviors: unknown + created_at: string + updated_at: string + deleted_at: string | null +} + +function clientFromRemote(r: RemoteClient): Client { + return { + id: r.id, + ownerId: r.owner_id, + name: r.name, + dateOfBirth: r.date_of_birth ?? undefined, + phone: r.phone ?? undefined, + address: r.address ?? undefined, + location: r.location ?? undefined, + targetBehaviors: (r.target_behaviors as Client['targetBehaviors']) ?? [], + createdAt: r.created_at, + updatedAt: r.updated_at, + _dirty: 0, + _deleted: r.deleted_at ? 1 : 0, + _syncedAt: new Date().toISOString(), + } +} + +// Sessions +function sessionToRemote(s: Session) { + return { + id: s.id, + owner_id: s.ownerId, + client_id: s.clientId, + client_name: s.clientName, + start_time: s.startTime, + end_time: s.endTime ?? null, + duration_ms: s.durationMs ?? null, + behavior_data: s.behaviorData ?? [], + notes: s.notes ?? '', + created_at: s.createdAt, + updated_at: s.updatedAt, + deleted_at: tombstoneTimestamp(s), + } +} + +interface RemoteSession { + id: string + owner_id: string + client_id: string + client_name: string + start_time: string + end_time: string | null + duration_ms: number | null + behavior_data: unknown + notes: string + created_at: string + updated_at: string + deleted_at: string | null +} + +function sessionFromRemote(r: RemoteSession): Session { + return { + id: r.id, + ownerId: r.owner_id, + clientId: r.client_id, + clientName: r.client_name, + startTime: r.start_time, + endTime: r.end_time ?? undefined, + durationMs: r.duration_ms ?? undefined, + behaviorData: (r.behavior_data as Session['behaviorData']) ?? [], + notes: r.notes ?? '', + createdAt: r.created_at, + updatedAt: r.updated_at, + _dirty: 0, + _deleted: r.deleted_at ? 1 : 0, + _syncedAt: new Date().toISOString(), + } +} + +// Treatment plan / goal / behavior definition / parent training: +// schema is jsonb-blob style (id, owner_id, client_id, data, ...) +type BlobLocal = + | TreatmentPlan + | TreatmentGoal + | BehaviorDefinition + | ParentTrainingProgram +type LocalBlobRow = BlobLocal & SyncedRow & { clientId: string } + +function blobToRemote(row: LocalBlobRow) { + return { + id: row.id, + owner_id: row.ownerId, + client_id: row.clientId, + data: stripSyncMeta(row as unknown as Record), + created_at: row.createdAt, + updated_at: row.updatedAt, + deleted_at: tombstoneTimestamp(row), + } +} + +interface RemoteBlob { + id: string + owner_id: string + client_id: string + data: Record + created_at: string + updated_at: string + deleted_at: string | null +} + +function blobFromRemote(r: RemoteBlob): LocalBlobRow { + const local = { + ...(r.data ?? {}), + id: r.id, + ownerId: r.owner_id, + clientId: r.client_id, + createdAt: r.created_at, + updatedAt: r.updated_at, + _dirty: 0, + _deleted: r.deleted_at ? 1 : 0, + _syncedAt: new Date().toISOString(), + } + return local as unknown as LocalBlobRow +} + +// ---------------------------------------------------------------------------- +// Per-table push/pull +// ---------------------------------------------------------------------------- + +async function withSuspendedHooks(fn: () => Promise): Promise { + suspendSyncHooks(true) + try { + return await fn() + } finally { + suspendSyncHooks(false) + } +} + +async function pushTable(table: SyncedTableName, ownerId: string): Promise { + if (!supabase) return 0 + const dirty = await db.table(table).where('_dirty').equals(1).toArray() + if (dirty.length === 0) return 0 + + // Make sure every row has the current ownerId stamped (handles initial + // upload of pre-auth local data). + for (const row of dirty) { + if (!row.ownerId) row.ownerId = ownerId + } + + let remoteRows: unknown[] + switch (table) { + case 'clients': + remoteRows = (dirty as Client[]).map(clientToRemote) + break + case 'sessions': + remoteRows = (dirty as Session[]).map(sessionToRemote) + break + default: + remoteRows = (dirty as LocalBlobRow[]).map(blobToRemote) + } + + const { error } = await supabase + .from(REMOTE_TABLE[table]) + .upsert(remoteRows, { onConflict: 'id' }) + if (error) throw error + + const now = new Date().toISOString() + await withSuspendedHooks(async () => { + // Hard-delete tombstones locally; clear dirty flag on the rest. + const tombstoneIds = dirty.filter(r => r._deleted).map(r => r.id) + if (tombstoneIds.length) { + await db.table(table).bulkDelete(tombstoneIds) + } + const liveIds = dirty.filter(r => !r._deleted).map(r => r.id) + if (liveIds.length) { + await db + .table(table) + .where('id') + .anyOf(liveIds) + .modify({ _dirty: 0, _syncedAt: now, ownerId }) + } + }) + + return dirty.length +} + +async function pullTable(table: SyncedTableName, ownerId: string): Promise { + if (!supabase) return 0 + const since = getLastSyncAt(ownerId, table) ?? '1970-01-01T00:00:00Z' + + const { data, error } = await supabase + .from(REMOTE_TABLE[table]) + .select('*') + .eq('owner_id', ownerId) + .gt('updated_at', since) + .order('updated_at', { ascending: true }) + .limit(1000) + if (error) throw error + if (!data || data.length === 0) return 0 + + let maxUpdatedAt = since + await withSuspendedHooks(async () => { + for (const remote of data) { + const r = remote as { id: string; updated_at: string; deleted_at: string | null } + if (r.updated_at > maxUpdatedAt) maxUpdatedAt = r.updated_at + + // Hard delete locally if remote is tombstoned. + if (r.deleted_at) { + await db.table(table).delete(r.id) + continue + } + + // Last-write-wins: skip if local is newer. + const local = await db.table(table).get(r.id) + if (local && (local as { updatedAt?: string }).updatedAt && (local as { updatedAt: string }).updatedAt >= r.updated_at) { + continue + } + + let localRow: unknown + switch (table) { + case 'clients': + localRow = clientFromRemote(remote as RemoteClient) + break + case 'sessions': + localRow = sessionFromRemote(remote as RemoteSession) + break + default: + localRow = blobFromRemote(remote as RemoteBlob) + } + await db.table(table).put(localRow as never) + } + }) + + setLastSyncAt(ownerId, table, maxUpdatedAt) + return data.length +} + +async function countPending(): Promise { + let total = 0 + for (const t of SYNCED_TABLES) { + total += await db.table(t).where('_dirty').equals(1).count() + } + return total +} + +// ---------------------------------------------------------------------------- +// Public API: softDelete + manual sync +// ---------------------------------------------------------------------------- + +export async function softDelete(table: SyncedTableName, id: string) { + await db.table(table).update(id, { + _deleted: 1, + _dirty: 1, + updatedAt: new Date().toISOString(), + }) + scheduleFlush() +} + +export async function softDeleteWhere( + table: SyncedTableName, + field: string, + value: string | number, +) { + const ids = (await db.table(table).where(field).equals(value).primaryKeys()) as string[] + for (const id of ids) await softDelete(table, id) +} + +// ---------------------------------------------------------------------------- +// Sync orchestration +// ---------------------------------------------------------------------------- + +let currentOwnerId: string | null = null +let flushTimer: ReturnType | null = null +let pullTimer: ReturnType | null = null +const PULL_INTERVAL_MS = 60_000 +const FLUSH_DEBOUNCE_MS = 1_000 + +export function scheduleFlush() { + if (!currentOwnerId || !supabase || !status.online) return + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(() => { + flushTimer = null + void runSync({ pull: false }) + }, FLUSH_DEBOUNCE_MS) +} + +export async function runSync(opts: { pull?: boolean } = {}): Promise { + const ownerId = currentOwnerId + if (!ownerId || !supabase) return + if (status.syncing) return + if (!status.online) return + + emit({ syncing: true, error: null }) + try { + for (const t of SYNCED_TABLES) { + await pushTable(t, ownerId) + } + if (opts.pull !== false) { + for (const t of SYNCED_TABLES) { + await pullTable(t, ownerId) + } + } + const pending = await countPending() + emit({ + syncing: false, + pending, + lastSyncAt: new Date().toISOString(), + error: null, + }) + } catch (e) { + const message = e instanceof Error ? e.message : 'Sync failed' + emit({ syncing: false, error: message, pending: await countPending() }) + } +} + +export async function startSync(ownerId: string) { + if (!isSupabaseConfigured) return + currentOwnerId = ownerId + setCurrentOwnerId(ownerId) + + // Mark any pre-auth local rows (no ownerId) so they upload on first push. + await withSuspendedHooks(async () => { + for (const t of SYNCED_TABLES) { + await db.table(t).filter((r: { ownerId?: string }) => !r.ownerId).modify({ + ownerId, + _dirty: 1, + }) + } + }) + + emit({ enabled: true, pending: await countPending() }) + + if (typeof window !== 'undefined') { + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + window.addEventListener('focus', handleFocus) + } + + if (pullTimer) clearInterval(pullTimer) + pullTimer = setInterval(() => void runSync(), PULL_INTERVAL_MS) + + void runSync() +} + +export function stopSync() { + currentOwnerId = null + setCurrentOwnerId(null) + if (flushTimer) clearTimeout(flushTimer) + if (pullTimer) clearInterval(pullTimer) + flushTimer = null + pullTimer = null + if (typeof window !== 'undefined') { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + window.removeEventListener('focus', handleFocus) + } + emit({ enabled: false, syncing: false, pending: 0, lastSyncAt: null, error: null }) +} + +function handleOnline() { + emit({ online: true }) + void runSync() +} +function handleOffline() { + emit({ online: false }) +} +function handleFocus() { + void runSync() +} + +// Re-emit pending count whenever Dexie changes a synced table so the UI badge +// stays accurate without polling. +if (typeof window !== 'undefined') { + for (const t of SYNCED_TABLES) { + db.table(t).hook('creating', () => { + if (currentOwnerId) void recomputePending() + }) + db.table(t).hook('updating', mods => { + if (currentOwnerId) void recomputePending() + return mods + }) + db.table(t).hook('deleting', () => { + if (currentOwnerId) void recomputePending() + }) + } +} + +let pendingDebounce: ReturnType | null = null +function recomputePending() { + if (pendingDebounce) clearTimeout(pendingDebounce) + pendingDebounce = setTimeout(async () => { + pendingDebounce = null + emit({ pending: await countPending() }) + scheduleFlush() + }, 200) +} diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..84f3df7 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,38 @@ +# Supabase setup + +The app works fully offline against IndexedDB. Adding Supabase enables cloud +sync so your clients and session data are backed up and accessible across +devices. + +## 1. Create a Supabase project + +1. Go to https://supabase.com and create a free project. +2. From **Project Settings → API**, copy: + - `Project URL` + - `anon public` key + +## 2. Configure the app + +Create `.env.local` in the repo root: + +``` +VITE_SUPABASE_URL=https://YOUR-PROJECT.supabase.co +VITE_SUPABASE_ANON_KEY=eyJhbGciOi... +``` + +If these vars are missing, the app keeps working — it just stays in +local-only mode and the sync UI is hidden. + +## 3. Run the migration + +In the Supabase dashboard, open **SQL Editor → New query**, paste the +contents of `supabase/migrations/001_init.sql`, and run it. This creates +the tables (`clients`, `sessions`, `treatment_plans`, `treatment_goals`, +`behavior_definitions`, `parent_training_programs`) and enables row-level +security so each therapist only sees their own data. + +## 4. Use it + +Restart `npm run dev`. A **Sync** item will appear in the bottom nav. Tap +it to sign up / sign in. On first sign-in, all of your existing local +data (including the fictitious test data) will be uploaded automatically. diff --git a/supabase/migrations/001_init.sql b/supabase/migrations/001_init.sql new file mode 100644 index 0000000..c1386dd --- /dev/null +++ b/supabase/migrations/001_init.sql @@ -0,0 +1,120 @@ +-- ABA Data Collection App: initial schema for Supabase cloud sync +-- Mirrors the local Dexie schema. Nested arrays stored as JSONB to avoid +-- maintaining parallel relational tables for each behavior session. +-- Every row is scoped to one therapist via owner_id + RLS. + +-- ============================================================================ +-- Tables +-- ============================================================================ + +create table if not exists clients ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + name text not null, + date_of_birth date, + 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(), + deleted_at timestamptz +); + +create table if not exists sessions ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + client_id uuid not null, + 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(), + deleted_at timestamptz +); + +create table if not exists treatment_plans ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + client_id uuid not null, + data jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists treatment_goals ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + client_id uuid not null, + data jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists behavior_definitions ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + client_id uuid not null, + data jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +create table if not exists parent_training_programs ( + id uuid primary key, + owner_id uuid not null references auth.users(id) on delete cascade, + client_id uuid not null, + data jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + deleted_at timestamptz +); + +-- ============================================================================ +-- Indexes (used by the pull query: "rows updated since last sync") +-- ============================================================================ + +create index if not exists clients_owner_updated_idx + on clients (owner_id, updated_at); +create index if not exists sessions_owner_updated_idx + on sessions (owner_id, updated_at); +create index if not exists sessions_owner_client_idx + on sessions (owner_id, client_id); +create index if not exists treatment_plans_owner_updated_idx + on treatment_plans (owner_id, updated_at); +create index if not exists treatment_goals_owner_updated_idx + on treatment_goals (owner_id, updated_at); +create index if not exists behavior_definitions_owner_updated_idx + on behavior_definitions (owner_id, updated_at); +create index if not exists parent_training_programs_owner_updated_idx + on parent_training_programs (owner_id, updated_at); + +-- ============================================================================ +-- Row-level security: a user can only see/modify their own rows. +-- ============================================================================ + +alter table clients enable row level security; +alter table sessions enable row level security; +alter table treatment_plans enable row level security; +alter table treatment_goals enable row level security; +alter table behavior_definitions enable row level security; +alter table parent_training_programs enable row level security; + +create policy clients_owner_rw on clients + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy sessions_owner_rw on sessions + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy treatment_plans_owner_rw on treatment_plans + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy treatment_goals_owner_rw on treatment_goals + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy behavior_definitions_owner_rw on behavior_definitions + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid()); +create policy parent_training_programs_owner_rw on parent_training_programs + for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());