feat(alerts): notification center - badge, popover, page and banners #89

Merged
KofTwentyTwo merged 2 commits from feature/notification-center into main 2026-10-13 17:11:25 +00:00
KofTwentyTwo commented 2026-10-13 15:13:30 +00:00 (Migrated from github.com)

What

Phase (b) of the qbit-alerts notifications design (qbit-alerts/docs/NOTIFICATIONS.md §8, API §8.1): the Alerts bell becomes a notification center. Gating from #85 is unchanged: no bell and zero requests unless metaData.apps.alerts is present (unit and e2e tests assert it).

  • Badge (main bundle): unread count (99+), red when highestUnreadSeverity is CRITICAL, label "Notifications, N unread". Polls GET /alerts/inbox/summary every pollSeconds (60), exponential backoff to 5 min on errors, paused while the tab is hidden, refetched when it shows.
  • Popover (lazy import()): newest first, severity icon (INFO, SUCCESS, WARNING, ERROR, CRITICAL), title, source, relative time, link (relative app path, or https in a new tab; opening marks it read), per-item mark read/unread and dismiss, footer "Mark all read" and "View all". Focus-trapped; Escape closes.
  • Page /app/notifications (lazy, next/dynamic): a fixed route under /app like /app/search, so the static export emits it and the dashboard layout wraps it. Filters All/Unread, category, severity and the offset live in the URL; paging with offset/limit (25); markdown bodies rendered through the existing MarkdownView (no raw HTML; it now also links relative app paths in the same tab); same actions.
  • Banners: the summary's banners[] show above the page content until dismissed (POST /inbox/dismiss). The banner UI loads only when there is a banner.
  • Actions: POST /inbox/read|unread|dismiss with {id}/{all:true}; optimistic on the summary and every loaded page, rolled back with a toast on failure, then both refetched.
  • Older backends: items without id (0.1.0-RC.1: alertId, group, status) render with group as the source, per-item buttons hidden, and opening marks read by {alertId}. A notificationsHref that is not an /app/... path (the backend currently sends /notifications) falls back to /app/notifications.

Tests

  • Unit (vitest + msw): gating, badge/99+/critical, pollDelay backoff, hidden-tab pause and refetch on visible, popover actions/focus trap/Escape/axe, optimistic update and rollback, legacy items, banner dismiss, page filters/URL/paging/markdown safety/empty/error, API normalization.
  • E2E (tests/e2e/notifications.spec.ts, mocked): no request without the app; bell, popover, banner and page at 390 and 1440 px in light and dark with no horizontal scrolling; axe; paging, Unread filter, mark read and banner dismiss. The acceptance suite has no bell coverage, so no acceptance spec was added.

Bundle (local static export, vs main)

main this PR
/app/_/_ first load 348.5 KB 349.2 KB (cap 350)
other /app routes +0.7 KB each
/app/notifications 339.5 KB (new)
total JS 1092.9 KB 1098.0 KB (cap 1100)
CSS 23.2 KB 23.2 KB
lazy notificationCenter 15.2 KB in 3 chunks (popover 4.3, banners 4.3, page 6.7), budget 16.5 KB

No cap was raised; perf-budget.json gains the notificationCenter lazy-chunk entry with the measurements in its $comment.

Screenshots

Taken from the mocked e2e run (light and dark, 390 and 1440 px: popover, page, banner); not attached here.

🤖 Generated with Claude Code

## What Phase (b) of the qbit-alerts notifications design (`qbit-alerts/docs/NOTIFICATIONS.md` §8, API §8.1): the Alerts bell becomes a notification center. Gating from #85 is unchanged: no bell and **zero requests** unless `metaData.apps.alerts` is present (unit and e2e tests assert it). - **Badge** (main bundle): unread count (99+), red when `highestUnreadSeverity` is CRITICAL, label "Notifications, N unread". Polls `GET /alerts/inbox/summary` every `pollSeconds` (60), exponential backoff to 5 min on errors, paused while the tab is hidden, refetched when it shows. - **Popover** (lazy `import()`): newest first, severity icon (INFO, SUCCESS, WARNING, ERROR, CRITICAL), title, source, relative time, link (relative app path, or https in a new tab; opening marks it read), per-item mark read/unread and dismiss, footer "Mark all read" and "View all". Focus-trapped; Escape closes. - **Page `/app/notifications`** (lazy, `next/dynamic`): a fixed route under `/app` like `/app/search`, so the static export emits it and the dashboard layout wraps it. Filters All/Unread, category, severity and the offset live in the URL; paging with `offset/limit` (25); markdown bodies rendered through the existing `MarkdownView` (no raw HTML; it now also links relative app paths in the same tab); same actions. - **Banners**: the summary's `banners[]` show above the page content until dismissed (`POST /inbox/dismiss`). The banner UI loads only when there is a banner. - **Actions**: `POST /inbox/read|unread|dismiss` with `{id}`/`{all:true}`; optimistic on the summary and every loaded page, rolled back with a toast on failure, then both refetched. - **Older backends**: items without `id` (0.1.0-RC.1: `alertId`, `group`, `status`) render with `group` as the source, per-item buttons hidden, and opening marks read by `{alertId}`. A `notificationsHref` that is not an `/app/...` path (the backend currently sends `/notifications`) falls back to `/app/notifications`. ## Tests - Unit (vitest + msw): gating, badge/99+/critical, `pollDelay` backoff, hidden-tab pause and refetch on visible, popover actions/focus trap/Escape/axe, optimistic update and rollback, legacy items, banner dismiss, page filters/URL/paging/markdown safety/empty/error, API normalization. - E2E (`tests/e2e/notifications.spec.ts`, mocked): no request without the app; bell, popover, banner and page at 390 and 1440 px in light and dark with no horizontal scrolling; axe; paging, Unread filter, mark read and banner dismiss. The acceptance suite has no bell coverage, so no acceptance spec was added. ## Bundle (local static export, vs main) | | main | this PR | |---|---|---| | `/app/_/_` first load | 348.5 KB | 349.2 KB (cap 350) | | other `/app` routes | | +0.7 KB each | | `/app/notifications` | | 339.5 KB (new) | | total JS | 1092.9 KB | 1098.0 KB (cap 1100) | | CSS | 23.2 KB | 23.2 KB | | lazy `notificationCenter` | | 15.2 KB in 3 chunks (popover 4.3, banners 4.3, page 6.7), budget 16.5 KB | No cap was raised; `perf-budget.json` gains the `notificationCenter` lazy-chunk entry with the measurements in its `$comment`. ## Screenshots Taken from the mocked e2e run (light and dark, 390 and 1440 px: popover, page, banner); not attached here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.