- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Bulk import
- Playwright
- Prettier
- Block Rebound
- Transportation
Balqalam targets schools where 2G/3G fallback, intermittent WiFi, and full outages are the norm — Sudan pilots, rural transit routes, exam halls in basements, applicants on prepaid mobile data. This page is the platform-wide strategy for delivering a production-grade offline engine with a no-compromise foundation, then layering per-role features in phases on top of that solid base.
We do not ship half-measures. The foundation is built once and built right; per-role features layer on top in phases. For tenancy and sync constraints see Multi-tenancy and Database. For prior-art epic shape this doc follows see Document Intelligence. For dictionary-key conventions see Translation.
Why this matters
Today the platform handles network failure in scattered, feature-specific ways. The pain points compound during the moments that matter most.
| Moment | Today | Cost |
|---|---|---|
| Teacher marking attendance offline | Single-transaction POST; failure shows a toast and discards | Day's attendance lost; manual paper re-entry |
| Student submitting exam on flaky 3G | Auto-save survives; final submitExamSession does not | Late-submit penalty applied to students with bad signal |
| Driver in tunnel during route | In-app boarding writes go straight to server; fails silently | Parents see no boarding event; trust erodes |
| Applicant uploading transcript on 2G | S3 PUT, no chunking, no resume | Application abandoned at 90% upload |
| Teacher swipe-grading 50 papers on phone | One server action per swipe; any failure halts queue | Grading session restart; ~30 min lost |
| Anyone reading timetable offline | SW dynamic cache only if previously visited | Cold-cache page = blank screen + offline fallback |
The platform serves ~12 K students across 8 active schools today; the Sudan and KSA pilot targets push that to ~40 K within 12 months. Sudan's mobile carriers average 1.2 Mbps downlink with frequent dropouts; KSA boarding schools see exam-hall-WiFi saturation during high-stakes weeks. A solid offline engine is the unblock for both deployments.
What success looks like
These are the headline metrics the team commits to at "Foundation gate passed" + "all per-role epics shipped".
- Zero attendance loss on disconnect — 100% of teacher attendance batches recorded within 60 s of reconnect.
- Zero exam-submit loss — 100% of
submitExamSessioncalls eventually complete, even after browser restart. - Sub-1 s offline load for timetable + announcements after initial visit (instant render from Dexie live query).
- PWA install rate ≥ 35% on mobile for active teachers within 4 weeks of rollout.
- Sync success rate ≥ 99.9% across all queued mutations (rolling 7-day window).
- Storage usage ≤ 25 MB per active user on average (Safari iOS quota headroom).
- Bundle size delta — foundation chunk ≤ 35 KB gzipped; per-epic chunks lazy-loaded.
- Main thread budget — sync flush never blocks the main thread > 16 ms (work moves to Web Worker).
- First-byte cache invalidation — server WebSocket push invalidates client cache within 200 ms of remote write.
Shipped 2026-08-29/30 — the outbox, and a policy
The first slice of this plan is live, scoped to Lumos lessons: an outbox
(src/lib/offline/outbox.ts, IndexedDB) queues playback position, completion,
quiz answers and assignment text for POST /api/offline/sync, which is
idempotent per item and answers a verdict per item; rejections park visibly on
/offline with retry/discard — so the "pending sync visibility" row above is
✓ for lessons and still ✗ for the other actions. A download-for-offline slice
(video + materials on the device) shipped on 08-29 and was withdrawn on
08-30 by school policy: videos and materials are viewed in the app, never
copied to a device, and IndexedDB v2 drops any media a device had stored. The
service worker (public/service-worker.js, v2) now actually installs.
Shipped 2026-09-12 — installable, and attendance in the outbox
The PWA layer was repaired after a live audit of demo and kingfahd: the manifest is now per-tenant (school name, Arabic RTL by default, the school's brand colour), the missing icons exist, and service worker v3 precaches /ar/offline and /en/offline instead of two redirects — so the offline fallback works for the first time — and no longer caches authenticated pages or API bodies. Quick attendance is the first non-lesson outbox kind: a mark made offline is saved on the phone and replayed through the same ownership check and markAttendance path when the connection returns; a newer mark already on the server wins. The "Teacher marking attendance offline" row above is now ✓. Verify any host with kun's pwa-audit script.
Two more pieces landed the same day: Web Push (a browser or the installed app subscribes from the notification preferences page; absence, exam and live-class notices arrive as system notifications and a tap opens the school's own route) and an install card on phone-sized dashboards that offers "Add to Home Screen" (Android shows the real install prompt; iPhone gets the Share → Add to Home Screen hint).
How to experience it (any school, today)
- Install. On Android Chrome open
https://<school>.balqalam.com, sign in, and a welcome sheet slides up offering the app — tap Continue: Android opens the install sheet; iPhone opens the share sheet, where you pick Add to Home Screen (a two-step guide appears underneath). The icon carries the school's name in Arabic, opens right-to-left in the school's colour, and has no browser chrome. - Mark attendance offline. Open Attendance → Quick for a section, put the phone in airplane mode, mark the absentees and save. The panel says the marks were saved on the device and the sync banner shows one pending item. Turn the network back on: the banner drains within seconds and the marks appear on the server; save the same section again and the server keeps whichever mark is newer. Guardians get the absence notice when the replay lands, not before.
- Turn on push. Notifications → Preferences → Push asks the browser's permission on the tap and subscribes this device. From then on an absence, exam or live-class notice also arrives as a system notification; tapping it opens that page in the installed app. On iPhone the toggle only works from the installed app (Safari cannot receive Web Push).
- Lose the connection anywhere. In airplane mode, opening any app route shows the school's offline page in the right language — with the outbox view listing what is queued — instead of the browser's error screen.
What is verified by tooling: the install audit passes on demo and kingfahd (0 FAIL), the worker controls the page after one reload, the outbox replay returns applied then duplicate, and the push cron reports the channel configured. What only a phone can verify: that a push actually shows on a device and that the offline page appears under a real disconnect — both tracked in hogwarts#408.
Shipped 2026-09-13/14 — explorable offline, and fast on thin networks
A live measurement of kingfahd on 4G explained the slow menu. Every dashboard open shipped 1.38 MB of HTML, 935 KB of it the dictionary, plus 63 script chunks, 1 MB of CSS from marketing-site clones, 36 prefetch requests and two 1.1 MB Server Action responses. The proxy wrote the locale cookie on every response, and Next answers any Server Action whose request carries a proxy-set cookie with a full re-render of the page. No static file was cached at Cloudflare's edge either: each chunk round-tripped to the single container, about 0.5 s apiece from Europe.
What changed for speed:
- The locale cookie is written only when it changes, so a Server Action answers with its own data again.
- The Worker serves hashed chunks, fonts, icons and optimised images from Cloudflare's edge. It asks the container for uncompressed bytes and lets the edge compress for each visitor, keys optimised images by the format the browser accepts, and stamps every static response
x-edge-cache: hit | miss | bypass. - The clone stylesheets load only on the pages that use them.
- Every sidebar route has a loading boundary, so its link is prefetched and a tap draws the skeleton at once. Pages visited in the last 30 s come back from memory.
- The header's messages link prefetches on hover, touch or focus, not on sight: its route group carried a 684 KB dictionary.
- The notification bell backs off for 15 s after a failed request. With the server unreachable it had been sending bursts of about 120 requests.
How offline works now (service worker v7):
- Saved per person. Every screen a signed-in user opens is kept in a cache named by a per-user session key the server sends with each response. A response carrying a different key, or none, empties the previous person's pages. The sign-out button, and the sign-in and join pages as they open, tell the worker to forget everything first: on a slow network a saved copy can appear before any response reveals who is signed in.
- Saved as whole pages. A client-side navigation leaves no HTML behind, so each dashboard page opened on a good connection (4G-class, data saver off) is also saved as a full page, once a day. The installed app also saves the whole sidebar once a day. A navigation's payload is saved too, but it only answers the exact navigation it came from.
- Network first. A page with a saved copy shows it when the network fails, or has not answered within four seconds, and the request keeps running to refresh the copy. It does so silently: no strip, no toast, no "offline", "slow" or "back online" notice (#416). When the connection comes back, a page showing its saved copy refreshes itself, and queued work drains on its own. The only strip left is work the server rejected — "N items need attention", linking to
/offline. - Unopened pages say so. A page with no saved copy shows "This page isn't on this device yet" with Back and Retry; an installed iPhone app has no back button of its own. The offline page and the files it needs live in their own cache, refreshed daily, so the fallback survives deploys.
- Lessons open offline; their videos do not. A saved lesson page holds text, quiz questions (never the answers) and a reference to the video — never the video, a recording or a material. Those stay behind
/api/lumos/*, which the worker never caches, and the video address refuses anything but the in-app player. This is the 2026-08-30 school policy (videos and materials are watched in the app, never copied to a device), and it is why a PRIVATE video cannot outlive its owner's choice on someone's phone. - The installed app cannot block screenshots. An installed PWA is still the browser: no web API stops or detects a screen capture. Every video, recording and the live room carry the viewer's code in a 1% watermark that survives a capture, plus a visible mark on capture signals — see Lumos → protection. Real blocking needs a native shell (hogwarts#411).
Corrected on 2026-09-14. The first version of this section passed a test that replayed navigations the same tab had just made. With the server actually stopped, three things failed, and a fourth gap showed on a slow network:
| Found | Cause | Fix |
|---|---|---|
| A sidebar click did nothing offline | Next's experimental useOffline was on; its scheduler sends no requests while offline, the likely cause | The flag is off, so every click reaches the worker |
| The dashboard stayed on screen under the Teachers address | A payload saved on students → teachers was replayed on dashboard → teachers | Saved payloads are keyed with Next's _rsc, which names the router tree they patch |
| Pages opened by a click had no offline copy | An offline click never repeats an online _rsc: a prefetched route sends a partial tree, an offline click the whole one | Each opened page is saved as HTML |
| On a slow network the next person could see the previous one's saved page | The worker learned about a new session only from a response | Sign-out and the sign-in and join pages clear saved pages first |
Measured on a local production build — offline checks with the server stopped, slow checks behind a proxy that holds every response for 5 s:
| Check | Result |
|---|---|
| Dashboard open: each of two Server Action responses | 1.1 MB before, about 1 KB after |
| Dashboard CSS | 1,078 KB before, 589 KB after |
| Tap Students → skeleton on screen | 58 ms |
| Offline: dashboard → a page opened before | Saved page in 283 ms (strip removed since, #416) |
| Offline: a page never opened | "This page isn't on this device yet"; Back returns |
| Slow: reload a saved page | Saved copy at 4.0 s (strip removed since, #416) |
| Slow: sign out | Saved pages gone before the sign-out request returned |
| Slow: the next account's first dashboard | Waited 5.1 s for its own page |
| One saved dashboard page on the device | 1,367 KB |
How to experience it
- Open the app on a good connection, sign in, and open a few sidebar pages.
- Airplane mode. Tap through the pages you opened: they render as last seen under "You're offline. Pages you've opened before still work…". A page you never opened shows "This page isn't on this device yet", and Back returns to where you were. Anything you mark is queued as before.
- On a weak signal rather than none, a saved page that takes longer than four seconds shows its saved copy, silently; it refreshes itself when the connection returns.
- Sign out and sign in as another account on the same phone: nothing the first account saved appears, even before the network answers.
To test from a computer, stop the server, or use a phone in airplane mode. The browser developer tools' Offline setting does not reach the service worker's own requests, so "offline" pages quietly load from the server.
Limits
- Storage. A saved dashboard page is about 1.4 MB, mostly the dictionary. The cap of 80 saved pages per person allows about 110 MB, far past the 25 MB average in What success looks like. Moving the dictionary out of the HTML fixes size and speed together.
- Server load. Saving opened pages costs one extra server render per page, per person, per day.
- Session expiry lands on the sign-in page, which also clears that same person's saved pages.
- Script eviction. Past 400 files the worker's static cache drops its oldest entries first, which can include scripts every page uses; a saved page whose scripts were dropped shows without them offline.
- Delete account signs out without clearing saved pages first; the session-key check clears them as soon as the network answers.
Files
| File | Role |
|---|---|
public/service-worker.js | v7: saved pages per session key, offline shell, offline/slow reason, outbox wake, Web Push |
src/components/offline/warmup.tsx | Asks the worker to save the opened page and, from the installed app, the whole sidebar |
src/components/offline/forget-saved-pages.tsx | Clears saved pages at sign-out, sign-in and join |
src/components/offline/sync-banner.tsx | Outbox drain triggers, silent refresh on reconnect, the "needs attention" strip |
src/components/offline/content.tsx | The offline page, and the "not on this device yet" card |
src/proxy.ts | Sends x-session-key; writes the locale cookie only when it changes |
cf/worker.js | Edge cache for static files |
Engineering principles (no compromise)
These are non-negotiable. Each principle drives concrete choices in the architecture.
- Sync never blocks the main thread. All replay, encryption, compression, and conflict checking run in a dedicated Web Worker (
sync-worker.ts). The main thread is reserved for UI. - Large blobs go to OPFS, not IDB. Attachments, exam papers, recorded audio, photos all use the Origin Private File System. IDB stores only structured payloads and chunk metadata.
- Every mutation is tamper-evident. Every envelope carries a SHA-256 content hash. The server verifies on intake; the client verifies on replay.
- Real-time cache invalidation. WebSocket pushes invalidation events from server to all connected clients. No silent staleness; never wait for
refetchOnReconnect. - Code-split per epic. Foundation chunk ≤ 35 KB. Each per-role epic ships its own lazy-loaded chunk. New schools download only the foundation; per-feature code loads on first use.
- Telemetry from day 1. Every state transition (enqueue, flush, conflict, error, retry) emits a structured event to Sentry and to
OfflineSyncLog. No retrofitting. - Feature flag gates new features, never the foundation.
School.offlineModetoggles features; the foundation library is always loaded for installed PWAs. Foundation cannot be partially adopted. - Performance budgets are CI gates. size-limit, Lighthouse CI, custom Sentry budgets. CI fails on regression; we never "fix it in a follow-up".
- Conflict resolution is always explicit to the user. Silent overwrites are forbidden. Every server-rejected mutation surfaces a structured conflict UI with the data on both sides + a resolution choice.
- The foundation library is versioned. Schema migrations between versions are tested. We never break older PWAs in the wild without a documented upgrade path.
- Conditions of use are observable.
useNetworkStatus,useStorageEstimate,usePendingSyncCountare public hooks. Any feature surface can show users what's happening. - Multi-tenant safety is architectural, not procedural. IDB DB name namespaced by
schoolId; SW reads tenant from session cookie at fetch time, never from a global; cross-tenant leak is impossible by construction.
Foundation architecture decisions
Each compromise we considered, and why we rejected it.
| Decision | Half-measure | No-compromise choice |
|---|---|---|
| IDB wrapper | Raw indexedDB (~0 KB) | dexie@^4 (~21 KB) with dexie-react-hooks for live queries, schema versioning, transactions |
| Sync engine location | Main thread | sync-worker.ts Web Worker — replay, encryption, compression, integrity checks all off main |
| Blob storage | IDB blob field | OPFS for any payload > 100 KB; IDB stores only the OPFS file handle reference |
| Cache invalidation | refetchOnReconnect | WebSocket cache-invalidate events from server; sub-200 ms invalidation |
| Mutation integrity | None | SHA-256 contentHash on every envelope; verified on intake and replay |
| Payload compression | None | MessagePack + LZ4 (via @msgpack/msgpack + WASM lz4-wasm) for payloads > 1 KB |
| Sync intake topology | Single endpoint | Per-domain shards: /api/offline/sync/{attendance,exam,transport,messaging,application} parallel |
| Bundle strategy | Single foundation chunk | Foundation (≤ 35 KB) + per-epic lazy chunks via dynamic() boundaries |
| State machine | enum status | XState-style finite state machine: idle → pending → in_flight → synced ∣ conflicted ∣ failed |
| Conflict resolution | Server LWW | Server-authoritative + structured merge UI (<ConflictResolver>) for resolvable conflicts |
| Service worker role | Cache + sync stub | Message bus between tab and sync worker; runs replay when no tab is open (background sync) |
| Real-time delivery | None / SSE polling | Reuse existing socket.io-client@4.8.1 for low-latency invalidation + push notifications |
| Tenant safety | Session cookie at API layer | + IDB DB namespaced by schoolId; SW reads tenant per-request from session cookie |
| Telemetry | Bolted on later | OfflineSyncLog + Sentry breadcrumbs + <OfflineDevtools> panel shipped with EPIC-0 |
Scope
10 epics, ~42 stories, 231 story points. EPIC-0 is the foundation that every other epic depends on and must pass the Foundation Gate (see below) before any per-role work starts.
| Epic | Goal | Points |
|---|---|---|
| EPIC-0 — Foundation | Dexie storage, OPFS blobs, Web Worker sync engine, SW message bus, WebSocket invalidation, content hashes, telemetry, devtools | 62 |
| EPIC-1 — PWA installability | App-shell pre-cache, install prompt, update flow, install-promo UI | 15 |
| EPIC-2 — Smart read cache | Timetable, announcements, profile, roster via Dexie live queries; WebSocket cache invalidation | 24 |
| EPIC-3 — Teacher offline | Bulk attendance, QR scanning, mobile swipe-grading queue, structured conflict UI | 32 |
| EPIC-4 — Student offline | Exam submit queue, proctor log persistence, paper pre-cache, lockdown integration | 22 |
| EPIC-5 — Driver offline | Transportation trip boarding mirroring geofence pattern; route + roster OPFS pre-load | 15 |
| EPIC-6 — Applicant offline | Resumable attachment upload via OPFS, Dexie-backed application draft, queued submit | 18 |
| EPIC-7 — Messaging outbound queue | Optimistic send, attachment queue, WhatsApp bridge offline policy, real-time receipt sync | 16 |
| EPIC-8 — Observability | OfflineSyncLog analytics, Sentry breadcrumbs, DEVELOPER dashboard tile, per-school SLI dashboard | 14 |
| EPIC-9 — Docs, i18n, admin config | This doc + AR mirror, ~30 dictionary keys, School.offlineMode flag, Playwright E2E suite | 13 |
Architecture context
The plan extends existing resilience scaffolding rather than replacing it. Two gold-standard implementations already live in the codebase — the exam auto-save hook and the geofence GPS tracker — and the foundation epic generalizes both into one reusable, production-grade library.
| Component | Location | Current state |
|---|---|---|
| Service worker | public/service-worker.js | v7: static assets cache-first; signed-in pages saved per session key; offline shell refreshed daily; /api/ network-only; wakes a tab to drain the outbox |
| SW registration | src/components/providers/service-worker-provider.tsx | Production only (NEXT_PUBLIC_SW_DEV=1 for a local next start); registers at once when the page has already loaded |
| Offline fallback page | src/components/offline/content.tsx | Outbox view; "not on this device yet" card with Back and Retry when served in place of an unsaved page |
| Manifest | src/app/manifest.ts | Installable; shortcuts for /attendance + /attendance/qr-code |
useOfflineQueue<T> | src/components/school-dashboard/attendance/shared/hooks.ts:541 | In-memory only; lost on reload — to be retired |
LocationQueue (IDB) | src/components/school-dashboard/attendance/geofencee/geo-tracker.tsx:59 | Full IndexedDB queue, batch flush — generalized into shared primitive |
| Exam auto-save | src/components/school-dashboard/exams/take/hooks/use-auto-save.ts | Debounce + localStorage + exp-backoff — generalized into shared primitive |
| Application draft | src/components/school-marketing/application/application-context.tsx:412 | Server session + per-campaign + per-user localStorage — migrated to Dexie |
| Onboarding draft | src/components/onboarding/util.ts:291 | DRAFT_STORAGE_KEY = "onboarding_draft_" — migrated to Dexie |
| Network strategies | src/lib/performance-optimization.ts | slow-2g/2g/3g/4g/saveData/rtt — wired into useNetworkStatus |
| LRU server cache | src/lib/cache/exam-cache.ts | TTL cache for grade boundaries, school branding |
| Circuit breaker | src/lib/circuit-breaker.ts | 5 failures → 30 s cooldown for DB |
@tanstack/react-query | package.json | Installed 5.90.12 — wired up in EPIC-0 |
socket.io-client | package.json | Installed 4.8.1 — used for WebSocket cache invalidation in EPIC-0 |
| Mobile API | src/app/api/mobile/** | 30+ JWT-auth endpoints (snake_case JSON) |
| Geofence boarding hook | src/app/api/transportation/geofence-boarding/route.ts | Bearer-token webhook; idempotent; ack-and-ignore for no-match codes |
Schema facts
- Every business model has
schoolIdwith@@index([schoolId]). Every offline write MUST preserve this. KioskLog.syncedToAttendancealready exists atprisma/models/attendance.prisma:404— currently set synchronously; will be reused as the deferred-sync flag.- New models required:
OfflineMutation(durable server-side queue),OfflineSyncLog(idempotency cache, 30-day TTL),CacheInvalidation(broadcast topic log, 7-day TTL). - New
School.offlineModeenum field:disabled/limited/full. Defaultlimitedfor new schools,fullfor pilot rollouts. - Translation strings live in
src/components/internationalization/{school-en,school-ar}.jsonunderdictionary.offline.*.
Patterns
Every epic follows the same conventions.
- Dexie tenant DB. One Dexie database per origin named
hogwarts-${schoolId}with versioned schema. Live queries viauseLiveQuery. Migrations tested in CI. - OPFS blobs. Files > 100 KB go to OPFS. IDB stores
{ opfsPath, size, sha256, mimeType }only. - Sync worker.
sync-worker.tsruns Dexie queries, payload compression, content-hash verification, replay HTTP. Main thread posts messages to it; worker streams progress back. - Discriminated mutation envelope.
{ id, schoolId, userId, action, payload, idempotencyKey, contentHash, attempts, lastError, state, createdAt }whereactionis a string literal resolved viaactionRegistryat flush time. - Idempotency keys. cuid v2 generated client-side; persisted in payload; server checks
OfflineSyncLogbefore re-applying. - Per-domain sync shards.
/api/offline/sync/{attendance|exam|transport|messaging|application}for parallel processing. Each endpoint dispatches to its domain's action handlers. - WebSocket invalidation. Server broadcasts
cache-invalidatemessages on writes. Client matches against active Dexie tables and triggers live-query refresh. - Thin react hooks. Feature code uses
useOfflineMutation/useLiveQuery/useNetworkStatus/usePendingSyncCount/useStorageEstimate. Never importsdexieor@tanstack/*directly. - i18n-first. Every offline string lives in
dictionary.offline.*. No hardcoded English in JSX, toasts, or error returns. - Code-split per epic. Per-feature offline code lives in lazy chunks.
import("./feature/offline.ts")boundaries.
Current state vs. target state
| Capability | Today | After foundation + role epics |
|---|---|---|
| Bulk attendance offline | ✗ lost | ✓ queued in Dexie, replayed by sync worker, content-hash verified |
| Exam final submit offline | ✗ user sees error toast | ✓ "submission queued" full-screen, completes on reconnect within 30 s |
| Mobile swipe grading | ✗ per-swipe RPC | ✓ 25-swipe batch, one mutation, optimistic UI with rollback |
| Driver in-app boarding offline | ✗ silent failure | ✓ Dexie queue + manual flush badge + offline geofence cache |
| Applicant attachment on 2G | ✗ restart from byte 0 | ✓ tus-style resumable via OPFS, completed parts persisted |
| Outbound messaging offline | ✗ optimistic UI but no queue | ✓ queued/sending/sent/failed bubble states + real-time delivery receipts |
| Read timetable offline | △ network-first cache, blank on cold cache | ✓ Dexie live query, instant render, WebSocket invalidation |
| PWA install | △ installable manifest, no prompt UI | ✓ install banner on 3rd visit + manual install in settings |
| SW update flow | △ silent SW updates | ✓ toast prompts reload when new SW waiting; staged rollout |
| Pending sync visibility | ✗ none | ✓ drawer in header showing queued mutations + retry/discard + conflict UI |
| Tenant safety | △ session cookie + middleware | ✓ + Dexie namespaced; SW reads tenant from session cookie per request |
| Telemetry | ✗ none on offline events | ✓ OfflineSyncLog + Sentry breadcrumbs + DEVELOPER dashboard + devtools |
| Main-thread budget | △ sync runs on main thread | ✓ Web Worker; main thread never blocked > 16 ms during sync |
| Cache invalidation | △ refetchOnReconnect | ✓ WebSocket push; sub-200 ms invalidation |
| Conflict resolution | ✗ silent overwrite or toast | ✓ structured <ConflictResolver> UI with both sides + resolution choice |
| Code splitting | △ single bundle | ✓ foundation < 35 KB; per-epic chunks lazy-loaded |
Conflict resolution policy per action
The default is server-authoritative with structured merge UI. Per-action overrides below.
| Action | Strategy | UI on conflict |
|---|---|---|
attendance.bulkUpload | last-write-wins by markedAt | Toast "Your save was older than {colleague}'s edit. Newer values kept." with diff link |
attendance.qrScan | first-write-wins by scannedAt | Toast "Student already marked present at {time}." |
exam.submit | reject on paperVersion mismatch | Full-screen <PaperVersionConflict> with re-fetch option |
exam.autoSave | last-write-wins by savedAt | Silent (drafts always accept newer) |
exam.proctorEvent | append-only | Silent (audit log; dedup server-side) |
grading.batchApply | reject on gradeVersion mismatch | <GradingConflict> panel showing your changes vs current state, per-row keep/discard |
transportation.boardingEvent | server-authoritative idempotent | Silent (already (schoolId, tripId, studentId) upsert) |
application.submit | first-write-wins by submittedAt | Toast "Application already submitted on {date}." |
application.draftSave | last-write-wins by savedAt | Silent |
messaging.send | append-only | Silent (idempotency on clientMessageId) |
messaging.markRead | last-write-wins (max read state) | Silent (monotonic increase) |
notification.acknowledge | append-only idempotent | Silent |
Browser compatibility matrix
The strategy targets the browsers schools actually use. Capability detection at runtime in useNetworkStatus and useOPFSSupport.
| Browser / OS | Dexie | OPFS | Web Worker | SW | Background Sync | beforeinstallprompt | Network Info | Notes |
|---|---|---|---|---|---|---|---|---|
| Chrome Android 110+ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Full support; primary target |
| Safari iOS 16+ | ✓ | ✓ (16.4+) | ✓ | ✓ | ✗ | ✗ (Add to Home) | ✗ | OPFS in 16.4; SW restricted; fall back to online |
| Samsung Internet 19+ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Full support |
| Chrome Desktop 110+ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Full support |
| Safari macOS 16+ | ✓ | ✓ (16.4+) | ✓ | ✓ | ✗ | ✗ | ✗ | Same as iOS Safari |
| Firefox 110+ | ✓ | ✓ (111+) | ✓ | ✓ | ✗ | ✗ | partial | OPFS in 111; treat as Safari for sync |
| Edge 110+ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Chromium-based, full support |
| In-app browsers (WhatsApp) | ✓ | ✗ | ✓ | △ | ✗ | ✗ | ✗ | OPFS unavailable; fall back to IDB blob storage |
Where OPFS is missing, the storage layer falls back to IDB blob fields with a quota warning. Where Background Sync is missing, sync runs on online event + visibility change while the tab is open; on PWA reopen the worker drains immediately.
Storage budget per role
navigator.storage.estimate() watchdog warns at 80% utilization. OPFS is included in the same origin quota; we partition deliberately.
| Role | IDB (Dexie) | OPFS | Total typical | Worst-case | Eviction policy |
|---|---|---|---|---|---|
| Teacher | 3 MB | 2 MB | 5 MB | 15 MB | Roster LRU 7d; attendance queue never evicted unsent |
| Student | 2 MB | 3 MB | 5 MB | 12 MB | Exam paper PDF evicted on session end; auto-saves never evicted unsent |
| Driver | 4 MB | 1 MB | 5 MB | 18 MB | Trip roster evicted on trip end; boarding queue never evicted unsent |
| Applicant | 2 MB | 8 MB | 10 MB | 25 MB | Attachment chunks in OPFS until upload complete; drafts kept until submit |
| Guardian | 1 MB | 0 MB | 1 MB | 4 MB | Read-only; cached announcements LRU 14d |
| Admin | 3 MB | 1 MB | 4 MB | 12 MB | Same as Teacher |
Performance budgets
These are the budgets CI enforces.
| Metric | Budget | Measured how |
|---|---|---|
| Foundation chunk gzipped | ≤ 35 KB | size-limit CI |
| Each per-epic chunk gzipped | ≤ 15 KB | size-limit CI |
| Time to interactive offline (cold IDB) | ≤ 1.5 s on 3G | Lighthouse CI |
| Dexie write p95 | ≤ 50 ms | Sentry transaction |
| Dexie read p95 | ≤ 20 ms | Sentry transaction |
| OPFS write p95 (per 1 MB chunk) | ≤ 200 ms | Sentry transaction |
| Sync worker flush per mutation | ≤ 300 ms | OfflineSyncLog.replayedAt - createdAt |
| WebSocket invalidation latency | ≤ 200 ms | Server timestamp → client live-query update |
| SW activation time | ≤ 800 ms | navigator.serviceWorker.ready timing |
| Reconnect-to-drained-queue (10 items) | ≤ 3 s | Manual Playwright test + Sentry |
| Cache hit ratio for timetable | ≥ 95% | Custom telemetry |
| Main-thread block during sync (p99) | ≤ 16 ms | Performance API long-task observer |
Pre-foundation phase (Week 0): Spikes & Research
Ten spike tasks ship before EPIC-0 starts. Each is timeboxed to 0.5-1 day and produces a Decision Record. All must merge before EPIC-0 starts — these answer questions the foundation depends on.
| Spike | Goal | Outcome | Owner |
|---|---|---|---|
| S-0.1 | Benchmark Dexie vs idb for 1000-write batches, live-query latency, schema migrations | Dexie chosen; benchmark numbers in DR | Dev A |
| S-0.2 | OPFS quotas + write perf on Safari iOS 16.4, Chrome Android 110, Firefox 111 | Capability + fallback matrix | Dev B |
| S-0.3 | Web Worker + Comlink RPC prototype for sync engine | Working POC; main-thread budget validated | Dev A |
| S-0.4 | LZ4-wasm vs Snappy benchmark on typical payloads (attendance batch, exam answers) | Compression chosen; compression ratio numbers | Dev B |
| S-0.5 | SHA-256 perf via SubtleCrypto vs WASM on 1 MB payloads | Hashing chosen; per-byte cost | Dev A |
| S-0.6 | WebSocket cache-invalidate topology — single channel vs per-school rooms | Topology + scaling plan | Dev B |
| S-0.7 | Service-worker → sync-worker message bus pattern | Architecture diagram + reference code | Dev A |
| S-0.8 | XState vs hand-rolled state machine for mutation lifecycle | Decision + bundle-cost analysis | Dev B |
| S-0.9 | Background Sync iOS PWA behavior — when does it actually fire | iOS fallback plan documented | Dev A |
| S-0.10 | Baseline current connection patterns from Sentry / Vercel Analytics | "X% of sessions see ≥1 network failure" baseline | Dev B |
Stories per epic
EPIC-0 — Foundation: Production-Grade Offline Engine (62 pts)
The foundation is built once, built right, and ships before any per-role work starts. Five sub-epics, all merged before the Foundation Gate (see below).
0.A — Storage layer (18 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 0.A.1 | Add Dexie + dexie-react-hooks; tenant DB factory hogwarts-${schoolId} | 5 | INFRA |
| 0.A.2 | Schema versioning + migration framework + CI migration tests | 5 | INFRA |
| 0.A.3 | OPFS abstraction (putBlob/getBlob/deleteBlob) with IDB fallback | 5 | INFRA |
| 0.A.4 | OfflineMutation + OfflineSyncLog + CacheInvalidation Prisma models | 3 | DB |
0.B — Sync worker (18 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 0.B.1 | sync-worker.ts Web Worker scaffold + Comlink RPC | 5 | INFRA |
| 0.B.2 | actionRegistry discriminated-union envelope types + Zod schemas | 3 | API |
| 0.B.3 | XState mutation lifecycle state machine (idle → pending → in_flight → ...) | 5 | API |
| 0.B.4 | LZ4 + MessagePack compression pipeline | 3 | INFRA |
| 0.B.5 | SHA-256 content hashing + replay verification | 2 | API |
0.C — Network + conflict primitives (12 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 0.C.1 | useNetworkStatus canonical hook (navigator.onLine + Network Info + heartbeat) | 3 | UI |
| 0.C.2 | useMutationQueue<T> / useOfflineMutation / useLiveQuery thin react hooks | 5 | UI |
| 0.C.3 | <ConflictResolver> atom + structured merge UI primitive | 4 | UI |
0.D — Service worker as coordinator (8 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 0.D.1 | Replace stub syncOfflineForms() at public/service-worker.js:160 | 3 | INFRA |
| 0.D.2 | Per-domain sync shard endpoints /api/offline/sync/{domain} | 3 | API |
| 0.D.3 | WebSocket cache-invalidate server broadcast + client receiver via socket.io | 2 | API |
0.E — Telemetry + devtools (6 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 0.E.1 | Sentry breadcrumbs + OfflineSyncLog instrumentation on every transition | 3 | INFRA |
| 0.E.2 | <OfflineDevtools> floating panel (DEVELOPER-only) showing queue + caches | 3 | UI |
The foundation epic ships the production-grade offline engine that every per-role epic consumes. New files land under src/lib/offline/{db,opfs,registry,types,state-machine,compression,integrity}.ts, the sync-worker.ts worker, and src/components/offline/{hooks,provider,devtools}/. Blocks every other epic until Foundation Gate is passed.
EPIC-1 — PWA Installability + App-Shell Pre-Cache (15 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 1.1 | App-shell pre-cache list + build-time version stamping | 5 | INFRA |
| 1.2 | <InstallPromptBanner> atom + beforeinstallprompt capture | 3 | UI |
| 1.3 | SW update flow + <UpdateAvailableToast> + staged rollout | 3 | UI |
| 1.4 | Manifest shortcuts for /exams, /messages, /timetable | 1 | INFRA |
| 1.5 | Offline page polish (i18n + pending-mutation count + storage UI) | 3 | UI |
Promotes the existing installable manifest into a true PWA. Cache name uses __BUILD_ID__ to invalidate on deploy. Install banner shown on third dashboard visit, dismissal persisted to Dexie. The update flow listens for controllerchange and prompts the user to reload when a new SW is waiting. Staged rollout enables SW updates to 10% → 50% → 100% of devices over 48 h.
EPIC-2 — Smart Read Cache (24 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 2.1 | Dexie tables for cached reads + live-query bindings | 5 | API |
| 2.2 | Timetable cached read (getTimetableForOffline) with WebSocket invalidation | 5 | API |
| 2.3 | Announcements cached read + read-status sync on reconnect | 3 | API |
| 2.4 | Profile + class roster cached reads | 3 | API |
| 2.5 | <CachedDataBanner> atom (shown when cache age > 5 min and offline) | 3 | UI |
| 2.6 | Cache eviction on tenant switch | 2 | INFRA |
| 2.7 | Storage-quota watchdog + LRU eviction policy enforcement | 3 | INFRA |
Read-mostly surfaces (timetable, announcements, profile, roster, dashboard) render instantly offline from Dexie live queries. Cache keys follow [schoolId, resource, id]. Network-aware staleTime derives from performance-optimization.ts thresholds — 5min on 4G, 30min on slow-2g. WebSocket pushes invalidate cached entries within 200 ms of remote writes.
EPIC-3 — Teacher Offline (Attendance + QR + Grading) (32 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 3.1 | Register bulkUploadAttendance as "attendance.bulkUpload" action handler | 5 | API |
| 3.2 | Attendance roster pre-cache (Dexie + OPFS for student photos) on class open | 5 | API |
| 3.3 | QR scan queue (reuses KioskLog.syncedToAttendance) | 5 | UI |
| 3.4 | Mobile swipe-grading batch (group 25 swipes into one mutation) | 5 | UI |
| 3.5 | <PendingMutationsDrawer> with retry/discard controls + grouped by action | 5 | UI |
| 3.6 | Structured conflict UI for last-write-wins (409 ATTENDANCE_NEWER_RECORD) using <ConflictResolver> | 5 | UI |
| 3.7 | Optimistic UI in attendance table via useOptimistic (React 19) with rollback | 2 | UI |
Wraps bulkUploadAttendance at src/components/school-dashboard/attendance/actions/bulk.ts:33 as a registered action. Roster pre-cache populates Dexie + OPFS (student photos) when a teacher opens a class. QR scan queue replaces single-try toast at qr-scanner.tsx:209. Mobile swipe-grading batches 25 swipes into one mutation.
EPIC-4 — Student Offline (Exam Submit + Proctor Log) (22 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 4.1 | Register submitExamSession as "exam.submit" action handler | 5 | API |
| 4.2 | Migrate use-auto-save to foundation primitives (drop bespoke retry) | 3 | API |
| 4.3 | Persist proctor securityFlags events offline | 3 | API |
| 4.4 | "Submission queued" full-screen on disconnect during submit | 5 | UI |
| 4.5 | Exam-take pre-cache (questions + options + assets) in OPFS | 3 | INFRA |
| 4.6 | paperVersion conflict resolver (full-screen <PaperVersionConflict>) | 3 | UI |
Closes the critical gap: today auto-save survives connectivity loss but the final submitExamSession at src/components/school-dashboard/exams/take/actions.ts:369 does not. Idempotency key = sessionId. Server rejects submit on paperVersion mismatch with structured conflict UI. Auto-save migration drops ~120 lines of bespoke retry/backoff.
EPIC-5 — Driver Offline (Trip Boarding) (15 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 5.1 | Register recordBoardingFromGeofenceInternal as action handler | 3 | API |
| 5.2 | Driver trip-mode pre-cache (route + roster + photos in OPFS) | 5 | API |
| 5.3 | <TripQueueBadge> with manual flush + retry UI | 4 | UI |
| 5.4 | Retire bespoke LocationQueue; migrate to foundation primitives | 3 | INFRA |
Mirrors the gold-standard geofence pattern into the in-app driver experience. recordBoardingFromGeofenceInternal is already idempotent on (schoolId, tripId, studentId). Pre-cache populates Dexie + OPFS at trip start. Story 5.4 retires the bespoke LocationQueue class at geo-tracker.tsx:59.
EPIC-6 — Applicant Offline (Resumable Attachments + Submit) (18 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 6.1 | tus-style resumable S3 multipart upload backed by OPFS chunk store | 8 | API |
| 6.2 | Application draft migrate from localStorage → Dexie (with BroadcastChannel cross-tab) | 5 | API |
| 6.3 | Queued final submit ("application.submit") | 3 | API |
| 6.4 | Network-aware upload progress UI (shows estimated time per effectiveType) | 2 | UI |
Applicants on flaky 3G can resume a 5 MB transcript upload after dropping mid-transfer — chunks live in OPFS. New /api/uploads/resumable/[uploadId]/[partNumber] endpoint tracks completed parts. Application context at application-context.tsx:412 upgrades from localStorage to Dexie for cross-tab consistency.
EPIC-7 — Messaging Outbound Queue (16 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 7.1 | "messaging.send" registered action with idempotency | 3 | API |
| 7.2 | Optimistic UI: chat bubble shows queued/sending/sent/failed via useOptimistic | 5 | UI |
| 7.3 | Attachment queue reusing EPIC-6 resumable OPFS upload | 3 | API |
| 7.4 | WhatsApp bridge offline policy (skip while offline; idempotent send on reconnect with clientMessageId) | 3 | API |
| 7.5 | Real-time delivery receipts via WebSocket | 2 | UI |
Closes the messaging gap. Bubble states map cleanly onto the XState mutation lifecycle from EPIC-0. WhatsApp bridge receives clientMessageId so duplicate-on-replay is prevented at the bridge layer.
EPIC-8 — Observability + Offline Analytics (14 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 8.1 | OfflineSyncLog analytics queries + getOfflineSyncStats(schoolId) | 3 | DB |
| 8.2 | Sentry breadcrumbs + custom transactions for queue state | 3 | INFRA |
| 8.3 | DEVELOPER dashboard tile: mutations queued, sync success rate, p95 sync time | 5 | UI |
| 8.4 | Per-school SLI dashboard (sync success rate, cache hit rate, install rate) | 3 | UI |
Answers "is offline helping users" with hard numbers. Per-school metrics surface in the SaaS dashboard. SLI dashboard exposed to ADMIN role so each school can self-monitor.
EPIC-9 — Docs, i18n, Admin Config (13 pts)
| Story | Title | Pts | Type |
|---|---|---|---|
| 9.1 | content/docs-en/offline.mdx + content/docs-ar/offline.mdx | 3 | DEVX |
| 9.2 | ~30 dictionary keys under dictionary.offline.* (en + ar) | 5 | UI |
| 9.3 | School.offlineMode admin UI toggle | 3 | UI |
| 9.4 | Playwright offline E2E + unit suite (≥ 95% coverage on foundation) | 2 | DEVX |
Self-contained delivery epic. Story 9.4 covers all four role flows via Playwright context.setOffline(true). Dictionary keys: online, offline, slowConnection, backOnline, syncing, pendingCount, pendingDrawerTitle, retry, discard, discardConfirm, lastSynced, cached, submissionQueued, submissionInFlight, conflictTitle, conflictBody, conflictResolveKeep, conflictResolveDiscard, conflictResolveMerge, installAvailable, installCta, updateAvailable, updateCta, offlinePageTitle, offlinePageBody, dataSaver, featureUnavailableOffline, storageNearFull, storageFullError, paperVersionConflict, gradingConflict.
Migration strategy
Moving from today's state to production-grade offline happens in three stages per feature. No big-bang switch.
- Dual-write phase. Feature still writes online directly AND enqueues for offline replay through the new foundation. Server-side
OfflineSyncLogdedupes via idempotency key. Detect divergence in telemetry; alert on any. - Cutover. Switch the feature to enqueue-first. Online flush is automatic and immediate. Feature flag
School.offlineMode='full'enables the queue path;limitedkeeps online-only. - Cleanup. Remove the bespoke retry / localStorage / in-memory queue specific to that feature. Delete dead code in
use-auto-save.ts,useOfflineQueue(attendance/shared/hooks.ts:541),LocationQueue(geo-tracker.tsx:59).
Order of feature cutover: Attendance → Exam submit → Driver boarding → Application attachments → Messaging. Each cutover lives behind the feature flag and ships independently.
Cross-cutting
Schema additions
Apply via Neon branch-before-touch protocol (mcp__Neon__create_branch → migrate on branch → mcp__Neon__compare_database_schema → mcp__Neon__complete_database_migration).
OfflineMutation— durable server-side queue:id,schoolId @@index,userId @@index,action,idempotencyKey @unique,contentHash,payload Json,status enum(PENDING|SUCCESS|FAILED|CONFLICT),attempts,lastError,createdAt,syncedAt.OfflineSyncLog— idempotency cache:idempotencyKey @id,schoolId @@index,userId,action,responseJson Json,replayedAt. 30-day TTL job.CacheInvalidation— broadcast topic log:id,schoolId @@index,topic,keyHash,emittedAt,propagatedAt. 7-day TTL job.School.offlineMode— enum fielddisabled/limited/full. Defaultlimited.KioskLog.syncedToAttendance— already exists. Reuse, do not duplicate.
RBAC
- Pending-sync drawer: visible to all authenticated roles. Each user sees only their own queued mutations.
- Retry / discard: scoped to owner role per feature.
- DEVELOPER dashboard tile: aggregate across schools.
- Per-school SLI dashboard: ADMIN role.
- Admin offline-mode toggle: ADMIN role only.
<OfflineDevtools>panel: DEVELOPER role only (gated by session check).
i18n
30 new dictionary keys under dictionary.offline.*. Zero hardcoded strings in offline UI code. Arabic strings reviewed by a native speaker before pilot rollout.
Testing strategy
| Test layer | Coverage | Tool |
|---|---|---|
| Unit | useMutationQueue timing, Dexie tenant namespacing, XState transitions, content-hash verification | Vitest |
| Integration | actionRegistry dispatch, per-domain sync shards, conflict policies, WebSocket invalidation | Vitest + msw |
| E2E | Four role flows with context.setOffline(true) + reconnect; tab-close persistence; subdomain switch | Playwright |
| Visual | <PendingMutationsDrawer>, <CachedDataBanner>, <ConflictResolver>, <OfflineDevtools> in states | Storybook + Chromatic |
| Manual | Real-device smoke on Safari iOS 16.4, Chrome Android 110, Samsung Internet 19 | TestFlight-style internal |
| Performance | All performance budgets above + Lighthouse CI | size-limit + Sentry + LH |
| Accessibility | All offline UI traversable by keyboard + screen-reader announces "you are offline" | axe + manual VoiceOver/TB |
| Migration | Dexie schema migrations forward + backward compat | Dexie test harness |
Telemetry & metrics
Every state transition emits a structured event. Event names use the offline.* namespace.
| Event | Payload | Sink |
|---|---|---|
offline.mutation.enqueued | { schoolId, userId, action, idempotencyKey, contentHash } | Sentry + OfflineSyncLog |
offline.mutation.flushed | { ..., durationMs, attempts, compressionRatio } | Sentry + OfflineSyncLog |
offline.mutation.failed | { ..., error, willRetry } | Sentry + OfflineSyncLog |
offline.mutation.conflict | { ..., conflictType, resolution } | Sentry + OfflineSyncLog |
offline.network.changed | { from, to, effectiveType, saveData } | Sentry |
offline.cache.hit | { schoolId, queryKey, ageMs, source } | Custom analytics |
offline.cache.miss | { schoolId, queryKey } | Custom analytics |
offline.cache.invalidated | { schoolId, topic, keyHash, latencyMs } | Custom analytics |
offline.install.prompted | { schoolId, userId, role } | Custom analytics |
offline.install.accepted | { schoolId, userId, role } | Custom analytics |
offline.install.dismissed | { schoolId, userId, role, dismissCount } | Custom analytics |
offline.sw.updated | { schoolId, fromVersion, toVersion, rolloutPercent } | Sentry |
offline.storage.warning | { schoolId, usagePercent, quota } | Sentry |
offline.worker.long_task | { schoolId, taskMs, action } | Sentry |
Rollout (feature flag)
School.offlineMode | SW registered | Read cache | Write queue | Install prompt |
|---|---|---|---|---|
disabled | ✗ | ✗ | ✗ | ✗ |
limited (default) | ✓ | ✓ | ✗ | ✗ |
full | ✓ | ✓ | ✓ | ✓ |
Pilot schools (Sudan, King Fahad) opt into full. SaaS marketing remains on default. ADMIN UI in school settings toggles between modes. DEVELOPER role can override any school's mode for testing.
Developer onboarding
A new contributor adds offline support to their feature in five steps.
- Register a new action handler in
src/lib/offline/registry.tswith a typed payload Zod schema and conflict-policy choice. - Call
useOfflineMutation("yourFeature.actionName", payload)from the client; for reads useuseLiveQuery(() => db.yourFeature.where(...).toArray()). - Add a Storybook entry for the conflict UI if conflicts are possible.
- Add a Playwright test in
playwright/tests/offline.spec.ts. - Add dictionary keys for any new offline strings under
dictionary.offline.<feature>.*.
A complete worked example lives at src/components/school-dashboard/attendance/ after EPIC-3 ships.
Foundation gate
EPIC-0 must pass all of the following before any per-role epic starts. Foundation work is not a partial deliverable.
- Dexie tenant DB factory + schema versioning + migration tests merged
- OPFS abstraction with IDB fallback merged + Safari iOS 16.4 manual test passed
-
sync-worker.tsruns all mutation replay; main-thread long-task observer reports zero blocks > 16 ms during sync -
actionRegistry+ XState mutation lifecycle merged with ≥ 95% unit test coverage - LZ4 + MessagePack compression measured at ≥ 3× ratio on typical payloads
- SHA-256 content hashing verifies on every replay; tamper test fails closed
- WebSocket cache-invalidate end-to-end latency ≤ 200 ms in staging
- Service worker drains queue on Background Sync event (Chrome) and on tab reopen (iOS)
-
<ConflictResolver>atom shipped with Storybook coverage of every conflict policy -
OfflineSyncLogwrites from every transition; Sentry breadcrumbs visible in dashboard -
<OfflineDevtools>panel renders for DEVELOPER role and shows live queue state - Foundation chunk size ≤ 35 KB gzipped (size-limit CI gate)
- Tenant-leak Playwright test green (queue on
school-a→ switch toschool-b→ queue invisible)
Foundation work continues until all boxes are checked. No per-role epic starts before then.
Risks and trade-offs
- IndexedDB + OPFS quotas. Safari iOS caps around 50 MB; Chrome Android allows up to 60% of disk. Mitigation: OPFS for blobs; payloads-only in IDB;
navigator.storage.estimate()watchdog warns at 80%; evict cached reads before queued writes. - Cross-tenant leak on subdomain switch. Mitigation: DB name namespaced by
schoolId; SW readsschoolIdfrom session cookie per-request; explicit Playwright test in Foundation Gate. - Stale exam questions vs. integrity. Mitigation: exam-take pre-cache stores
paperVersion; server rejects submit on mismatch with structured<PaperVersionConflict>. - WhatsApp bridge duplicates. Mitigation:
clientMessageIdpassed through to bridge as idempotency key. - Multi-subdomain SW scope. Each
school-X.databayt.orggets its own SW scope (correct). - iOS Safari Background Sync absence. Mitigation:
onlineevent + visibility-change listener in tab; on PWA reopen, sync worker drains queue immediately. - OPFS unavailable on older Safari / in-app browsers. Mitigation: storage layer detects capability at init; falls back to IDB blob fields with storage warning.
- Cold-cache offline page. Mitigation: aggressive shell pre-cache on install; offline page useful even on first visit (shows install CTA).
- Background Sync throttling. Mitigation: foreground flush is primary path; background sync is best-effort.
- Time-skew on stored mutations. Mitigation: server records
serverAcceptedAtas authoritative; clientmarkedAtretained as audit metadata only. - WebSocket connection limits per origin. Mitigation: single socket per tab; SharedWorker-style coordination across tabs of same origin.
- OS-level storage eviction. Mitigation: PWA install promotes to "managed storage" with eviction guarantees; non-installed users see warning toast.
Detailed timeline (14 weeks, week-by-week)
Two developers, aligned phases. Week numbers are calendar weeks starting at project kickoff.
| Week | Phase | Dev A focus | Dev B focus | Milestone |
|---|---|---|---|---|
| 0 | Spikes | S-0.1, S-0.3, S-0.5, S-0.7, S-0.9 | S-0.2, S-0.4, S-0.6, S-0.8, S-0.10 | All Decision Records merged |
| 1 | Foundation 0.A | Dexie tenant DB + schema versioning | OfflineMutation + OfflineSyncLog + CacheInvalidation | Prisma models live on Neon |
| 2 | Foundation 0.A+B | OPFS abstraction + IDB fallback | sync-worker.ts scaffold + Comlink RPC | Dexie + OPFS verified on iOS 16.4 |
| 3 | Foundation 0.B+C | XState mutation lifecycle + compression + content hashes | useNetworkStatus + useOfflineMutation + useLiveQuery | Main-thread budget validated |
| 4 | Foundation 0.D+E | SW message bus + per-domain sync shards | WebSocket invalidation + Sentry + <OfflineDevtools> | Foundation Gate — all checkboxes green |
| 5 | PWA + read cache | EPIC-1.1, 1.2, 1.4 | EPIC-2.1, 2.2, 2.6 | Install prompt visible in staging |
| 6 | PWA + read cache | EPIC-1.3, 1.5 | EPIC-2.3, 2.4, 2.5, 2.7 | EPIC-1 done; EPIC-2 done |
| 7 | Teacher offline | EPIC-3.1, 3.2, 3.7 | EPIC-4.1, 4.5 | Teacher attendance offline working in staging |
| 8 | Teacher offline | EPIC-3.3, 3.4 | EPIC-4.2, 4.3 | Exam auto-save migrated to foundation |
| 9 | Teacher + Student | EPIC-3.5, 3.6 | EPIC-4.4, 4.6 | EPIC-3 done; EPIC-4 done |
| 10 | Driver + Applicant | EPIC-5.1, 5.2 | EPIC-6.1, 6.2 | Driver in-app boarding offline + OPFS resumable upload |
| 11 | Driver + Applicant | EPIC-5.3, 5.4 | EPIC-6.3, 6.4 | EPIC-5 done; EPIC-6 done |
| 12 | Messaging | EPIC-7.1, 7.2, 7.5 | EPIC-7.3, 7.4 | EPIC-7 done |
| 13 | Observability | EPIC-8.1, 8.2 | EPIC-8.3, 8.4 | DEVELOPER dashboard + per-school SLI dashboard live |
| 14 | Docs + pilot | EPIC-9.1, 9.2 | EPIC-9.3, 9.4 + pilot enablement | Pilot enabled at one Sudan school |
Buffer: 1 week reserved for staging soak before pilot enablement.
P0 — EPIC-0, 1, 3, 4 (foundation + teacher daily + student high-stakes). P1 — EPIC-2, 5, 6, 9 (read cache, driver, applicant, docs). P2 — EPIC-7, 8 (messaging queue + analytics).
Pilot rollout playbook
Enabling School.offlineMode='full' for a pilot school is a deliberate, observable change. Five steps.
- Pre-flight (T-7 days). Confirm school on latest release; smoke-test online flows; baseline current connection-failure rate from Sentry; brief admin on changes ("install prompts; pending sync drawer for offline marks").
- Soft enable (T-0). ADMIN toggles
offlineMode='full'in school settings. Service worker registers on next visit. Read cache populates in background; no user-visible change yet beyond install banner. - Day 1 monitoring. Watch
OfflineSyncLogfor first ~50 enqueued mutations; verify all flush successfully; check Sentry for unexpected errors; verify storage quota stays under 80% on sampled devices. - Day 7 review. Compare sync success rate, install rate, average time-to-sync against headline metrics. If any below 95% target, root-cause and either patch or roll back to
limited. - Day 30 retro. Decide: keep on
full, promote to default for similar schools, or refine. Document learnings.
Rollback is instant: ADMIN sets offlineMode='limited'. Queue is preserved client-side and drains on next visit when re-enabled.
Smoke test checklist
Manual checks before declaring a feature "offline-done". Run on Safari iOS 16.4, Chrome Android 110, Chrome Desktop 110 each.
- Service worker registered (
chrome://serviceworker-internalsshows active) - Manifest installable (install icon visible or "Add to Home Screen" on iOS)
- App-shell loads with
Network: Offlineafter one prior online visit - Dashboard offline: a page opened while online renders from its saved copy under the offline strip; a page never opened shows "This page isn't on this device yet" and Back returns. Test with airplane mode or the server stopped — the developer tools' Offline setting does not reach the worker's own requests
- Slow network, responses held longer than 4 s: a saved page shows its copy under the slow-connection strip, and Retry refreshes it
- Shared device: sign out, sign in as another account — no page saved by the first account appears, on a slow network too
- Offline page renders with last-sync time + pending-mutation count + storage UI
- Teacher bulk attendance: mark 20 students offline → reload → all visible as pending → reconnect → all flushed → no duplicates
- Student exam: enter answers offline → submit offline → "submission queued" full-screen → reconnect → submitted
- Driver boarding: scan 5 students in tunnel-simulated offline → reconnect → all 5 recorded
- Applicant: upload 5 MB attachment, kill connection mid-upload → reconnect → upload resumes from completed parts in OPFS
- Messaging: send 3 messages offline → bubble shows
queued→ reconnect → all showsent - Subdomain switch: queue items on
school-a→ navigate toschool-b→ queue invisible → return toschool-a→ queue intact - PWA install prompt: visit dashboard 3 times → banner appears → accept → launches as standalone
- SW update: deploy new SW → toast appears → reload completes
- All UI in Arabic when
?lang=ar→ strings render right-to-left - Pending drawer: retry button works; discard button works; both confirm before destructive
- Storage near-full: fill IDB+OPFS to 80% → warning toast appears
-
offlineMode='disabled'→ no SW registered (verify in DevTools Application tab) - WebSocket invalidation: write from device A → device B's live query refreshes within 1 s
- Conflict UI: simulate version conflict →
<ConflictResolver>shows both sides → user chooses keep/discard - Main-thread profiler shows no long tasks > 16 ms during sync flush of 50 items
Acceptance criteria — "Production-grade offline shipped"
- With
School.offlineMode='full', a teacher onchrome://devtools network: Offlinemarks attendance, refreshes, closes the tab, reopens — all marks pending; reconnect replays with zero duplicates and zero main-thread blocks. - A student loses connectivity for 10+ minutes mid-exam, continues answering, hits submit, sees "submission queued"; reconnect completes within 30 seconds with zero data loss.
- SW
sync-formsevent fires on reconnect; drains the Dexie queue without the page being open. - Browser close/reopen preserves the queue (Dexie, not in-memory).
- Subdomain switch
school-a→school-bdoes NOT replayschool-a's queue againstschool-b. - Playwright
tests/offline.spec.tsgreen across all four role flows + tenant safety + conflict resolution. - All offline UI strings render in
enandarfrom dictionary keys; zero hardcoded English in grep. offlineMode='disabled'disables SW registration entirely.- Foundation chunk ≤ 35 KB gzipped (CI gate). Per-epic chunks ≤ 15 KB each.
- Sentry telemetry shows ≥ 99.9% sync success rate over rolling 7-day window across all
full-mode schools. - Main-thread long-task observer shows zero blocks > 16 ms during sync flush in p99 measurements.
- WebSocket cache invalidation latency p95 ≤ 200 ms in production.
- OPFS attachment resume works after killing the upload mid-flight on iOS 16.4.
Key files
| Path | Role |
|---|---|
src/lib/offline/db.ts (new) | Dexie tenant DB factory + schema versioning |
src/lib/offline/opfs.ts (new) | OPFS abstraction (putBlob/getBlob/deleteBlob) |
src/lib/offline/registry.ts (new) | actionRegistry typed map |
src/lib/offline/types.ts (new) | Discriminated-union mutation envelope types |
src/lib/offline/state-machine.ts (new) | XState mutation lifecycle |
src/lib/offline/compression.ts (new) | LZ4 + MessagePack compression pipeline |
src/lib/offline/integrity.ts (new) | SHA-256 content-hash verification |
src/lib/offline/sync-worker.ts (new) | Web Worker sync engine |
src/components/offline/use-mutation-queue.ts (new) | Canonical persistent queue hook |
src/components/offline/use-network-status.ts (new) | Composed network status hook |
src/components/offline/use-offline-mutation.ts (new) | react-query wrapper for mutations |
src/components/offline/use-live-query.ts (new) | Dexie live query wrapper |
src/components/offline/use-storage-estimate.ts (new) | navigator.storage.estimate() reactive hook |
src/components/offline/provider.tsx (new) | <OfflineProvider> |
src/components/offline/pending-mutations-drawer.tsx (new) | UI for in-flight queue |
src/components/offline/conflict-resolver.tsx (new) | Structured merge UI primitive |
src/components/offline/install-prompt-banner.tsx (new) | beforeinstallprompt capture UI |
src/components/offline/update-available-toast.tsx (new) | SW update flow UI |
src/components/offline/cached-data-banner.tsx (new) | "data is from cache" indicator |
src/components/offline/devtools.tsx (new) | <OfflineDevtools> panel (DEVELOPER only) |
src/app/api/offline/sync/[domain]/route.ts (new) | Per-domain sync shard endpoints |
src/app/api/cache-invalidate/route.ts (new) | WebSocket cache invalidation broadcast |
src/app/api/uploads/resumable/[uploadId]/[partNumber]/route.ts (new) | tus-style chunked upload |
public/service-worker.js | v7 shipped; the syncOfflineForms() stub is gone — the worker wakes a tab to drain the outbox |
prisma/models/offline-sync.prisma (new) | OfflineMutation + OfflineSyncLog + CacheInvalidation |
src/components/school-dashboard/attendance/actions/bulk.ts:33 | Register as "attendance.bulkUpload" |
src/components/school-dashboard/exams/take/actions.ts:369 | Register as "exam.submit" |
src/components/school-dashboard/attendance/geofencee/geo-tracker.tsx:59 | Retire LocationQueue; use shared primitives |
src/components/school-dashboard/attendance/shared/hooks.ts:541 | Replace in-memory useOfflineQueue |
src/lib/performance-optimization.ts | Wire as useNetworkStatus dependency |
src/app/manifest.ts | Add /exams, /messages, /timetable shortcuts |
src/components/internationalization/{school-en,school-ar}.json | Add dictionary.offline.* keys |
playwright/tests/offline.spec.ts (new) | E2E covering four role flows + tenant + conflicts |
Glossary
- Action handler — A server action registered in
actionRegistryby string name (e.g."exam.submit"). Same function called online or via offline replay. - App shell — The minimal HTML, CSS, and JS needed to render the offline-aware navigation chrome. Pre-cached at install time.
- Background Sync — Browser API that fires a
syncevent when the device is online, even if no tab is open. Limited browser support. - Comlink — Library for ergonomic Web Worker RPC.
- Conflict resolution — Policy applied when a queued mutation can no longer be cleanly applied. Default is server-authoritative with structured merge UI.
- Content hash — SHA-256 of the mutation payload, stored on the envelope. Verified on intake and replay. Tamper-evident.
- Dexie — Wrapper library over IndexedDB with rich query semantics, transactions, live queries, schema versioning.
- Idempotency key — Client-generated cuid v2 attached to every mutation. Server uses
OfflineSyncLogto dedupe. - IDB — IndexedDB, the browser-native indexed key-value store. Per-origin quota; persists across reloads.
- LZ4 — Fast compression algorithm; used here via WASM for payload compression > 1 KB.
- MessagePack — Binary serialization format. More compact than JSON for the envelope encoding.
- Mutation envelope — The typed wrapper around a queued write. Carries metadata for replay including idempotency key and content hash.
- Network Information API —
navigator.connection.effectiveType/saveData/rtt. Used to adapt UX to connection quality. - OPFS — Origin Private File System. Browser-native file system scoped to the origin; better than IDB for large blobs.
- PWA — Progressive Web App. An installable, service-worker-backed web app with offline capability.
- Pre-cache — Storing assets or data before the user needs them offline.
- Sync intake —
POST /api/offline/sync/{domain}, the per-domain endpoints that receive queued mutations and dispatch them to handlers. - Sync worker —
sync-worker.ts, the Web Worker that handles all offline replay off the main thread. - Tenant-namespaced — Storage keyed by
schoolIdso data never leaks across schools. - XState — Library for finite-state-machine modeling. Used for the mutation lifecycle.
See also
- Multi-tenancy —
schoolIdscoping rules every offline write must respect - Database — Prisma + Neon safety protocol for new tables
- Notifications — read-side cache patterns share infrastructure
- Translation — dictionary key conventions for offline strings
- Document Intelligence — prior-art epic-doc template this doc follows
- Architecture — mirror pattern and tenant context resolution
- MVP — maturity badge conventions for tracking epic progress
- Roadmap — quarter-outlook for offline rollout
On This Page
Why this mattersWhat success looks likeShipped 2026-08-29/30 — the outbox, and a policyShipped 2026-09-12 — installable, and attendance in the outboxHow to experience it (any school, today)Shipped 2026-09-13/14 — explorable offline, and fast on thin networksHow to experience itLimitsFilesEngineering principles (no compromise)Foundation architecture decisionsScopeArchitecture contextSchema factsPatternsCurrent state vs. target stateConflict resolution policy per actionBrowser compatibility matrixStorage budget per rolePerformance budgetsPre-foundation phase (Week 0): Spikes & ResearchStories per epicEPIC-0 — Foundation: Production-Grade Offline Engine (62 pts)EPIC-1 — PWA Installability + App-Shell Pre-Cache (15 pts)EPIC-2 — Smart Read Cache (24 pts)EPIC-3 — Teacher Offline (Attendance + QR + Grading) (32 pts)EPIC-4 — Student Offline (Exam Submit + Proctor Log) (22 pts)EPIC-5 — Driver Offline (Trip Boarding) (15 pts)EPIC-6 — Applicant Offline (Resumable Attachments + Submit) (18 pts)EPIC-7 — Messaging Outbound Queue (16 pts)EPIC-8 — Observability + Offline Analytics (14 pts)EPIC-9 — Docs, i18n, Admin Config (13 pts)Migration strategyCross-cuttingSchema additionsRBACi18nTesting strategyTelemetry & metricsRollout (feature flag)Developer onboardingFoundation gateRisks and trade-offsDetailed timeline (14 weeks, week-by-week)Pilot rollout playbookSmoke test checklistAcceptance criteria — "Production-grade offline shipped"Key filesGlossarySee also