diff --git a/README.md b/README.md index c200c68..0e9d3ac 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ A web-based behavioral observation application for Board Certified Behavior Anal ### Data Management - **Auto-Save** - Automatic localStorage persistence with timestamp indicator +- **Offline Mode** - The form remains usable without internet; submissions queue locally and sync automatically when connectivity returns (see [Offline mode](#offline-mode) below) - **Export to CSV** - Full data export in CSV format - **Export to Word** - Professional formatted .docx document generation - **Print Support** - Print-optimized layout @@ -91,6 +92,47 @@ src/ 5. **Set Recommendations** - Check applicable recommendations and next steps 6. **End & Export** - Click "End Observation" and export to CSV or Word +## Offline mode + +The app works offline once it has been loaded online at least once. Behind the scenes +it uses a service worker (PWA) to cache the JavaScript, styles, and fonts needed to +run, and a local sync queue to hold submissions until the network returns. + +### How to set up a device for offline use + +1. Open the app on the device while connected to the internet. +2. Wait until you see the green **Offline-ready** pill next to the connectivity dot + in the bottom bar. That confirms the service worker has cached everything. +3. (Optional but recommended) **Add to Home Screen** so the app launches like a + native app: + - iOS Safari: Share → Add to Home Screen + - Android Chrome: Menu → Install app +4. After that, the app will launch and the form will work even without internet. + +### How offline submissions work + +- The form is **always usable**, online or offline. Your draft is auto-saved to + the device on every change. +- When you click **Submit** while offline (the button reads **Save & Queue**), the + full report is added to a local sync queue and the editor is reset so you can + start the next observation. +- The connectivity dot shows a small badge with the number of pending reports. +- When the device reconnects, queued reports sync automatically. You can also use + the **Sync** button at any time to push queued reports manually. +- If a sync fails (e.g. the server returns an error), the failed item is shown + with **Retry** and **Discard** controls. Other queued items continue to sync. + +### Limitations and verification + +- **First-ever launch must be online.** Web browsers cannot open a page they have + never fetched before. After one online launch the app caches everything and + works offline forever after. +- Viewing previously submitted reports under "My Reports" requires connectivity; + only the create-and-save flow works offline. +- To verify offline behavior in Chrome: open DevTools → Application → Service + Workers → tick **Offline**, then hard-reload. The app shell should render and + Submit should queue. + ## Mobile/Tablet Optimization - Large touch targets (44px minimum) diff --git a/src/App.jsx b/src/App.jsx index f69795d..18767ba 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,9 @@ import { useState, useCallback, useEffect } from 'react'; import { useObservationStorage } from './hooks/useLocalStorage'; import { useSupabase } from './hooks/useSupabase'; +import { useSyncQueue } from './hooks/useSyncQueue'; +import { useConnectivity } from './hooks/useConnectivity'; +import { usePwaStatus } from './hooks/usePwaStatus'; // Components import { ObservationHeader } from './components/Header/ObservationHeader'; @@ -35,11 +38,16 @@ const TABS = [ function App() { const { data, setData, updateField, resetObservation, loadObservation, lastSaved } = useObservationStorage(); const { submitObservation, submitting, submitError, submitSuccess, submitMode } = useSupabase(); + const connectivity = useConnectivity(); + const { canSubmit } = connectivity; + const syncQueue = useSyncQueue(); + const { offlineReady } = usePwaStatus(); const [activeTab, setActiveTab] = useState('narrative'); const [isObserving, setIsObserving] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [showMyReports, setShowMyReports] = useState(false); const [actionsOpen, setActionsOpen] = useState(false); + const [queueNotice, setQueueNotice] = useState(null); useEffect(() => { setIsObserving(!!data.header.startTime && !data.header.endTime); @@ -128,12 +136,28 @@ function App() { }, [resetObservation]); const handleSubmit = useCallback(async () => { - const result = await submitObservation(data); - if (result && result.mode === 'insert' && result.id) { - // Stamp the new Supabase row id so subsequent submits UPDATE instead of INSERT. - updateField('remoteId', result.id); + if (canSubmit) { + const result = await submitObservation(data); + if (result && result.mode === 'insert' && result.id) { + // First submit succeeded online: free the editor for a new observation. + resetObservation(); + setQueueNotice({ kind: 'submitted', at: Date.now() }); + } else if (result && result.mode === 'update' && result.id) { + // Editing a previously-submitted row succeeded — keep the data on screen. + updateField('remoteId', result.id); + } else if (!result) { + // Server-side failure: queue locally so the user does not lose work. + syncQueue.enqueue(data); + resetObservation(); + setQueueNotice({ kind: 'queued-after-fail', at: Date.now() }); + } + return; } - }, [submitObservation, data, updateField]); + // Offline: queue immediately and start a fresh observation. + syncQueue.enqueue(data); + resetObservation(); + setQueueNotice({ kind: 'queued-offline', at: Date.now() }); + }, [canSubmit, submitObservation, data, updateField, resetObservation, syncQueue]); const handleResumeReport = useCallback((report) => { loadObservation(report); @@ -296,6 +320,11 @@ function App() { onMyReportsOpen={() => setShowMyReports(true)} open={actionsOpen} onClose={() => setActionsOpen(false)} + connectivity={connectivity} + syncQueue={syncQueue} + offlineReady={offlineReady} + queueNotice={queueNotice} + onDismissQueueNotice={() => setQueueNotice(null)} /> ); diff --git a/src/components/Export/ExportButtons.jsx b/src/components/Export/ExportButtons.jsx index 30050df..8d937ee 100644 --- a/src/components/Export/ExportButtons.jsx +++ b/src/components/Export/ExportButtons.jsx @@ -1,12 +1,30 @@ -import { useEffect } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { downloadCSV } from './generateCSV'; import { downloadDocx } from './generateDocx'; import { downloadPdf } from './generatePdf'; import { downloadReportFile } from './generateReportFile'; -import { useConnectivity } from '../../hooks/useConnectivity'; import { useKeyboardVisible } from '../../hooks/useKeyboardVisible'; -function ConnectivityDot({ isOnline, supabaseReachable, checking, showLabelOnMobile = false }) { +function OfflineReadyPill({ offlineReady }) { + if (!offlineReady) return null; + return ( + + + + + Offline-ready + + ); +} + +function ConnectivityDot({ isOnline, supabaseReachable, checking, pendingCount = 0, showLabelOnMobile = false }) { let color, label; if (!isOnline) { color = 'bg-red-500'; @@ -22,36 +40,145 @@ function ConnectivityDot({ isOnline, supabaseReachable, checking, showLabelOnMob label = 'Server unreachable'; } + const titleWithPending = pendingCount > 0 + ? `${label} • ${pendingCount} pending sync` + : label; + return ( - - - {label} + + + + {pendingCount > 0 && ( + + {pendingCount} + + )} + + {label} ); } -function StatusMessages({ isOnline, supabaseReachable, checking, submitError, submitSuccess, submitMode }) { - if (isOnline && supabaseReachable !== false && !submitError && !submitSuccess) return null; +function StatusMessages({ + isOnline, + supabaseReachable, + checking, + submitError, + submitSuccess, + submitMode, + pending = [], + syncing = false, + offlineReady = false, + queueNotice = null, + onDismissQueueNotice, + syncedFlash = null, + onRetryItem, + onRemoveItem, +}) { + const queuedCount = pending.filter((p) => p.status === 'queued' || p.status === 'syncing').length; + const erroredItems = pending.filter((p) => p.status === 'error'); + + const showAny = + !isOnline || + (isOnline && supabaseReachable === false && !checking) || + submitError || + submitSuccess || + queuedCount > 0 || + erroredItems.length > 0 || + queueNotice || + syncedFlash; + + if (!showAny) return null; + return (
- {!isOnline && ( + {!isOnline && queuedCount === 0 && (

- You are offline. Connect to the internet to submit reports. + You are offline. Submissions will queue locally and sync automatically when you reconnect. +

+ )} + {!isOnline && queuedCount > 0 && ( +

+ You are offline — {queuedCount} report{queuedCount === 1 ? '' : 's'} will sync automatically when you reconnect.

)} {isOnline && supabaseReachable === false && !checking && (

- Cannot reach the server. Submissions are disabled until connectivity is restored. + Cannot reach the server. {queuedCount > 0 + ? `${queuedCount} report${queuedCount === 1 ? '' : 's'} queued locally.` + : 'Submissions will queue locally until connectivity is restored.'} +

+ )} + {isOnline && supabaseReachable !== false && queuedCount > 0 && ( +

+ {syncing + ? `Syncing ${queuedCount} pending report${queuedCount === 1 ? '' : 's'}…` + : `${queuedCount} report${queuedCount === 1 ? '' : 's'} queued for sync.`} +

+ )} + {syncedFlash && ( +

+ All {syncedFlash} report{syncedFlash === 1 ? '' : 's'} synced. +

+ )} + {queueNotice?.kind === 'queued-offline' && ( +

+ Saved offline. It will sync automatically when you reconnect. + +

+ )} + {queueNotice?.kind === 'queued-after-fail' && ( +

+ Server didn't respond. Saved locally — will retry automatically. + +

+ )} + {queueNotice?.kind === 'submitted' && ( +

+ Report submitted. Ready for the next one. +

)} {submitError && (

{submitError}

)} - {submitSuccess && ( + {submitSuccess && !queueNotice && (

{submitMode === 'update' ? 'Report updated successfully!' : 'Report submitted successfully!'}

)} + {erroredItems.map((item) => ( +
+ + Sync failed:{' '} + {item.payload?.header?.studentName || 'Untitled report'} + {item.lastError ? ` — ${item.lastError}` : ''} + + + + + +
+ ))}
); } @@ -59,7 +186,20 @@ function StatusMessages({ isOnline, supabaseReachable, checking, submitError, su const PRIMARY = 'min-h-[44px] py-2.5 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2'; const ICON = 'min-h-[44px] min-w-[44px] rounded-lg text-sm font-medium transition-colors flex items-center justify-center'; -function ActionButtons({ data, onClear, onSubmit, submitting, canSubmit, onAdminOpen, onMyReportsOpen, onActionTaken, variant }) { +function ActionButtons({ + data, + onClear, + onSubmit, + submitting, + canSubmit, + pendingCount, + syncing, + onSyncNow, + onAdminOpen, + onMyReportsOpen, + onActionTaken, + variant, +}) { const wrap = (fn) => () => { fn(); onActionTaken?.(); @@ -73,13 +213,22 @@ function ActionButtons({ data, onClear, onSubmit, submitting, canSubmit, onAdmin ? 'flex items-center gap-2 mt-2' : 'flex items-center gap-2 mt-2 md:contents md:gap-3 md:mt-0'; + const submitLabel = submitting + ? 'Submitting…' + : canSubmit + ? 'Submit' + : 'Save & Queue'; + const submitTitle = canSubmit + ? 'Submit report to cloud' + : 'No connection — your report will be saved locally and synced automatically when you reconnect'; + return ( <>
+ )} + +
+ + + ); + } +} diff --git a/src/hooks/usePwaStatus.js b/src/hooks/usePwaStatus.js new file mode 100644 index 0000000..6ec2646 --- /dev/null +++ b/src/hooks/usePwaStatus.js @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'pwa-offline-ready'; + +function readInitial() { + try { + return window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + return false; + } +} + +export function usePwaStatus() { + const [offlineReady, setOfflineReady] = useState(readInitial); + + useEffect(() => { + const handler = () => setOfflineReady(true); + window.addEventListener('app:offline-ready', handler); + return () => window.removeEventListener('app:offline-ready', handler); + }, []); + + return { offlineReady }; +} diff --git a/src/hooks/useSupabase.js b/src/hooks/useSupabase.js index c50ea88..62fdadb 100644 --- a/src/hooks/useSupabase.js +++ b/src/hooks/useSupabase.js @@ -1,17 +1,6 @@ import { useState, useCallback } from 'react'; import { supabase, isSupabaseConfigured } from '../lib/supabase'; - -function buildPayload(data) { - return { - observer_name: data.header?.observer || '', - student_name: data.header?.studentName || '', - student_id: data.header?.studentId || '', - school: data.header?.school || '', - observation_date: data.header?.date || '', - status: 'submitted', - data, - }; -} +import { submitToSupabase } from '../lib/submitObservation'; export function useSupabase() { const [submitting, setSubmitting] = useState(false); @@ -30,29 +19,11 @@ export function useSupabase() { setSubmitMode(null); try { - const payload = buildPayload(data); - if (data.remoteId) { - const { error } = await supabase - .from('observations') - .update(payload) - .eq('id', data.remoteId); - if (error) throw error; - setSubmitSuccess(true); - setSubmitMode('update'); - setTimeout(() => setSubmitSuccess(false), 4000); - return { id: data.remoteId, mode: 'update' }; - } - - const { data: row, error } = await supabase - .from('observations') - .insert(payload) - .select() - .single(); - if (error) throw error; + const result = await submitToSupabase(data, data.remoteId); setSubmitSuccess(true); - setSubmitMode('insert'); + setSubmitMode(result.mode); setTimeout(() => setSubmitSuccess(false), 4000); - return { id: row?.id, mode: 'insert' }; + return result; } catch (err) { setSubmitError(err.message); return false; diff --git a/src/hooks/useSyncQueue.js b/src/hooks/useSyncQueue.js new file mode 100644 index 0000000..dda5ccc --- /dev/null +++ b/src/hooks/useSyncQueue.js @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useLocalStorage } from './useLocalStorage'; +import { useConnectivity } from './useConnectivity'; +import { submitToSupabase } from '../lib/submitObservation'; +import { isSupabaseConfigured } from '../lib/supabase'; + +const STORAGE_KEY = 'observation-sync-queue'; +const PRUNE_DELAY_MS = 5000; +const AUTO_FLUSH_DELAY_MS = 500; + +function makeLocalId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function useSyncQueue() { + const [queue, setQueue] = useLocalStorage(STORAGE_KEY, []); + const queueRef = useRef(queue); + useEffect(() => { + queueRef.current = queue; + }, [queue]); + + const [syncing, setSyncing] = useState(false); + const syncingRef = useRef(false); + + const { canSubmit, supabaseReachable } = useConnectivity(); + + const updateItem = useCallback((localId, patch) => { + setQueue((prev) => prev.map((i) => (i.localId === localId ? { ...i, ...patch } : i))); + }, [setQueue]); + + const removeItem = useCallback((localId) => { + setQueue((prev) => prev.filter((i) => i.localId !== localId)); + }, [setQueue]); + + const enqueue = useCallback((payload) => { + const item = { + localId: makeLocalId(), + payload, + status: 'queued', + remoteId: payload?.remoteId || null, + lastError: null, + createdAt: new Date().toISOString(), + }; + setQueue((prev) => [...prev, item]); + return item; + }, [setQueue]); + + const flushQueue = useCallback(async () => { + if (!isSupabaseConfigured) return; + if (syncingRef.current) return; + if (!navigator.onLine) return; + + syncingRef.current = true; + setSyncing(true); + try { + // Snapshot the IDs of items to process. New items enqueued during the flush + // will be handled by the next auto-drain or manual sync. + const ids = queueRef.current + .filter((i) => i.status === 'queued') + .map((i) => i.localId); + + for (const localId of ids) { + const item = queueRef.current.find((i) => i.localId === localId); + if (!item || item.status !== 'queued') continue; + + updateItem(localId, { status: 'syncing', lastError: null }); + try { + const result = await submitToSupabase( + item.payload, + item.remoteId || item.payload?.remoteId, + ); + updateItem(localId, { + status: 'synced', + remoteId: result.id, + syncedAt: new Date().toISOString(), + }); + // Prune shortly after success so the UI can show the synced state briefly. + setTimeout(() => removeItem(localId), PRUNE_DELAY_MS); + } catch (err) { + updateItem(localId, { + status: 'error', + lastError: err?.message || 'Sync failed', + }); + // Errored items remain in the queue. They are excluded from this loop's + // filter and will only be retried via retryItem() or a full reflush after + // the user toggles them back to 'queued'. + } + } + } finally { + syncingRef.current = false; + setSyncing(false); + } + }, [removeItem, updateItem]); + + const retryItem = useCallback((localId) => { + updateItem(localId, { status: 'queued', lastError: null }); + setTimeout(() => flushQueue(), 50); + }, [updateItem, flushQueue]); + + const retryAll = useCallback(() => { + setQueue((prev) => + prev.map((i) => (i.status === 'error' ? { ...i, status: 'queued', lastError: null } : i)), + ); + setTimeout(() => flushQueue(), 50); + }, [setQueue, flushQueue]); + + // Auto-drain when Supabase becomes reachable and there is something to send. + useEffect(() => { + const hasQueued = queue.some((i) => i.status === 'queued'); + if (canSubmit && supabaseReachable && hasQueued) { + const t = setTimeout(() => flushQueue(), AUTO_FLUSH_DELAY_MS); + return () => clearTimeout(t); + } + return undefined; + }, [canSubmit, supabaseReachable, queue, flushQueue]); + + // Also drain on the raw browser online event so we react before a reachability + // probe completes. + useEffect(() => { + const handler = () => { + setTimeout(() => flushQueue(), AUTO_FLUSH_DELAY_MS); + }; + window.addEventListener('online', handler); + return () => window.removeEventListener('online', handler); + }, [flushQueue]); + + return { + pending: queue, + syncing, + enqueue, + flushQueue, + retryItem, + retryAll, + removeItem, + }; +} diff --git a/src/lib/submitObservation.js b/src/lib/submitObservation.js new file mode 100644 index 0000000..7dfbb7e --- /dev/null +++ b/src/lib/submitObservation.js @@ -0,0 +1,40 @@ +import { supabase, isSupabaseConfigured } from './supabase'; + +export function buildPayload(data) { + return { + observer_name: data.header?.observer || '', + student_name: data.header?.studentName || '', + student_id: data.header?.studentId || '', + school: data.header?.school || '', + observation_date: data.header?.date || '', + status: 'submitted', + data, + }; +} + +// Insert a new observation row, or update an existing one if `remoteId` is provided. +// Throws on any Supabase error so callers can decide whether to surface or queue. +export async function submitToSupabase(data, remoteId = null) { + if (!isSupabaseConfigured) { + throw new Error('Supabase is not configured.'); + } + const payload = buildPayload(data); + const targetId = remoteId ?? data.remoteId ?? null; + + if (targetId) { + const { error } = await supabase + .from('observations') + .update(payload) + .eq('id', targetId); + if (error) throw error; + return { id: targetId, mode: 'update' }; + } + + const { data: row, error } = await supabase + .from('observations') + .insert(payload) + .select() + .single(); + if (error) throw error; + return { id: row?.id, mode: 'insert' }; +} diff --git a/src/main.jsx b/src/main.jsx index 1aadc7d..8907d1a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,13 +3,35 @@ import ReactDOM from 'react-dom/client' import App from './App.jsx' import './index.css' import { registerSW } from 'virtual:pwa-register' +import { ErrorBoundary } from './components/UI/ErrorBoundary' if ('serviceWorker' in navigator) { - registerSW({ immediate: true }) + const updateSW = registerSW({ + immediate: true, + onOfflineReady() { + try { + window.localStorage.setItem('pwa-offline-ready', '1'); + } catch { + // localStorage can throw in private mode; the in-memory event still fires. + } + window.dispatchEvent(new CustomEvent('app:offline-ready')); + }, + onRegisteredSW(_swUrl, registration) { + if (registration) { + setInterval(() => { + registration.update().catch(() => {}); + }, 60 * 60 * 1000); + } + }, + }); + // updateSW is intentionally retained for autoUpdate side effects. + void updateSW; } ReactDOM.createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/vite.config.js b/vite.config.js index 3ae79eb..20ecbda 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,7 +27,11 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], navigateFallback: '/index.html', - maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, + navigateFallbackDenylist: [/^\/api\//, /^\/icons\//], + cleanupOutdatedCaches: true, + // pdfmake's font VFS and the docx bundle both exceed the default 5 MB cap and would + // otherwise be silently dropped from precache, breaking offline export. + maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, runtimeCaching: [ { urlPattern: ({ url }) => url.hostname.endsWith('supabase.co'), @@ -41,6 +45,7 @@ export default defineConfig({ }, ], }, + devOptions: { enabled: false }, }), ], server: {