Offline Contract
What the app actually promises when the network is down or unreliable — what's queued, what's persisted, what happens when something can't land, and how staleness is decided. Written for a contributor deciding whether a change breaks the offline story, not for an end user; see docs/features/goals.md § Offline-first check-ins for the user-facing mechanics.
This page exists because it didn't before (v4.58 Phase 6, OBJ-3745). Offline behavior was documented in fragments across six files with no single source of truth, which is how docs/reference/pwa-vs-native-parity-matrix.md came to claim "Parity: complete" for a flow the shipped code could not execute. Every claim below cites the file it was checked against; docs/features/* link here rather than restating this contract.
What works offline, and what doesn't
Works offline:
- Check-ins (
checkInHabit) and goal progress events (addGoalEvent) without an image/file attachment queue automatically and sync when connectivity returns. - Reading already-loaded data. The Apollo cache persists to disk (see Persistence boundary below), so a cold, offline app start still renders last-known
userandgoalsdata — the only offline read path on native, which has no service worker (ionic_frontend/src/apollo-client.ts:244-247). - The web PWA app shell (HTML/JS/CSS/fonts/SVGs) — precached by Workbox, so the app loads offline even on a fresh navigation (
ionic_frontend/vite.config.ts:92-94).
Doesn't work offline:
- Every other mutation. Only
checkInHabitandaddGoalEventare queued — see What's queued, and why only that below. addGoalEventwith an image or file attachment. Not queued offline; it degrades with a clear error toast (docs/features/goals.md § Limitations).- Any GraphQL query the user hasn't already loaded. There is no offline query cache beyond the persisted
user/goalssnapshot — a query the app hasn't run before going offline has nothing to read. - Native Web Background Sync. The web PWA's mutation queue only drains while the tab is open and online — see the Background Sync row in the parity matrix for the platform-gap distinction from the app's own (working) sync queue.
Scoped unavailability
Surfaces that genuinely cannot work offline say so, rather than silently failing or pretending to queue: SurfaceUnavailableCard.vue (generalized from the earlier Coach-only CoachUnavailableCard.vue in v4.58 Phase 4d, PR #3208, with a verified zero-behavior diff at the existing Coach call site) is wired into Coach (CoachChatPanel.vue), Feed (ActivityFeed.vue, Community.vue, Communities.vue), and Teams (TeamHomeView.vue, TeamLeaderboardView.vue). Goal-progress media uploads are the one surface that legitimately blocks outright rather than showing an unavailable card, and only what it blocks (docs/features/goals.md § Limitations).
What's queued, and why only that
The sync queue (ionic_frontend/src/stores/syncStore.ts) accepts exactly two mutation names — PendingMutationName = 'checkInHabit' | 'addGoalEvent' (ionic_frontend/src/stores/types.ts:1). This is a deliberate boundary, not an oversight: extending it to goal creation, edits, or social actions each needs its own conflict-resolution story, and Scoped unavailability above is what makes that boundary honest rather than a silent gap — surfaces outside the queue's coverage tell the user the action is unavailable offline rather than pretending to queue it.
How a mutation gets queued: useOfflineMutation.ts (ionic_frontend/src/composables/useOfflineMutation.ts) wraps a mutation call. On a network error (syncStore.isNetworkError), it calls syncStore.enqueue() and shows a toast ("Saved offline. Syncing later."); on any other error, it rethrows. Every queued mutation carries a client-generated clientEventId (crypto.randomUUID()) and clientTimestamp, both attached before the first send attempt — this is what makes replay idempotent server-side (see Idempotency below).
How the queue drains: App.vue's watchEffect (ionic_frontend/src/App.vue:442-446) calls syncStore.processQueue() the instant useNetworkStatus() reports online with a non-empty queue — automatic, not user-initiated. Items replay in FIFO order; a network_error result stops the drain at that item (preserving order for the next attempt) rather than skipping ahead.
Idempotency
Both queued mutations accept a client-generated clientEventId and use it as the primary dedup key, ahead of any date-based lookup:
checkInHabit—GoalTracking::CheckInHabit#existing_completionchecksclient_event_idfirst; a replay carrying a differentcompleted_datestill returns the original completion. Falls back to acompleted_date-scoped lookup only when noclient_event_idwas sent (rails_api/app/interactions/goal_tracking/check_in_habit.rb:47-54). A concurrent replay race onclient_event_idis caught by aRecordNotUniqueretry (check_in_habit.rb:62-71).addGoalEvent— same shape:client_event_iddedup lands first (rails_api/app/interactions/goal_tracking/add_goal_event.rb:18-40).
checkInHabit's completed_date argument must fall within the last 7 days in the user's timezone (widened from next-day-only in v4.58 Phase 1, PR #3163, OBJ-3740 — see Key Decisions below) — rails_api/app/graphql/mutations/check_in_habit.rb:73-91. useOfflineMutation.ts derives completedDate from the local calendar day at the moment the user tapped, not the day the mutation eventually replays (ionic_frontend/src/composables/useOfflineMutation.ts:34-41), so a multi-day offline session still credits each check-in to the correct day.
Persistence boundary
The Apollo InMemoryCache persists to disk (SecureStorage, hand-rolled — no apollo3-cache-persist dependency) so a cold, offline app start still has last-known data to render (ionic_frontend/src/apollo-client.ts:244-427).
Default-deny, not a denylist. Only two ROOT_QUERY fields are ever persisted — PERSISTED_ROOT_QUERY_FIELDS = ['user', 'goals'] (apollo-client.ts:266) — plus whatever normalized entities they transitively reference via filterToPersistableEntities(). Everything else in the live cache (mood logs, community/team feed items, coach messages, feedback threads) is excluded by construction: it's simply never reachable from those two root fields, not filtered out by typename after the fact. This is the distinction worth carrying forward — a denylist rots the moment a new root query field lands and nobody remembers to add it to the exclusion list; an allowlist fails closed instead, so a new field is invisible to persistence until someone deliberately adds it.
Bounded, not unbounded. CACHE_SIZE_LIMIT_BYTES = 2 * 1024 * 1024 (2MB) — a byte-size budget, not an item count (unlike the sync queue's 50-item cap; the two don't map to each other). evictToFit() drops non-essential entities first when over budget; if the essential set (ROOT_QUERY / User:* / Goal:*) alone still exceeds the limit, the write is skipped entirely rather than persisting a truncated snapshot (apollo-client.ts:336-348).
The kill switch clears, it doesn't just stop. VITE_DISABLE_APOLLO_CACHE_PERSISTENCE=true both skips future hydration and actively clears whatever is already on disk on next boot — a flag flip alone can't roll back a stateful change, so the kill switch does both halves (apollo-client.ts:256-259, 409-413).
Sign-out and account switch purge the persisted copy at four call sites; an in-session account switch (no explicit sign-out) is covered too, via useClerkSync.ts's currentUserId watcher — see the code comment at apollo-client.ts:369-372 for the full site list.
Workbox's GET /graphql caching rule is separate from this, and is dead code. See docs/architecture/pwa-augment.md § Caching Strategy — the rule can never match a real request, since the backend has no GET /graphql route and the frontend never sends one. The Apollo cache persistence above is the only offline read path that actually functions, on both native and web.
What happens when a queued item can't land
Nothing is silently destroyed. Three outcomes exist for a queue item, and only one of them deletes data:
- Exhausted (
MAX_ATTEMPTS = 5failed replay attempts) or rejected (the server returns a realerrorsresponse, e.g. an out-of-windowcompletedDate) — the item moves fromqueueto a separatefailedarray, persisted separately (sync_failed_v1, distinct from the queue's ownsync_queue_v1). A toast tells the user ("N check-ins didn't send.") with a "Review" action that opensDidntSendSheet, where the user can retry (syncStore.retry(id), moves it back toqueue) or explicitly discard it.discard(id)is the only code path that actually deletes a failed payload (syncStore.ts:256-260). - Queue overflow (>50 items) — the oldest pending item is moved to
failedthe same way, with a Sentry signal (syncStore.ts:129-138). Same fate as exhaustion: reviewable, not deleted. - Success — removed from
queue, nothing more to do.
This is a deliberate contrast with an earlier claim that had drifted into docs/features/goals.md: exhausted items were once documented as dropping "silently... with a Sentry signal for monitoring." That was never how the shipped code behaves — the Sentry signal exists, but the user-visible path (toast + DidntSendSheet) was always there too, and the item was never actually deleted on exhaustion.
Staleness
isCachedDayStale is the single staleness notion — no second threshold anywhere in the app (ionic_frontend/src/helpers/cacheStaleness.ts:24-26). It compares the persisted cache's hydration day against the current local day; once they differ, the cached day is stale. Fed by apollo-client.ts's getCacheHydratedAt(), wired to ConnectionStatusBar and the dashboard surfaces in v4.58 Phase 5c (OBJ-3841).
Two consuming rules, not one:
- §6.2 rule 1 — the offline status bar shows a stamp ("Showing what we had on {day}") once the cached day is stale; no threshold below which it's withheld (
ConnectionStatusBar.vue:58-64). - §6.2 rule 2 — any dashboard value derived from today renders
—rather than a confident0once the cached day is stale (blankIfStaleDay(),cacheStaleness.ts:33-41); streak values (not day-scoped) get a qualifier instead ("Streak last seen at N days").
Focus-mode ruling (§6.3)
A stale cached day never renders a Focus-mode surface — on the Auto path and an explicit focus preference (ionic_frontend/src/views/Dashboard.vue:1284-1306, v4.58 Phase 5d, OBJ-3867). Reuses isStreakStale (== isCachedDayStale(cachedAt)) — no second staleness threshold was invented for this. The redirect latches for the session: once triggered it holds until the user explicitly taps back into Focus mode (releasing the latch) or the app relaunches, so a mid-session cache refresh doesn't swap the view out from under the user. This exists because allHabitsCheckedIn on a stale cold start is yesterday's truth — without this gate, Auto mode could route a user into a "you're all done" Focus view built on a day-old snapshot.
This is user-visible behavior, not implementation detail — a stale cold start with all-habits-checked-in in the persisted snapshot would otherwise silently show a false "all done" Focus surface. Verified by code read + dedicated Vitest only; there is no rendered evidence for this path in this repo (see Evidence gaps below).
Evidence gaps
Not every offline surface in this milestone has rendered proof it works, and this page states that honestly rather than letting a passing CI job stand in for verification it didn't do:
- The Focus-mode staleness path (§6.3 above) has no rendered capture.
ionic_frontend/tests/smoke-playwright/specs/dashboard/offline-cold-start.spec.tspredates Phase 5d and does not reach it. Coverage is code reading and dedicated Vitest only. Capture Storybook Evidencereports a green pass when it captures nothing. The job only runs the Storybook build if the PR body carries aUI-Evidence-Stories:directive; without one, it skips the build entirely and still reports success. This has been rediscovered by review twice (OBJ-2863, then this milestone's Phase 5d) rather than caught by tooling. There is also noDashboardstory, and the route-capture job cannot simulate a stale cache — so two of Phase 5d's visible states have no rendered evidence anywhere in this repo. The CI-side fix (fail or neutral, rather than pass, when the directive is absent) is a separate issue outside this milestone — this page documents the gap, it doesn't close it.- A gated CI job can report
skippedunder an all-green workflow rollup. During this milestone's go-live (Phase 5b), two staging runs reported workflow-level success whilePlaywright Smoke Tests (Staging)never actually executed (guard_stale's correctly-skipped-as-stale outcome reads identically to a real pass in the run list — seedocs/development/gotchas.md#guard_stales-skip-on-stale-run-reads-identically-to-a-real-pass-and-can-misdirect-a-ci-bisection). General rule: for a gated job, the job's own conclusion is the evidence — the workflow's rollup conclusion is not. Same shape as the trap already recorded forrelease-integrity-guard.yml.
Key Decisions
- The check-in date window widened to 7 days (Phase 1, PR #3163, OBJ-3740) — from next-day-only. Flagged product-visible for Roy at the time; not pushed back on. See Idempotency above.
- The Apollo cache persistence boundary is default-deny, not a denylist (Phase 5a, PR #3218) — see Persistence boundary above. The distinction matters more than the current two-field list: an allowlist fails closed as new root fields land.
- A new export on
apollo-client.tsis a two-file change.ionic_frontend/.storybook/mocks/apollo-client.tsis aliased over the real module for every Storybook Vitest run (ionic_frontend/vitest.storybook.config.ts:89, HARNESS-FIX-5/OBJ-3131) — ~30 non-story files importapolloClient/onCableConnectionChangefrom the real singleton directly, bypassing provide/inject, so the mock has to mirror the real module's exports by hand. Phase 5c lost a fix round to this because it wasn't written down anywhere. - §6.3's Focus-mode ruling — see Focus-mode ruling above.
UI-Evidence-Stories:false-green — see Evidence gaps above. Document-only; CI fix is a separate issue.- A gated CI job can be
skippedunder a green workflow rollup — see Evidence gaps above.
Follow-ups
vite.config.ts'sgraphql-getWorkbox rule is dead code and could be removed — it was deliberately left in place during Phase 5a pending this page's investigation (.planning/milestones/v4.58-offline-capability-rebuild-ROADMAP.md§ Phase 5a) and this docs-only phase doesn't change app code. Worth its own small cleanup issue.- The
Capture Storybook Evidenceand gated-CI-job-under-green-rollup gaps (see Evidence gaps) both need CI-side fixes tracked outside this milestone.
Verified against source:
ionic_frontend/src/composables/useOfflineMutation.tsionic_frontend/src/stores/syncStore.tsionic_frontend/src/stores/types.tsionic_frontend/src/apollo-client.tsionic_frontend/src/helpers/cacheStaleness.tsionic_frontend/src/components/pwa/ConnectionStatusBar.vueionic_frontend/src/App.vueionic_frontend/src/views/Dashboard.vueionic_frontend/vite.config.tsionic_frontend/vitest.storybook.config.ts/.storybook/mocks/apollo-client.tsrails_api/app/interactions/goal_tracking/check_in_habit.rbrails_api/app/interactions/goal_tracking/add_goal_event.rbrails_api/app/graphql/mutations/check_in_habit.rbrails_api/config/routes.rb.planning/milestones/v4.58-offline-capability-rebuild-ROADMAP.md
Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745 — first version of this page)