# SDD Tasks: Notification Center ## Review Workload Forecast | Field | Value | |-------|-------| | Estimated changed lines | ~1,800 total (PR-1 ~600; PR-2 ~250; PR-3 ~700; PR-4 ~250) | | 400-line budget risk | High | | Chained PRs recommended | Yes | | Suggested split | PR 1 (Backend Core) → PR 2 (Backend Integration) → PR 3 (Frontend Core) → PR 4 (Toast Coordination) | | Delivery strategy | auto-chain | | Chain strategy | stacked-to-main | ``` Decision needed before apply: No Chained PRs recommended: Yes Chain strategy: stacked-to-main 400-line budget risk: High ``` > **Note:** PR-1 (~600 lines) and PR-3 (~700 lines) exceed the 400-line review budget. PR-3 in particular carries High risk. Tasks within each PR are grouped into autonomous work units. If review fanout is available, PR-3 can be split into (a) Provider + Hook + Styles and (b) NotificationCenter + NotificationItem + AppShell integration. PR-1 can be split into (a) Migration + Model + Service and (b) Router + Registration + Tests. --- ## PR-1: Backend Core **Goal:** Establish the persistent notification backend: database schema, SQLAlchemy model, NotificationService singleton, FastAPI router with Pydantic schemas, and comprehensive unit + integration tests. **Estimated Lines:** ~600 **Review Risk:** Medium --- ### NC-PR1-001: Create Alembic migration for notifications table **Description:** Write an Alembic revision that creates the `notifications` table with all columns, constraints, indexes, and the foreign key to `users.id` as specified in the design. **Files to modify:** - `apps/api/alembic/versions/2026_05_29_add_notifications_table.py` *(new)* **Acceptance criteria:** - [ ] Migration creates `notifications` table with columns: `id`, `user_id`, `category`, `severity`, `title`, `message`, `source_type`, `source_id`, `metadata`, `read_at`, `dismissed_at`, `created_at`. - [ ] Foreign key `user_id` references `users.id` with `ON DELETE CASCADE`. - [ ] Index `idx_notifications_user_created_at` on `(user_id, created_at DESC)`. - [ ] Partial index `idx_notifications_user_unread` on `(user_id, read_at)` where `read_at IS NULL`. - [ ] `upgrade()` and `downgrade()` are both implemented and pass `alembic upgrade head` / `alembic downgrade -1`. - [ ] Migration depends on current `head` revision. **Estimated effort:** Small (2–3 hours) **Dependencies:** None --- ### NC-PR1-002: Create SQLAlchemy Notification model and export **Description:** Add the `Notification` SQLAlchemy model following the existing `UUIDPrimaryKeyMixin` + `Base` pattern. Export it from `models/__init__.py` for Alembic autogenerate discovery. **Files to modify:** - `apps/api/src/models/notification.py` *(new)* - `apps/api/src/models/__init__.py` **Acceptance criteria:** - [ ] `Notification` model matches the design schema exactly with correct types (`UUID`, `String(32)`, `String(16)`, `String(255)`, `Text`, `JSONB`, `DateTime(timezone=True)`). - [ ] `user_id` has `ForeignKey("users.id", ondelete="CASCADE")`, `nullable=False`, `index=True`. - [ ] `read_at` and `created_at` are indexed. - [ ] `metadata` column defaults to `{}`. - [ ] Model is exported in `models/__init__.py`. - [ ] `alembic revision --autogenerate` produces no drift against the hand-written migration. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-001 --- ### NC-PR1-003: [RED] Write NotificationService unit tests — basic CRUD **Description:** Write failing pytest unit tests for `NotificationService` covering create, list, count, mark_read, mark_all_read, and dismiss happy paths. **Files to modify:** - `apps/api/tests/unit/test_notification_service.py` *(new)* **Acceptance criteria:** - [ ] `test_create_notification`: assert row inserted with correct values, `read_at` NULL, `dismissed_at` NULL. - [ ] `test_list_notifications_orders_by_created_at_desc`: 3 rows inserted, newest first. - [ ] `test_list_notifications_excludes_dismissed`: dismissed row not returned. - [ ] `test_list_notifications_unread_only`: `unread_only=True` returns only unread. - [ ] `test_get_unread_count`: 5 rows, 2 unread → count is 2. - [ ] `test_mark_read_sets_read_at`: `read_at` is not NULL after call. - [ ] `test_mark_all_read_affects_all_unread`: all unread rows updated. - [ ] `test_dismiss_sets_dismissed_at`: `dismissed_at` is not NULL after call. - [ ] Tests use `db_session` fixture and create test `User` rows in session. **Estimated effort:** Small (3–4 hours) **Dependencies:** NC-PR1-002 --- ### NC-PR1-004: [GREEN] Implement NotificationService **Description:** Implement the `NotificationService` singleton with all methods. The service accepts `AsyncSession` explicitly and filters all queries by `user_id`. **Files to modify:** - `apps/api/src/services/notification_service.py` *(new)* **Acceptance criteria:** - [ ] `create_notification(session, user_id, *, category, severity, title, ...)` inserts row and returns `Notification`. - [ ] `list_notifications(session, user_id, *, limit=20, offset=0, unread_only=False, mute_categories=None)` returns `(items, total)` tuple, excludes `dismissed_at IS NOT NULL`, orders by `created_at DESC`. - [ ] `get_unread_count(session, user_id)` counts rows where `read_at IS NULL` and `dismissed_at IS NULL`. - [ ] `mark_read(session, notification_id, user_id)` sets `read_at = now()`, returns updated row; raises 404-equivalent if not found or not owned. - [ ] `mark_all_read(session, user_id)` sets `read_at = now()` on all unread rows for user; returns count updated. - [ ] `dismiss(session, notification_id, user_id)` sets `dismissed_at = now()`; raises 404-equivalent if not found or not owned. - [ ] All methods filter by `user_id`. - [ ] `NC-PR1-003` tests pass. **Estimated effort:** Medium (4–5 hours) **Dependencies:** NC-PR1-003 --- ### NC-PR1-005: [TRIANGULATE] NotificationService edge-case and isolation tests **Description:** Add unit tests for cross-user isolation, wrong-owner failures, mute category filtering, and partial index usage. **Files to modify:** - `apps/api/tests/unit/test_notification_service.py` **Acceptance criteria:** - [ ] `test_mark_read_wrong_owner_raises`: User A creates notification; User B calls `mark_read` → exception raised. - [ ] `test_dismiss_wrong_owner_raises`: User A creates notification; User B calls `dismiss` → exception raised. - [ ] `test_list_notifications_mute_categories`: pass `mute_categories=["instance"]`; instance rows excluded, system rows returned. - [ ] `test_get_unread_count_excludes_dismissed`: unread but dismissed row → count is 0. - [ ] `test_get_unread_count_query_uses_partial_index`: query plan uses `idx_notifications_user_unread` (verified via `EXPLAIN` or SQLite equivalent). **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-004 --- ### NC-PR1-006: [RED] Write API integration tests — basic endpoints **Description:** Write failing integration tests for the notifications API router covering list, unread count, mark read, mark all read, and dismiss. **Files to modify:** - `apps/api/tests/integration/test_notifications_api.py` *(new)* **Acceptance criteria:** - [ ] `test_list_requires_auth`: `GET /notifications` without auth → `401`. - [ ] `test_list_returns_only_own_notifications`: create for user A; user B lists → not in response. - [ ] `test_unread_count_endpoint`: create 3 unread; `GET /notifications/unread` → `{count: 3}`. - [ ] `test_mark_read_endpoint`: create unread; `PATCH /notifications/{id}/read` → `200`, `read_at` set. - [ ] `test_mark_all_read_endpoint`: create 4 unread; `POST /notifications/mark-all-read` → `{marked_count: 4}`. - [ ] `test_dismiss_endpoint`: create notification; `DELETE /notifications/{id}` → `204`; subsequent list excludes it. - [ ] Uses `authenticated_client` and `db_session` fixtures. **Estimated effort:** Small (3–4 hours) **Dependencies:** NC-PR1-004 --- ### NC-PR1-007: [GREEN] Implement FastAPI notifications router and Pydantic schemas **Description:** Create the FastAPI `APIRouter` for `/notifications` with all endpoints and Pydantic response models. Read `mute_categories` from `UserConfig` and pass to `list_notifications`. **Files to modify:** - `apps/api/src/api/notifications.py` *(new)* - `apps/api/src/api/__init__.py` **Acceptance criteria:** - [ ] `GET /notifications` with `limit`, `offset`, `unread_only` query params; returns `NotificationListResponse`. - [ ] `GET /notifications/unread` returns `UnreadCountResponse`. - [ ] `PATCH /notifications/{id}/read` returns `NotificationItem`; `404` if not owned. - [ ] `POST /notifications/mark-all-read` returns `MarkAllReadResponse`. - [ ] `DELETE /notifications/{id}` returns `204 No Content`; `404` if not owned. - [ ] Router reads `notification_mute_categories` from user's `UserConfig.config` and passes to `list_notifications`. - [ ] `limit` capped at 100. - [ ] All endpoints use `get_current_user_id` / `get_db_session` dependencies. - [ ] Router exported from `api/__init__.py`. - [ ] `NC-PR1-006` tests pass. **Estimated effort:** Medium (4–5 hours) **Dependencies:** NC-PR1-006 --- ### NC-PR1-008: [TRIANGULATE] API edge-case and ownership tests **Description:** Add integration tests for pagination, ownership enforcement, and mute categories filtering at the API layer. **Files to modify:** - `apps/api/tests/integration/test_notifications_api.py` **Acceptance criteria:** - [ ] `test_list_pagination`: create 25 notifications; `limit=10&offset=10` → items length 10, total 25. - [ ] `test_mark_read_404_for_other_user`: create for user A; user B PATCH → `404`. - [ ] `test_dismiss_404_for_other_user`: user B DELETE user A's notification → `404`. - [ ] `test_mute_categories_filter_in_list`: set user config `mute_categories=["instance"]`, create instance + system notifications; `GET /notifications` returns only system. - [ ] `test_mark_all_read_affects_only_caller`: user A has 3 unread, user B has 2; A calls mark-all-read → A=0, B=2. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-007 --- ### NC-PR1-009: Register router in main.py and import model for Alembic **Description:** Import and include the notifications router in the FastAPI app. Import the `Notification` model in `main.py` for Alembic autogenerate discovery. **Files to modify:** - `apps/api/src/main.py` **Acceptance criteria:** - [ ] `notifications_router` imported and included with `app.include_router(...)`. - [ ] `Notification` model imported in `main.py` (F401 noqa comment if unused). - [ ] App boots without import cycles. - [ ] `GET /health` still returns `200`. - [ ] `GET /notifications` returns `401` when unauthenticated (smoke test). **Estimated effort:** Small (1 hour) **Dependencies:** NC-PR1-007 --- ### NC-PR1-010: [REFACTOR] Backend code quality and type safety pass **Description:** Run `ruff check .`, `mypy .`, and `pytest` on the new code. Fix any lint errors, type annotations, or docstring gaps. Ensure no `print` statements or debug logs remain. **Files to modify:** - Any of the above files with lint/type issues. **Acceptance criteria:** - [ ] `ruff check .` passes with zero errors on new files. - [ ] `mypy .` passes with zero type errors on new files. - [ ] `pytest tests/unit/test_notification_service.py tests/integration/test_notifications_api.py` passes. - [ ] All public methods have docstrings. - [ ] No `print()` or leftover `logger.debug` from development. **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR1-008, NC-PR1-009 --- ## PR-2: Backend Integration **Goal:** Wire lifecycle hooks and health monitor to create notifications, extend UserConfig for preferences, and validate the end-to-end event producer flow. **Estimated Lines:** ~250 **Review Risk:** Low --- ### NC-PR2-001: Wire lifecycle_hooks.py to call NotificationService **Description:** After `publish_lifecycle_event()` publishes the raw event to `InstanceEventBus`, call `NotificationService.create_notification()` with `user_id=tool_instance.owner_id`. Wrap in `try/except` so event pipeline is never blocked. **Files to modify:** - `apps/api/src/services/lifecycle_hooks.py` **Acceptance criteria:** - [ ] `publish_lifecycle_event` calls `notification_service.create_notification(...)` after bus publish. - [ ] `user_id` is set to `instance.owner_id`. - [ ] `category="instance"`. - [ ] `severity` mapped: `info` for created/started/stopped/restarted/deleted; `error` for error. - [ ] `title` derived from event type (e.g., "Container started"). - [ ] `source_type="tool_instances"`, `source_id=instance.id`. - [ ] Service call wrapped in `try/except`; on failure, error is logged with `correlation_id` and execution continues. - [ ] Original event bus publish and audit row insert are unaffected by notification failure. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-010 --- ### NC-PR2-002: Wire health_monitor.py to call NotificationService **Description:** After `HealthMonitor` detects a state change and publishes the event, call `NotificationService.create_notification()` with `user_id=instance.owner_id`. Wrap in `try/except`. **Files to modify:** - `apps/api/src/services/health_monitor.py` **Acceptance criteria:** - [ ] `_handle_state_change` calls `notification_service.create_notification(...)` after bus publish. - [ ] `user_id` is set to `instance.owner_id`. - [ ] `category="health"` for health changes; `"instance"` for errors. - [ ] `severity` mapped: `error` for crash, `warning` for unhealthy, `info` for recovery. - [ ] `source_type="tool_instances"`, `source_id=instance.id`. - [ ] Service call wrapped in `try/except`; on failure, error is logged with `correlation_id` and loop continues. - [ ] Original event bus publish and health check insert are unaffected. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-010 --- ### NC-PR2-003: Extend UserConfig schema for notification preferences **Description:** Add `notification_mute_categories` and `notification_toast_level` to the `UserConfigResponse` and `UserConfigUpdate` Pydantic models. Apply `mute_categories` filtering in `list_notifications`. **Files to modify:** - `apps/api/src/api/user_config.py` - `apps/api/src/services/notification_service.py` **Acceptance criteria:** - [ ] `UserConfigResponse` includes `notification_mute_categories: list[str] | None = None` and `notification_toast_level: str | None = None`. - [ ] `UserConfigUpdate` includes the same optional fields. - [ ] `list_notifications` in `NotificationService` accepts `mute_categories` and filters with `Notification.category.not_in(mute_categories)`. - [ ] `GET /users/me/config` returns new keys when present in JSON blob. - [ ] `PATCH /users/me/config` persists new keys into the JSON blob. - [ ] Existing config keys are unaffected. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR1-010 --- ### NC-PR2-004: [RED] Write event producer integration tests **Description:** Write integration tests that exercise real lifecycle and health monitor endpoints and assert notification rows are created for the instance owner. **Files to modify:** - `apps/api/tests/integration/test_notification_producers.py` *(new)* **Acceptance criteria:** - [ ] `test_lifecycle_event_creates_notification`: trigger `instance.started` via lifecycle hook; assert notification row exists with `category="instance"`, `severity="info"`, `user_id=owner_id`. - [ ] `test_health_monitor_error_creates_notification`: simulate health monitor detecting crash; assert notification row with `severity="error"`. - [ ] `test_notification_failure_does_not_block_event_pipeline`: mock `create_notification` to raise; assert event is still published and no exception escapes. - [ ] `test_notification_ownership_matches_instance_owner`: create instance for user A; trigger event; assert notification `user_id` is A's ID, not the calling user's. - [ ] Uses `authenticated_client`, `db_session`, and `test_project_and_repo` fixtures. **Estimated effort:** Medium (3–4 hours) **Dependencies:** NC-PR2-001, NC-PR2-002, NC-PR2-003 --- ### NC-PR2-005: [GREEN / REFACTOR] Verify producer tests pass and clean up **Description:** Run the producer integration tests, fix any failures, and do a final lint/type check on all modified files. **Files to modify:** - Any files with issues found during test runs. **Acceptance criteria:** - [ ] `pytest tests/integration/test_notification_producers.py` passes. - [ ] `ruff check .` passes on modified files. - [ ] `mypy .` passes on modified files. - [ ] No regressions in existing `pytest` suite. **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR2-004 --- ## PR-3: Frontend Core **Goal:** Build the frontend notification surface: icon registry, React context with polling, hook, notification list components, styles, and AppShell integration. **Estimated Lines:** ~700 **Review Risk:** High --- ### NC-PR3-001: Add bell icon to icon registry **Description:** Register the Phosphor `Bell` icon in the frontend icon registry under the name `"bell"`. **Files to modify:** - `apps/web/src/utils/icons.ts` **Acceptance criteria:** - [ ] `"bell"` added to `IconName` union type. - [ ] `bell: Bell` added to `iconRegistry` map. - [ ] `Bell` imported from `@phosphor-icons/react`. - [ ] `` renders without error in a quick manual check. **Estimated effort:** Small (30 minutes) **Dependencies:** None (can be prepared before PR-2 merges) --- ### NC-PR3-002: [RED] Write useNotifications hook tests **Description:** Write failing tests for the `useNotifications` hook covering state exposure, optimistic updates, and revert behavior. **Files to modify:** - `apps/web/src/hooks/use-notifications.test.ts` *(new)* **Acceptance criteria:** - [ ] `test_returns_notifications_and_unreadCount_from_context`: mock provider value; assert hook returns same array and count. - [ ] `test_optimistically_updates_on_markRead`: call `markRead`; assert local `read_at` set and `unreadCount` decremented before API resolves. - [ ] `test_reverts_optimistic_update_on_markRead_failure`: mock API rejection; assert state reverted. - [ ] `test_optimistically_updates_on_dismiss`: call `dismiss`; assert item removed and count decremented. - [ ] `test_reverts_optimistic_update_on_dismiss_failure`: mock API rejection; assert item restored. - [ ] `test_calls_refreshList_when_invoked`: assert `GET /notifications` called. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR3-001 --- ### NC-PR3-003: [GREEN] Implement NotificationProvider context with polling **Description:** Create the `NotificationProvider` React context that polls the backend endpoints, manages notification list and unread count, and handles tab visibility pause/resume. **Files to modify:** - `apps/web/src/state/notifications.tsx` *(new)* **Acceptance criteria:** - [ ] Context maintains `notifications: NotificationItem[]` and `unreadCount: number`. - [ ] Polls `GET /notifications/unread` every 15 seconds. - [ ] Polls `GET /notifications` every 30 seconds when dropdown is closed. - [ ] Pauses all polling when `document.hidden` is true; resumes on visible. - [ ] On dropdown open: immediately fetches list, pauses 30s list poll. - [ ] On dropdown close: restarts 30s list poll. - [ ] On logout: stops polling and clears state. - [ ] Polling errors are silently logged; next cycle proceeds. - [ ] On `401` response: stops all polling. **Estimated effort:** Medium (4–5 hours) **Dependencies:** NC-PR3-002 --- ### NC-PR3-004: [GREEN] Implement useNotifications hook **Description:** Create the `useNotifications()` consumer hook that exposes state and mutation callbacks with optimistic updates. **Files to modify:** - `apps/web/src/hooks/use-notifications.ts` *(new)* **Acceptance criteria:** - [ ] Hook returns `notifications`, `unreadCount`, `isLoading`, `error`, `markRead`, `markAllRead`, `dismiss`, `refreshList`. - [ ] `markRead(id)`: optimistically sets `read_at` and decrements `unreadCount`; calls `PATCH /notifications/{id}/read`; reverts on failure. - [ ] `markAllRead()`: optimistically sets `read_at` on all items and `unreadCount=0`; calls `POST /notifications/mark-all-read`; reverts on failure. - [ ] `dismiss(id)`: optimistically removes item and decrements `unreadCount` if unread; calls `DELETE /notifications/{id}`; reverts on failure. - [ ] `refreshList()`: calls `GET /notifications` and updates state. - [ ] Errors are surfaced as `error` state but not thrown. - [ ] `NC-PR3-002` tests pass. **Estimated effort:** Medium (3–4 hours) **Dependencies:** NC-PR3-003 --- ### NC-PR3-005: [TRIANGULATE] Hook edge-case and error handling tests **Description:** Add tests for 401 handling, polling pause, and multiple rapid mutations. **Files to modify:** - `apps/web/src/hooks/use-notifications.test.ts` **Acceptance criteria:** - [ ] `test_stops_polling_on_401`: simulate 401; assert polling intervals cleared. - [ ] `test_pauses_polling_when_document_hidden`: simulate `visibilitychange` to hidden; assert `clearInterval` called. - [ ] `test_resumes_polling_when_document_visible`: simulate hidden then visible; assert intervals restarted and immediate fetches fired. - [ ] `test_multiple_markRead_calls_decrement_correctly`: mark 3 items read rapidly; assert `unreadCount` decrements by 3. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR3-004 --- ### NC-PR3-006: [RED] Write NotificationItem component tests **Description:** Write failing render tests for the `NotificationItem` presentational component. **Files to modify:** - `apps/web/src/components/notification-item.test.tsx` *(new)* **Acceptance criteria:** - [ ] `test_displays_title_and_relative_time`: render with sample data; assert title and relative time visible. - [ ] `test_applies_unread_styling_when_read_at_is_null`: assert unread CSS class present. - [ ] `test_applies_read_styling_when_read_at_is_set`: assert read CSS class present. - [ ] `test_calls_onMarkRead_when_mark_read_clicked`: simulate click; assert callback with correct id. - [ ] `test_calls_onDismiss_when_dismiss_clicked`: simulate click; assert callback with correct id. - [ ] `test_displays_severity_icon`: assert severity icon element present. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR3-001 --- ### NC-PR3-007: [GREEN] Implement NotificationItem component **Description:** Build the presentational row component for a single notification. **Files to modify:** - `apps/web/src/components/notification-item.tsx` *(new)* **Acceptance criteria:** - [ ] Accepts `notification: NotificationItem`, `onMarkRead: (id: string) => void`, `onDismiss: (id: string) => void`. - [ ] Displays severity icon mapped from `severity` to Phosphor icon (`Info`, `Warning`, `XCircle`, `CheckCircle`). - [ ] Displays `title` and relative timestamp (e.g., "2m ago"). - [ ] Unread rows have `.notification-item--unread` class (bolder text, accent border, background tint). - [ ] Read rows have `.notification-item--read` class (reduced opacity). - [ ] Renders "Mark read" and "Dismiss" action buttons. - [ ] `NC-PR3-006` tests pass. **Estimated effort:** Small (3–4 hours) **Dependencies:** NC-PR3-006 --- ### NC-PR3-008: [RED] Write NotificationCenter component tests **Description:** Write failing render and interaction tests for the `NotificationCenter` component. **Files to modify:** - `apps/web/src/components/notification-center.test.tsx` *(new)* **Acceptance criteria:** - [ ] `test_renders_bell_icon`: assert bell icon visible. - [ ] `test_shows_badge_when_unread_count_gt_0`: provider state `unreadCount=3`; assert badge text is "3". - [ ] `test_hides_badge_when_unread_count_is_0`: assert badge not in document. - [ ] `test_opens_dropdown_on_bell_click`: simulate click; assert dropdown panel visible. - [ ] `test_closes_dropdown_on_outside_click`: open dropdown; click outside; assert panel not visible. - [ ] `test_closes_dropdown_on_escape`: open dropdown; fire `Escape` key; assert panel not visible. - [ ] `test_renders_empty_state_when_no_notifications`: assert empty state text visible. - [ ] `test_renders_notification_items`: list has 2 items; assert 2 `NotificationItem` components rendered. - [ ] `test_calls_markAllRead_on_footer_button_click`: simulate click; assert mock called. - [ ] `test_refreshes_list_immediately_on_open`: open dropdown; assert `refreshList` mock called. **Estimated effort:** Small (3–4 hours) **Dependencies:** NC-PR3-007 --- ### NC-PR3-009: [GREEN] Implement NotificationCenter component **Description:** Build the `NotificationCenter` component: bell icon with badge, dropdown panel with list, empty state, footer actions, outside-click/Escape close, and mobile terminal hiding. **Files to modify:** - `apps/web/src/components/notification-center.tsx` *(new)* **Acceptance criteria:** - [ ] Renders bell icon (``). - [ ] Shows unread count badge when `unreadCount > 0`; caps display at "99+". - [ ] Badge uses existing `nav-badge` CSS class. - [ ] Dropdown opens on bell click, closes on outside click or `Escape`. - [ ] Dropdown is a positioned panel below the bell, right-aligned. - [ ] Contains scrollable list of `NotificationItem` components. - [ ] Shows empty state message when list is empty (e.g., "No notifications"). - [ ] Footer has "Mark all as read" button calling `markAllRead()`. - [ ] Calls `refreshList()` immediately when opening. - [ ] Hidden when `isMobileTerminal` is true. - [ ] Uses `useNotifications()` hook. - [ ] `NC-PR3-008` tests pass. **Estimated effort:** Medium (4–5 hours) **Dependencies:** NC-PR3-008 --- ### NC-PR3-010: Add notification CSS styles **Description:** Add utility classes for the notification dropdown, items, badge, and empty state to `styles.css`. **Files to modify:** - `apps/web/src/styles.css` **Acceptance criteria:** - [ ] `.notification-dropdown` has absolute positioning, `z-index` above header, `max-height`, scroll, shadow, and matches light/dark theme variables. - [ ] `.notification-item` has padding, border-bottom, hover state. - [ ] `.notification-item--unread` has distinct styling (accent left border, slightly different background). - [ ] `.notification-item--read` has reduced opacity. - [ ] `.notification-badge` reuses or extends existing `nav-badge` styles. - [ ] `.notification-empty` has centered text and muted color. - [ ] Styles work in both light and dark themes. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR3-009 --- ### NC-PR3-011: Integrate NotificationCenter into AppShell **Description:** Mount `` inside the `AppShell` `header-actions` area. Wrap with `NotificationProvider` at the appropriate level. **Files to modify:** - `apps/web/src/components/app-shell.tsx` **Acceptance criteria:** - [ ] `` wraps the authenticated app layout (inside or alongside `EventProvider`). - [ ] `` rendered inside `header-actions` div, before the user chip. - [ ] Component is hidden when `isMobileTerminal` is true. - [ ] No visual regressions in existing header layout. - [ ] Existing tests for `AppShell` still pass (or are updated if needed). **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR3-009, NC-PR3-010 --- ### NC-PR3-012: [REFACTOR] Frontend code quality and type check pass **Description:** Run `npm run typecheck`, `npm run lint`, and frontend tests. Fix any errors. Verify accessibility (keyboard navigation, ARIA labels). **Files to modify:** - Any files with type/lint issues. **Acceptance criteria:** - [ ] `npm run typecheck` passes with zero errors. - [ ] `npm run lint` passes with zero errors. - [ ] `npm test` (or `vitest run`) passes for all new test files. - [ ] Bell icon has `aria-label="Notifications"`. - [ ] Dropdown panel has `role="menu"` or `role="dialog"` and appropriate `aria-*` attributes. - [ ] Mark read / dismiss buttons have accessible labels. - [ ] No `console.log` left from development. **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR3-011 --- ## PR-4: Toast Coordination **Goal:** Update the toast bridge to respect notification preferences, extend settings UI for preference controls, and verify coordination end-to-end. **Estimated Lines:** ~250 **Review Risk:** Low --- ### NC-PR4-001: Extend toast-rules.ts with category/severity mapping **Description:** Add `mapEventToCategory` and `mapEventToSeverity` functions to `toast-rules.ts` so the bridge can evaluate events against user preferences. **Files to modify:** - `apps/web/src/components/toast-rules.ts` **Acceptance criteria:** - [ ] `mapEventToCategory(event)` returns `"instance"` for `instance.*` events, `"health"` for `health.*`, `"system"` otherwise. - [ ] `mapEventToSeverity(event)` returns `"error"` for `instance.error` / `health.error`; `"warning"` for unhealthy health changes; `"info"` for created/started/stopped/restarted/deleted; `"success"` for recovery to running. - [ ] Functions are pure and exported. - [ ] Existing toast mapping behavior is preserved (no regressions in current tests). - [ ] New functions have unit tests. **Estimated effort:** Small (2–3 hours) **Dependencies:** PR-3 merged (frontend types available) --- ### NC-PR4-002: [RED] Write EventToastBridge preference check tests **Description:** Write failing tests for the updated `EventToastBridge` that verify preference-based toast suppression. **Files to modify:** - `apps/web/src/components/event-toast-bridge.test.tsx` *(new)* **Acceptance criteria:** - [ ] `test_shows_toast_when_level_is_all_and_category_not_muted`: assert toast shown. - [ ] `test_suppresses_toast_when_level_is_none`: assert no toast. - [ ] `test_suppresses_info_toast_when_level_is_errors`: event severity `info`; assert no toast. - [ ] `test_shows_error_toast_when_level_is_errors`: event severity `error`; assert toast shown. - [ ] `test_suppresses_toast_when_category_is_muted`: config mute contains event category; assert no toast. - [ ] Tests mock `useEventContext`, user config context, and `toast-rules.ts` as needed. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR4-001 --- ### NC-PR4-003: [GREEN] Update EventToastBridge with preference checks **Description:** Modify `EventToastBridge` to read `notification_toast_level` and `notification_mute_categories` from user config and skip toasts based on the preference hierarchy. **Files to modify:** - `apps/web/src/components/event-toast-bridge.tsx` **Acceptance criteria:** - [ ] Bridge reads user config (from existing settings API / context). - [ ] Evaluation order: mute categories first, then toast level. - [ ] If `notification_toast_level === "none"`: no toasts shown. - [ ] If `notification_toast_level === "errors"`: only toasts for `severity === "error"`. - [ ] If `notification_toast_level === "all"`: toasts shown as before. - [ ] If event category is in `notification_mute_categories`: toast suppressed. - [ ] Backend notification creation is unaffected; bridge only controls toast surfacing. - [ ] `NC-PR4-002` tests pass. **Estimated effort:** Small (2–3 hours) **Dependencies:** NC-PR4-002 --- ### NC-PR4-004: [TRIANGULATE] Bridge edge-case and integration tests **Description:** Add tests for preference changes taking effect immediately, mixed mute + level constraints, and no regressions in existing deduplication. **Files to modify:** - `apps/web/src/components/event-toast-bridge.test.tsx` **Acceptance criteria:** - [ ] `test_preference_change_is_immediate`: config changes from `"all"` to `"none"`; next event suppressed. - [ ] `test_muted_category_overrides_all_level`: level `"all"` but category muted; toast suppressed. - [ ] `test_deduplication_still_works_with_preferences`: two identical allowed events within 1s → one toast. - [ ] `test_unmapped_event_defaults_to_info`: unknown event type → category `"system"`, severity `"info"`. **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR4-003 --- ### NC-PR4-005: Extend settings UI with notification preferences **Description:** Add notification preference controls to the existing General settings tab: a multi-select/checkbox group for mute categories and a select for toast level. **Files to modify:** - `apps/web/src/pages/settings.tsx` - `apps/web/src/api/settings.ts` **Acceptance criteria:** - [ ] `UserConfig` interface in `api/settings.ts` includes `notification_mute_categories?: string[]` and `notification_toast_level?: "all" | "errors" | "none"`. - [ ] `UserConfigUpdate` interface includes the same optional fields. - [ ] General settings tab has a "Notifications" section. - [ ] Toast level select with options: "All", "Errors only", "None". - [ ] Mute categories checkboxes for known categories: `instance`, `system`, `health`, `security`. - [ ] Preferences save via existing `updateUserConfig` API. - [ ] Saved preferences persist after page reload. - [ ] Default values: `notification_toast_level="all"`, `notification_mute_categories=[]`. **Estimated effort:** Small (3–4 hours) **Dependencies:** NC-PR4-003 --- ### NC-PR4-006: [REFACTOR] Final quality pass and verification **Description:** Run full frontend type check, lint, and test suite. Do a manual smoke test of the notification center + toast coordination. **Files to modify:** - Any files with issues found. **Acceptance criteria:** - [ ] `npm run typecheck` passes. - [ ] `npm run lint` passes. - [ ] `npm test` passes for all new and modified test files. - [ ] Manual smoke test: trigger an `instance.error` event → notification appears in dropdown → toast appears (if level="all") → mark read → badge clears. - [ ] Manual smoke test: set toast level to "none" → trigger event → no toast appears, but notification still created. - [ ] No regressions in existing settings page functionality. **Estimated effort:** Small (1–2 hours) **Dependencies:** NC-PR4-004, NC-PR4-005 --- ## Dependency Graph (PR Level) ``` PR-1: Backend Core │ ├─► NC-PR1-001 ──► NC-PR1-002 │ ├─► NC-PR1-003 ──► NC-PR1-004 ──► NC-PR1-005 │ ├─► NC-PR1-006 ──► NC-PR1-007 ──► NC-PR1-008 │ ├─► NC-PR1-009 │ └─► NC-PR1-010 PR-2: Backend Integration (depends on PR-1 merged) │ ├─► NC-PR2-001 │ ├─► NC-PR2-002 │ ├─► NC-PR2-003 │ ├─► NC-PR2-004 │ └─► NC-PR2-005 PR-3: Frontend Core (depends on PR-1/PR-2 merged) │ ├─► NC-PR3-001 │ ├─► NC-PR3-002 ──► NC-PR3-003 ──► NC-PR3-004 ──► NC-PR3-005 │ ├─► NC-PR3-006 ──► NC-PR3-007 │ ├─► NC-PR3-008 ──► NC-PR3-009 ──► NC-PR3-010 ──► NC-PR3-011 │ └─► NC-PR3-012 PR-4: Toast Coordination (depends on PR-3 merged) │ ├─► NC-PR4-001 │ ├─► NC-PR4-002 ──► NC-PR4-003 ──► NC-PR4-004 │ ├─► NC-PR4-005 │ └─► NC-PR4-006 ``` --- ## Task Summary | PR | Task ID | Description | TDD Phase | Effort | |----|---------|-------------|-----------|--------| | 1 | NC-PR1-001 | Alembic migration for notifications table | — | S | | 1 | NC-PR1-002 | SQLAlchemy Notification model and export | — | S | | 1 | NC-PR1-003 | Service unit tests — basic CRUD | RED | S | | 1 | NC-PR1-004 | Implement NotificationService | GREEN | M | | 1 | NC-PR1-005 | Service edge-case and isolation tests | TRIANGULATE | S | | 1 | NC-PR1-006 | API integration tests — basic endpoints | RED | S | | 1 | NC-PR1-007 | Implement FastAPI router and Pydantic schemas | GREEN | M | | 1 | NC-PR1-008 | API edge-case and ownership tests | TRIANGULATE | S | | 1 | NC-PR1-009 | Register router in main.py | — | S | | 1 | NC-PR1-010 | Backend code quality and type safety pass | REFACTOR | S | | 2 | NC-PR2-001 | Wire lifecycle_hooks.py to NotificationService | — | S | | 2 | NC-PR2-002 | Wire health_monitor.py to NotificationService | — | S | | 2 | NC-PR2-003 | Extend UserConfig schema for preferences | — | S | | 2 | NC-PR2-004 | Event producer integration tests | RED | M | | 2 | NC-PR2-005 | Verify producer tests and clean up | GREEN / REFACTOR | S | | 3 | NC-PR3-001 | Add bell icon to icon registry | — | S | | 3 | NC-PR3-002 | useNotifications hook tests | RED | S | | 3 | NC-PR3-003 | Implement NotificationProvider context | GREEN | M | | 3 | NC-PR3-004 | Implement useNotifications hook | GREEN | M | | 3 | NC-PR3-005 | Hook edge-case and error handling tests | TRIANGULATE | S | | 3 | NC-PR3-006 | NotificationItem component tests | RED | S | | 3 | NC-PR3-007 | Implement NotificationItem component | GREEN | S | | 3 | NC-PR3-008 | NotificationCenter component tests | RED | S | | 3 | NC-PR3-009 | Implement NotificationCenter component | GREEN | M | | 3 | NC-PR3-010 | Add notification CSS styles | — | S | | 3 | NC-PR3-011 | Integrate NotificationCenter into AppShell | — | S | | 3 | NC-PR3-012 | Frontend code quality and type check pass | REFACTOR | S | | 4 | NC-PR4-001 | Extend toast-rules.ts with mapping | — | S | | 4 | NC-PR4-002 | EventToastBridge preference check tests | RED | S | | 4 | NC-PR4-003 | Update EventToastBridge with preference checks | GREEN | S | | 4 | NC-PR4-004 | Bridge edge-case and integration tests | TRIANGULATE | S | | 4 | NC-PR4-005 | Extend settings UI with notification preferences | — | S | | 4 | NC-PR4-006 | Final quality pass and verification | REFACTOR | S | **Total tasks:** 31 **Total estimated effort:** ~100 hours (backend ~40h, frontend ~45h, integration ~15h)