Skip to content

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:

LayerWhenPersistenceRequires
Live PollingPage is openUpdates every 10sNothing
Sonner ToastsEvent transitionUntil dismissedNothing
Web NotificationsTab is backgroundedOS notificationPermission
Push NotificationsBrowser is closedOS notificationSubscription

Data Flow

How notifications travel from the official API to the user. Click any node for details. Use the filters to trace individual flows.

Server / Worker
Database
Transport
Client
Notification
Click any node for details

In-App (Polling + Toasts)

External API / Transport
Server
Client
Notification
Click any node for details

Push (Separate Channel)

Server
Database
Transport
Notification
Click any node for details

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: visibilitychange listener fires immediate poll on tab focus (browsers throttle setInterval in 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 or navigator.onLine is 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:

TransitionDetectionToast Type
Campaign startedNew event_id appearsDefault
Campaign wonactive to successSuccess
Campaign lostactive to failError

Events are matched by both event_id and type (defend/attack).

Client vs Server

  • Client-side (LiveToasts.jsx): Compares prevData from useLiveData hook. 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-glow animation
  • Positioned: Bottom-right via Sonner's <Toaster>, co-located inside LiveToasts (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 NotificationToggle component (not on page load)
  • Leader election: BroadcastChannel ensures only one tab sends OS notifications (all tabs show Sonner toasts independently)
  • Trigger: Same detectChanges output as toasts, but only fires when document.hidden === true

Push Notifications

Server-initiated notifications that work when the browser is closed:

Setup

  1. Generate VAPID keys: npx web-push generate-vapid-keys
  2. Set environment variables: VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT
  3. Set NEXT_PUBLIC_VAPID_PUBLIC_KEY for the client

Subscription Flow

  1. User clicks "Enable notifications" (NotificationToggle component — enables both web notifications and push in one action)
  2. Service worker registers, pushManager.subscribe() creates a subscription
  3. POST /api/notifications/subscribe stores it in push_subscription table
  4. Validated with Zod: endpoint URL max 2048 chars, keys base64 max 256 chars

Push Delivery

  1. checkAndNotify() runs after each update (fire-and-forget)
  2. Detects event transitions using the same detectChanges function
  3. Builds payload with faction icon, badge, per-event tag, and renotify flag
  4. Fans out via web-push library with 50 concurrent request limit
  5. 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 same sendWithConcurrencyLimit path 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/badge same-origin, passes tag/renotify to showNotification()
  • Notification clicks: Focuses existing tab or opens new one
  • Caching: Serwist defaultCache provides Next.js-optimized runtime caching strategies
  • API exclusion: Explicit NetworkOnly route 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 TypeStrategyRationale
API routes (/api/*)NetworkOnly (explicit)Live data must never be served from cache
NavigationNetwork-firstHTML must match current build
Static assetsStale-while-revalidateServe fast from cache, update in background

Offline Behavior

When offline, the app loads precached assets and displays last known war data:

  1. Shell: SW serves precached HTML + assets → app renders visually complete
  2. Data: useLiveData hook falls back to localStorage (hd1-live-cache)
  3. Status: StatusDot shows red "offline" indicator (via navigator.onLine check)
  4. Timestamps: "Last updated X ago" via formatTimeAgo shows 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 chrome
  • orientation: 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 mobile
  • appleWebApp: capable with black-translucent status bar — iOS standalone mode
  • 20 portrait iOS splash screens for branded cold-boot experience

Connection States

StatusDot component shows poll status from LiveDataContext:

StateIndicatorMeaning
liveGreen dotLast poll succeeded
pollingOrange dotPoll request in flight
offlineRed dotLast poll failed or navigator.onLine is false

Key Files

FileRole
src/sw.jsSerwist SW source (precache + push handlers)
serwist.config.jsSerwist build config (configurator mode)
src/app/api/h1/live/route.jsPolling endpoint (getCampaign + computeMapState)
src/shared/hooks/useLiveData.mjsPolling hook + BroadcastChannel leader
src/shared/utils/game/detectChanges.mjsEvent transition detection (shared)
src/features/notifications/LiveToasts.jsxSonner toasts + Web Notifications
src/features/notifications/NotificationToggle.jsxCombined notification + push toggle
src/shared/components/StatusDot.jsxTri-state connection indicator
src/update/pushNotifier.mjsServer-side push fan-out
src/app/api/notifications/subscribe/route.jsPush subscription CRUD
src/app/site.webmanifestPWA manifest (install, icons, orientation)