PWA Augment — Service Worker & Offline Shell
Progressive Web Application support for Objectuve web and desktop users, running alongside (not replacing) the native Capacitor builds for mobile.
Overview
The PWA layer adds:
- Service Worker — asset caching and offline shell fallback
- Web App Manifest — metadata for installation and home screen icon
- Offline Status — a non-blocking chrome bar that reflects network/sync state without occluding the app
- Installation Prompt — invite to install as a standalone app
The native Capacitor apps remain authoritative for iOS and Android. This is an augment, not a replacement — both runtimes coexist without interference.
Service Worker Registration
The service worker is registered manually in src/main.ts inside requestIdleCallback(), after custom elements are defined. This timing prevents blocking the app shell from rendering.
Registration is gated on the web platform:
// src/lib/swInit.ts
export function initServiceWorker(): void {
if (!Capacitor.isNativePlatform()) {
const { register } = useServiceWorker()
register()
}
}The !Capacitor.isNativePlatform() guard uses the same idiom as usePushNotifications.ts (line 25–27), ensuring the Capacitor WebView never attempts SW registration.
Build-time: The vite-plugin-pwa plugin (Workbox-based) generates dist/sw.js and dist/manifest.webmanifest at build time.
Runtime: useServiceWorker.ts handles registration with registerType: 'prompt' and injectRegister: false (no auto-inject script tag). Manual registration in main.ts gives us full control over timing.
Caching Strategy
Workbox runtime caching rules in vite.config.ts specify cache handlers and TTLs:
| Resource | Handler | TTL | Max Entries | Notes |
|---|---|---|---|---|
/statics/**, /uploads/** | CacheFirst | 30 days | 200 | Images, avatars, static assets |
| Google Fonts stylesheets | StaleWhileRevalidate | — | — | Serve cached + validate in background |
| Google Fonts files | CacheFirst | 1 year | 30 | Font files rarely change |
GET /graphql | NetworkFirst | 5 min | 50 | Dead rule — see note below |
GraphQL mutations are intentionally excluded from caching. The GET /graphql rule matches request.method === 'GET' only, so POST /graphql mutations bypass the service worker entirely. This ensures mutations always go to the network (no stale cache serving mutations) — critical since OBJ-32 (Offline-First Sync Queue) owns mutation buffering.
The GET /graphql rule never actually fires (v4.58 Phase 6, OBJ-3745). Backend routing (rails_api/config/routes.rb:17) registers only post '/graphql' — there is no GET /graphql route to serve. On the client, apollo-client.ts builds its link from createUploadLink({ uri: API_ENDPOINT }) with no useGETForQueries option, so Apollo Client's default applies and every operation — queries included — goes out as POST. No request this app ever sends can match request.method === 'GET' on /graphql; the rule has been structurally unreachable since it was added, independent of the Phase 5a Apollo cache-persistence question that originally flagged it for review (.planning/milestones/v4.58-offline-capability-rebuild-ROADMAP.md § Phase 5a). It was left in place rather than removed here, since deleting a Workbox rule is a behavior change outside this docs-only phase's scope — flagged as a follow-up (see docs/architecture/offline-contract.md).
WebSocket routes (/cable) are also excluded via navigateFallbackDenylist: [/^\/graphql/, /^\/cable/]. This prevents the offline shell fallback from serving API and WebSocket endpoints.
Update Flow
New service worker versions are detected by useServiceWorker.ts:
- Browser fetches
sw.jsin the background (part of the update check). - If a new SW arrives, it enters the
'installing'state. - Once installed, it enters the
'installed'state while the old SW remains active (no immediate takeover). - We detect this via the
'updatefound'event and setupdateAvailable.value = true. - User sees a persistent toast (
usePwaUpdateToast.ts) prompting "Update ready → Refresh". - On click, we call
postMessage({ type: 'SKIP_WAITING' })to the waiting SW. - The new SW immediately activates (
'activate'event), and we reload the page to pick up new assets.
Alternatively, if the user opens the app in a second tab/window and the new SW activates there, the 'controllerchange' event fires in all tabs, also setting updateAvailable.value = true.
Toast behavior: The update toast fires only in PROD (import.meta.env.PROD). During local development, updates are logged to console to reduce noise.
Offline Status
When the network goes down, the service worker's navigateFallback routes users to the app shell without a specific route. This shell is rendered by src/App.vue.
Key design (v4.58 Phase 4b, OBJ-3743): offline/sync state is surfaced by ConnectionStatusBar.vue — chrome, not a modal. It never occludes the app underneath it, so a check-in stays reachable and still enqueues while offline. This replaces the earlier full-viewport PwaOfflineOverlay.vue (removed in this phase, along with its App.vue mount and its cachedGoalName last-goal callout), which blocked interaction with everything behind it while shown.
See docs/architecture/offline-contract.md for the full offline contract — what's queued, what's persisted, and how staleness and failure are handled.
The ConnectionStatusBar.vue component:
- Mounts in
App.vuebehind theoffline_status_bar_enabledflag (100% rollout in production as of v4.58 Phase 5b, OBJ-3800, 2026-09-17 — go-live is complete) and has zero DOM footprint while online with an empty sync queue and no failures - Renders as a docked bar (
role="status",aria-live="polite"; noaria-modal, no backdrop, no focus trap) — mobile docks it flush above the tab bar, desktop docks it at the content edge;ion-contentgains matching bottom padding so the bar never covers scrollable content - Reflects one of four states, in precedence order —
failed>offline>syncing>synced— driven byuseNetworkStatus()andsyncStore's queue/failed arrays - The
syncedstate fires once per drain-to-zero and self-dismisses ~2.5s later (see docs/reference/motion-vocabulary.md § Status surfaces — this value is an unlocked fallback, not a ruled tier) - No retry button and no
router.go(0)reload — the bar reports state, it doesn't drive recovery;syncStorehandles retry on network return - Carries no platform branch: since it never blocks, native never needed the exclusion the old overlay's grace-timer callback carried (
Capacitor.isNativePlatform()) — that guarantee is now architectural rather than a runtime check
Network Status — OBJ-32 Integration
Both the PWA and the upcoming Offline-First Sync Queue (OBJ-32) need to know when the network is available. There is one canonical source: useNetworkStatus.ts.
Contract:
- Platform-agnostic interface — returns
{ isOnline: Ref<boolean>, connectionType: Ref<string | null> } - Web: Wraps
navigator.onLine+ windowonline/offlineevents; readsnavigator.connection.effectiveTypefor connection type - Native: Wraps
@capacitor/networkplugin - Both consumers (PWA and OBJ-32
syncStore) must import from this composable, not roll their own
This ensures:
- Single source of truth for network state across all platforms
- No duplication or divergence between offline detection in the SW and in the sync queue
- Consistent behavior across tabs (navigator.onLine is global)
Out of scope: iOS PWA push notifications and Web Background Sync API remain unsupported — iOS Safari PWA capability gaps make these unreliable. See OBJ-226 for PWA strategy and risk analysis.
Components & Composables
Installation:
InstallPwaPrompt.vue— Banner that appears when the browser firesbeforeinstallprompt. Shows on desktop (Chrome, Edge, Firefox) and some Android browsers; iOS Safari does not fire the event (users add via "Share → Add to Home Screen" manually).
Updates:
usePwaUpdateToast.ts— WatchesuseServiceWorker().updateAvailableand fires a persistent toast in PROD.
Offline:
ConnectionStatusBar.vue— Non-blocking chrome bar reflecting offline/syncing/synced/failed state (v4.58 Phase 4b, OBJ-3743; behindoffline_status_bar_enabled, 100% rollout since Phase 5b/OBJ-3800, 2026-09-17).useNetworkStatus.ts— Platform-agnostic composable for network detection.
Build & Manifest
The Web App Manifest (manifest.webmanifest) is generated by vite-plugin-pwa at build time:
{
"name": "Objectuve",
"short_name": "Objectuve",
"description": "Gamified goal tracking. Build streaks, earn badges, and join communities that celebrate your progress.",
"theme_color": "#177bbb",
"background_color": "#0a0a0a",
"display": "standalone",
"start_url": "/",
"scope": "/",
"orientation": "portrait",
"icons": [...]
}Icons include:
pwa-192.png(192×192, general purpose)pwa-512.png(512×512, general purpose)pwa-maskable-512.png(512×512, with safe zone for adaptive masking on Android)
Platform Gate Idiom
Service worker registration gates on the web platform to prevent activation on native:
if (!Capacitor.isNativePlatform()) {
const { register } = useServiceWorker()
register()
}This same pattern is used in usePushNotifications.ts and is the standard way to conditionally load web-only features in a Capacitor app.
Rollback & Cache Clearing
If a PWA service worker version causes issues:
- Deploy a new version of the app (e.g., fixed assets)
- On next visit, the browser checks for a new
sw.js - Workbox's
precachemanifest (generated at build time) includes hashes of all assets; a mismatch invalidates the entire cache - Users who had the broken version installed will see an update prompt on next visit
To manually clear the SW and cache (development only):
// In browser console
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(reg => reg.unregister())
})
caches.keys().then(names => {
names.forEach(name => caches.delete(name))
})Testing Coverage
tests/unit/composables/useServiceWorker.spec.ts— registration, update detection, skip-waiting flowtests/unit/composables/useNetworkStatus.spec.ts— online/offline event handling, Capacitor integrationtests/smoke/specs/pwa/01-pwa-foundation.cy.ts— smoke test verifying SW registers and cache headers respond
Related Issues & Epics
- OBJ-226 — Convert frontend app to a PWA (parent epic)
- OBJ-32 — Offline-First: Optimistic UI & Sync Queue (consumes
useNetworkStatus) - PWA strategy analysis — rationale for augment vs. replace documented in epic OBJ-226
Verified against source: b595a99aa
ionic_frontend/src/lib/swInit.tsionic_frontend/src/composables/useServiceWorker.tsionic_frontend/src/composables/useNetworkStatus.tsionic_frontend/src/composables/usePwaUpdateToast.tsionic_frontend/src/components/pwa/InstallPwaPrompt.vueionic_frontend/src/components/pwa/ConnectionStatusBar.vueionic_frontend/src/App.vue(mount +ion-content::part(scroll)reflow, lines ~143–158, ~532–549)ionic_frontend/vite.config.ts(Workbox config, lines 31–108)rails_api/config/routes.rb(confirms noGET /graphqlroute exists)
Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745: offline_status_bar_enabled rollout corrected to 100% (Phase 5b/OBJ-3800 go-live); documented the GET /graphql Workbox rule as structurally dead code; linked the new offline contract page)