Skip to main content

Notifications

The portal has an in-app notification system: a bell in the sidebar footer with a live unread badge, a popover panel of recent items, a full page at /dashboard/notifications, and a live SSE channel that keeps every open tab consistent. It is built entirely on the repo's own primitives — the store-kit external-store pattern, the shared realtime SSE client, and the @enode/ui feedback/overlay components — rather than a new state library.

Backend contract

The live backend (backdev) exposes REST + SSE. The API base URL already ends in /api, so client paths are /events… (see packages/core/src/notifications/api.ts).

MethodPathPurpose
GET/events?filter=all|unread&limit=&before={ notifications, nextCursor }
GET/events/unread-count{ count }
POST/events/:id/readmark one read (204)
POST/events/read-allmark all read (204)
DELETE/events/:iddelete one (204)
DELETE/events?filter=readdelete all read (204)

SSE topic pro:user:<userID> emits notification.snapshot ({ unreadCount, notifications }, sent on every connect and reconnect), notification.created, notification.updated ({ id, readAt }), notification.deleted ({ id }), and heartbeat.

The notification object carries id, category, title, detail, channel, created, plus an optional action (actionType + linkUrl). Quirks the client normalizes (schema.ts):

  • created is epoch-ms (a number) over REST but an ISO string over SSE — coerced to a canonical ISO string.
  • nextCursor is omitted entirely on the last page (not null) — normalized to null.
  • category is an open string (e.g. "systemEvent") — mapped to a badge with a fallback, not a fixed enum.
  • actionType is a known enum (openSession / openCompetition / openLicense / openExportDownload / none) but parsed leniently — an unknown/absent value degrades to null rather than dropping the payload.
  • linkUrl is an absolute URL or null/absent (→ null); a present-but-not-a-URL value is treated as malformed and drops the payload.

Read-state is still server-side (there is no per-item readAt):

  • The unread total comes from the backend; the set of unread notifications is fetched via filter=unread. A row renders unread when its id is in that set (the store's unread bucket) — see useUnreadIds.
  • No mark-as-unread endpoint — the UI ships Mark-as-read + Delete only. (The notification.updated readAt → null case is still handled by applyUpdated.)

Clicking a notification (a bell-panel row, the arrival peek, or a dashboard-card row) always opens the notifications page with that row expanded: openNotificationOnPage marks it read and pushes ?expand=<id>, which the page reads to expand the row (via the DataTable expandKey prop — additive + idempotent). routeFor(n) (route.ts) → { kind: "external"; href } | null is now used only INSIDE that expanded row, to render the export Download link (openExportDownload + a non-null linkUrl).

Data export ready

A notification.created with category: "systemEvent", actionType: "openExportDownload", and a signed linkUrl (valid 60 minutes, then 401/404). channel: ["portal"] (no mobile duplicate). title/detail are server-hardcoded English, rendered verbatim — not run through t().

  • BadgeCategoryBadge swaps the glyph to a download icon when actionType === "openExportDownload" (brand tone kept; the glyph distinguishes it).
  • CountdownexportLinkExpiry(n, now) (pure; created + 60min) drives a sub-label: "Expires in _N_m" while valid, "Link expired" (muted) after. The panel ticks a 30s clock (useNow) only while open. The label text (client UI) is translated; exportExpiryLabel formats it.
  • Click — like every notification, clicking the peek/panel row opens the notifications page with this row expanded (openNotificationOnPage); the expanded row renders the Download export link (window.open(linkUrl, "_blank", "noopener,noreferrer")) while valid, or "Link expired" once past the 60-minute TTL. The countdown is the primary guard — we never surface a link the server would 401.

An earlier spec also had a fixed category enum, an ISO created, and per-item readAt/referenceID; the client was adapted to the live backend. The schema tolerates future richer fields (unknown keys are stripped, not rejected).

Architecture

One store, fed by three writers, read by all consumers via useSyncExternalStore:

SSE (realtimeSubscribe pro:user:<uid>) ─┐
REST (apiRequest /events…) ─────────────┼──► notifications store ──► hooks ──► UI
optimistic mutations + rollback ─────────┘ (module cache + createStoreStatus)
  • reducers.ts — pure, immutable cache transforms (applySnapshot, applyCreated/Updated/Deleted, applyListPage, and the optimistic applyMarkRead/MarkAllRead/Remove). Two invariants make the system robust:
    • Read-state = unread-bucket membership. With no per-item readAt, a notification is unread iff it's in the unread bucket (filled from filter=unread). Marking read drops it from that bucket and decrements the count; the count delta is derived from whether the id was unread, so an optimistic markRead and the echoed notification.updated converge instead of double-counting. Re-applying any event is a no-op.
    • Snapshot authority. applySnapshot replaces all + the count wholesale (a reconnect self-heals); it doesn't carry the unread set, so the store refetches filter=unread after each snapshot.
  • store.ts — the store-kit store (module cache + createStoreStatus + registerRevalidation). SSE ingest* writers, REST loadNotifications / loadMore / refreshUnreadCount, and optimistic mutations that snapshot → apply → REST → rollback + rethrow on failure. Reset from logout() via clearNotifications().
  • stream.ts — wires realtimeSubscribe to the store; every frame is zod-validated (schema.ts), and a malformed payload is dropped (reported once) rather than crashing the stream.
  • hooks.tsuseNotifications(filter), useUnreadCount(), useNotificationActions() (a module-stable action bundle).
  • route.tsrouteFor(notification): actionType → portal route (or null = no navigation, just mark read).

Realtime, reconnect, auth

The stream reuses @enode/core/realtime (realtimeSubscribe), which already handles bearer auth, exponential backoff (1→30 s), a 35 s heartbeat watchdog, background suspend / foreground resume, and firing a fresh snapshot on every (re)connect. On foreground the store also refreshes the unread count (belt-and-suspenders). There is no token refresh in the portal: a 401 on any REST call fires enode:unauthorizedAuthGuard logs out; the SSE client gives up quietly on a never-connected 401/403/404 (never a retry-loop).

The underlying connection is a swappable RealtimeTransport. On web it's a fetch + ReadableStream reader; on native (Capacitor) the tracking app swaps in the EnodeSse plugin, because CapacitorHttp makes window.fetch unable to stream — see ADR 0007. Everything above the transport (auth, backoff, watchdog, framing) is identical.

Multi-tab

No BroadcastChannel: every tab is independently subscribed to the same SSE topic and receives the same updated/deleted events, so state stays consistent automatically.

UI

  • Presentational, Storybook-covered components live in packages/ui/src/notifications/ (NotificationItem, NotificationList, NotificationEmpty, NotificationSkeleton, CategoryBadge). Category visuals reuse existing design tokens via the inline-alert badge pattern (tone in the icon disc, glyph carries identity).
  • Wiring/containers live in apps/portal/src/components/notifications/ (NotificationBell, NotificationPanel, NotificationStreamHost, NotificationArrivalPeek, DashboardNotificationsCard + its minimized header-button twin) and apps/portal/src/app/dashboard/notifications/ (the full page + filters + bulk bar). The bell's panel is a trigger-anchored, focus-trapped popover on the persistent-sidebar breakpoint (≥1024px) and a bottom sheet with a dimming scrim below it. When the panel navigates (row click / "View all"), the bell's onNavigate also closes the mobile sidebar overlay so the destination page isn't hidden behind it.
  • The panel shows only unread. Because the badge count (SSE snapshot) and the unread list (filter=unread) arrive separately, an empty list under a non-zero badge renders the skeleton — and any failed load with nothing cached (even at badge 0: when the backend is down the snapshot fails too, so a zero count proves nothing) a "Couldn't load notifications" state with a Try-again — never a false "You're all caught up". The full page instead renders the shared ConnectionCard (names offline / unreachable / maintenance + Try again) above its still-skeletoned table, like every other data screen (see docs/error-handling.md).
  • The dashboard card (DashboardNotificationsCard) surfaces the first 3 unread on the dashboard while any exist; minimizing collapses it to a compact header bell (shared persisted state via useAnnouncementState). Row clicks behave like the panel's.
  • On the full page, expanding a row's detail marks it read — but only on the "All" list. On "Unread", marking read drops the row from the (unread-only) cache, so it would vanish the instant you opened it; there the bulk "Mark read" action stays the explicit path. Wired via the shared DataTable's onRowExpand callback (fires on a collapsed → expanded transition only) → the page's markReadOnExpand.
  • Live arrival cue. On each notification.created, NotificationStreamHost emits an in-app pulse (notification-pulse.ts). Two consumers react: the bell glyph rings (a CSS bell-ring swing, re-keyed per pulse; dropped under reduced-motion), and NotificationArrivalPeek — mounted at the layout level so it fires even where the sidebar/bell is unmounted (mobile) — drops in a viewport-anchored preview banner (top-center full-width on phones, a top-right column on wider screens). Banners stack — a new arrival lands on top of any still-showing ones (newest first, capped at 3; the oldest drops beyond that) rather than replacing them. Each card animates its own ROW HEIGHT (grid 0fr ↔ 1fr, stack-item-in / -out) so the others slide harmoniously as it enters/leaves instead of jumping, with its own 5 s auto-dismiss (hover pauses, ✕ dismisses, click opens the page with that row expanded). Both cues are gated to a visible tab + off the notifications page. New notifications are also announced to assistive tech via a hidden aria-live="polite" region in the stream host.

Known limitations

  • No category filter. The full page has All/Unread tabs only — the backend currently exposes a single open category (systemEvent), so a fixed category multi-select doesn't fit. Revisit when the backend defines a stable category set.
  • Per-item unread needs the unread set. Because read-state isn't on the item, the store loads filter=unread (after each snapshot / on the full page) so the "All" view can style unread rows. Until it resolves, rows render read; the badge count is always correct (it comes straight from unreadCount).
  • No list virtualization yet. The full page uses paged infinite scroll; virtualization (@tanstack/react-virtual) is a follow-up if profiles show jank past ~100 rows.

Verifying without the backend

The dev-only Notifications sandbox on /dashboard/debug drives the store's ingest* functions with fabricated events (snapshot / created / updated / deleted), so the bell, panel, and full page can be exercised before the endpoints go live. Push snapshot seeds the unread set through the notification.updated seam (a real snapshot only carries the count; the follow-up filter=unread fetch needs the backend), so unread styling and the badge stay consistent offline. The "created" buttons also fire an arrival pulse, so the bell rings + the preview banner drops in (close the panel + stay off the notifications page to see it). Push export ready injects a "data export ready" notification (download badge + live countdown); Push expired export backdates it 61 minutes to land on the "Link expired" state — its preview click opens the page with the row expanded, which shows "Link expired" instead of a download link.

Tests

Pure logic is unit-tested (Vitest, node env): packages/core/src/notifications/{reducers,route,schema,export-link-expiry}.test.ts, apps/portal/src/components/notifications/open-notification-on-page.test.ts, and packages/core/src/format/relative-time.test.ts — covering reducer idempotency (incl. optimistic + echo convergence), routeFor, the export-link expiry math, the open-on-page helper (mark read + ?expand deep-link href), zod accept/reject (incl. the actionType/linkUrl cases), and relative-time formatting. Presentational components (incl. the export-ready + expired states) are documented as Storybook stories.

The RTL component test and Playwright E2E from the change spec are not implemented: this repo runs Vitest in node env with DOM component tests deliberately excluded (vitest.config.ts) and has no Playwright harness or dependency. Adding either would be new test infrastructure; the equivalent behaviour is covered by the logic unit tests above + the Storybook stories, and the sandbox supports manual two-tab verification.