Notifications
Real-time notification system that keeps the dashboard live and alerts users to campaign events.
Overview
Four notification layers, each serving a different purpose:
| Layer | When | Persistence | Requires |
|---|---|---|---|
| Live Polling | Page is open | Updates every 10s | Nothing |
| Sonner Toasts | Event transition | Until dismissed | Nothing |
| Web Notifications | Tab is backgrounded | OS notification | Permission |
| Push Notifications | Browser is closed | OS notification | Subscription |
Data Flow
How notifications travel from the official API to the user. Click any node for details. Use the filters to trace individual flows.
In-App (Polling + Toasts)
Push (Separate Channel)
Live Data Transport
The dashboard uses client-side polling to fetch live campaign data.
Polling Architecture
useLiveData hook (src/shared/hooks/useLiveData.mjs) polls GET /api/h1/live every 10 seconds via setInterval + fetch:
- Lightweight endpoint:
getCampaign()+computeMapState()→ JSON response - Visibility-aware:
visibilitychangelistener fires immediate poll on tab focus (browsers throttlesetIntervalin background tabs) - Module-level singleton: One poll interval per tab, shared across all hook instances
- Status tri-state:
'polling'(request in flight),'live'(last poll succeeded),'offline'(last poll failed ornavigator.onLineis false) - Leader election: BroadcastChannel ensures only one tab fires Web Notifications
Deferred emissions
Poll data lives outside React in a module-level store object. React subscribes via useState + useEffect — the emit() function notifies listeners when the store changes. To prevent a crash (chunk.reason.enqueueModel is not a function) caused by setState firing during RSC Flight stream processing on client-side navigation (vercel/next.js#92362), emit() defers listener notification to requestIdleCallback (with setTimeout fallback). Rapid-fire emissions are coalesced so listeners always receive the latest snapshot.
Object.freeze(INITIAL_STORE) guarantees the initial state is a stable reference for hydration. The isFirstMessage flag tracks whether the first successful poll has occurred, preventing false change detection on initial page load.
Data Fallback Chain
snapshot.data ?? initialData ?? cachedState ?? null
↑ ↑ ↑
live poll server-rendered localStorage
Change Detection
Detected Transitions
detectChanges(prevEvents, nextEvents) in src/shared/utils/game/detectChanges.mjs:
| Transition | Detection | Toast Type |
|---|---|---|
| Campaign started | New event_id appears | Default |
| Campaign won | active to success | Success |
| Campaign lost | active to fail | Error |
Events are matched by both event_id and type (defend/attack).
Client vs Server
- Client-side (
LiveToasts.jsx): ComparesprevDatafromuseLiveDatahook. Fires Sonner toasts and Web Notifications. - Server-side (
pushNotifier.mjs): Keeps previous events in memory. Fires push notifications. Resets on server restart (acceptable — misses one transition at most).
First-Message Baseline
The first successful poll after page load is treated as a silent state reset. No change detection runs against SSR data, preventing false transition toasts when the server-rendered snapshot is stale. Instead, LiveToasts fires separate catch-up toasts for any active events present in the initial data.
Toast Notifications
Two modes:
- Catch-up toasts: On page load, shows an 8-second "in progress" toast for each active event
- Transition toasts: Persistent (
duration: Infinity) toasts when events start, are won, or are lost
Styling:
- Faction-colored: Right-side accent line using design tokens (
--color-faction-bugs, etc.) - Animated: Pulsing glow matching the contested region
card-glowanimation - Positioned: Bottom-right via Sonner's
<Toaster>, co-located insideLiveToasts(not in root layout — required for module singleton sharing) - Dark themed: Styled with
--color-surface-1,--color-text,--color-ghost
Web Notifications
Browser-native notifications for when the tab is backgrounded:
- Permission: Requested via
NotificationTogglecomponent (not on page load) - Leader election:
BroadcastChannelensures only one tab sends OS notifications (all tabs show Sonner toasts independently) - Trigger: Same
detectChangesoutput as toasts, but only fires whendocument.hidden === true
Push Notifications
Server-initiated notifications that work when the browser is closed:
Setup
- Generate VAPID keys:
npx web-push generate-vapid-keys - Set environment variables:
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT - Set
NEXT_PUBLIC_VAPID_PUBLIC_KEYfor the client
Subscription Flow
- User clicks "Enable notifications" (
NotificationTogglecomponent — enables both web notifications and push in one action) - Service worker registers,
pushManager.subscribe()creates a subscription POST /api/notifications/subscribestores it inpush_subscriptiontable- Validated with Zod: endpoint URL max 2048 chars, keys base64 max 256 chars
Push Delivery
checkAndNotify()runs after each update (fire-and-forget)- Detects event transitions using the same
detectChangesfunction - Builds payload with faction icon, badge, per-event tag, and renotify flag
- Fans out via
web-pushlibrary with 50 concurrent request limit - Stale subscriptions (410/404 responses) are automatically deleted
Notifications for the same event (e.g., started → won) replace each other via matching tag, with renotify: true ensuring the user is re-alerted.
Admin Debug Buttons
The admin section on the profile page (/profile) has a Debug section with two buttons:
- Test Push — sends a test push notification to all subscribers via
web-push, using the samesendWithConcurrencyLimitpath as production. Reports{ sent, stale }counts. - Test Toast — fires a faction-colored Sonner toast locally to verify toast styling and
<Toaster>connectivity.
Service Worker
src/sw.js handles push-related concerns (Serwist manages caching):
- Push events: Parses payload, validates
icon/badgesame-origin, passestag/renotifytoshowNotification() - Notification clicks: Focuses existing tab or opens new one
- Caching: Serwist
defaultCacheprovides Next.js-optimized runtime caching strategies - API exclusion: Explicit
NetworkOnlyroute for/api/*— live data is never cached
PWA & Offline
The app is a Progressive Web App with offline support powered by Serwist (@serwist/next).
Service Worker Updates
The service worker uses skipWaiting: true and clientsClaim: true — new versions activate immediately on install without user interaction. Serwist handles registration automatically via the register: true config option.
Caching Strategy
Serwist manages two layers of caching:
Precaching (build-time): Serwist generates a precache manifest at build time with content hashes for all build output. Changed files get new hashes — cache busting is automatic on every deploy. No manual version bumps needed.
Runtime caching: Serwist's defaultCache from @serwist/next/worker provides Next.js-optimized strategies:
| Request Type | Strategy | Rationale |
|---|---|---|
API routes (/api/*) | NetworkOnly (explicit) | Live data must never be served from cache |
| Navigation | Network-first | HTML must match current build |
| Static assets | Stale-while-revalidate | Serve fast from cache, update in background |
Offline Behavior
When offline, the app loads precached assets and displays last known war data:
- Shell: SW serves precached HTML + assets → app renders visually complete
- Data:
useLiveDatahook falls back tolocalStorage(hd1-live-cache) - Status: StatusDot shows red "offline" indicator (via
navigator.onLinecheck) - Timestamps: "Last updated X ago" via
formatTimeAgoshows data age
PWA Metadata
The web app manifest (src/app/site.webmanifest) and layout metadata enable full PWA installability:
display: standalone— app launches without browser chromeorientation: portrait— locked to portrait (mobile-first dashboard)start_url: /— required for Chrome install prompt- Icon purpose
any maskable— works in both standard and adaptive icon contexts themeColor: #282828— colors browser chrome on mobileappleWebApp: capablewithblack-translucentstatus bar — iOS standalone mode- 20 portrait iOS splash screens for branded cold-boot experience
Connection States
StatusDot component shows poll status from LiveDataContext:
| State | Indicator | Meaning |
|---|---|---|
| live | Green dot | Last poll succeeded |
| polling | Orange dot | Poll request in flight |
| offline | Red dot | Last poll failed or navigator.onLine is false |
Key Files
| File | Role |
|---|---|
src/sw.js | Serwist SW source (precache + push handlers) |
serwist.config.js | Serwist build config (configurator mode) |
src/app/api/h1/live/route.js | Polling endpoint (getCampaign + computeMapState) |
src/shared/hooks/useLiveData.mjs | Polling hook + BroadcastChannel leader |
src/shared/utils/game/detectChanges.mjs | Event transition detection (shared) |
src/features/notifications/LiveToasts.jsx | Sonner toasts + Web Notifications |
src/features/notifications/NotificationToggle.jsx | Combined notification + push toggle |
src/shared/components/StatusDot.jsx | Tri-state connection indicator |
src/update/pushNotifier.mjs | Server-side push fan-out |
src/app/api/notifications/subscribe/route.js | Push subscription CRUD |
src/app/site.webmanifest | PWA manifest (install, icons, orientation) |
