Skip to main content

Notifications

Not to be confused with the tracking app's two OS-level notification systems: Device reminders, scheduled on the phone itself, and Push notifications, sent by the server via APNs/FCM. This page is the in-app feed of server-sent events.

Both apps have an in-app notification system: a bell with a live unread badge, a list of recent items, 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.

The two apps share everything below the chrome — one store, one API client, one feed component — and differ only in how each surfaces it:

PortalTracking
TriggerBell icon button in the sidebar footerA "Notifications" row in the sidebar (NotificationsRow), so the collapsed spine gets a badged bell and the expanded panel a bell + unread count
Quick viewAnchored popover ≥1024px / bottom sheet below—
Full listPage at /dashboard/notifications (DataTable, bulk actions, ?expand=)Right-hand DrawerStack with All/Unread tabs + infinite scroll — tracking has no such route
List renderingEdge-to-edge divided rows (NotificationList)Day-grouped list-rows cards with expandable rows (NotificationCardList) — the feed's variant="cards"
Outstanding tasks—A pinned "Open" lane above the history (NotificationPromptCard) — see Device reminders in the feed
Live arrivalBell rings and NotificationArrivalPeek bannersBell rings only; a banner over a set in progress would be disruptive
Row activationDeep-links to the page with that row expandedExpands in place; a row that has an action also shows a CTA pill that runs it

Neither app filters on a notification's channel array: GET /events has no channel parameter, so a client-side filter would desynchronize the list from the server's unread count.

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 / openSessionRpeQuestionnaire / none) but parsed leniently — an unknown/absent value degrades to null rather than dropping the payload. Adding a value means editing two places, the union in types.ts and the KNOWN_ACTION_TYPES allow-list in schema.ts; miss the second and the action silently arrives as null.
  • 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().

  • Badge — CategoryBadge swaps the glyph to a download icon when actionType === "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; 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).

Session RPE prompt (tracking)​

A notification with actionType: "openSessionRpeQuestionnaire" and no linkUrl. It is the only entry point to the post-workout session-RPE questionnaire — tracking has no dashboard card for it — and it breaks two of the rules above, on purpose:

  • It doesn't route. The action opens an in-app sheet, so routeFor returns null for it and the tracking drawer's handleActivate branches on the action type directly.
  • Activating it does NOT mark it read, and neither does expanding it. The prompt is an invitation, not a record: it stays unread until the rating is submitted, so an abandoned questionnaire can still be found in the feed. page.tsx marks it read in the sheet's onSubmitted handler — the single place that happens. A read prompt is spent, so its "Rate your session" CTA is rendered only while unread.
  • Exactly one is alive at a time — the backend overwrites a stale prompt rather than stacking them. That is what lets useSessionRpePrompt() (apps/tracking/.../session-rpe/prompt.ts) resolve "the prompt" by lookup instead of threading an id from whatever opened the questionnaire, which in turn is how the push deep-link entrance (push-notifications.md) marks the right notification read on completion.

Training-data recalculation finished (portal)​

The portal's user drawer starts a background recalculation of one athlete's derived training values (GET /users/recalculate_trainingdata/{userID}; the backend contract and the open asks live in handoffs/RECALCULATE_TRAINING_DATA_HANDOFF.md). The button follows the same grant as the drawer's Save, so it also shows on your own row (a trainable owner) — the route accepts the path user themselves. Two saves start the same job without a click: a History session edit that changed loads, validity or deletions (see history-feature-plan.md, Phase 4), and a profile reps drawer save on a backend without the per-pair route. The server accepts the job and answers immediately, so the coach may close the drawer while it runs; the notification that follows is what tells them it is done.

It is the second consumer of the arrival pulse as a job signal, and the mechanism is worth reading before adding a third:

  • apps/portal/src/app/dashboard/users/recalculation-jobs.ts keeps the running jobs per user, outside React — the drawer form is remounted on every open, so component state could not block the button across a close and reopen.
  • The release comes from subscribeNotificationPulse, not from the cache: the pulse fires once per live notification.created, whereas a referenceID in the cache may belong to an older notification about the same athlete (unlike a roster upload's csvID, a user id repeats). Matching is case-insensitive.
  • Releasing also invalidates every cache keyed by that athlete, so the recomputed 1RMs are what the next render reads — whether or not the drawer is still open.
  • A 15-minute timeout releases a job whose notification never arrives (a server-side failure, or an SSE drop: a reconnect snapshot does not pulse).

The wire payload, live-verified on backdev 2026-09-11 (category: "systemEvent", no actionType/linkUrl → both normalize to null), on the acting user's topic — not the athlete's:

{
"id": "7A938A52-…", "category": "systemEvent",
"referenceID": "B415260A-…",
"title": "Training data recalculated",
"detail": "Recalc Probe Athlete's training data: 187 exercises were updated with new velocity profiles and 1RMs.",
"channel": ["portal"], "created": "2026-09-11T13:04:37Z"
}

TODO: the route does not return early yet — the same probe measured the request itself answering 200 after 35.8 s, with the notification arriving ~1 s BEFORE it. The client is built for both shapes (it blocks the button on click and reads nothing off the response), so nothing here changes when the early return lands; drop this note then.

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.ts — useNotifications(filter), useUnreadCount(), useNotificationActions() (a module-stable action bundle).
  • route.ts — routeFor(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: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, NotificationCardRow, NotificationCardList, NotificationPromptCard, NotificationEmpty, plus the generic SwipeActionRow, NotificationSkeleton, CategoryBadge). Category visuals reuse existing design tokens via the inline-alert badge pattern (tone in the icon disc, glyph carries identity).
  • NotificationFeed (packages/ui/src/notifications/notification-feed.tsx) is the shared body of every list surface: it loads its filter, resolves the loading / failed / empty / list phase, formats the relative-time and export-expiry sub-labels against one 30 s clock, and wires the per-row mark-read / delete actions. It renders no chrome — no header, no tabs — so the portal's popover and tracking's drawer each wrap it in their own. Also shared: NotificationStreamHost (mount once per app, inside the auth guard), NotificationBoundary (a surface fault must never take down the sidebar), and useBellPulse (the ring counter).
  • The feed paints its rows in one of two variants, because the two apps have different list vocabularies. variant="rows" (the default) is the portal's edge-to-edge NotificationList. variant="cards" is NotificationCardList — see Tracking's card variant. Everything above the rows (loading, failure, empty, the 30 s clock, the infinite-scroll sentinel) is shared; only maxVisibleRows is "rows"-only, because its measurement reads a flat <ul> the grouped variant doesn't have.
  • Portal wiring lives in apps/portal/src/components/notifications/ (NotificationBell, NotificationPanel, 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.
  • Tracking wiring lives in apps/tracking/src/app/workouts/today/notifications/. NotificationsRow is a plain SidebarRow — one declaration covering both the collapsed spine (round bell + badge) and the expanded panel — and reads the unread count from the store itself rather than through SidebarActionsState. NotificationsDrawer is a DrawerView bare so the feed is the single scroller (nesting it inside DrawerView's own scroller would break the infinite-scroll sentinel). The page owns the open state, so the drawer spans the viewport rather than the sidebar's width.

Tracking's card variant​

The tracking app's drawers are bordered list-rows cards under a muted section label, inset by the same gutter DrawerView gives its scroller — Settings, workout details and the sheets all read that way. An edge-to-edge feed inside one is a different app's furniture, so variant="cards" renders the same store through that vocabulary instead:

  • Day groups. groupNotifications (pure, unit-tested) buckets the list into today / yesterday / thisWeek / earlier on local calendar days, not elapsed hours — 00:30 today and 23:30 yesterday are 1 h apart and must still land in different groups. It is a closed set of four keys, not one group per date, so each heading is an extractable literal t("…") call. A future created (device clock behind the server's) reads as today and an unparseable one lands in earlier; neither drops the notification.
  • The heading names the day, so the row only places it inside one: a clock time in the two named-day groups, a short date further back. It replaces the relative stamp, whose "2 months ago" repeated down a whole card is what the grouping exists to fix.
  • Unread is the leading platinum-green accent + a semibold title, the same marker the portal's list and table rows carry. The card clips it into its rounded corners, which is what makes it read as a native list rather than a web table.
  • A row is an index, not the message: collapsed it shows the title and ONE line of detail. A tap opens it (one at a time, accordion-style) and that is purely reading — the full text plus the exact date and time, and no buttons. Acting on a row lives where it can't be confused with reading it: the swipe deletes, the CTA runs the notification's own action, selection mode does both in bulk. There is deliberately no per-row ⋯; ten identical rows each carrying one is noise.
  • The expansion always shows the full timestamp, which is what stops the chevron from lying: a one-line detail already fitted, so without it opening some rows would deliver nothing. Collapsed bodies are inert so a screen reader doesn't read every row's detail twice.
  • Swipe left to delete (SwipeActionRow), and Select in the tool row for multi-select — see Native list gestures.
  • An action gets a CTA pill inside the row ("Rate your session" / "Download export"), always visible rather than hidden behind the expansion, so an action is still one tap — the same shape as the Today screen's check-in card. It is withdrawn when it can't work: an expired export link shows the muted countdown label alone, and a read session-RPE prompt shows nothing.
  • Expanding marks read, matching the portal's full page, with the same two exemptions: not on the Unread tab (where the row would vanish out from under you as it leaves the unread-only cache), and not for the session-RPE prompt (reading an invitation is not answering it). Both live in the drawer's handleExpand.
  • The portal 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". This decision is the pure feedPhase (packages/ui/src/notifications/feed-phase.ts), unit-tested in isolation. The portal's 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 (@enode/core/notifications/pulse). Two consumers react. In both apps the bell glyph rings — a CSS bell-ring swing, re-keyed per pulse via useBellPulse (dropped under reduced-motion). In the portal only, 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 (both apps).

Empty states​

The drawer can be empty for two different reasons, so it says two different things (NotificationFeed's emptyTitle / emptyMessage / emptyAction):

FilterCopyAction
All"No notifications" · "Updates about your training will show up here."—
Unread"No unread notifications" · "Everything here has been read."Show all, back out of the filter

An empty filtered view is not a dead end, which is what the Show-all button is for. The wording deliberately names notifications rather than saying "you're all caught up", because a pinned check-in prompt can be sitting right above it — claiming the whole screen is clear would contradict it.

suppressEmpty is therefore not used here (it exists for the portal, whose aboveList roster panels are part of the same list). The pinned prompt is its own lane; hiding the history's empty state under it just left a hole.

The bell's markers​

SidebarRow presents the bell twice, with different room, so it carries two markers:

  • Collapsed (spine) — the count badge, red-orange.
  • Expanded — SidebarRow ignores badge on purpose there (the subtitle carries the detail), so the colour comes from the icon bubble's tone, set to warn so it matches the badge instead of inventing a second accent.

It also rings when the sidebar opens on something waiting: Sidebar renders the expanded panel only while open (open ? children : rail), so the row mounts with it and the CSS bell-ring plays once from that mount. Gated to the expanded row — otherwise closing the sidebar would ring the spine as it takes over. Live arrivals still ring both, through the useBellPulse re-key. Reduced motion drops the animation; the badge and the tone carry the state without it.

Native list gestures​

The tracking drawer behaves like an iOS list, because that is what it looks like.

Swipe to delete — packages/ui/src/swipe-action-row.tsx, reusable and not notification-specific. Dragging a row left reveals a red Delete panel that grows with the pull; releasing past 45 % of the row width deletes outright, and anything less snaps to open or shut. Three rules keep it from fighting the list:

  • touchAction: "pan-y" leaves vertical panning to the browser, so the scroller never has to compete for the same finger.
  • The axis is locked once, after 8 px. A drag that starts more vertical than horizontal is handed back to the scroller and never reconsidered, so a diagonal flick can't leave a row half-open behind you.
  • The list owns which row is open, so revealing one puts the previous one back, and entering selection mode closes it — an open row swallows the next tap to close itself, which in a mode where every tap is a pick would eat the first one.

The commit threshold is a share of the row width, so the row has to be draggable the whole way. An earlier version capped the drag near the panel's width, which put the threshold permanently out of reach and made the full swipe silently dead. If you re-tune ACTION_WIDTH, don't reintroduce a cap.

The swipe is never the only way to delete — a gesture is invisible to a keyboard and to a screen reader. Selection mode is that path, which is what lets the expansion carry no buttons at all. The revealed panel is inert while shut for the same reason.

Selection — a mode, entered from Select in the tool row beside the All/Unread filter. While it lasts, rows carry a leading SelectionCircle and a tap picks instead of opens, the swipe is off, the pinned prompt steps aside (it is not a row that can be read or deleted), and a toolbar appears at the foot.

That toolbar carries the same circle the rows do, lined up under their column (the bar's gutter plus the row's own px-4) so it reads as that column's header rather than a stray control. It is binary, not tri-state: filled only when everything is picked, so a tap always means "select all" until it means "clear". The count rides the action labels — "Mark 9 as read", "Delete 9" — instead of a separate "9 selected", because the buttons are what the number is for; at zero they fall back to the plain labels. Bulk delete confirms first; a single swipe does not, because one row is obvious and reversible in a way that "17 notifications" is not. A selection is cleared by leaving the mode and by switching filter — the two lists hold different rows.

Select all + Mark as read calls markAllRead(), not one POST per row. The per-id path would fan out a request per row on a long feed and still miss everything the infinite scroll hasn't pulled in — which is not what a user who ticked "all" asked for. That is also why Mark-as-read stays enabled on a full selection with nothing visibly unread: unreadIds comes from a separate filter=unread fetch that may not have resolved, and the bulk call clears the server's count regardless.

The drawer header therefore carries no trailing action. "Mark all as read" used to sit there and is now Select all → Mark as read: two taps more, one less button doing what the toolbar already does.

Device reminders in the feed​

The 07:00 wellness check-in nudge is a device reminder (device-reminders.md), scheduled by the phone and never seen by this system. It still shows up in the tracking drawer — as a pinned "Open" card above the day groups, not as a notification.

The distinction is the whole design:

  • It is a live condition, not a record. There is no id, no read-state and nothing stored: the card exists exactly while the athlete owes today's check-in and vanishes the moment one lands. That is why it needs no mark-read / delete actions — and why not having them is what makes the two lanes read as different things in one list.
  • It is not injected into the store, which would be the obvious shortcut and is a trap: applySnapshot replaces the cache wholesale on every reconnect (the invariant that makes reconnect self-healing), so a synthetic row would be wiped on the next connect, and its markRead / remove would POST /events/:id for an id the backend has never heard of. It rides the feed's existing aboveList slot instead.
  • One condition, published once. page.tsx already computes showPersonalCheckIn for the check-in flow and publishes it into notifications/open-prompts.ts, a small module store read by both the drawer and the sidebar bell. A store rather than props, because the bell is rendered from SidebarActions two call sites away and threading a boolean through SidebarActionsState would drag it through the dev gallery too — the same reason NotificationsRow already reads the unread count itself. The card's text comes from wellnessCheckInRule.content(), i.e. the very rule that words the 07:00 banner, and its CTA sets the same cardCheckIn state the check-in flow uses: one condition, one wording, one exit.
  • The banner stays the alarm; the card is the standing offer. The OS notification fires at 07:00 and deep-links to CHECK_IN_ROUTE; the in-app card is visible all day while the check-in is open, because it states a condition rather than a time.
  • The bell badges it, alongside the unread count — the drawer shows both, so a bell that ignored the second would sit there unbadged with something to do behind it. The subtitle therefore reads "n waiting", not "n unread": the two things it adds up are not the same kind. See The bell's markers.
  • The Today screen no longer offers the athlete's own check-in. Two prominent entrances to one questionnaire on one screen is exactly the duplication this lane exists to remove. WellnessCheckInCard is left with the job nothing else does — a coach answering for athletes who have no login and can never be prompted — and hides entirely when that list is empty.

Doing this server-side was considered and rejected: the backend would need a timezone-bucketed sweep plus an idempotency and a "spend" rule, in order to answer "has this athlete checked in today, in their timezone" — which is exactly the query the client already runs (fetchLastCheckInDateKey). It would duplicate a derivation and add a scheduler to fire it on a clock. Revisit only if the prompt has to reach the portal or survive as a record, neither of which it does today.

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, packages/ui/src/notifications/{feed-phase,group-notifications}.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 feed's phase decision (the badge-vs-list race and the dataless-failure rule), the card variant's day bucketing (local-day boundaries, the 7-day edge, unusable timestamps), 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.