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" |
| 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
- Connection indicator — Top bar or floating dot. Green = online, Amber = offline+working, Red = no data.
- Sync status badge — Shows pending upload count. Tappable → opens sync detail.
- Pending event indicator — On the event list, pending events show an outlined/ghosted state with a clock icon.
- Offline banner — Full-width amber banner: "📡 You're offline. Entries will sync when connected."
- 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)
- No offline login — User must have logged in at least once online
- Last-write-wins only — No intelligent conflict resolution
- No background sync (Sync API) — Relies on
window.online+ periodic timer - No encrypted storage — Token stored in IndexedDB plaintext
- No offline roster management — Only cached roster, no new athlete creation offline
- No file attachments — Images/videos not supported offline
- 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 │
└─────────────────────────────┘