# Track 2: Offline-First Architecture Design > 2026-05-09 | Daedalus subagent (planning only, no execution) --- ## 1. Problem Statement The 3-tap sideline entry is the core interaction of RTSport. Athletic trainers use it on sidelines with notoriously poor cellular connectivity (metal bleachers, concrete stadiums, rural fields). If the app requires connectivity to log an injury, trainers will default to paper or Notes — and the product fails. **Requirements:** - Sideline entry must work with zero network connectivity - Static assets (HTML/CSS/JS) must load from cache - Entered data must sync when connectivity returns - Auth must work offline for some window (re-login only when expired) - Sync must be reliable — no data loss on conflicts for MVP --- ## 2. Caching Strategy ### Static Assets: Cache First (ServiceWorker) **Strategy:** Cache First with network fallback for all static assets. ``` ServiceWorker Install → Pre-cache: /rtsport/*, /static/css/*, /static/js/*, /static/data/* User Request → Check Cache → Hit → Return cached → Miss → Fetch from network → Cache response → Return Network Available → Background update of cached assets (Stale-While-Revalidate pattern after install) ``` **Files to cache on install:** - All dashboard HTML templates (coach, parent, at, ad, login) - All CSS files (rtsport.css, dashboard-tabs.css, parent-tabs.css, login.css, admin-tabs.css) - All JS files (dashboard-tabs.js, parent-tabs.js, login.js, admin-tabs.js, mock-data.js) - Static data files (athletes.json, body-parts.json, cases.json — but these become seed data, superseded by IndexedDB) **Cache versioning:** Use a `CACHE_VERSION` constant in the ServiceWorker. Bump on deployment → triggers `install` event → new cache. Old cache cleaned on `activate`. ### API Responses: Network First (Cache for Offline) **Strategy:** Network First with cache fallback for API responses. ``` User Request API → Network available → Fetch → Cache response → Return → Network fails → Return cached (if available) → Return error (if no cache) ``` **Endpoints to cache:** - `GET /dashboard/coach` — last known team status - `GET /dashboard/parent` — last known child dashboard - `GET /dashboard/parent/children` — child list - `GET /dashboard/at` — AT dashboard - `GET /roster` — roster (important for offline athlete search in sideline entry) - `GET /body-parts` — body part categories (almost static, can be aggressively cached) **Endpoints NEVER cached:** - `POST /events/sideline-entry` — queued, not cached - `POST /auth/login` — never cache credentials - `POST /messages/*` — queued, not cached --- ## 3. IndexedDB Schema ### Database Name: `rtsport_offline` ### Version: 1 ### Object Stores #### 1. `athletes` Mirrors the server's athletes table for offline athlete search. | Key | Field | Type | Notes | |-----|-------|------|-------| | `id` (primary key) | athleteId | string | ath_001 | | | firstName | string | "Jake" | | | lastName | string | "Larson" | | | sport | string | "Football" | | | team | string | "Varsity" | | | grade | number | 12 | | | currentStatus | string | "out", "modified", "cleared" | | | lastUpdated | timestamp | For sync freshness | **Indexes:** `sport`, `team`, `currentStatus`, `by_name` (composite firstName+lastName for search) #### 2. `cases` Mirrors the server's cases table. | Key | Field | Type | |-----|-------|------| | `id` (primary key) | caseId | string | | | athleteId | string | | | title | string | | | severity | string | | | status | string | | | phaseLabel | string | | | openedAt | timestamp | | | lastUpdated | timestamp | **Indexes:** `athleteId`, `status` #### 3. `milestones` Mirrors the server's milestones. | Key | Field | Type | |-----|-------|------| | `id` (primary key) | milestoneId | string | | | caseId | string | | | title | string | | | status | string | | | targetDate | timestamp | | | achievedAt | timestamp/null | **Indexes:** `caseId` #### 4. `events` Mirrors the server's events table. This is the WRITE-heavy store — sideline entries go here first. | Key | Field | Type | Notes | |-----|-------|------|-------| | `id` (primary key) | eventId | string | Server-assigned after sync, client UUID before | | | caseId | string | May be null if creating new case + event together | | | eventType | string | "sideline_entry", "note", "milestone", "restriction" | | | content | JSON | { athleteId, bodyPart, severity, removedFromPlay, note } | | | visibility | string[] | ["clinical", "parent"] | | | timestamp | timestamp | When the event actually occurred (not when synced) | | | syncStatus | string | "pending", "syncing", "synced", "failed" | | | createdAt | timestamp | When created locally | **Indexes:** `syncStatus`, `caseId`, `eventType`, `timestamp` #### 5. `sync_queue` The outbound queue for pending writes. | Key | Field | Type | Notes | |-----|-------|------|-------| | `id` (primary key) | queueId | auto-increment | | | endpoint | string | "/api/v1/events/sideline-entry" | | | method | string | "POST", "PATCH" | | | body | JSON | The request payload | | | localId | string | Reference to local event ID | | | createdAt | timestamp | When queued | | | retryCount | number | 0-based, max 3 | | | lastError | string/null | Error message from last attempt | **Indexes:** `createdAt` (for FIFO processing) #### 6. `auth_cache` Offline JWT storage. | Key | Field | Type | Notes | |-----|-------|------|-------| | `id` (primary key) | userId | string | "usr_coach001" | | | email | string | "coach@preble.k12.wi.us" | | | token | string | JWT access token | | | role | string | "coach" | | | schoolId | string | "schl_001" | | | expiresAt | timestamp | JWT `exp` claim | | | lastVerified | timestamp | When we last confirmed token was valid online | --- ## 4. Sync Protocol ### 4.1 Overview **Pattern:** Background sync with FIFO queue + conflict resolution for MVP. ``` ┌─────────────────────┐ Network Available ┌─────────────────────┐ │ User creates │ ────────────────────────→ │ Process Queue │ │ event offline │ │ │ │ │ │ POST /events/... │ │ Event saved to │ ┌──────────────┐ │ Response 201 → │ │ IndexedDB (pending) │ │ Periodic │ │ Update local ID │ │ sync_queue entry │ │ Sync Timer │ │ Mark as synced │ │ created │ │ (every 30s │ │ Remove from queue │ └─────────────────────┘ │ when online) │ └─────────────────────┘ └──────────────┘ │ Network Not Available │ ▼ ┌─────────────────────┐ │ Queue persists. │ │ Retry on next │ │ network event. │ └─────────────────────┘ ``` ### 4.2 Queue Processing ``` processSyncQueue(): while queue not empty: item = dequeue() try: response = fetch(item.endpoint, { method: item.method, body: item.body, headers: auth headers }) if response.ok: data = await response.json() updateLocalEvent(item.localId, { serverId: data.id, syncStatus: 'synced' }) markQueueItemComplete(item.id) else if response.status == 401: // Token expired — need re-auth pauseQueue() notifyUser('Session expired. Please log in to sync') break else: // 4xx/5xx — retry logic item.retryCount++ if item.retryCount >= 3: markQueueItemFailed(item.id, response.statusText) else: reEnqueue(item, backoffDelay(item.retryCount)) except NetworkError: // No connectivity — break out, try again later break ``` **Retry backoff:** 5s, 30s, 5min (exponential capped at 5min). ### 4.3 Triggering Sync | Trigger | Action | |---------|--------| | Online event fires (`window.online`) | Process entire queue | | 30-second interval (if online) | Process queue, debounce if empty | | Event created (if online) | Process that event immediately | | App comes to foreground | Process queue | | User taps "Sync Now" button | Force process queue | ### 4.4 Conflict Resolution (MVP: Last-Write-Wins) For MVP, **last-write-wins** is acceptable: - Sideline entries are new records, not updates — no conflict possible - Case milestone updates: server timestamp wins if local event was already synced - Status changes: if both sides modified, server wins (trainer's device is source of truth) **Future:** Add conflict detection UI when server rejects an update (e.g., case was closed while user was offline). ### 4.5 Read-Only Data Sync (Pull) On connectivity restored, also pull fresh data: ``` pullLatestData(): athletes = fetch('/api/v1/roster?school_id=...') mergeIntoLocal('athletes', athletes) cases = fetch('/api/v1/cases/athlete/*' for watched athletes) mergeIntoLocal('cases', cases) // Don't delete pending local events — they have higher precedence ``` **Merge rule:** Server data overwrites local data UNLESS local event with `syncStatus='pending'` references the record. --- ## 5. Offline Auth ### 5.1 How It Works ``` Login Online: POST /api/v1/auth/login/json → JWT + role + school_id + exp Store in auth_cache (IndexedDB) Start offline grace period timer Offline: checkAuth() reads from auth_cache Token valid (not expired) → proceed with cached token Token expired → show "Session expired" → redirect to login No cached token → redirect to login (login page itself is cached) Grace Period: Allow offline use for up to N hours past token expiry Configured per-role (shorter for admin, longer for AT) Default: 4 hours for AT, 2 hours for coach, 1 hour for parent ``` ### 5.2 Token Storage Security **Constraints:** - Cannot use `localStorage` for offline storage (cleared by browser on storage reset) - Must use IndexedDB (persistent, survives cache clears) - Must encrypt at rest? Not for MVP — treat same as session cookie **MVP approach:** ``` Store in IndexedDB auth_cache (not encrypted — same trust model as localStorage) Remove on logout Remove on token expiry + grace period expiry ``` **Future:** - Encrypt token with device-specific key (Web Crypto API) - Biometric unlock for offline access ### 5.3 Auth Flow ``` App loads → checkAuth(): if online: fetch('/api/v1/auth/me') with cached token if 200: token valid → refresh grace_period timer return token elif 401: try auto-login from stored creds? No — too dangerous redirect to login page else (offline): token = getCachedToken() if token: if token.exp > now: return token elif token.exp + grace_period > now: return token (degraded mode — warn user) else: show "Session expired" → redirect to login else: redirect to login ``` ### 5.4 Offline Login (MVP: Not Supported) For MVP, you cannot log in offline. The user must have logged in at least once while online to cache their token. This is acceptable for the sideline use case — the AT logs in before going to the field. **Future:** Support offline login with cached password hash + local credential verification. --- ## 6. 3-Tap Entry Offline Flow This is the most critical offline path. ``` User opens sideline entry → Already offline → ServiceWorker serves cached HTML+CSS+JS → checkAuth() returns cached token (within grace period) → Proceed to sideline entry screen Tap 1: Select athlete Search from IndexedDB athletes store (synced during last online session) ✅ Full offline search — no network needed Tap 2: Select body part Body parts loaded from IndexedDB (or static JSON, cached aggressively) ✅ Full offline — body parts rarely change Tap 3: Select severity + removed_from_play → Submit Create event object: { id: "local_evt_uuid", // Client-generated UUID event_type: "sideline_entry", content: { athlete_id: "ath_001", body_part: "Ankle - Right", severity: "moderate", removed_from_play: true, note: "Rolled ankle during third quarter" }, timestamp: "2026-05-09T14:30:00Z", // When injury happened, not when synced syncStatus: "pending" } Save to IndexedDB events store → show confirmation toast Create sync_queue entry → POST /api/v1/events/sideline-entry with same payload ✅ Complete — event exists locally, sync happens when connectivity returns On connectivity restored: Process sync queue → POST to server → Server 201 Created Update local event: syncStatus = "synced", localId → serverId Pull updated data (case opened, athlete status changed) ``` ### What If Network Returns Mid-Entry? The entry was created entirely locally, then sync happens. The user never sees a failure state. If the network returns during entry creation, the event is still created locally + added to queue — the queue processor will send it immediately (likely before the user finishes the toast animation). --- ## 7. ServiceWorker Implementation ### File: `sw.js` ```javascript const CACHE_VERSION = 'v1'; const STATIC_CACHE = `rtsport-static-${CACHE_VERSION}`; const API_CACHE = `rtsport-api-${CACHE_VERSION}`; const STATIC_ASSETS = [ '/rtsport/', '/rtsport/coach', '/rtsport/parent', '/rtsport/at', '/rtsport/ad', '/rtsport/login', '/rtsport/static/css/rtsport.css', '/rtsport/static/css/dashboard-tabs.css', '/rtsport/static/css/parent-tabs.css', '/rtsport/static/css/login.css', '/rtsport/static/css/admin-tabs.css', '/rtsport/static/js/dashboard-tabs.js', '/rtsport/static/js/parent-tabs.js', '/rtsport/static/js/login.js', '/rtsport/static/js/admin-tabs.js', '/rtsport/static/js/mock-data.js', '/rtsport/static/data/body-parts.json', ]; // Install: pre-cache all static assets self.addEventListener('install', event => { event.waitUntil( caches.open(STATIC_CACHE).then(cache => cache.addAll(STATIC_ASSETS)) ); self.skipWaiting(); }); // Activate: clean old caches self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all(keys.filter(k => k !== STATIC_CACHE && k !== API_CACHE).map(k => caches.delete(k))) ) ); self.clients.claim(); }); // Fetch: static → Cache First, API → Network First self.addEventListener('fetch', event => { const url = new URL(event.request.url); // Static assets: Cache First if (url.pathname.startsWith('/rtsport/static/') || url.pathname === '/rtsport/' || url.pathname.match(/^\/rtsport\/(coach|parent|at|ad|login)$/)) { event.respondWith(cacheFirst(event.request)); return; } // API dashboard/roster endpoints: Network First if (url.pathname.startsWith('/api/v1/dashboard/') || url.pathname.startsWith('/api/v1/roster') || url.pathname.startsWith('/api/v1/body-parts')) { event.respondWith(networkFirst(event.request)); return; } // Everything else: Network Only (including mutations) return; }); async function cacheFirst(request) { const cached = await caches.match(request); return cached || fetch(request); } async function networkFirst(request) { try { const response = await fetch(request); const cache = await caches.open(API_CACHE); cache.put(request, response.clone()); return response; } catch (e) { const cached = await caches.match(request); return cached || new Response(null, { status: 503 }); } } ``` ### Registration ```html ``` --- ## 8. Offline UI/UX ### States to handle: | State | UI Treatment | |-------|-------------| | Online, synced | Normal. Subtle green dot or no indicator. | | Online, pending sync | Subtle "Syncing..." badge in header. Toast when done. | | Offline, cached data available | Banner: "You're offline — showing last known data". Yellow/amber banner at top. | | Offline, cached token expired | Login page with message: "No internet connection. Login required when you reconnect." | | Offline, no cache | "You need internet to load this page for the first time." | | Offline, entry pending | Toast after entry: "Saved offline. Will sync when connected." | | Sync failed (permanent) | Badge on sync icon with count. "2 events failed to sync — tap for details" | | Reconnected, syncing | "Syncing 3 events..." → progress bar → "All synced!" toast | ### UI Components 1. **Connection indicator** — Top bar or floating dot. Green = online, Amber = offline+working, Red = no data. 2. **Sync status badge** — Shows pending upload count. Tappable → opens sync detail. 3. **Pending event indicator** — On the event list, pending events show an outlined/ghosted state with a clock icon. 4. **Offline banner** — Full-width amber banner: "📡 You're offline. Entries will sync when connected." 5. **Force sync button** — In settings/connection menu. "Sync Now" with last synced timestamp. --- ## 9. Data Freshness & Staleness | Data Type | Staleness Tolerance | Refresh Strategy | |-----------|---------------------|------------------| | Athlete roster | 24 hours | Pull on login, pull on sync | | Body parts | Very high (months) | Cache on install, refresh monthly | | Dashboard stats | 1 hour | Pull on each dashboard load (network first) | | Event history | User-dependent | Load on demand, cache per-view | | Auth token | Per JWT expiry + grace period | Token refresh on 401 | **Staleness indicator:** If data is > 1 hour stale, show a small "Last updated: 2h ago" under the section header. --- ## 10. Estimated Implementation Effort | Component | Effort | Dependencies | |-----------|--------|-------------| | ServiceWorker setup + static caching | 4-6 hours | None | | IndexedDB schema + CRUD helpers | 8-12 hours | None | | Sync queue (+ processor + retry) | 8-12 hours | IndexedDB helpers | | Auth offline cache + grace period | 4-6 hours | IndexedDB helpers | | Sideline entry offline flow | 4-8 hours | IndexedDB + auth offline | | Offline UI (banners, badges, states) | 6-10 hours | All above | | Dashboard data caching | 4-6 hours | IndexedDB + SW | | Conflict resolution (MVP: LWW) | 2-4 hours | Sync queue | | Testing: offline scenarios | 8-12 hours | Everything above | | **Total** | **~48-76 hours** (2-3 weeks) | — | ### Prioritization for Beta | Must Have (Beta) | Nice to Have | Future | |-----------------|--------------|--------| | ServiceWorker static caching | Dashboard data cache | Encrypted token storage | | IndexedDB athletes + events | Offline banner UI | Biometric unlock | | Sync queue (FIFO, retry) | Force sync button | Conflict resolution UI | | Sideline entry offline | Auth grace period | Pull-to-refresh sync | | Auth cache (basic) | PWA manifest + install prompt | Background sync API | --- ## 11. Limitations (MVP) 1. **No offline login** — User must have logged in at least once online 2. **Last-write-wins only** — No intelligent conflict resolution 3. **No background sync** (Sync API) — Relies on `window.online` + periodic timer 4. **No encrypted storage** — Token stored in IndexedDB plaintext 5. **No offline roster management** — Only cached roster, no new athlete creation offline 6. **No file attachments** — Images/videos not supported offline 7. **Single device** — No cross-device sync awareness (trainer uses 1 device) --- ## 12. Appendix: Offline Flow Sequence Diagram ``` ┌─────────────────────────────┐ │ User enters sideline │ │ (No Internet) │ └─────────────┬───────────────┘ │ ▼ ┌─────────────────────────────┐ │ ServiceWorker intercepts │ │ HTML/CSS/JS → Cache Hit │ │ Serves from rtsport-static-v1│ └─────────────┬───────────────┘ │ ▼ ┌─────────────────────────────┐ │ checkAuth(): │ │ IndexedDB auth_cache → │ │ token exists, not expired │ │ Returns cached JWT │ └─────────────┬───────────────┘ │ ▼ ┌─────────────────────────────┐ │ loadSidelineEntry(): │ │ IndexedDB athletes store → │ │ Athlete list for search │ │ IndexedDB bodyParts store → │ │ Body part categories │ └─────────────┬───────────────┘ │ ▼ ┌─────────────────────────────┐ │ User completes 3-tap entry │ │ Tap → Tap → Tap → Submit │ └─────────────┬───────────────┘ │ ▼ ┌─────────────────────────────┐ │ 1. Generate local UUID │ │ 2. Save to IndexedDB events │ │ syncStatus: "pending" │ │ 3. Add to sync_queue │ │ 4. Show confirmation toast │ │ 5. Reset entry form │ │ 6. Return to dashboard │ └─────────────┬───────────────┘ │ Network Returns (30s later) │ ▼ ┌─────────────────────────────┐ │ window.online fires → │ │ processSyncQueue(): │ │ POST /api/v1/events/ │ │ sideline-entry │ │ Server: 201 Created │ │ Update local: syncStatus= │ │ "synced", serverId assigned │ │ Remove from sync_queue │ │ Pull fresh roster/cases │ │ Show "Synced!" toast │ └─────────────────────────────┘ ```