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:
| Portal | Tracking | |
|---|---|---|
| Trigger | Bell icon button in the sidebar footer | A "Notifications" row in the sidebar (NotificationsRow), so the collapsed spine gets a badged bell and the expanded panel a bell + unread count |
| Quick view | Anchored popover ≥1024px / bottom sheet below | — |
| Full list | Page at /dashboard/notifications (DataTable, bulk actions, ?expand=) | Right-hand DrawerStack with All/Unread tabs + infinite scroll — tracking has no such route |
| List rendering | Edge-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 arrival | Bell rings and NotificationArrivalPeek banners | Bell rings only; a banner over a set in progress would be disruptive |
| Row activation | Deep-links to the page with that row expanded | Expands 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).
| 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/openSessionRpeQuestionnaire/none) but parsed leniently — an unknown/absent value degrades tonullrather than dropping the payload. Adding a value means editing two places, the union intypes.tsand theKNOWN_ACTION_TYPESallow-list inschema.ts; miss the second and the action silently arrives asnull.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).
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
routeForreturnsnullfor it and the tracking drawer'shandleActivatebranches 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.tsxmarks it read in the sheet'sonSubmittedhandler — 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.tskeeps 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 livenotification.created, whereas areferenceIDin the cache may belong to an older notification about the same athlete (unlike a roster upload'scsvID, 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 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,NotificationCardRow,NotificationCardList,NotificationPromptCard,NotificationEmpty, plus the genericSwipeActionRow,NotificationSkeleton,CategoryBadge). Category visuals reuse existing design tokens via theinline-alertbadge 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), anduseBellPulse(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-edgeNotificationList.variant="cards"isNotificationCardList— see Tracking's card variant. Everything above the rows (loading, failure, empty, the 30 s clock, the infinite-scroll sentinel) is shared; onlymaxVisibleRowsis"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) 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. - Tracking wiring lives in
apps/tracking/src/app/workouts/today/notifications/.NotificationsRowis a plainSidebarRow— 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 throughSidebarActionsState.NotificationsDraweris aDrawerView bareso the feed is the single scroller (nesting it insideDrawerView'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 intotoday/yesterday/thisWeek/earlieron 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 literalt("…")call. A futurecreated(device clock behind the server's) reads as today and an unparseable one lands inearlier; 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
inertso 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 purefeedPhase(packages/ui/src/notifications/feed-phase.ts), unit-tested in isolation. The portal's 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 (@enode/core/notifications/pulse). Two consumers react. In both apps the bell glyph rings — a CSSbell-ringswing, re-keyed per pulse viauseBellPulse(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 (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 (both apps).
Empty states
The drawer can be empty for two different reasons, so it says two different
things (NotificationFeed's emptyTitle / emptyMessage / emptyAction):
| Filter | Copy | Action |
|---|---|---|
| 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 —
SidebarRowignoresbadgeon purpose there (the subtitle carries the detail), so the colour comes from the icon bubble'stone, set towarnso 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:unreadIdscomes from a separatefilter=unreadfetch 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:
applySnapshotreplaces 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 itsmarkRead/removewouldPOST /events/:idfor an id the backend has never heard of. It rides the feed's existingaboveListslot instead. - One condition, published once.
page.tsxalready computesshowPersonalCheckInfor the check-in flow and publishes it intonotifications/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 fromSidebarActionstwo call sites away and threading a boolean throughSidebarActionsStatewould drag it through the dev gallery too — the same reasonNotificationsRowalready reads the unread count itself. The card's text comes fromwellnessCheckInRule.content(), i.e. the very rule that words the 07:00 banner, and its CTA sets the samecardCheckInstate 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.
WellnessCheckInCardis 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 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,
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
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.