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).
| Method | Path | Purpose |
|---|---|---|
| GET | /events?filter=all|unread&limit=&before= | { notifications, nextCursor } |
| GET | /events/unread-count | { count } |
| POST | /events/:id/read | mark one read (204) |
| POST | /events/read-all | mark all read (204) |
| DELETE | /events/:id | delete one (204) |
| DELETE | /events?filter=read | delete 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):
createdis epoch-ms (a number) over REST but an ISO string over SSE — coerced to a canonical ISO string.nextCursoris omitted entirely on the last page (notnull) — normalized tonull.categoryis an open string (e.g."systemEvent") — mapped to a badge with a fallback, not a fixed enum.actionTypeis a known enum (openSession/openCompetition/openLicense/openExportDownload/none) but parsed leniently — an unknown/absent value degrades tonullrather than dropping the payload.linkUrlis an absolute URL ornull/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'sunreadbucket) — seeuseUnreadIds. - No mark-as-unread endpoint — the UI ships Mark-as-read + Delete only. (The
notification.updatedreadAt → nullcase is still handled byapplyUpdated.)
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().
- Badge —
CategoryBadgeswaps the glyph to a download icon whenactionType === "openExportDownload"(brand tone kept; the glyph distinguishes it). - Countdown —
exportLinkExpiry(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;exportExpiryLabelformats 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-itemreadAt/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 optimisticapplyMarkRead/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 theunreadbucket (filled fromfilter=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 optimisticmarkReadand the echoednotification.updatedconverge instead of double-counting. Re-applying any event is a no-op. - Snapshot authority.
applySnapshotreplacesall+ the count wholesale (a reconnect self-heals); it doesn't carry the unread set, so the store refetchesfilter=unreadafter each snapshot.
- Read-state = unread-bucket membership. With no per-item
store.ts— thestore-kitstore (module cache +createStoreStatus+registerRevalidation). SSEingest*writers, RESTloadNotifications/loadMore/refreshUnreadCount, and optimistic mutations that snapshot → apply → REST → rollback + rethrow on failure. Reset fromlogout()viaclearNotifications().stream.ts— wiresrealtimeSubscribeto the store; every frame is zod-validated (schema.ts), and a malformed payload is dropped (reported once) rather than crashing the stream.hooks.ts—useNotifications(filter),useUnreadCount(),useNotificationActions()(a module-stable action bundle).route.ts—routeFor(notification):actionType→ portal route (ornull= 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:unauthorized → AuthGuard 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 theinline-alertbadge 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) andapps/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'sonNavigatealso 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 sharedConnectionCard(names offline / unreachable / maintenance + Try again) above its still-skeletoned table, like every other data screen (seedocs/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 viauseAnnouncementState). 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'sonRowExpandcallback (fires on a collapsed → expanded transition only) → the page'smarkReadOnExpand. - Live arrival cue. On each
notification.created,NotificationStreamHostemits an in-app pulse (notification-pulse.ts). Two consumers react: the bell glyph rings (a CSSbell-ringswing, re-keyed per pulse; dropped under reduced-motion), andNotificationArrivalPeek— 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 (grid0fr ↔ 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 hiddenaria-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 fromunreadCount). - 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
nodeenv 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.