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:
checkInHabitaccepts a client-generatedclient_event_idas its primary dedup key, ahead of any date-based lookup, with aRecordNotUniqueretry for a concurrent replay race.addGoalEventships the same shape. ThecompletedDateacceptance window widened from next-day-only to the last 7 days in the user's timezone. - Day-accurate offline queueing:
useOfflineMutation.tsderivescompletedDatefrom 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 opensDidntSendSheet, where the user canretry()or explicitlydiscard()— the only code path that actually deletes a failed payload. ConnectionStatusBar.vuereplaced 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 noaria-modaland 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
InMemoryCachenow persists to disk on a default-deny allowlist (onlyuser/goalsroot 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 false0, 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-focuspaths. - Go-live:
offline_status_bar_enabledpromoted 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.mdsource-of-truth page, plus corrections to six files where documentation had drifted from — or in two cases directly contradicted — the shipped code.
Phases
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 0 | HOTFIX — platform-gate the offline overlay off native | Shipped | 1 | Same-day hotfix; overlay could trap a native user behind an unrecoverable retry loop |
| 1 | Explicit idempotency for checkInHabit | Shipped | 1 | client_event_id dedup, 7-day acceptance window |
| 2a | The queue records the day it was tapped | Shipped | 1 | completedDate derived at tap time, not replay time |
| 2b | Nothing is silently destroyed | Shipped | 1 | failed[] array, DidntSendSheet, retry/discard |
| 3 | UI-SPEC — the offline state | Shipped | 1 | Desi's design contract for every Phase 4–5 surface |
| 4a | Close gate g31 — celebrate once | Shipped | 1 | A queued check-in drained later still celebrates exactly once |
| 4b | Replace the overlay — ConnectionStatusBar | Shipped | 1 | Non-modal chrome bar, no focus trap, native included |
| 4c | The sync surfaces — chip, sheet, C2, C3 | Shipped | 1 | Pending-sync chip and didn't-send review sheet |
| 4d | Scoped unavailability and cold start | Shipped | 1 | Generalized unavailability card; carried item closed by OBJ-3831 |
| 5a | Persist the Apollo cache, safely | Shipped | 1 | Default-deny allowlist, bounded, account-switch purge |
| 5c | Honest staleness — wire §6.2 rule 2 | Shipped | 1 | Blank instead of false 0 on a stale cached day |
| 5d | Honest staleness in Focus mode | Shipped | 1 | Inserted mid-milestone; Auto + explicit-focus paths |
| 5b | Go-live — promote the flag | Shipped | 1 | offline_status_bar_enabled → 100% rollout |
| 6 | Docs reconciliation | Shipped | 1 | New 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/goalsroot 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. isCachedDayStaleis 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.vuerather than either blocking the whole app or pretending to queue an action that will never sync. - A gated CI job can report
skippedunder 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 indocs/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.
| Category | Count | Status |
|---|---|---|
| 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 /graphqlWorkbox caching rule invite.config.tsis 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 indocs/features/home-widgets.md, unrelated to this milestone's own drift, filed to backlog.
Related Artifacts
- Roadmap: v4.58-offline-capability-rebuild-ROADMAP.md
- Milestone Audit: v4.58-offline-capability-rebuild-MILESTONE-AUDIT.md
- Source-of-truth page: docs/architecture/offline-contract.md
- PRD: none — scope was derived from a code audit against source issue OBJ-3738, not a written PRD.
- Git tag: not yet tagged — rides the next Wednesday 08:00 CT release train per docs/operations/deployment.md. The feature is already live to users via the
offline_status_bar_enabledPostHog flag, independent of the tag. - Merge PRs: #3167 (Phase 0) · #3163 (Phase 1) · #3172 (Phase 2a) · #3178 (Phase 2b) · #3168 (Phase 3) · #3180 (Phase 4a) · #3181 (Phase 4b) · #3192 (Phase 4c) · #3208 (Phase 4d) · #3218 (Phase 5a) · #3226 (Phase 5c) · #3253 (Phase 5d) · #3301 (Phase 6)
Related Commits
399d53de13— fix(pwa): platform-gate offline overlay off native (Phase 0, OBJ-3764)3ff122dab2— feat(goal-tracking): explicitcheckInHabitidempotency (Phase 1, OBJ-3740)9637bcb382— fix(sync): close gate g31 — celebrate once (Phase 4a, OBJ-3776)53e5d66719— feat(pwa): replace blocking offline overlay withConnectionStatusBar(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