📄 TRACK2-OFFLINE-ARCH.md 24,678 bytes Today 03:55 📋 Raw

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

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

<!-- In each dashboard.html, add: -->
<script>
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/rtsport/sw.js')
    .then(() => console.log('RTSport SW registered'))
    .catch(e => console.error('RTSport SW failed:', e));
}
</script>

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         
                    └─────────────────────────────┘