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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=
132 changes: 132 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.104.1",
"date-fns": "^4.1.0",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
Expand Down
56 changes: 56 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,25 @@ 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() {
const location = useLocation()
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
Expand All @@ -38,6 +48,7 @@ function App() {
<Route path="/data" element={<DataPage />} />
<Route path="/data/:clientId" element={<DataPage />} />
<Route path="/data/:clientId/session/:sessionId" element={<SessionDetailPage />} />
<Route path="/sync" element={<SyncPage />} />
</Routes>
</main>

Expand Down Expand Up @@ -67,6 +78,51 @@ function App() {
</svg>
Data
</NavLink>
{configured && (
<NavLink to="/sync" className={({ isActive }) => isActive ? 'active' : ''}>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
{user && sync.pending > 0 && (
<span
aria-label={`${sync.pending} pending`}
style={{
position: 'absolute',
top: -4,
right: -8,
background: 'var(--danger)',
color: 'white',
borderRadius: 10,
fontSize: 10,
lineHeight: 1,
padding: '2px 5px',
fontWeight: 700,
}}
>
{sync.pending}
</span>
)}
{user && sync.pending === 0 && !sync.error && (
<span
aria-label="synced"
style={{
position: 'absolute',
top: 0,
right: -4,
width: 8,
height: 8,
borderRadius: 4,
background: sync.online ? 'var(--success)' : 'var(--text-secondary)',
}}
/>
)}
</span>
Sync
</NavLink>
)}
<button
onClick={toggle}
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
Expand Down
84 changes: 82 additions & 2 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,16 @@ export interface TargetBehavior {
currentStoId?: string;
}

export interface Client {
// Sync metadata applied to every cloud-synced row.
// Pages don't need to set these — Dexie hooks (see bottom of file) do.
export interface SyncMeta {
ownerId?: string; // Supabase auth.uid() of the owner; null until first sync
_dirty?: number; // 1 = needs upload, 0 = synced
_deleted?: number; // 1 = tombstone (soft delete), 0 = present
_syncedAt?: string; // ISO timestamp of last successful upload
}

export interface Client extends SyncMeta {
id: string;
name: string;
dateOfBirth?: string;
Expand Down Expand Up @@ -150,7 +159,7 @@ export interface BehaviorData {
abcRecords?: ABCRecord[];
}

export interface Session {
export interface Session extends SyncMeta {
id: string;
clientId: string;
clientName: string;
Expand Down Expand Up @@ -197,6 +206,77 @@ db.version(3).stores({
parentTrainingPrograms: 'id, clientId, programId, status, createdAt'
});

// Version 4: Sync metadata for Supabase cloud sync.
// _dirty=1 marks rows that still need to be pushed; ownerId scopes per user.
// Existing rows are marked dirty so they upload on first sign-in.
const SYNCED_TABLE_NAMES = [
'clients',
'sessions',
'treatmentPlans',
'treatmentGoals',
'behaviorDefinitions',
'parentTrainingPrograms',
] as const;

db.version(4).stores({
clients: 'id, name, createdAt, updatedAt, ownerId, _dirty, _deleted',
sessions: 'id, clientId, startTime, createdAt, updatedAt, ownerId, _dirty, _deleted',
treatmentPlans: 'id, clientId, status, createdAt, updatedAt, ownerId, _dirty, _deleted',
treatmentGoals: 'id, clientId, goalId, category, status, createdAt, updatedAt, ownerId, _dirty, _deleted',
behaviorDefinitions: 'id, clientId, behaviorName, behaviorType, createdAt, updatedAt, ownerId, _dirty, _deleted',
parentTrainingPrograms: 'id, clientId, programId, status, createdAt, updatedAt, ownerId, _dirty, _deleted',
}).upgrade(async tx => {
for (const name of SYNCED_TABLE_NAMES) {
await tx.table(name).toCollection().modify((row: Record<string, unknown>) => {
row._dirty = 1;
row._deleted = 0;
if (!row.updatedAt) row.updatedAt = row.createdAt ?? new Date().toISOString();
});
}
});

// ----------------------------------------------------------------------------
// Sync hooks
// ----------------------------------------------------------------------------
// Every create/update on a synced table is automatically tagged with _dirty=1
// so the sync service knows to push it. The sync service itself sets
// `suspendSyncHooks(true)` while clearing dirty flags so it doesn't loop.

let syncHooksSuspended = false;
let currentOwnerId: string | null = null;

export function suspendSyncHooks(suspend: boolean) {
syncHooksSuspended = suspend;
}

export function setCurrentOwnerId(ownerId: string | null) {
currentOwnerId = ownerId;
}

export function getCurrentOwnerId(): string | null {
return currentOwnerId;
}

export const SYNCED_TABLES = SYNCED_TABLE_NAMES;

for (const name of SYNCED_TABLE_NAMES) {
const table = db.table(name);
table.hook('creating', (_pk, obj) => {
if (syncHooksSuspended) return;
const row = obj as Record<string, unknown>;
if (currentOwnerId && !row.ownerId) row.ownerId = currentOwnerId;
if (row._dirty === undefined) row._dirty = 1;
if (row._deleted === undefined) row._deleted = 0;
if (!row.updatedAt) row.updatedAt = new Date().toISOString();
});
table.hook('updating', mods => {
if (syncHooksSuspended) return; // leave the update untouched
const next: Record<string, unknown> = { ...mods, _dirty: 1 };
if (!('updatedAt' in next)) next.updatedAt = new Date().toISOString();
return next;
});
}

export { db };

// Re-export types for convenience
Expand Down
Loading