diff --git a/public/sw.js b/public/sw.js
index 11eb0cd..fbe836f 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -174,3 +174,116 @@ self.addEventListener("fetch", (event) => {
}
// Everything else: let the browser handle it normally
});
+
+/* ============================================================
+ * Push notification router
+ * - Foreground client present → postMessage (in-app toast)
+ * - Background → system Notification, coalesced
+ * within a 60s window via a shared tag (the coalescence key).
+ * ============================================================ */
+
+const COALESCENCE_BODY_PREFIX = 80;
+const MAX_NOTIFICATION_ACTIONS = 2;
+
+/* Mirror of src/utils/topicRouter.ts coalescenceKey (the SW cannot import it). */
+function coalescenceKey(topic, body) {
+ return `${topic} ${(body || "").slice(0, COALESCENCE_BODY_PREFIX)}`;
+}
+
+async function handlePush(event) {
+ let payload = null;
+ try {
+ payload = event.data ? event.data.json() : null;
+ } catch {
+ payload = null;
+ }
+ if (!payload || typeof payload.topic !== "string") return;
+
+ const { topic, title = "Notification", body = "", data = {}, actions = [] } =
+ payload;
+ const key = coalescenceKey(topic, body);
+
+ const clientList = await self.clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+
+ // Foreground detection: route to the in-app toast queue instead of the OS.
+ const hasVisibleClient = clientList.some(
+ (c) => c.visibilityState === "visible" || c.focused
+ );
+ if (hasVisibleClient) {
+ for (const client of clientList) {
+ client.postMessage({ type: "push-notification", coalescenceKey: key, payload });
+ }
+ return;
+ }
+
+ // Background: coalesce identical events sharing the tag into one notification.
+ const existing = await self.registration.getNotifications({ tag: key });
+ const prevCount =
+ existing.length && existing[0].data ? existing[0].data.count || 1 : 0;
+ const count = prevCount + 1;
+
+ await self.registration.showNotification(
+ count > 1 ? `${title} (${count})` : title,
+ {
+ body,
+ tag: key, // identical tag replaces the prior notification (coalescence)
+ renotify: count > 1,
+ icon: "/icon-192.png",
+ badge: "/icon-192.png",
+ data: { ...data, topic, count, actions, coalescenceKey: key },
+ actions: (actions || [])
+ .slice(0, MAX_NOTIFICATION_ACTIONS)
+ .map((a) => ({ action: a.action, title: a.title, icon: a.icon })),
+ }
+ );
+}
+
+self.addEventListener("push", (event) => {
+ event.waitUntil(handlePush(event));
+});
+
+/* ---------- Notification click: route to URL or app route ---------- */
+function resolveTarget(notificationData, actionId) {
+ const actions = (notificationData && notificationData.actions) || [];
+ if (actionId) {
+ const matched = actions.find((a) => a.action === actionId);
+ if (matched) return matched.url || matched.route || "/";
+ }
+ const first = actions[0];
+ return (first && (first.url || first.route)) || "/";
+}
+
+async function openOrFocus(target, payload) {
+ const clientList = await self.clients.matchAll({
+ type: "window",
+ includeUncontrolled: true,
+ });
+ for (const client of clientList) {
+ if ("focus" in client) {
+ await client.focus();
+ client.postMessage({ type: "notification-action", target, payload });
+ return;
+ }
+ }
+ if (self.clients.openWindow) {
+ await self.clients.openWindow(target);
+ }
+}
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+ const data = event.notification.data || {};
+ const target = resolveTarget(data, event.action);
+ event.waitUntil(openOrFocus(target, data));
+});
+
+/* ---------- Control messages from clients ---------- */
+self.addEventListener("message", (event) => {
+ const msg = event.data;
+ if (msg && msg.type === "skip-waiting") {
+ self.skipWaiting();
+ }
+});
diff --git a/src/components/panels/ToastContainer.tsx b/src/components/panels/ToastContainer.tsx
new file mode 100644
index 0000000..01eb05a
--- /dev/null
+++ b/src/components/panels/ToastContainer.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useNotifications, notificationStore } from "@/store/slices/notificationSlice";
+import {
+ MAX_NOTIFICATION_ACTIONS,
+ type AppNotification,
+ type NotificationAction,
+} from "@/types/notification";
+
+/**
+ * In-app toast renderer. Reads the coalesced notification queue and shows one
+ * toast per coalescence key, suffixing "(N more)" when a key has coalesced more
+ * than once. Action buttons map to a URL or internal route (max 2).
+ */
+
+export interface ToastContainerProps {
+ /** Navigate to an internal app route (e.g. router.push). */
+ onNavigate?: (route: string) => void;
+ /** Called when an action button is pressed, before navigation. */
+ onAction?: (notification: AppNotification, action: NotificationAction) => void;
+ className?: string;
+}
+
+function topicAccent(topic: string): string {
+ const domain = topic.split(".")[0];
+ switch (domain) {
+ case "meter":
+ return "border-l-blue-500";
+ case "contract":
+ return "border-l-amber-500";
+ case "system":
+ return "border-l-red-500";
+ default:
+ return "border-l-border";
+ }
+}
+
+export function ToastContainer({
+ onNavigate,
+ onAction,
+ className,
+}: ToastContainerProps) {
+ const notifications = useNotifications();
+
+ if (notifications.length === 0) return null;
+
+ const handleAction = (
+ notification: AppNotification,
+ action: NotificationAction
+ ) => {
+ onAction?.(notification, action);
+ if (action.route) {
+ onNavigate?.(action.route);
+ } else if (action.url && typeof window !== "undefined") {
+ window.open(action.url, "_blank", "noopener,noreferrer");
+ }
+ notificationStore.dismiss(notification.id);
+ };
+
+ return (
+
+ {notifications.map((n) => (
+
+
+
+
+ {n.title}
+ {n.count > 1 && (
+
+ ({n.count - 1} more)
+
+ )}
+
+
+ {n.body}
+
+
+ {n.topic}
+
+
+
+
+
+ {n.actions && n.actions.length > 0 && (
+
+ {n.actions.slice(0, MAX_NOTIFICATION_ACTIONS).map((action) => (
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+export default ToastContainer;
diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts
new file mode 100644
index 0000000..151c189
--- /dev/null
+++ b/src/hooks/usePushNotifications.ts
@@ -0,0 +1,162 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ getActiveSubscription,
+ isPushSupported,
+ subscribe as subscribePush,
+ unsubscribe as unsubscribePush,
+ type PushSubscriptionManagerDeps,
+} from "@/services/pushSubscriptionManager";
+import { topicRouter } from "@/utils/topicRouter";
+import { notificationStore, useNotifications } from "@/store/slices/notificationSlice";
+import type {
+ AppNotification,
+ PushTopicHandler,
+ SwPushMessage,
+} from "@/types/notification";
+
+export type PushPermission = NotificationPermission | "unsupported";
+
+export interface UsePushNotificationsOptions {
+ /** Inject subscription-manager dependencies (tests / custom transport). */
+ deps?: PushSubscriptionManagerDeps;
+ /** Subscribe automatically once permission is granted. @default false */
+ autoSubscribe?: boolean;
+}
+
+export interface UsePushNotificationsResult {
+ permission: PushPermission;
+ isSubscribed: boolean;
+ supported: boolean;
+ notifications: AppNotification[];
+ requestPermission: () => Promise;
+ subscribe: () => Promise;
+ unsubscribe: () => Promise;
+ /** Register a handler for a topic/pattern. Returns an unsubscribe function. */
+ registerTopic: (pattern: string, handler: PushTopicHandler) => () => void;
+ dismiss: (key: string) => void;
+}
+
+function readPermission(): PushPermission {
+ if (typeof Notification === "undefined") return "unsupported";
+ return Notification.permission;
+}
+
+function isSwPushMessage(data: unknown): data is SwPushMessage {
+ return (
+ typeof data === "object" &&
+ data !== null &&
+ (data as { type?: unknown }).type === "push-notification"
+ );
+}
+
+/**
+ * Orchestrates push notifications: requests permission, registers the push
+ * subscription, and wires foreground delivery. When the SW detects a visible
+ * client it posts the event here instead of showing a system notification; the
+ * hook fans it out to topic handlers and the in-app toast queue.
+ */
+export function usePushNotifications(
+ options: UsePushNotificationsOptions = {}
+): UsePushNotificationsResult {
+ const { deps, autoSubscribe = false } = options;
+ const supported = typeof window !== "undefined" && isPushSupported();
+
+ const [permission, setPermission] = useState(() =>
+ typeof window === "undefined" ? "unsupported" : readPermission()
+ );
+ const [isSubscribed, setIsSubscribed] = useState(false);
+ const notifications = useNotifications();
+
+ const depsRef = useRef(deps);
+ depsRef.current = deps;
+
+ // Foreground delivery: the SW posts coalesced push events to this client.
+ useEffect(() => {
+ if (typeof navigator === "undefined" || !navigator.serviceWorker) return;
+ const onMessage = (event: MessageEvent) => {
+ if (!isSwPushMessage(event.data)) return;
+ const { payload } = event.data;
+ topicRouter.emit(payload);
+ notificationStore.receive(payload);
+ };
+ navigator.serviceWorker.addEventListener("message", onMessage);
+ return () =>
+ navigator.serviceWorker.removeEventListener("message", onMessage);
+ }, []);
+
+ // Reflect any pre-existing subscription on mount.
+ useEffect(() => {
+ let cancelled = false;
+ if (!supported) return;
+ getActiveSubscription(depsRef.current)
+ .then((sub) => {
+ if (!cancelled) setIsSubscribed(sub !== null);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [supported]);
+
+ const requestPermission = useCallback(async (): Promise => {
+ if (typeof Notification === "undefined") return "unsupported";
+ const result = await Notification.requestPermission();
+ setPermission(result);
+ return result;
+ }, []);
+
+ const subscribe = useCallback(async (): Promise => {
+ if (!supported) return false;
+ let perm = readPermission();
+ if (perm === "default") perm = await requestPermission();
+ if (perm !== "granted") {
+ setPermission(perm);
+ return false;
+ }
+ try {
+ await subscribePush(depsRef.current);
+ setIsSubscribed(true);
+ return true;
+ } catch {
+ setIsSubscribed(false);
+ return false;
+ }
+ }, [supported, requestPermission]);
+
+ const unsubscribe = useCallback(async (): Promise => {
+ const result = await unsubscribePush(depsRef.current);
+ if (result) setIsSubscribed(false);
+ return result;
+ }, []);
+
+ const registerTopic = useCallback(
+ (pattern: string, handler: PushTopicHandler) =>
+ topicRouter.insert(pattern, handler),
+ []
+ );
+
+ const dismiss = useCallback((key: string) => {
+ notificationStore.dismiss(key);
+ }, []);
+
+ // Optional auto-subscribe once permission is already granted.
+ useEffect(() => {
+ if (autoSubscribe && supported && permission === "granted" && !isSubscribed) {
+ void subscribe();
+ }
+ }, [autoSubscribe, supported, permission, isSubscribed, subscribe]);
+
+ return {
+ permission,
+ isSubscribed,
+ supported,
+ notifications,
+ requestPermission,
+ subscribe,
+ unsubscribe,
+ registerTopic,
+ dismiss,
+ };
+}
diff --git a/src/services/pushSubscriptionManager.ts b/src/services/pushSubscriptionManager.ts
new file mode 100644
index 0000000..1131196
--- /dev/null
+++ b/src/services/pushSubscriptionManager.ts
@@ -0,0 +1,136 @@
+"use client";
+
+/**
+ * Registers and unregisters Web Push subscriptions with the backend.
+ *
+ * `subscribe()` asks the active service worker's PushManager for a subscription
+ * (VAPID `applicationServerKey`, `userVisibleOnly: true`) and POSTs it to the
+ * server; `unsubscribe()` tears it down and DELETEs it. Transport and the
+ * registration source are injectable so the flow is testable without a real SW.
+ */
+
+export interface PushSubscriptionManagerDeps {
+ /** Resolve the active service worker registration. */
+ getRegistration?: () => Promise;
+ fetchFn?: typeof fetch;
+ /** Base64url VAPID public key. Defaults to NEXT_PUBLIC_VAPID_PUBLIC_KEY. */
+ applicationServerKey?: string;
+ /** REST base path. @default "/api/push" */
+ apiBase?: string;
+}
+
+/** Decode a base64url VAPID key into the Uint8Array the PushManager expects. */
+export function urlBase64ToUint8Array(base64: string): Uint8Array {
+ const padding = "=".repeat((4 - (base64.length % 4)) % 4);
+ const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
+ const raw = atob(normalized);
+ const output = new Uint8Array(raw.length);
+ for (let i = 0; i < raw.length; i++) {
+ output[i] = raw.charCodeAt(i);
+ }
+ return output;
+}
+
+function resolveDeps(deps: PushSubscriptionManagerDeps = {}) {
+ return {
+ getRegistration:
+ deps.getRegistration ?? (() => navigator.serviceWorker.ready),
+ fetchFn: deps.fetchFn ?? fetch.bind(globalThis),
+ applicationServerKey:
+ deps.applicationServerKey ?? process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
+ apiBase: deps.apiBase ?? "/api/push",
+ };
+}
+
+export class PushNotSupportedError extends Error {
+ constructor() {
+ super("Push messaging is not supported in this environment");
+ this.name = "PushNotSupportedError";
+ }
+}
+
+/** True when this environment can register push subscriptions. */
+export function isPushSupported(): boolean {
+ return (
+ typeof navigator !== "undefined" &&
+ "serviceWorker" in navigator &&
+ typeof window !== "undefined" &&
+ "PushManager" in window &&
+ "Notification" in window
+ );
+}
+
+/**
+ * Ensure an active push subscription exists and is registered with the backend.
+ * Reuses an existing subscription when present (idempotent).
+ */
+export async function subscribe(
+ deps: PushSubscriptionManagerDeps = {}
+): Promise {
+ if (!isPushSupported()) throw new PushNotSupportedError();
+ const { getRegistration, fetchFn, applicationServerKey, apiBase } =
+ resolveDeps(deps);
+ if (!applicationServerKey) {
+ throw new Error("Missing VAPID applicationServerKey");
+ }
+
+ const registration = await getRegistration();
+ const existing = await registration.pushManager.getSubscription();
+ const subscription =
+ existing ??
+ (await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ // Cast: the DOM lib types the key as BufferSource; the generic Uint8Array
+ // returned here is structurally compatible but not auto-assignable.
+ applicationServerKey: urlBase64ToUint8Array(
+ applicationServerKey
+ ) as unknown as BufferSource,
+ }));
+
+ const res = await fetchFn(`${apiBase}/subscribe`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(subscription.toJSON()),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to register subscription: HTTP ${res.status}`);
+ }
+ return subscription;
+}
+
+/**
+ * Remove the active subscription from the backend and the browser. Resolves
+ * `true` if a subscription was torn down, `false` if there was none.
+ */
+export async function unsubscribe(
+ deps: PushSubscriptionManagerDeps = {}
+): Promise {
+ if (!isPushSupported()) return false;
+ const { getRegistration, fetchFn, apiBase } = resolveDeps(deps);
+
+ const registration = await getRegistration();
+ const subscription = await registration.pushManager.getSubscription();
+ if (!subscription) return false;
+
+ // Best-effort backend cleanup before dropping the local subscription.
+ try {
+ await fetchFn(`${apiBase}/subscribe`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ endpoint: subscription.endpoint }),
+ });
+ } catch {
+ // proceed with local unsubscribe regardless
+ }
+ return subscription.unsubscribe();
+}
+
+/** Return the current subscription, or null if none / unsupported. */
+export async function getActiveSubscription(
+ deps: PushSubscriptionManagerDeps = {}
+): Promise {
+ if (!isPushSupported()) return null;
+ const { getRegistration } = resolveDeps(deps);
+ const registration = await getRegistration();
+ return registration.pushManager.getSubscription();
+}
diff --git a/src/store/slices/notificationSlice.ts b/src/store/slices/notificationSlice.ts
new file mode 100644
index 0000000..4f82fbd
--- /dev/null
+++ b/src/store/slices/notificationSlice.ts
@@ -0,0 +1,151 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { coalescenceKey } from "@/utils/topicRouter";
+import {
+ COALESCENCE_WINDOW_MS,
+ MAX_NOTIFICATION_ACTIONS,
+ type AppNotification,
+ type PushPayload,
+} from "@/types/notification";
+
+/**
+ * In-app notification queue with 60-second coalescence.
+ *
+ * Identical events (same {@link coalescenceKey}) within the window increment a
+ * count on the existing notification and extend its auto-dismiss timer instead
+ * of creating duplicates. Implemented as a custom store (matching the codebase
+ * pattern) consumed via {@link useNotifications}. Timers are injectable so the
+ * coalescence behaviour is testable with fake clocks.
+ */
+
+interface Entry {
+ notification: AppNotification;
+ timer: ReturnType | null;
+}
+
+export interface NotificationStoreDeps {
+ setTimeoutFn?: (cb: () => void, ms: number) => ReturnType;
+ clearTimeoutFn?: (id: ReturnType) => void;
+ now?: () => number;
+ window?: number;
+}
+
+type Listener = (notifications: AppNotification[]) => void;
+
+const EMPTY: AppNotification[] = [];
+
+export class NotificationStore {
+ private entries = new Map();
+ private listeners = new Set();
+ /** Cached, referentially-stable snapshot for useSyncExternalStore. */
+ private snapshot: AppNotification[] = EMPTY;
+
+ private readonly setTimeoutFn: NonNullable;
+ private readonly clearTimeoutFn: NonNullable;
+ private readonly now: () => number;
+ private readonly window: number;
+
+ constructor(deps: NotificationStoreDeps = {}) {
+ this.setTimeoutFn =
+ deps.setTimeoutFn ?? ((cb, ms) => setTimeout(cb, ms));
+ this.clearTimeoutFn = deps.clearTimeoutFn ?? ((id) => clearTimeout(id));
+ this.now = deps.now ?? Date.now;
+ this.window = deps.window ?? COALESCENCE_WINDOW_MS;
+ }
+
+ getState = (): AppNotification[] => this.snapshot;
+
+ subscribe = (listener: Listener): (() => void) => {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ };
+
+ /**
+ * Ingest a push payload. Coalesces into an existing entry when one with the
+ * same key is live, otherwise creates a new one. Returns the coalescence key.
+ */
+ receive(payload: PushPayload): string {
+ const key = coalescenceKey(payload.topic, payload.body);
+ const t = this.now();
+ const existing = this.entries.get(key);
+
+ if (existing) {
+ if (existing.timer !== null) this.clearTimeoutFn(existing.timer);
+ existing.notification = {
+ ...existing.notification,
+ // Refresh display fields to the latest occurrence.
+ title: payload.title,
+ body: payload.body,
+ data: payload.data,
+ actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS),
+ lastSeenAt: t,
+ count: existing.notification.count + 1,
+ };
+ existing.timer = this.arm(key);
+ } else {
+ const notification: AppNotification = {
+ id: key,
+ topic: payload.topic,
+ title: payload.title,
+ body: payload.body,
+ data: payload.data,
+ actions: payload.actions?.slice(0, MAX_NOTIFICATION_ACTIONS),
+ firstSeenAt: t,
+ lastSeenAt: t,
+ count: 1,
+ };
+ this.entries.set(key, { notification, timer: this.arm(key) });
+ }
+
+ this.commit();
+ return key;
+ }
+
+ /** Manually dismiss a notification (e.g. operator acknowledged it). */
+ dismiss(key: string): void {
+ const entry = this.entries.get(key);
+ if (!entry) return;
+ if (entry.timer !== null) this.clearTimeoutFn(entry.timer);
+ this.entries.delete(key);
+ this.commit();
+ }
+
+ /** Remove all notifications and cancel their timers. */
+ clear(): void {
+ for (const entry of this.entries.values()) {
+ if (entry.timer !== null) this.clearTimeoutFn(entry.timer);
+ }
+ this.entries.clear();
+ this.commit();
+ }
+
+ /** Current coalescence count for a key (0 if absent). */
+ countFor(key: string): number {
+ return this.entries.get(key)?.notification.count ?? 0;
+ }
+
+ private arm(key: string): ReturnType {
+ return this.setTimeoutFn(() => this.dismiss(key), this.window);
+ }
+
+ /** Recompute the cached snapshot (newest first) and notify subscribers. */
+ private commit(): void {
+ const list = Array.from(this.entries.values(), (e) => e.notification);
+ list.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
+ this.snapshot = list.length === 0 ? EMPTY : list;
+ for (const listener of this.listeners) listener(this.snapshot);
+ }
+}
+
+/** Shared singleton notification store. */
+export const notificationStore = new NotificationStore();
+
+/** React binding: subscribe a component to the coalesced notification queue. */
+export function useNotifications(): AppNotification[] {
+ return useSyncExternalStore(
+ notificationStore.subscribe,
+ notificationStore.getState,
+ notificationStore.getState
+ );
+}
diff --git a/src/types/notification.ts b/src/types/notification.ts
new file mode 100644
index 0000000..dc2b12d
--- /dev/null
+++ b/src/types/notification.ts
@@ -0,0 +1,83 @@
+/**
+ * Types and invariants for the push-notification router with a topic-based
+ * subscription registry.
+ *
+ * Push events carry a three-level topic (`domain.subdomain.action`, e.g.
+ * `meter.water.breach`). UI modules register handlers against exact topics or
+ * trailing-wildcard ancestors (`meter.water.*`, `meter.*`, `*`). Identical
+ * events are coalesced within a 60-second window. In the foreground the service
+ * worker routes to an in-app toast queue instead of the system Notification API.
+ */
+
+/** Topic taxonomy depth (domain.subdomain.action). */
+export const TOPIC_MAX_DEPTH = 3;
+/** Maximum distinct registered topics/patterns. */
+export const MAX_SUBSCRIPTIONS = 200;
+/** Maximum handler callbacks per topic/pattern. */
+export const MAX_HANDLERS_PER_TOPIC = 5;
+/** Coalescence window: identical events within this window are merged (ms). */
+export const COALESCENCE_WINDOW_MS = 60_000;
+/** Chrome desktop caps notification action buttons at 2. */
+export const MAX_NOTIFICATION_ACTIONS = 2;
+/** Browser push payload size limit (bytes). */
+export const PUSH_PAYLOAD_LIMIT_BYTES = 4096;
+/** Number of body characters folded into the coalescence key. */
+export const COALESCENCE_BODY_PREFIX = 80;
+
+/** An actionable notification button. Maps to a URL or an internal app route. */
+export interface NotificationAction {
+ /** Stable action id echoed back on click (e.g. "acknowledge", "view-map"). */
+ action: string;
+ /** Button label, e.g. "Acknowledge". */
+ title: string;
+ /** External URL to open on click. */
+ url?: string;
+ /** Internal app route to navigate to on click. */
+ route?: string;
+ /** Optional icon URL. */
+ icon?: string;
+}
+
+/** The push payload delivered over the wire (≤ 4 KB). */
+export interface PushPayload {
+ /** Three-level topic, e.g. "contract.execution.reverted". */
+ topic: string;
+ title: string;
+ body: string;
+ /** Optional structured data (reserved within the 4 KB budget). */
+ data?: Record;
+ /** Up to {@link MAX_NOTIFICATION_ACTIONS} action buttons. */
+ actions?: NotificationAction[];
+}
+
+/** Handler registered against a topic/pattern in the router. */
+export type PushTopicHandler = (payload: PushPayload) => void;
+
+/** A coalesced, in-app notification rendered by the toast container. */
+export interface AppNotification {
+ /** Stable id (the coalescence key). */
+ id: string;
+ topic: string;
+ title: string;
+ body: string;
+ data?: Record;
+ actions?: NotificationAction[];
+ /** First time this coalescence key was seen (ms). */
+ firstSeenAt: number;
+ /** Most recent occurrence (ms). */
+ lastSeenAt: number;
+ /** Number of coalesced occurrences (≥ 1). */
+ count: number;
+}
+
+/** Message the service worker posts to a foreground client. */
+export interface SwPushMessage {
+ type: "push-notification";
+ coalescenceKey: string;
+ payload: PushPayload;
+}
+
+/** Message a client posts to the SW (e.g. to acknowledge). */
+export type ClientSwMessage =
+ | { type: "ack"; coalescenceKey: string }
+ | { type: "skip-waiting" };
diff --git a/src/utils/topicRouter.ts b/src/utils/topicRouter.ts
new file mode 100644
index 0000000..a5cbdb7
--- /dev/null
+++ b/src/utils/topicRouter.ts
@@ -0,0 +1,180 @@
+/**
+ * Topic trie for routing push events to registered handlers.
+ *
+ * Subscriptions are keyed by a topic pattern: an exact topic
+ * (`meter.water.breach`) or a trailing-wildcard ancestor (`meter.water.*`,
+ * `meter.*`, `*`). A trailing `*` matches one or more remaining segments, so
+ * `meter.*` matches `meter.water.breach`. Matching collects handlers from the
+ * exact path and every wildcard ancestor.
+ */
+
+import {
+ COALESCENCE_BODY_PREFIX,
+ MAX_HANDLERS_PER_TOPIC,
+ MAX_SUBSCRIPTIONS,
+ TOPIC_MAX_DEPTH,
+ type PushPayload,
+ type PushTopicHandler,
+} from "@/types/notification";
+
+const SEGMENT_RE = /^[a-z0-9_-]+$/;
+
+/** Validate a subscription pattern (segments + optional trailing wildcard). */
+export function isValidPattern(pattern: string): boolean {
+ if (!pattern) return false;
+ const segments = pattern.split(".");
+ if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false;
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i];
+ if (seg === "*") {
+ // A wildcard is only valid as the final segment.
+ if (i !== segments.length - 1) return false;
+ continue;
+ }
+ if (!SEGMENT_RE.test(seg)) return false;
+ }
+ return true;
+}
+
+/** Validate a concrete (non-wildcard) topic. */
+export function isValidTopic(topic: string): boolean {
+ if (!topic) return false;
+ const segments = topic.split(".");
+ if (segments.length < 1 || segments.length > TOPIC_MAX_DEPTH) return false;
+ return segments.every((s) => SEGMENT_RE.test(s));
+}
+
+/**
+ * Coalescence key: identical events within the window share `topic` plus the
+ * first {@link COALESCENCE_BODY_PREFIX} chars of the body. Mirrored inline in
+ * `public/sw.js` (the SW cannot import this module).
+ */
+export function coalescenceKey(topic: string, body: string): string {
+ return `${topic} ${(body ?? "").slice(0, COALESCENCE_BODY_PREFIX)}`;
+}
+
+interface TrieNode {
+ children: Map;
+ handlers: Set;
+}
+
+function createNode(): TrieNode {
+ return { children: new Map(), handlers: new Set() };
+}
+
+export class TopicRouter {
+ private readonly root = createNode();
+ /** Number of distinct patterns currently holding at least one handler. */
+ private patternCount = 0;
+
+ /** Distinct subscribed patterns. */
+ get size(): number {
+ return this.patternCount;
+ }
+
+ /**
+ * Register `handler` for `pattern`. Returns an unsubscribe function. Throws if
+ * the subscription or per-topic handler limits would be exceeded.
+ */
+ insert(pattern: string, handler: PushTopicHandler): () => void {
+ if (!isValidPattern(pattern)) {
+ throw new Error(`Invalid topic pattern: "${pattern}"`);
+ }
+
+ const segments = pattern.split(".");
+ let node = this.root;
+ for (const seg of segments) {
+ let child = node.children.get(seg);
+ if (!child) {
+ child = createNode();
+ node.children.set(seg, child);
+ }
+ node = child;
+ }
+
+ const isNewPattern = node.handlers.size === 0;
+ if (isNewPattern && this.patternCount >= MAX_SUBSCRIPTIONS) {
+ throw new Error(
+ `Subscription limit reached (${MAX_SUBSCRIPTIONS} topics)`
+ );
+ }
+ if (node.handlers.size >= MAX_HANDLERS_PER_TOPIC) {
+ throw new Error(
+ `Handler limit reached for "${pattern}" (${MAX_HANDLERS_PER_TOPIC})`
+ );
+ }
+
+ node.handlers.add(handler);
+ if (isNewPattern) this.patternCount += 1;
+
+ let active = true;
+ return () => {
+ if (!active) return;
+ active = false;
+ if (node.handlers.delete(handler) && node.handlers.size === 0) {
+ this.patternCount -= 1;
+ }
+ };
+ }
+
+ /** All handlers matching `topic` (exact path + wildcard ancestors). */
+ match(topic: string): PushTopicHandler[] {
+ if (!isValidTopic(topic)) return [];
+ const segments = topic.split(".");
+ const out: PushTopicHandler[] = [];
+ this.collect(this.root, segments, 0, out);
+ return out;
+ }
+
+ private collect(
+ node: TrieNode,
+ segments: string[],
+ index: number,
+ out: PushTopicHandler[]
+ ): void {
+ if (index === segments.length) {
+ // Exact match terminates here.
+ for (const h of node.handlers) out.push(h);
+ return;
+ }
+
+ // A trailing wildcard child matches the remaining (≥1) segments.
+ const star = node.children.get("*");
+ if (star) {
+ for (const h of star.handlers) out.push(h);
+ }
+
+ // Descend the exact segment path.
+ const exact = node.children.get(segments[index]);
+ if (exact) {
+ this.collect(exact, segments, index + 1, out);
+ }
+ }
+
+ /**
+ * Dispatch `payload` to every matching handler. Handler exceptions are
+ * isolated so one bad subscriber cannot break delivery to the others.
+ * Returns the number of handlers invoked.
+ */
+ emit(payload: PushPayload): number {
+ const handlers = this.match(payload.topic);
+ for (const handler of handlers) {
+ try {
+ handler(payload);
+ } catch {
+ // isolate handler failures
+ }
+ }
+ return handlers.length;
+ }
+
+ /** Remove all subscriptions. */
+ clear(): void {
+ this.root.children.clear();
+ this.root.handlers.clear();
+ this.patternCount = 0;
+ }
+}
+
+/** Shared singleton router for the app. */
+export const topicRouter = new TopicRouter();
diff --git a/tests/unit/notificationSlice.test.ts b/tests/unit/notificationSlice.test.ts
new file mode 100644
index 0000000..fd8e69b
--- /dev/null
+++ b/tests/unit/notificationSlice.test.ts
@@ -0,0 +1,139 @@
+import { describe, it, expect } from "vitest";
+import { NotificationStore } from "@/store/slices/notificationSlice";
+import type { PushPayload } from "@/types/notification";
+
+/** A controllable scheduler so coalescence timers can be driven deterministically. */
+function fakeScheduler() {
+ let nextId = 1;
+ let clock = 0;
+ const timers = new Map void }>();
+ return {
+ now: () => clock,
+ setTimeoutFn: (cb: () => void, ms: number) => {
+ const id = nextId++;
+ timers.set(id, { fireAt: clock + ms, cb });
+ return id as unknown as ReturnType;
+ },
+ clearTimeoutFn: (id: ReturnType) => {
+ timers.delete(id as unknown as number);
+ },
+ advance: (ms: number) => {
+ clock += ms;
+ for (const [id, t] of [...timers.entries()]) {
+ if (t.fireAt <= clock) {
+ timers.delete(id);
+ t.cb();
+ }
+ }
+ },
+ pending: () => timers.size,
+ };
+}
+
+function makeStore(sched: ReturnType) {
+ return new NotificationStore({
+ setTimeoutFn: sched.setTimeoutFn,
+ clearTimeoutFn: sched.clearTimeoutFn,
+ now: sched.now,
+ window: 60_000,
+ });
+}
+
+function payload(topic: string, body: string, extra?: Partial): PushPayload {
+ return { topic, title: "Title", body, ...extra };
+}
+
+describe("NotificationStore coalescence", () => {
+ it("creates a notification with count 1", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ const key = store.receive(payload("meter.water.breach", "leak"));
+ const state = store.getState();
+ expect(state).toHaveLength(1);
+ expect(state[0].id).toBe(key);
+ expect(state[0].count).toBe(1);
+ });
+
+ it("coalesces identical events into a single incremented entry", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(payload("meter.water.breach", "leak"));
+ store.receive(payload("meter.water.breach", "leak"));
+ store.receive(payload("meter.water.breach", "leak"));
+ const state = store.getState();
+ expect(state).toHaveLength(1);
+ expect(state[0].count).toBe(3);
+ });
+
+ it("keeps distinct keys separate", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(payload("meter.water.breach", "leak"));
+ store.receive(payload("contract.execution.reverted", "tx failed"));
+ expect(store.getState()).toHaveLength(2);
+ });
+
+ it("auto-dismisses after the coalescence window", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(payload("system.health.cpu", "spike"));
+ expect(store.getState()).toHaveLength(1);
+ sched.advance(60_000);
+ expect(store.getState()).toHaveLength(0);
+ });
+
+ it("extends the window on each repeat occurrence", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(payload("system.health.cpu", "spike"));
+ sched.advance(40_000); // not yet expired
+ store.receive(payload("system.health.cpu", "spike")); // resets the 60s timer
+ sched.advance(40_000); // 80s since first, but only 40s since last
+ expect(store.getState()).toHaveLength(1);
+ expect(store.getState()[0].count).toBe(2);
+ sched.advance(20_000); // now 60s since last
+ expect(store.getState()).toHaveLength(0);
+ });
+
+ it("dismiss removes the entry and cancels its timer", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ const key = store.receive(payload("meter.water.breach", "leak"));
+ store.dismiss(key);
+ expect(store.getState()).toHaveLength(0);
+ expect(sched.pending()).toBe(0);
+ });
+
+ it("caps actions at two", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(
+ payload("meter.water.breach", "leak", {
+ actions: [
+ { action: "a", title: "A" },
+ { action: "b", title: "B" },
+ { action: "c", title: "C" },
+ ],
+ })
+ );
+ expect(store.getState()[0].actions).toHaveLength(2);
+ });
+
+ it("returns a stable snapshot reference when unchanged", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ const a = store.getState();
+ const b = store.getState();
+ expect(a).toBe(b);
+ });
+
+ it("clear() empties the queue and cancels timers", () => {
+ const sched = fakeScheduler();
+ const store = makeStore(sched);
+ store.receive(payload("meter.water.breach", "leak"));
+ store.receive(payload("system.health.cpu", "spike"));
+ store.clear();
+ expect(store.getState()).toHaveLength(0);
+ expect(sched.pending()).toBe(0);
+ });
+});
diff --git a/tests/unit/pushSubscriptionManager.test.ts b/tests/unit/pushSubscriptionManager.test.ts
new file mode 100644
index 0000000..4fe2e06
--- /dev/null
+++ b/tests/unit/pushSubscriptionManager.test.ts
@@ -0,0 +1,158 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ urlBase64ToUint8Array,
+ subscribe,
+ unsubscribe,
+ isPushSupported,
+} from "@/services/pushSubscriptionManager";
+
+// --- Make isPushSupported() return true in jsdom ---------------------------
+beforeEach(() => {
+ Object.defineProperty(window, "PushManager", { value: class {}, configurable: true });
+ Object.defineProperty(window, "Notification", { value: class {}, configurable: true });
+ Object.defineProperty(globalThis, "Notification", {
+ value: class {},
+ configurable: true,
+ });
+ Object.defineProperty(navigator, "serviceWorker", {
+ value: {},
+ configurable: true,
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function fakeSubscription(endpoint = "https://push.example/abc") {
+ return {
+ endpoint,
+ toJSON: () => ({ endpoint, keys: { p256dh: "k", auth: "a" } }),
+ unsubscribe: vi.fn().mockResolvedValue(true),
+ } as unknown as PushSubscription;
+}
+
+function fakeRegistration(existing: PushSubscription | null) {
+ const subscribeMock = vi.fn().mockResolvedValue(fakeSubscription());
+ return {
+ registration: {
+ pushManager: {
+ getSubscription: vi.fn().mockResolvedValue(existing),
+ subscribe: subscribeMock,
+ },
+ } as unknown as ServiceWorkerRegistration,
+ subscribeMock,
+ };
+}
+
+describe("urlBase64ToUint8Array", () => {
+ it("decodes a base64url key with padding and url-safe chars", () => {
+ // "AAAA" → 3 zero bytes; verifies padding handling and length.
+ const out = urlBase64ToUint8Array("AAAA");
+ expect(out).toEqual(new Uint8Array([0, 0, 0]));
+ });
+
+ it("maps url-safe '-' and '_' to '+' and '/'", () => {
+ const std = urlBase64ToUint8Array("a-b_");
+ const expected = (() => {
+ const raw = atob("a+b/");
+ return new Uint8Array([...raw].map((c) => c.charCodeAt(0)));
+ })();
+ expect(std).toEqual(expected);
+ });
+});
+
+describe("isPushSupported", () => {
+ it("is true once SW/PushManager/Notification are present", () => {
+ expect(isPushSupported()).toBe(true);
+ });
+});
+
+describe("subscribe", () => {
+ it("creates a subscription and POSTs it to the backend", async () => {
+ const { registration, subscribeMock } = fakeRegistration(null);
+ const fetchFn = vi.fn().mockResolvedValue({ ok: true });
+
+ const sub = await subscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: fetchFn as unknown as typeof fetch,
+ applicationServerKey: "AAAA",
+ });
+
+ expect(subscribeMock).toHaveBeenCalledWith(
+ expect.objectContaining({ userVisibleOnly: true })
+ );
+ expect(fetchFn).toHaveBeenCalledWith(
+ "/api/push/subscribe",
+ expect.objectContaining({ method: "POST" })
+ );
+ expect(sub.endpoint).toContain("push.example");
+ });
+
+ it("reuses an existing subscription (idempotent)", async () => {
+ const existing = fakeSubscription("https://push.example/existing");
+ const { registration, subscribeMock } = fakeRegistration(existing);
+ const fetchFn = vi.fn().mockResolvedValue({ ok: true });
+
+ const sub = await subscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: fetchFn as unknown as typeof fetch,
+ applicationServerKey: "AAAA",
+ });
+
+ expect(subscribeMock).not.toHaveBeenCalled();
+ expect(sub.endpoint).toBe("https://push.example/existing");
+ });
+
+ it("throws when the VAPID key is missing", async () => {
+ const { registration } = fakeRegistration(null);
+ await expect(
+ subscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: vi.fn() as unknown as typeof fetch,
+ applicationServerKey: "",
+ })
+ ).rejects.toThrow(/VAPID/);
+ });
+
+ it("throws when the backend rejects the subscription", async () => {
+ const { registration } = fakeRegistration(null);
+ const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 500 });
+ await expect(
+ subscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: fetchFn as unknown as typeof fetch,
+ applicationServerKey: "AAAA",
+ })
+ ).rejects.toThrow(/HTTP 500/);
+ });
+});
+
+describe("unsubscribe", () => {
+ it("DELETEs the subscription and tears it down locally", async () => {
+ const existing = fakeSubscription("https://push.example/x");
+ const { registration } = fakeRegistration(existing);
+ const fetchFn = vi.fn().mockResolvedValue({ ok: true });
+
+ const result = await unsubscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: fetchFn as unknown as typeof fetch,
+ });
+
+ expect(fetchFn).toHaveBeenCalledWith(
+ "/api/push/subscribe",
+ expect.objectContaining({ method: "DELETE" })
+ );
+ expect(existing.unsubscribe).toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("returns false when there is no active subscription", async () => {
+ const { registration } = fakeRegistration(null);
+ const result = await unsubscribe({
+ getRegistration: () => Promise.resolve(registration),
+ fetchFn: vi.fn() as unknown as typeof fetch,
+ });
+ expect(result).toBe(false);
+ });
+});
diff --git a/tests/unit/topicRouter.test.ts b/tests/unit/topicRouter.test.ts
new file mode 100644
index 0000000..697d4bd
--- /dev/null
+++ b/tests/unit/topicRouter.test.ts
@@ -0,0 +1,170 @@
+import { describe, it, expect, vi } from "vitest";
+import {
+ TopicRouter,
+ coalescenceKey,
+ isValidPattern,
+ isValidTopic,
+} from "@/utils/topicRouter";
+import {
+ MAX_HANDLERS_PER_TOPIC,
+ MAX_SUBSCRIPTIONS,
+ type PushPayload,
+} from "@/types/notification";
+
+function payload(topic: string, body = "b"): PushPayload {
+ return { topic, title: "t", body };
+}
+
+describe("pattern / topic validation", () => {
+ it("accepts up to three valid segments", () => {
+ expect(isValidTopic("meter.water.breach")).toBe(true);
+ expect(isValidPattern("meter.water.breach")).toBe(true);
+ });
+
+ it("accepts a trailing wildcard pattern", () => {
+ expect(isValidPattern("meter.water.*")).toBe(true);
+ expect(isValidPattern("meter.*")).toBe(true);
+ expect(isValidPattern("*")).toBe(true);
+ });
+
+ it("rejects a non-trailing wildcard", () => {
+ expect(isValidPattern("meter.*.breach")).toBe(false);
+ });
+
+ it("rejects more than three segments and bad characters", () => {
+ expect(isValidTopic("a.b.c.d")).toBe(false);
+ expect(isValidTopic("Meter.Water")).toBe(false);
+ expect(isValidTopic("meter water")).toBe(false);
+ });
+});
+
+describe("TopicRouter matching", () => {
+ it("matches an exact topic", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ r.insert("meter.water.breach", h);
+ expect(r.match("meter.water.breach")).toContain(h);
+ expect(r.match("meter.water.other")).not.toContain(h);
+ });
+
+ it("matches via a same-level trailing wildcard", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ r.insert("meter.water.*", h);
+ expect(r.match("meter.water.breach")).toContain(h);
+ expect(r.match("meter.gas.breach")).not.toContain(h);
+ });
+
+ it("matches via a higher wildcard ancestor", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ r.insert("meter.*", h);
+ expect(r.match("meter.water.breach")).toContain(h);
+ expect(r.match("meter.gas.low")).toContain(h);
+ expect(r.match("contract.execution.reverted")).not.toContain(h);
+ });
+
+ it("a root wildcard matches everything", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ r.insert("*", h);
+ expect(r.match("system.health.cpu")).toContain(h);
+ expect(r.match("contract.execution.reverted")).toContain(h);
+ });
+
+ it("collects handlers from the exact path and all wildcard ancestors", () => {
+ const r = new TopicRouter();
+ const exact = vi.fn();
+ const mid = vi.fn();
+ const top = vi.fn();
+ const root = vi.fn();
+ r.insert("meter.water.breach", exact);
+ r.insert("meter.water.*", mid);
+ r.insert("meter.*", top);
+ r.insert("*", root);
+ const matched = r.match("meter.water.breach");
+ expect(matched).toEqual(expect.arrayContaining([exact, mid, top, root]));
+ expect(matched).toHaveLength(4);
+ });
+
+ it("does not match a wildcard against a shorter topic", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ r.insert("meter.water.*", h);
+ expect(r.match("meter.water")).not.toContain(h);
+ });
+});
+
+describe("TopicRouter.emit", () => {
+ it("invokes all matching handlers with the payload", () => {
+ const r = new TopicRouter();
+ const a = vi.fn();
+ const b = vi.fn();
+ r.insert("contract.execution.reverted", a);
+ r.insert("contract.*", b);
+ const p = payload("contract.execution.reverted");
+ expect(r.emit(p)).toBe(2);
+ expect(a).toHaveBeenCalledWith(p);
+ expect(b).toHaveBeenCalledWith(p);
+ });
+
+ it("isolates a throwing handler from the others", () => {
+ const r = new TopicRouter();
+ const bad = vi.fn(() => {
+ throw new Error("boom");
+ });
+ const good = vi.fn();
+ r.insert("system.health.cpu", bad);
+ r.insert("system.health.cpu", good);
+ expect(() => r.emit(payload("system.health.cpu"))).not.toThrow();
+ expect(good).toHaveBeenCalled();
+ });
+});
+
+describe("TopicRouter unsubscribe & limits", () => {
+ it("unsubscribe removes the handler and frees the pattern slot", () => {
+ const r = new TopicRouter();
+ const h = vi.fn();
+ const off = r.insert("meter.water.breach", h);
+ expect(r.size).toBe(1);
+ off();
+ expect(r.match("meter.water.breach")).not.toContain(h);
+ expect(r.size).toBe(0);
+ off(); // idempotent
+ expect(r.size).toBe(0);
+ });
+
+ it("throws past the per-topic handler limit", () => {
+ const r = new TopicRouter();
+ for (let i = 0; i < MAX_HANDLERS_PER_TOPIC; i++) {
+ r.insert("meter.water.breach", vi.fn());
+ }
+ expect(() => r.insert("meter.water.breach", vi.fn())).toThrow(/Handler limit/);
+ });
+
+ it("throws past the global subscription limit", () => {
+ const r = new TopicRouter();
+ for (let i = 0; i < MAX_SUBSCRIPTIONS; i++) {
+ r.insert(`meter.water.t${i}`, vi.fn());
+ }
+ expect(r.size).toBe(MAX_SUBSCRIPTIONS);
+ expect(() => r.insert("meter.water.overflow", vi.fn())).toThrow(
+ /Subscription limit/
+ );
+ });
+
+ it("rejects an invalid pattern", () => {
+ const r = new TopicRouter();
+ expect(() => r.insert("Bad.Topic", vi.fn())).toThrow(/Invalid topic pattern/);
+ });
+});
+
+describe("coalescenceKey", () => {
+ it("combines topic with the first 80 body chars", () => {
+ expect(coalescenceKey("meter.water.breach", "leak detected")).toBe(
+ "meter.water.breach leak detected"
+ );
+ const long = "x".repeat(200);
+ expect(coalescenceKey("a.b.c", long)).toBe(`a.b.c ${"x".repeat(80)}`);
+ });
+});