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 (
- 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.