{
"source": "67147822f55b911c033617f759dc472d0d348e72",
"capture": "autoplan-1789475414787-SuaYfd",
"observedOutcome": "timeout",
"snapshotAt": "2026-09-15T13:07:31.359383Z",
"originalImplementation": "# Plan: User Dashboard Page\n\n## Context\nWe're shipping a new user dashboard at `/dashboard` showing recent activity,\nnotifications panel, and quick-action buttons. Users land here after login.\n\n## UI Scope\n- New React page component `UserDashboard.tsx` at `src/pages/`\n- Three new sub-components: `ActivityFeed`, `NotificationsPanel`, `QuickActions`\n- Tailwind CSS for layout, mobile-first responsive (breakpoints: sm/md/lg)\n- Empty state, loading skeleton, error state for each panel\n- Hover states + focus-visible outlines on every interactive element\n- Modal dialog for \"Mark all as read\" on notifications panel\n- Toast notification system for action feedback\n\n## Backend\n- New REST endpoint `GET /api/dashboard` returns `{ activity, notifications, quickActions }`\n- Backed by existing PostgreSQL tables; no schema changes\n\n## Out of scope\n- Dark mode (separate plan)\n- Personalization / customization (separate plan)\n\n## Existing product and application contracts\n\nThis is the existing single-role member workspace, not a new product or a new\nonboarding flow. Members currently visit three separate pages after login to\nresume work, check alerts, and inspect recent changes. In the team's last task\nwalkthrough, finding the next item took a median 75 seconds. The dashboard's\nsuccess measure is login-to-first-completed-task time, targeting 45 seconds,\nwith completed-task rate and permission-error rate as guardrails. Existing\nanalytics records login, action start, action completion, and permission errors;\nthe new page still needs its own exposure and interaction instrumentation.\n\nActivity is the immutable audit history of workspace changes. Notifications are\nmember-specific alerts with persistent read state; acknowledging an alert does\nnot alter audit history. The existing action registry supplies three actions\n(create an item, resume assigned work, invite a member), with stable IDs, labels,\nroute targets, and server-side eligibility predicates. These are links into\nexisting workflows; action ranking and a new configuration service do not exist.\n\nThe application already uses cookie sessions and workspace membership middleware.\nIts request context supplies the authenticated member and workspace IDs. Existing\nrepository methods apply both IDs where appropriate; callers do not accept a\nworkspace ID from query parameters. Mutations already require CSRF tokens. The\nnew dashboard endpoint must compose these methods and follow the same boundaries;\nits handler, authorization integration, and failure paths have not been written.\n\nExisting list methods return the latest 20 records plus a cursor and have indexed\nworkspace/member and created-at access paths. The existing full activity and\nnotification pages own older-page navigation. The member-scoped bulk-read API is\nidempotent and marks only notifications at or before the supplied snapshot time,\nso later arrivals remain unread. Existing HTTP clients expose typed unauthenticated,\nforbidden, validation, retryable-service, and network errors. Each dashboard panel\nstill needs to map these results to its loading, empty, error, retry, and success\nstates; the aggregate endpoint's response composition and partial-failure behavior\nremain new implementation work. No schema migration or new mutation API is needed.\n\nThe app already has Tailwind spacing/color/type tokens, a responsive page shell,\nbuttons, links, and a dialog primitive with focus trapping, Escape dismissal, and\nfocus return. These primitives do not implement any dashboard panel, confirmation\nflow, or toast system. The new modal and toast feedback must also work with keyboard\nand screen readers; existing accessibility policy requires named controls, a live\nregion for nonblocking feedback, sufficient contrast, and reduced-motion support.\nThe dashboard still needs its own layout, content hierarchy, mobile behavior, and\nstate-specific copy at sm/md/lg breakpoints.\n\nVitest, React Testing Library, and Playwright already run in CI. Existing fixtures\ncover authenticated members, another workspace, empty lists, and service failures;\nthere are no dashboard-specific tests yet. Existing staging feature flags and\nrequest/error metrics support a member-cohort rollout and rollback to the current\nlanding page. The dashboard's rollout criteria, endpoint performance checks,\ninteraction tests, and accessibility verification must be specified and added.\n\nAll dashboard screen, panel, aggregate-endpoint, modal, and toast work listed above\nis new. The existing contracts describe dependencies to reuse, not completed work\nor prior approval of an implementation approach.\n",
"acceptedCeoBlock": "\n- Dashboard endpoint response shape (CEO-D1, provisional taste decision; the original `{ activity, notifications, quickActions }` wording stands until the Final Approval Gate): `GET /api/dashboard` returns `{ activity, notifications, quickActions }`. Each key is an independent envelope, either `{ ok: true, data, asOf }` or `{ ok: false, error: { kind } }`. Per-envelope `asOf` is an ISO 8601 UTC timestamp captured by the application server immediately before that source's query runs, so a record created after it may appear in the list and, for notifications, legitimately remain unread after a mark-all. There is no top-level timestamp; nothing consumes one. The handler starts the three source calls concurrently (verify in the first hour that the request-scoped database connection model allows this; otherwise run them sequentially and record the choice), applies a per-source deadline (default 2× that source's baselined p95, capped at 3 seconds, always below the client's 10 second request timeout; a source exceeding it yields `retryable-service`), isolates each failure, and returns HTTP 200 with mixed envelopes; one source failing or hanging never fails the response. Unauthenticated requests and requests from a session whose member is not (or is no longer) a member of the workspace in context are rejected by the existing session and membership middleware before the handler runs (401 and 403 as today, so whole-request 403 is reachable for a logged-in member); the handler reads member and workspace IDs only from request context and accepts no query parameters. The handler performs no retries of its own. Canonical panel identifiers everywhere (envelope keys, Server-Timing entries, metric tags, event payloads) are `activity`, `notifications`, `quickActions`; their display names are \"Recent activity\", \"Notifications\", \"Quick actions\" (Phase 2 may rename the display names only).\n- Per-envelope error kinds (CEO-D1): `retryable-service` (origin: the source timed out against its deadline, returned a service error or a database error; copy: \"Couldn't load {display name}.\"; automatic retry: yes; Retry button: yes) and `unknown` (origin: any other exception, including an unexpected authorization exception from a source for a member who already passed the membership middleware, logged with a correlation id and counted by origin; copy: \"Something went wrong.\"; automatic retry: no; Retry button: yes, because the origin is by definition unknown and may be transient). There is no per-envelope `forbidden`, `unauthenticated`, `validation` or `network` kind; those are whole-request outcomes handled below.\n- Whole-request failure (CEO-D1): if `GET /api/dashboard` itself fails, the page maps the existing typed client error: `unauthenticated` → the existing login redirect; `forbidden` → a page-level message \"You don't have access to this workspace.\" with a link to the current landing page and no panels; `network` (including the client's 10 second timeout) or a 5xx or non-JSON response → all three panels behave as `{ ok: false, error: { kind: \"retryable-service\" } }` and follow the retry rule below; `validation` cannot occur for a parameterless request and is treated as `unknown` on all three panels.\n- Dashboard endpoint data (CEO-D1): `activity.data = { items }` from the existing workspace activity list method (latest 20), each item a `{ id, kind, summary, actorName, createdAt, href }` DTO mapped from the record, where `kind` is the record's change type rendered as a short label from a fixed map owned by Phase 2 (unmapped kinds render no label); `notifications.data = { items, unreadCount }` where `items` come from the existing member-scoped notification list method (latest 20), each item a `{ id, title, body, createdAt, read, href }` DTO, and `unreadCount` is NEW server work: a member-scoped count of unread notifications over the same scope as the list method (a new repository read method, no schema change; verify in the first hour whether a count method already exists); `quickActions.data = { items }` where each item is `{ id, label, href }` for every registered action whose server-side eligibility predicate passes for this member. Ineligible actions are omitted from the response; the client never renders disabled actions. The list methods' cursors are not returned by this endpoint because nothing in this plan consumes them; the full activity and notification pages own pagination. This plan refers to the registry's stable action IDs as `create-item`, `resume-work` and `invite-member`; the implementer verifies the real IDs in the first hour and substitutes them. `actorName` and `href` are optional on both activity and notification items: if audit records carry no actor display name, `actorName` is omitted and the row shows no actor; if a record has no route target, `href` is omitted and the row renders as non-link text. Raw repository records never pass through the endpoint. Activating a notification or activity row navigates to its `href` and does not change read state in this plan (no single-item read mutation is added or called).\n- If the Final Approval Gate selects CEO-D1 option B (three parallel client calls to existing endpoints, no aggregate endpoint) instead of option C: option B is viable only if the first-hour check confirms an existing HTTP endpoint for eligible quick actions and an unread count (or a cheap way to add one) on the notifications list endpoint; if either is missing, B collapses to C and the gate decision is recorded as superseded. Under B the notifications list endpoint must emit an application-stamped `X-As-Of` response header captured before its query (a small server change), and `notifications.asOf` is that header, never the `Date` header (which a proxy may stamp) and never the client clock. Whole-request rules apply per call: `unauthenticated` on any call → login redirect; `forbidden` on any call → the page-level message; `retryable-service` and `network` → the retry rule; all other errors → `unknown`. The single automatic retry and every refetch re-issue only the failing calls (retry) or all three calls (manual Retry and post-mark-all refetch). The Server-Timing requirement and endpoint integration tests below are replaced by client-side per-request timing events and per-endpoint tests. Everything else in this block stands.\n- Toast system (CEO-D2, provisional taste decision): one shared `ToastProvider` mounted once in the app shell exposes a `useToast()` hook. It renders a single `aria-live=\"polite\"` region and shows one toast at a time; a newer toast replaces the visible one. Plain toasts auto-dismiss after 6 seconds; toasts that carry an action persist until dismissed or replaced. Every toast has a named Dismiss button. Enter and exit motion is disabled when `prefers-reduced-motion` is set. Dashboard components call only the hook and own no toast markup. Multi-toast queueing is left to the first feature that needs it.\n- Mark all as read (CEO-D3): the control is rendered only when the notifications envelope currently rendered is `ok: true` and its `unreadCount` is greater than 0. Confirming submits the `asOf` of the notifications envelope currently rendered as the bulk-read snapshot time; the client never uses its own clock. The existing CSRF token requirement applies to this mutation. First-hour stop condition: the bulk-read API's scoping must match the list method's scoping (same member and, if the list is workspace-scoped, same workspace); if the bulk-read is member-only while the list is workspace-scoped, the control is not shipped until the mismatch is resolved, because it would mark alerts from other workspaces that the member never saw. On success the page refetches `GET /api/dashboard` in place without skeletons (same path as manual Retry) so read state and `unreadCount` come from the server, a toast reads \"All notifications marked as read\", and because the control unmounts when `unreadCount` becomes 0, focus moves to the Notifications panel heading (`tabindex=\"-1\"`) instead of the vanished button. On failure the panel is unchanged and an error toast with a Retry action appears; Retry re-submits the bulk-read with the same `asOf` without reopening the dialog.\n- Confirmation modal (CEO-D4, kept as stated): the \"Mark all as read\" dialog wraps the existing dialog primitive (focus trap, Escape dismissal, focus return to the triggering button on Cancel or Escape), titled \"Mark all {unreadCount} notifications as read?\" (singular form \"Mark 1 notification as read?\"), with Cancel receiving initial focus and a \"Mark all as read\" confirm button.\n- Rollout (CEO-D6): feature flag `dashboard_landing`, default off, evaluated per member ID with a stable hash so a member's arm does not change during the ramp except by promotion; if the first-hour check finds the flag system cannot bucket a stable percentage of members, the post-login redirect handler applies a deterministic hash of the member ID against a percentage read from the flag's value instead. Flag on for a member: post-login redirect targets `/dashboard` and the route is served. Flag off: post-login redirect targets the current landing page and `/dashboard` redirects there too, so rollback is a flag flip with no deploy. The post-login redirect edit is the one change to the login flow in this plan. Ramp: staging, then 5%, 25%, 100% of members, each step held at least 3 days and, for the 5% step, until at least 500 login sessions per arm have been recorded; if that floor is not reached within 14 days the feature owner records a decision to extend or stop. Metric definition: login-to-first-completed-task is the time from the login event to the first action-completion event within the same session; sessions with no completion are censored at session end and reported separately as the completed-task rate. Two tiers of criteria. Alerting guardrails, computed over a rolling 15 minute window and paging the feature owner on breach, who flips the flag off within the hour (manual, no automation added by this plan): endpoint 5xx rate under 0.5%; endpoint p95 latency within the budget (800 ms default, re-derived after the first-hour baseline as the maximum baselined source p95 plus 100 ms when concurrent, or the sum of source p95s plus 100 ms when sequential, adding the unread count query p95 wherever it is serialized); per-source envelope failure rate (`ok: false` share per source) under 1%. Promotion gate, evaluated at the end of each step over the full hold period, cohort vs control: median login-to-first-completed-task at least 10 seconds better than control; completed-task rate no more than 2 points lower; permission-error rate no more than 0.5 points higher. A cohort median more than 5 seconds worse than control at step end is a guardrail breach (flag off). The 45 second target is the goal at 100%, not a gate. These thresholds are defaults the feature owner may tune before the ramp starts and must record.\n- Loading skeletons (SEL-2): each panel's skeleton renders N placeholder rows where N is that panel's maximum render count (a Phase 2 design decision; provisional values 5 activity rows, 5 notification rows, 3 quick-action buttons), at the same row height as loaded rows at each breakpoint, sets `aria-busy=\"true\"` on the panel, and announces nothing. To keep row height fixed, notification `title`, quick-action `label`, activity `actorName` and the activity `kind` label clamp to 1 line, and notification `body` and activity `summary` clamp to 1 line at sm and 2 lines at md and lg (Phase 2 may adjust the clamp values, not the fixed-height rule). A loaded panel with fewer than N rows shrinks; that shift is accepted.\n- Error state and retry (SEL-4): when any envelope is `retryable-service` after the initial fetch, the client performs at most one automatic retry of `GET /api/dashboard` per page load, regardless of how many panels are retryable, after a 1–2 second randomized delay, while failed panels keep their skeletons. The automatic retry's response replaces every envelope that was not `ok` (including `unknown`); rendered `ok` envelopes keep their data and `asOf`. If a panel is still failing after the retry, it renders the error copy for its kind and a Retry button. Manual Retry sets the button to a disabled busy state with `aria-busy=\"true\"` until the response settles, refetches `GET /api/dashboard`, and refreshes all three panels in place without skeletons. When an in-place refetch (manual Retry or post-mark-all) returns `ok: false` for a panel that currently shows data, the panel keeps its rendered data and `asOf`, shows an inline \"Couldn't refresh\" notice with a Retry link, and the automatic retry is not re-armed. Retry telemetry: one `dashboard_panel_retry { panel, kind, automatic }` event per failing panel per automatic retry request; manual Retry emits one event for the clicked panel only.\n- Timestamps (SEL-6): every activity and notification row renders ``; relative text refreshes at most once per minute; items older than 7 days show the absolute date instead of relative text.\n- Empty states (SEL-8): a panel is empty when its envelope is `ok` and `items.length === 0`. Activity empty shows \"No activity yet\" and, only when the quickActions envelope is `ok` and contains `create-item`, a link to that action; notifications empty shows \"No notifications yet\" (the Mark all as read control is already hidden by the unreadCount rule; a non-empty list with `unreadCount` 0 renders normally without the control); quick actions empty (no eligible action) shows \"No actions available for your account\". Only the activity panel carries a CTA. Empty states are plain text regions with no required illustration.\n- Observability (SEL-10): the endpoint records per-source duration and outcome (`dashboard.source.duration_ms` tagged `source` in {activity, notifications, quickActions} and `ok`), total handler duration, and sets a `Server-Timing` header with `activity`, `notifications`, `quickActions` entries. The client emits `dashboard_viewed { okPanels: string[] }` once per page load after the initial fetch settles (success or whole-request failure), with `okPanels` reflecting the state before any automatic retry; `dashboard_panel_error { panel, kind }` once per panel each time it renders the error state (an `unknown` panel later replaced by the automatic retry has still emitted once); `dashboard_panel_retry { panel, kind, automatic }` as defined above; `dashboard_action_clicked { actionId }`; `dashboard_notification_opened { id }` and `dashboard_activity_opened { id }` when the user activates a row's link; and `dashboard_mark_all_read { outcome }` with `outcome` in {confirmed-success, confirmed-failure, cancelled}.\n- Tests required by this phase (minimum; extended in later phases): endpoint integration tests for an authenticated member receiving 200 with three `ok` envelopes and a `Server-Timing` header; a member of another workspace receiving only their workspace's activity and their own notifications; an unauthenticated request receiving 401; a non-member receiving 403; one failing source yielding 200 with one `ok: false` and two `ok: true` envelopes; a source exceeding its deadline yielding 200 with a `retryable-service` envelope; an ineligible action omitted; item DTOs containing only the fields listed above; the bulk-read call rejected without a CSRF token. React Testing Library tests for each panel rendering loading, empty, error, and success from envelope fixtures; a single automatic retry per page load when two panels fail; the automatic retry replacing only non-`ok` envelopes; the failed panel keeping its skeleton during the automatic retry; the Retry button busy and disabled while in flight; an in-place refetch failure keeping rendered data and showing the inline notice; Mark all as read hidden when `unreadCount` is 0 or the envelope failed; Mark all as read sending the rendered envelope's `asOf`, opening the dialog with focus trapped, returning focus to the button on Cancel, moving focus to the panel heading after a successful confirm, and refetching in place on success; the failure toast's Retry re-submitting the same `asOf`; whole-request `forbidden` rendering the page-level message; whole-request `network` making all panels retryable and triggering exactly one automatic retry; each `dashboard_*` event emitted at the defined moment with the defined payload; each row rendering a `