Skip to content

v4.58 — Offline Capability Rebuild

A user whose network drops keeps using the app — their check-in lands, credited to the day they tapped it, they can see what hasn't synced, and nothing the app promised them is silently thrown away.

Summary

Before this milestone, Objectuve's offline story was two features that couldn't actually work together. A full-screen blocking overlay (PwaOfflineOverlay.vue) took over the UI two seconds after the network dropped — but the app also had a mutation queue (useOfflineMutation) meant to let check-ins keep working offline. The overlay only left a two-second window for that queue to ever fire. Worse, the overlay had no native-platform gate, so it could trap a native app user behind a retry loop with no way through — a live production issue that shipped ahead of the rest of this milestone as a same-day hotfix. And even where the queue did fire, an offline check-in was credited to the day it happened to replay, not the day the user actually tapped it, despite the backend already supporting the argument needed to fix that.

This milestone was not scoped from a specific bug report — Josh raised it as an aside while reporting an unrelated backend performance issue and called it "likely a separate epic." The scope below came from a code audit, not a repro, and grew substantially as the audit's own findings forced re-cuts: seven phases at kickoff became fourteen at close, as work in flight repeatedly found the original scope too narrow.

What shipped: idempotent, day-accurate offline check-ins; a non-blocking status bar that replaced the old overlay everywhere, including native; a reviewable list of anything that failed to sync, with retry and discard as explicit user actions; a persisted Apollo cache so a cold, offline app start still shows real data; and staleness handling honest enough that no dashboard surface — including Focus mode — ever presents yesterday's numbers as today's. The rebuilt surface is live to 100% of users as of 2026-09-17.

Goal

a user whose network drops keeps using the app — they can still check in and journal, what they tapped is credited to the day they tapped it, they can see what hasn't synced, and nothing the app promised them is silently thrown away.

Scope — What Shipped

  • Backend idempotency: checkInHabit accepts a client-generated client_event_id as its primary dedup key, ahead of any date-based lookup, with a RecordNotUnique retry for a concurrent replay race. addGoalEvent ships the same shape. The completedDate acceptance window widened from next-day-only to the last 7 days in the user's timezone.
  • Day-accurate offline queueing: useOfflineMutation.ts derives completedDate from the local calendar day at the moment the user tapped, not the day the mutation eventually replays — so a multi-day offline session still credits each check-in correctly.
  • Nothing silently destroyed: a queue item that exhausts its retry attempts, is rejected by the server, or is dropped for queue overflow moves to a separate, persisted failed[] array instead of disappearing. A toast with a "Review" action opens DidntSendSheet, where the user can retry() or explicitly discard() — the only code path that actually deletes a failed payload.
  • ConnectionStatusBar.vue replaced the blocking full-viewport overlay everywhere, including native (which the old overlay could trap with no way through). A docked, non-modal chrome bar with no aria-modal and no focus trap, reflecting failed/offline/syncing/synced state in priority order.
  • Scoped unavailability: surfaces that genuinely can't work offline (Coach, Feed, Teams) say so via a generalized SurfaceUnavailableCard.vue, rather than failing silently or pretending to queue.
  • Persisted Apollo cache: the InMemoryCache now persists to disk on a default-deny allowlist (only user/goals root query fields, bounded to 2MB, fail-closed on overflow rather than persisting a truncated snapshot), purged on sign-out and in-session account switch. A cold, offline app start renders last-known data instead of nothing.
  • Honest staleness: a single staleness notion (isCachedDayStale) drives every consuming surface — the status bar's "Showing what we had on {day}" stamp, dashboard values blanking rather than rendering a false 0, streak values showing a qualifier instead of a stale number, and Focus mode redirecting away from a "you're all done" surface built on a day-old snapshot, on both the Auto and explicit-focus paths.
  • Go-live: offline_status_bar_enabled promoted to 100% rollout via the PostHog sync script (not the dashboard), gated on both the Focus-mode fix and a sync-sheet accessibility fix landing first. The old overlay's flag, pwa_offline_shell_enabled, stays at 0% permanently.
  • Docs reconciliation: a new docs/architecture/offline-contract.md source-of-truth page, plus corrections to six files where documentation had drifted from — or in two cases directly contradicted — the shipped code.

Phases

PhaseNameStatusPlansHighlights
0HOTFIX — platform-gate the offline overlay off nativeShipped1Same-day hotfix; overlay could trap a native user behind an unrecoverable retry loop
1Explicit idempotency for checkInHabitShipped1client_event_id dedup, 7-day acceptance window
2aThe queue records the day it was tappedShipped1completedDate derived at tap time, not replay time
2bNothing is silently destroyedShipped1failed[] array, DidntSendSheet, retry/discard
3UI-SPEC — the offline stateShipped1Desi's design contract for every Phase 4–5 surface
4aClose gate g31 — celebrate onceShipped1A queued check-in drained later still celebrates exactly once
4bReplace the overlay — ConnectionStatusBarShipped1Non-modal chrome bar, no focus trap, native included
4cThe sync surfaces — chip, sheet, C2, C3Shipped1Pending-sync chip and didn't-send review sheet
4dScoped unavailability and cold startShipped1Generalized unavailability card; carried item closed by OBJ-3831
5aPersist the Apollo cache, safelyShipped1Default-deny allowlist, bounded, account-switch purge
5cHonest staleness — wire §6.2 rule 2Shipped1Blank instead of false 0 on a stale cached day
5dHonest staleness in Focus modeShipped1Inserted mid-milestone; Auto + explicit-focus paths
5bGo-live — promote the flagShipped1offline_status_bar_enabled → 100% rollout
6Docs reconciliationShipped1New offline-contract page; corrected two backwards doc claims

Key Decisions

  • A PostHog flag promotion is an operational act, not a diff — go-live was split out of Phase 4d into its own Phase 5b, gated on explicit preconditions (two other fixes landing first) rather than bundled into a code-shipping phase.
  • The Apollo cache persistence boundary is a default-deny allowlist, not a denylist. Only user/goals root fields — and whatever they transitively reference — are ever persisted. A new root query field is invisible to persistence until someone deliberately adds it, rather than leaking by default the moment a denylist goes stale.
  • isCachedDayStale is the single staleness notion in the app — no second threshold was ever invented, including for Phase 5d's Focus-mode fix, which reuses the exact same check.
  • Scoped unavailability, not silent failure. Surfaces that can't work offline (Coach, Feed, Teams) say so explicitly via SurfaceUnavailableCard.vue rather than either blocking the whole app or pretending to queue an action that will never sync.
  • A gated CI job can report skipped under an all-green workflow rollup. Cost this milestone verification rounds in both go-live and docs reconciliation — for a gated job, the job's own conclusion is the evidence, the workflow rollup's is not. Now recorded as a general rule in docs/architecture/offline-contract.md.
  • Name the grep, not the file list, when scoping a docs audit. Phase 6's four-file scope missed three more files carrying the same drift; walking every grep -ril offline docs/ hit found them. A named file list reads as a ceiling, not a floor.

Requirements Coverage

77 / 78 phase-level acceptance criteria satisfied (1 deliberately-scoped evidence gap — see Tech Debt). Quoted from v4.58-offline-capability-rebuild-MILESTONE-AUDIT.md.

CategoryCountStatus
OFFLINE-*All satisfied
SPEC-*All satisfied
UI-*All satisfied
DOC-*All satisfied

This milestone has no standalone, numbered REQUIREMENTS.md — the ROADMAP declares the four prefix families as wildcards rather than an enumerated table; coverage is derived from the ROADMAP's own 78 per-phase acceptance-criterion checkboxes, independently re-verified in the audit above.

Outcomes

A user whose network drops keeps using the app: their check-in lands and is credited to the day they tapped it, not the day it happened to sync; they can see exactly what hasn't synced yet via a non-blocking status bar instead of a blocking overlay; anything that fails to send is reviewable and recoverable, not silently dropped; surfaces that genuinely can't work offline say so instead of breaking; the app cold-starts to real data instead of nothing; and no dashboard surface, Focus mode included, ever presents a stale cached day as if it were current. offline_status_bar_enabled has been live at 100% rollout since 2026-09-17.

Tech Debt

  • (Phase 5d) The Focus-mode staleness path has no rendered (Storybook/Playwright) test evidence anywhere in this repo — coverage is code reading plus dedicated Vitest only. The obvious first candidate if Playwright coverage for this surface is ever extended.
  • (Phase 6, follow-up) The dead GET /graphql Workbox caching rule in vite.config.ts is documented as dead code but not removed — a docs-only phase correctly left the behavior change out of scope; needs its own small cleanup issue.
  • (Phase 6, follow-up) OBJ-3922 — pre-existing dead links in docs/features/home-widgets.md, unrelated to this milestone's own drift, filed to backlog.
  • 399d53de13 — fix(pwa): platform-gate offline overlay off native (Phase 0, OBJ-3764)
  • 3ff122dab2 — feat(goal-tracking): explicit checkInHabit idempotency (Phase 1, OBJ-3740)
  • 9637bcb382 — fix(sync): close gate g31 — celebrate once (Phase 4a, OBJ-3776)
  • 53e5d66719 — feat(pwa): replace blocking offline overlay with ConnectionStatusBar (Phase 4b, OBJ-3743)
  • 570edd8505 — feat(pwa): persist the Apollo cache, safely (Phase 5a, OBJ-3744)
  • 52340c224e — fix(dashboard): honest staleness in Focus mode (Phase 5d, OBJ-3867)
  • b34dfadd4e — docs: reconcile offline capability docs against shipped v4.58 code (Phase 6, OBJ-3745)

Verified against: .planning/milestones/v4.58-offline-capability-rebuild-ROADMAP.md, .planning/milestones/v4.58-offline-capability-rebuild-MILESTONE-AUDIT.md, docs/architecture/offline-contract.md, CHANGELOG.md, and independent git merge-base --is-ancestor checks against origin/master for every phase-carrying commit.

Last updated: 2026-09-17

Loading…