Please use a larger screen to view this site.
Real-time notification system that keeps the dashboard live and alerts users to campaign events.
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 |
How notifications travel from the official API to the user. Click any node for details. Use the filters to trace individual flows.
The dashboard uses client-side polling to fetch live campaign data.
useLiveData hook (src/shared/hooks/useLiveData.mjs) polls GET /api/h1/live every 10 seconds via setInterval + fetch:
getCampaign() + computeMapState() → JSON responsevisibilitychange listener fires immediate poll on tab focus (browsers throttle setInterval in background tabs)'polling' (request in flight), 'live' (last poll succeeded), 'offline' (last poll failed or navigator.onLine is false)The polling endpoint includes an appVersion field in its JSON response. useLiveData compares the server's version against the build-time NEXT_PUBLIC_APP_VERSION baked into the client bundle. On mismatch (i.e., the server was redeployed with a new version), the client triggers guardedReload('version') — a hard page reload protected by a localStorage-backed circuit breaker (30s TTL, max 3 attempts) to prevent infinite loops. Detection happens within ~10 seconds of a deployment.
This is one of three detection layers — see src/shared/utils/reloadGuard.mjs for the shared guard utility.
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.
snapshot.data ?? initialData ?? cachedState ?? null
↑ ↑ ↑
live poll server-rendered localStorage
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).
LiveToasts.jsx): Compares prevData from useLiveData hook. Fires Sonner toasts and Web Notifications.pushNotifier.mjs): Keeps previous events in memory. Fires push notifications. Resets on server restart (acceptable — misses one transition at most).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.
Two modes:
duration: Infinity) toasts when events start, are won, or are lostStyling:
--color-faction-bugs, etc.)card-glow animation<Toaster>, co-located inside LiveToasts (not in root layout — required for module singleton sharing)--color-surface-1, --color-text, --color-ghostBrowser-native notifications for when the tab is backgrounded:
NotificationToggle component (not on page load)BroadcastChannel ensures only one tab sends OS notifications (all tabs show Sonner toasts independently)detectChanges output as toasts, but only fires when document.hidden === trueServer-initiated notifications that work when the browser is closed:
npx web-push generate-vapid-keysVAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECTNEXT_PUBLIC_VAPID_PUBLIC_KEY for the clientNotificationToggle component — enables both web notifications and push in one action)pushManager.subscribe() creates a subscriptionPOST /api/notifications/subscribe stores it in push_subscription tablecheckAndNotify() runs after each update (fire-and-forget)detectChanges functionweb-push library with 50 concurrent request limitNotifications for the same event (e.g., started → won) replace each other via matching tag, with renotify: true ensuring the user is re-alerted.
The admin section on the profile page (/profile) has a Debug section with two buttons:
web-push, using the same sendWithConcurrencyLimit path as production. Reports { sent, stale } counts.<Toaster> connectivity.src/sw.js handles push-related concerns (Serwist manages caching):
icon/badge same-origin, passes tag/renotify to showNotification()defaultCache provides Next.js-optimized runtime caching strategiesNetworkOnly route for /api/* — live data is never cachedThe app is a Progressive Web App with offline support powered by Serwist (@serwist/next).
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.
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 |
When offline, the app loads precached assets and displays last known war data:
useLiveData hook falls back to localStorage (hd1-live-cache)navigator.onLine check)formatTimeAgo shows data ageThe 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 promptany maskable — works in both standard and adaptive icon contextsthemeColor: #282828 — colors browser chrome on mobileappleWebApp: capable with black-translucent status bar — iOS standalone modeStatusDot 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 |
| 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 + appVersion) |
src/shared/hooks/useLiveData.mjs | Polling hook + BroadcastChannel leader + version detection |
src/shared/utils/reloadGuard.mjs | Version mismatch reload guard (circuit breaker) |
src/instrumentation-client.js | ChunkLoadError handler (global safety net) |
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) |