From 055026c2a19b465b8bf2c45167bee935b51d09ba Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 02:51:48 -0600 Subject: [PATCH 01/20] docs: add phase 8 readiness design --- ...-phase8-readiness-command-center-design.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md diff --git a/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md b/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md new file mode 100644 index 0000000..e5dffed --- /dev/null +++ b/docs/superpowers/specs/2026-04-26-phase8-readiness-command-center-design.md @@ -0,0 +1,253 @@ +# Phase 8 Readiness Command Center Design + +## Product Intent + +Phase 8 turns the project workspace from a set of working surfaces into a decision surface. + +The product already has Jira-like execution, implementation planning, GitHub engineering status, lightweight docs/comments, and in-app notifications. The remaining product gap is synthesis: a project lead should be able to open Overview and quickly understand whether the project is ready to move forward, what risk blocks it, and which concrete actions the team should take next. + +## Audience + +Phase 8 serves two audiences in one surface: + +- **Lead / reporting view:** The first screen answers "Is this project ready, risky, or blocked?" +- **Team execution view:** The lower screen answers "What should we do next?" + +The lead view gets priority in the Overview first screen. Team actionability follows immediately below it. + +## Scope + +### 1. Readiness Overview + +Replace the current light Overview summary with a readiness command center. + +The Overview should show: + +- overall readiness status: `Ready`, `Ready with risk`, or `Blocked` +- short readiness narrative in plain language +- scope completion ratio from plan items and work items +- blocked and urgent issue counts +- GitHub health: open/review PRs, failing checks, and deploy state +- notification pressure for the current user +- current stage and next gate signal + +The readiness status is derived from local project state. No external analytics service is introduced. + +### 2. Decision Cues + +Add a compact side panel that explains why the readiness status was chosen. + +Decision cues should include: + +- ship gate status +- primary blocker +- current engineering risk +- owner attention signal from unread notifications +- current stage gate signal + +The goal is not to create a full executive dashboard. The goal is to make the Overview defensible in a standup, stakeholder check-in, or release discussion. + +### 3. Team Action List + +Add a generated action list below the executive snapshot. + +Actions should be deterministic and source-linked. Examples: + +- fix a failing check linked to a work item +- resolve a blocked item +- review a PR that is waiting +- move a stale current-stage plan item forward +- respond to unread high-priority notifications + +The action list should not invent AI-style recommendations. It should be simple rule-based synthesis from existing project, work item, GitHub, plan, and notification state. + +### 4. Scoped Readiness Search + +Add a narrow project search surface that supports readiness work. + +Search should cover: + +- work item identifier, title, description, status, priority, labels +- plan item title, outcome, blocker +- project stage title, goal, gate status +- comment body +- GitHub PR title/branch/status where available +- notification title/body for the current user + +Search is project-scoped. Phase 8 does not add a global cross-workspace search center. + +### 5. Product Polish Where It Supports Readiness + +Polish should be limited to readiness-critical rough edges: + +- empty states for Overview, Engineering, Notifications, and Search +- loading and API error states for readiness search and notification-backed action signals +- clearer no-repository and no-GitHub-link states +- responsive behavior for the readiness cards and action list + +Manual GitHub link management remains out of scope unless required to make readiness signals explainable. + +## Non-Goals + +- Global portfolio dashboard across workspaces +- Full analytics warehouse or historical trends +- AI-generated summaries or recommendations +- Full-text search infrastructure such as Elasticsearch +- Global command palette +- Manual ambiguous GitHub link resolution workflow +- Email, Slack, push, or real-time delivery +- New billing, permissions, or workspace administration surfaces + +## Information Architecture + +Overview becomes the primary Phase 8 surface: + +1. **Executive Snapshot** + - readiness status + - narrative summary + - key metrics chips + +2. **Decision Cues** + - gate status + - primary blocker + - engineering risk + - notification pressure + +3. **Signal Cards** + - plan completion + - blocked / urgent work + - GitHub health + - notification pressure + +4. **Team Action List** + - deterministic next actions with source links + +5. **Scoped Search** + - project readiness search + - grouped result types + +Existing Board, Plan, Engineering, Docs, and Notifications surfaces stay in place. Overview links into those surfaces when the user needs detail. + +## Readiness Model + +The readiness model should be implemented as a server-side projection. + +### Inputs + +- project record +- project stages and gate statuses +- plan items +- work items +- comments +- task GitHub statuses and engineering records +- current user's notification inbox rows + +### Derived Status + +`Blocked` when any of these are true: + +- the current stage status is `Blocked` +- one or more work items are blocked +- one or more GitHub checks are failing on linked work + +`Ready with risk` when none of the blocked rules are true, but any of these are true: + +- urgent work exists +- open or review-requested PRs exist +- production deploy is missing while staging exists +- current user has unread high-priority notifications +- current stage has incomplete plan items + +`Ready` when: + +- no blocked rule is true +- no risk rule is true +- current stage plan items are complete or there are no current-stage plan items + +### Readiness Narrative + +The narrative should be deterministic and concise. + +Examples: + +- `Blocked by 1 failing check and 2 blocked work items.` +- `Ready with risk: 3 plan items remain and 1 PR is awaiting review.` +- `Ready: current stage has no blocking work and engineering signals are stable.` + +## Search Design + +Search should be implemented with simple Postgres-backed matching for Phase 8. + +Use case-insensitive substring matching over normalized source fields. Ranking can be lightweight: + +1. exact work item identifier match +2. work item title match +3. blocker / urgent / failing GitHub matches +4. comment, plan, stage, and notification text matches + +Results should be grouped by type: + +- Work items +- Plan +- Comments +- Engineering +- Notifications + +Each result should include: + +- type label +- title +- short context snippet +- status/risk chip where relevant +- link to the best existing project surface + +## UI Direction + +Keep the current dark, rounded, high-contrast project workspace language. Do not introduce a new visual system. + +The Overview should feel more intentional: + +- larger readiness headline +- fewer generic health lines +- stronger hierarchy between executive snapshot and team actions +- compact, scannable metrics +- source-linked actions instead of decorative cards + +Search should be lightweight, not a heavy modal or global command center in this phase. + +## API and Data Boundaries + +Prefer server projections over client-side assembly. + +Recommended boundaries: + +- `apps/web/src/server/projects/readiness.ts` owns readiness derivation. +- `apps/web/src/server/projects/search.ts` owns project-scoped search. +- `getProjectWorkspaceForUser` includes readiness data in the Overview view model. +- API routes expose search only for authenticated workspace members. + +Readiness and search must respect existing workspace membership and project scoping. Users should never see comments, notifications, or work items from another workspace. + +## Verification + +Automated coverage should prove: + +- readiness status derivation for ready, ready-with-risk, and blocked states +- action list ordering and source links +- search returns project-scoped grouped results +- search excludes cross-workspace data +- Overview renders snapshot, decision cues, signal cards, action list, empty states, and search states +- existing Board, Plan, Engineering, Notifications, and Docs tests continue to pass + +Full final verification: + +- `npm run lint` +- `npm run typecheck` +- `npm test` +- `npm run build` + +## Open Tradeoffs Resolved + +- **Reporting vs search vs polish:** Reporting/readiness is the backbone. Search and polish are scoped support work. +- **Lead vs team audience:** Lead view owns the first screen; team actions sit directly underneath. +- **Recommendation style:** Rule-based synthesis only. No AI-generated guidance in Phase 8. From 8fbfe3eb7c1e56ec4fe9983437bafe23c2ec20a4 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 02:58:21 -0600 Subject: [PATCH 02/20] docs: add phase 8 readiness implementation plan --- ...6-04-26-phase8-readiness-command-center.md | 1446 +++++++++++++++++ 1 file changed, 1446 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md diff --git a/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md new file mode 100644 index 0000000..23d7841 --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md @@ -0,0 +1,1446 @@ +# Phase 8 Readiness Command Center Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Overview into a readiness command center with deterministic readiness reporting, source-linked team actions, scoped readiness search, and readiness-critical polish. + +**Architecture:** Keep Phase 8 local-first and rule-based. Add a pure server-side readiness projection, wire it into the existing project workspace view model, add a project-scoped Postgres search service plus API route, and render the new Overview as a lead-first readiness snapshot with team actions below. + +**Tech Stack:** Next.js App Router, React client components where interactivity is required, Drizzle ORM, Postgres, existing project workspace server loaders, Node integration tests, Vitest UI tests, shared TypeScript contracts. + +--- + +## File Structure + +- `apps/web/src/server/projects/readiness.ts` owns pure readiness derivation from project, stage, plan, work item, GitHub, engineering, and inbox inputs. +- `apps/web/src/server/projects/search.ts` owns project-scoped readiness search and RBAC checks. +- `apps/web/src/server/projects/workspace.ts` continues to assemble the project workspace view and now includes readiness in `overview`. +- `apps/web/src/features/overview/overview-view.tsx` becomes the high-level page composition. +- `apps/web/src/features/overview/readiness-summary.tsx` renders the executive snapshot. +- `apps/web/src/features/overview/readiness-signal-card.tsx` renders the signal cards. +- `apps/web/src/features/overview/readiness-action-list.tsx` renders deterministic source-linked team actions. +- `apps/web/src/features/overview/readiness-search.tsx` owns the client-side scoped search UI. +- `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` exposes project-scoped search. +- `tests/phase8-readiness-projection.test.mjs` covers pure readiness rules and workspace integration. +- `tests/phase8-project-search.test.mjs` covers search ranking and workspace isolation. +- `tests/phase8-search-api.test.mjs` covers auth, RBAC, query validation, and response shape. +- `apps/web/src/features/overview/__tests__/overview-view.test.tsx` covers the new Overview UI. + +--- + +### Task 1: Add Pure Readiness Projection + +**Files:** +- Create: `apps/web/src/server/projects/readiness.ts` +- Create: `tests/phase8-readiness-projection.test.mjs` + +- [ ] **Step 1: Write failing tests for readiness status rules** + +Add `tests/phase8-readiness-projection.test.mjs` with three pure projection tests: + +```js +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectReadiness } from "../apps/web/src/server/projects/readiness.ts"; + +const now = new Date("2026-04-26T12:00:00.000Z").toISOString(); + +function task(overrides = {}) { + return { + id: overrides.id ?? "task-1", + projectId: "project-1", + workspaceId: "workspace-1", + identifier: overrides.identifier ?? "OPS-1", + title: overrides.title ?? "Ship the readiness view", + description: overrides.description ?? "", + status: overrides.status ?? "Doing", + type: "task", + parentId: null, + assigneeId: overrides.assigneeId ?? "user-2", + priority: overrides.priority ?? "medium", + labels: overrides.labels ?? null, + workflowStateId: null, + stageId: overrides.stageId ?? "stage-current", + planItemId: overrides.planItemId ?? "plan-current", + position: overrides.position ?? 0, + blockedReason: overrides.blockedReason ?? null, + dueDate: null, + completedAt: null, + createdAt: now, + updatedAt: now + }; +} + +function stage(overrides = {}) { + return { + id: overrides.id ?? "stage-current", + projectId: "project-1", + slug: "current", + title: overrides.title ?? "Phase 8: Readiness", + goal: "Make project readiness clear.", + status: overrides.status ?? "In Progress", + gateStatus: overrides.gateStatus ?? "In review", + sortOrder: overrides.sortOrder ?? 0 + }; +} + +function planItem(overrides = {}) { + return { + id: overrides.id ?? "plan-current", + stageId: overrides.stageId ?? "stage-current", + title: overrides.title ?? "Finish readiness projection", + outcome: "Overview can explain readiness.", + status: overrides.status ?? "Todo", + blocker: overrides.blocker ?? null, + sortOrder: overrides.sortOrder ?? 0 + }; +} + +function githubStatus(overrides = {}) { + return { + id: overrides.id ?? "github-1", + taskId: overrides.taskId ?? "task-1", + prStatus: overrides.prStatus ?? "No PR", + ciStatus: overrides.ciStatus ?? "Unknown", + deployStatus: overrides.deployStatus ?? "Not deployed" + }; +} + +function inbox(overrides = {}) { + return { + event: { + id: "event-1", + workspaceId: "workspace-1", + projectId: "project-1", + workItemId: "task-1", + sourceType: "work_item", + sourceId: "task-1", + eventType: "priority_raised", + actorId: "user-2", + priority: overrides.priority ?? "high", + title: "OPS-1 needs attention", + body: "Priority was raised.", + url: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + metadata: null, + createdAt: now + }, + recipient: { + id: "recipient-1", + eventId: "event-1", + workspaceId: "workspace-1", + recipientId: "user-1", + reason: "participant", + readAt: overrides.readAt ?? null, + dismissedAt: null, + createdAt: now + }, + workItemIdentifier: "OPS-1", + projectKey: "OPS", + workspaceSlug: "platform-ops", + isUnread: overrides.readAt === undefined || overrides.readAt === null + }; +} + +test("readiness projection marks blocked projects and explains the primary blockers", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + identifier: "OPS-12", + title: "Fix release pipeline", + status: "Blocked", + blockedReason: "CI is failing" + }) + ], + githubStatuses: [githubStatus({ taskId: "task-1", ciStatus: "Failing", prStatus: "Open PR" })], + engineeringItems: [], + notificationInbox: [] + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /1 failing check/); + assert.match(readiness.narrative, /1 blocked work item/); + assert.equal(readiness.decisionCues[0]?.label, "Ship gate"); + assert.equal(readiness.actions[0]?.sourceType, "github"); + assert.equal(readiness.actions[1]?.sourceType, "work_item"); +}); + +test("readiness projection marks projects ready with risk when work remains but blockers are absent", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "In Review" })], + tasks: [task({ identifier: "OPS-7", priority: "urgent" })], + githubStatuses: [githubStatus({ prStatus: "Review requested", ciStatus: "Passing", deployStatus: "Staging" })], + engineeringItems: [], + notificationInbox: [inbox()] + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /urgent/); + assert.ok(readiness.metrics.some((metric) => metric.label === "Notifications" && metric.value === "1 unread")); + assert.ok(readiness.actions.some((action) => action.sourceType === "notification")); +}); + +test("readiness projection marks stable projects ready", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "medium" })], + githubStatuses: [githubStatus({ prStatus: "Merged", ciStatus: "Passing", deployStatus: "Production" })], + engineeringItems: [], + notificationInbox: [] + }); + + assert.equal(readiness.status, "Ready"); + assert.match(readiness.narrative, /no blocking work/); + assert.equal(readiness.actions.length, 0); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs +``` + +Expected: FAIL because `apps/web/src/server/projects/readiness.ts` does not exist. + +- [ ] **Step 3: Implement readiness types and projection** + +Create `apps/web/src/server/projects/readiness.ts` with these exported types and functions: + +```ts +import type { + NotificationInboxItem, + PlanItemRecord, + ProjectStageRecord, + TaskGithubStatusRecord, + WorkItemRecord +} from "@the-platform/shared"; + +import type { ProjectWorkspaceEngineeringItemView } from "../../features/workspace/project-workspace-view"; + +export type ProjectReadinessStatus = "Ready" | "Ready with risk" | "Blocked"; +export type ProjectReadinessTone = "success" | "warning" | "danger" | "neutral"; +export type ProjectReadinessActionSource = "work_item" | "plan" | "github" | "notification"; + +export interface ProjectReadinessMetric { + label: string; + value: string; + detail: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessDecisionCue { + label: string; + value: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessAction { + id: string; + title: string; + detail: string; + href: string; + sourceType: ProjectReadinessActionSource; + priority: "high" | "medium" | "low"; +} + +export interface ProjectReadinessView { + status: ProjectReadinessStatus; + tone: ProjectReadinessTone; + narrative: string; + metrics: ProjectReadinessMetric[]; + decisionCues: ProjectReadinessDecisionCue[]; + actions: ProjectReadinessAction[]; +} + +interface BuildProjectReadinessInput { + currentStage: ProjectStageRecord | null; + stages: ProjectStageRecord[]; + planItems: PlanItemRecord[]; + tasks: WorkItemRecord[]; + githubStatuses: TaskGithubStatusRecord[]; + engineeringItems: ProjectWorkspaceEngineeringItemView[]; + notificationInbox: NotificationInboxItem[]; +} +``` + +Implement `buildProjectReadiness(input: BuildProjectReadinessInput): ProjectReadinessView` with these deterministic rules: + +- `Blocked` if the current stage is `Blocked`, any task has `status === "Blocked"` or a non-empty `blockedReason`, or any GitHub status has `ciStatus === "Failing"`. +- `Ready with risk` if not blocked and any task has `priority === "urgent"`, any GitHub status has `prStatus === "Open PR"` or `prStatus === "Review requested"`, any status has `deployStatus === "Staging"` without a production deploy in the project, any unread notification has event priority `high`, or the current stage has incomplete plan items. +- `Ready` if no blocked rule and no risk rule applies. +- Sort actions by priority order: failing checks, blocked work items, urgent work, review PRs, unread high-priority notifications, incomplete current-stage plan items. +- Cap `actions` at five entries. + +- [ ] **Step 4: Run the readiness projection test** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs +``` + +Expected: PASS with 3 tests passing. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/readiness.ts tests/phase8-readiness-projection.test.mjs +git commit -m "feat: add project readiness projection" +``` + +--- + +### Task 2: Wire Readiness Into Project Workspace View + +**Files:** +- Modify: `apps/web/src/server/projects/workspace.ts` +- Modify: `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts` +- Modify: `tests/phase4-project-workspace.test.mjs` + +- [ ] **Step 1: Extend workspace projection tests** + +In `tests/phase4-project-workspace.test.mjs`, update the first workspace projection test after the existing `workspaceView.overview.health` assertion: + +```js +assert.equal(workspaceView.overview.readiness.status, "Blocked"); +assert.ok(workspaceView.overview.readiness.metrics.some((metric) => metric.label === "GitHub")); +assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "github")); +``` + +Add a new test near the existing overview milestone test: + +```js +test("project workspace overview includes readiness signals from the current user's inbox", async () => { + const session = createNamedSession("readiness-owner"); + const project = { + id: "project-readiness", + workspaceId: "workspace-readiness", + key: "OPS3", + itemCounter: 1, + title: "Ops Readiness", + description: "Readiness notification harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "In review", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-readiness", + workspaceId: project.workspaceId, + projectId: project.id, + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Webhook failed", + body: "GitHub webhook failed processing.", + url: "/workspaces/alpha-workspace/projects/OPS3/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-readiness", + eventId: "event-readiness", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: project.key, + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready with risk"); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "notification")); +}); +``` + +- [ ] **Step 2: Run the failing workspace projection test** + +Run: + +```bash +node --import tsx --test tests/phase4-project-workspace.test.mjs +``` + +Expected: FAIL because `overview.readiness` is not defined and `getProjectWorkspaceForUser` does not accept notification inbox options. + +- [ ] **Step 3: Update workspace view model** + +In `apps/web/src/server/projects/workspace.ts`: + +- Import `buildProjectReadiness` and `NotificationInboxItem`. +- Add `readiness: ProjectReadinessView` to `ProjectWorkspaceView["overview"]`. +- Add a fourth optional parameter to `getProjectWorkspaceForUser`: + +```ts +interface ProjectWorkspaceOptions { + notificationInbox?: NotificationInboxItem[]; +} + +export async function getProjectWorkspaceForUser( + dependencies: ProjectWorkspaceDependencies, + session: AppSession, + workspaceSlug: string, + projectKey: string, + options: ProjectWorkspaceOptions = {} +): Promise { + // existing body +} +``` + +When building `overview`, call: + +```ts +readiness: buildProjectReadiness({ + currentStage, + stages: sortedStages, + planItems, + tasks: sortedTasks, + githubStatuses, + engineeringItems, + notificationInbox: options.notificationInbox ?? [] +}) +``` + +- [ ] **Step 4: Pass inbox rows from the project page loader** + +In `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts`, fetch notifications before calling `getProjectWorkspaceForUser`: + +```ts +const [workspaces, project, notificationRows, notificationPreferences] = await Promise.all([ + listWorkspacesForUser(workspaceRepository, session), + getProjectForUser(projectRepository, session, workspaceSlug, projectKey), + listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), + getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) +]); + +const [workspaceView, projectStages, planItems] = await Promise.all([ + getProjectWorkspaceForUser( + { + projectRepository, + workItemRepository + }, + session, + workspaceSlug, + projectKey, + { notificationInbox: notificationRows } + ), + projectRepository.listProjectStages(project.id), + projectRepository.listPlanItems(project.id) +]); +``` + +Keep the existing `notificationInbox` object shape returned from `loadProjectPageData`. + +- [ ] **Step 5: Run workspace projection tests** + +Run: + +```bash +node --import tsx --test tests/phase4-project-workspace.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/workspace.ts apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts tests/phase4-project-workspace.test.mjs +git commit -m "feat: wire readiness into project overview data" +``` + +--- + +### Task 3: Redesign Overview Readiness UI + +**Files:** +- Modify: `apps/web/src/features/overview/overview-view.tsx` +- Create: `apps/web/src/features/overview/readiness-summary.tsx` +- Create: `apps/web/src/features/overview/readiness-signal-card.tsx` +- Create: `apps/web/src/features/overview/readiness-action-list.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` + +- [ ] **Step 1: Replace the Overview UI test with readiness expectations** + +Update `apps/web/src/features/overview/__tests__/overview-view.test.tsx` so the fixture includes `overview.readiness`: + +```tsx +const overview = { + currentStage: "Phase 8: Readiness", + health: ["Scope: stable", "Delivery risk: low", "Engineering risk: stable"], + milestones: [ + { label: "Notifications", monthStart: 0, monthSpan: 2, tone: "completed" as const }, + { label: "Readiness", monthStart: 2, monthSpan: 2, tone: "current" as const } + ], + readiness: { + status: "Ready with risk" as const, + tone: "warning" as const, + narrative: "Ready with risk: 1 PR is awaiting review.", + metrics: [ + { label: "Plan", value: "4/5 done", detail: "1 current-stage item remains", tone: "warning" as const }, + { label: "Issues", value: "1 urgent", detail: "No blocked work", tone: "warning" as const }, + { label: "GitHub", value: "1 review", detail: "Checks are passing", tone: "warning" as const }, + { label: "Notifications", value: "2 unread", detail: "1 high priority", tone: "warning" as const } + ], + decisionCues: [ + { label: "Ship gate", value: "In review", tone: "warning" as const }, + { label: "Primary blocker", value: "None", tone: "success" as const } + ], + actions: [ + { + id: "review-pr-task-1", + title: "Review OPS-4 pull request", + detail: "Review requested before release.", + href: "/workspaces/platform-ops/projects/OPS/engineering", + sourceType: "github" as const, + priority: "medium" as const + } + ] + } +}; +``` + +Assert: + +```tsx +expect(screen.getByText("Readiness command center")).toBeInTheDocument(); +expect(screen.getByText("Ready with risk")).toBeInTheDocument(); +expect(screen.getByText("Ready with risk: 1 PR is awaiting review.")).toBeInTheDocument(); +expect(screen.getByText("4/5 done")).toBeInTheDocument(); +expect(screen.getByText("Decision cues")).toBeInTheDocument(); +expect(screen.getByRole("link", { name: /Review OPS-4 pull request/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS/engineering" +); +expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); +``` + +- [ ] **Step 2: Run the failing UI test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: FAIL because the readiness components are not rendered. + +- [ ] **Step 3: Add `ReadinessSignalCard`** + +Create `apps/web/src/features/overview/readiness-signal-card.tsx`: + +```tsx +import type { ProjectReadinessMetric } from "@/server/projects/readiness"; + +const toneClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSignalCard({ metric }: { metric: ProjectReadinessMetric }) { + return ( +
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ ); +} +``` + +- [ ] **Step 4: Add `ReadinessActionList`** + +Create `apps/web/src/features/overview/readiness-action-list.tsx`: + +```tsx +import Link from "next/link"; + +import type { ProjectReadinessAction } from "@/server/projects/readiness"; + +const sourceLabel: Record = { + github: "GitHub", + notification: "Notification", + plan: "Plan", + work_item: "Work item" +}; + +export function ReadinessActionList({ actions }: { actions: ProjectReadinessAction[] }) { + return ( +
+
+
+

Team action list

+

Next best moves

+
+ + Rule based + +
+
+ {actions.length > 0 ? ( + actions.map((action) => ( + +
+

{action.title}

+ + {sourceLabel[action.sourceType]} + +
+

{action.detail}

+ + )) + ) : ( +
+ No readiness actions. The current stage has no blocking work or high-priority signals. +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 5: Add `ReadinessSummary`** + +Create `apps/web/src/features/overview/readiness-summary.tsx`: + +```tsx +import type { ProjectReadinessView } from "@/server/projects/readiness"; + +import { ReadinessSignalCard } from "./readiness-signal-card"; + +const statusClass: Record = { + danger: "border-red-300/30 bg-red-500/10 text-red-100", + neutral: "border-white/10 bg-black/10 text-planka-text", + success: "border-emerald-300/30 bg-emerald-500/10 text-emerald-100", + warning: "border-amber-300/30 bg-amber-500/10 text-amber-100" +}; + +export function ReadinessSummary({ readiness }: { readiness: ProjectReadinessView }) { + return ( +
+
+
+

+ Readiness command center +

+
+

{readiness.status}

+ + {readiness.tone} + +
+

{readiness.narrative}

+
+ +
+
+ {readiness.metrics.map((metric) => ( + + ))} +
+
+ ); +} +``` + +- [ ] **Step 6: Compose the new Overview** + +Modify `apps/web/src/features/overview/overview-view.tsx`: + +- Import `ReadinessSummary` and `ReadinessActionList`. +- Render `ReadinessSummary` first. +- Render `MilestoneRoadmap` below it. +- Render `ReadinessActionList` in the right/lower area on desktop. +- Keep the current milestone roadmap visible. + +- [ ] **Step 7: Run the Overview UI test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +Run: + +```bash +git add apps/web/src/features/overview apps/web/src/features/overview/__tests__/overview-view.test.tsx +git commit -m "feat: add readiness overview UI" +``` + +--- + +### Task 4: Add Project-Scoped Readiness Search Service + +**Files:** +- Create: `apps/web/src/server/projects/search.ts` +- Create: `tests/phase8-project-search.test.mjs` + +- [ ] **Step 1: Write failing search tests** + +Create `tests/phase8-project-search.test.mjs` that: + +- creates two workspaces +- creates one project in each workspace +- adds work items, plan items, comments, and GitHub status to the first project +- calls `searchProjectForUser` +- asserts grouped result types and ranking +- asserts cross-workspace data is excluded + +Use this assertion shape: + +```js +assert.deepEqual(results.results.map((result) => result.type), [ + "work_item", + "comment", + "plan", + "engineering" +]); +assert.equal(results.results[0].title, "OPS-1 Fix release pipeline"); +assert.ok(results.results.every((result) => result.href.startsWith(`/workspaces/${workspace.slug}/projects/${projectKey}`))); +assert.ok(!results.results.some((result) => result.title.includes("Other workspace"))); +``` + +- [ ] **Step 2: Run the failing search test** + +Run: + +```bash +node --import tsx --test tests/phase8-project-search.test.mjs +``` + +Expected: FAIL because `apps/web/src/server/projects/search.ts` does not exist. + +- [ ] **Step 3: Implement search result types** + +Create `apps/web/src/server/projects/search.ts` with: + +```ts +import { and, eq, ilike, isNull, or } from "drizzle-orm"; + +import { + comments, + db, + githubPullRequests, + planItems, + projectStages, + projects, + taskGithubStatus, + tasks, + workItemGithubLinks +} from "@the-platform/db"; + +import { resolveWorkspaceContext } from "../work-management/utils"; +import { WorkspaceError } from "../workspaces/core"; +import type { AppSession } from "../workspaces/types"; +import type { ProjectRepository } from "./types"; + +export type ProjectSearchResultType = "work_item" | "plan" | "comment" | "engineering" | "notification"; + +export interface ProjectSearchResult { + id: string; + type: ProjectSearchResultType; + title: string; + snippet: string; + href: string; + chip: string; + rank: number; +} + +export interface ProjectSearchResponse { + query: string; + results: ProjectSearchResult[]; +} + +interface SearchProjectDependencies { + projectRepository: Pick; +} +``` + +- [ ] **Step 4: Implement `searchProjectForUser`** + +Add: + +```ts +export async function searchProjectForUser( + dependencies: SearchProjectDependencies, + session: AppSession, + workspaceSlug: string, + projectKey: string, + query: string +): Promise { + const trimmed = query.trim(); + if (trimmed.length < 2) { + return { query: trimmed, results: [] }; + } + + const { workspace } = await resolveWorkspaceContext( + dependencies.projectRepository, + session, + workspaceSlug, + "viewer" + ); + const project = await dependencies.projectRepository.getProjectByKey(workspace.id, projectKey); + if (!project) { + throw new WorkspaceError(404, "project not found."); + } + + const pattern = `%${trimmed}%`; + const baseHref = `/workspaces/${workspace.slug}/projects/${project.key}`; + + const [workItemRows, planRows, commentRows, engineeringRows] = await Promise.all([ + db.select().from(tasks).where( + and( + eq(tasks.projectId, project.id), + or( + ilike(tasks.identifier, pattern), + ilike(tasks.title, pattern), + ilike(tasks.description, pattern) + ) + ) + ), + db + .select({ planItem: planItems, stage: projectStages }) + .from(planItems) + .innerJoin(projectStages, eq(planItems.stageId, projectStages.id)) + .where( + and( + eq(projectStages.projectId, project.id), + or( + ilike(planItems.title, pattern), + ilike(planItems.outcome, pattern), + ilike(planItems.blocker, pattern), + ilike(projectStages.title, pattern), + ilike(projectStages.goal, pattern), + ilike(projectStages.gateStatus, pattern) + ) + ) + ), + db + .select({ comment: comments, task: tasks }) + .from(comments) + .innerJoin(tasks, eq(comments.workItemId, tasks.id)) + .where(and(eq(tasks.projectId, project.id), isNull(comments.deletedAt), ilike(comments.content, pattern))), + db + .select({ task: tasks, githubStatus: taskGithubStatus, pullRequest: githubPullRequests }) + .from(taskGithubStatus) + .innerJoin(tasks, eq(taskGithubStatus.taskId, tasks.id)) + .leftJoin(workItemGithubLinks, eq(workItemGithubLinks.workItemId, tasks.id)) + .leftJoin(githubPullRequests, eq(workItemGithubLinks.pullRequestId, githubPullRequests.id)) + .where( + and( + eq(tasks.projectId, project.id), + or( + ilike(tasks.identifier, pattern), + ilike(tasks.title, pattern), + ilike(githubPullRequests.title, pattern), + ilike(githubPullRequests.headBranch, pattern) + ) + ) + ) + ]); + + const results: ProjectSearchResult[] = [ + ...workItemRows.map((item) => ({ + id: `work-item-${item.id}`, + type: "work_item" as const, + title: `${item.identifier ?? "Work item"} ${item.title}`, + snippet: item.description || item.status, + href: `${baseHref}?selected=${item.identifier ?? item.id}`, + chip: item.priority === "urgent" || item.status === "Blocked" ? "Risk" : item.status, + rank: item.identifier?.toLowerCase() === trimmed.toLowerCase() ? 0 : 10 + })), + ...commentRows.map((row) => ({ + id: `comment-${row.comment.id}`, + type: "comment" as const, + title: `${row.task.identifier ?? "Comment"} comment`, + snippet: row.comment.content, + href: `${baseHref}?selected=${row.task.identifier ?? row.task.id}`, + chip: "Comment", + rank: 30 + })), + ...planRows.map((row) => ({ + id: `plan-${row.planItem.id}`, + type: "plan" as const, + title: row.planItem.title, + snippet: row.planItem.blocker ?? row.planItem.outcome, + href: `${baseHref}/plan`, + chip: row.stage.gateStatus, + rank: row.planItem.blocker ? 20 : 40 + })), + ...engineeringRows.map((row) => ({ + id: `engineering-${row.task.id}`, + type: "engineering" as const, + title: `${row.task.identifier ?? "Work item"} engineering`, + snippet: row.pullRequest?.title ?? `${row.githubStatus.prStatus} ยท ${row.githubStatus.ciStatus}`, + href: `${baseHref}/engineering`, + chip: row.githubStatus.ciStatus === "Failing" ? "Failing" : row.githubStatus.prStatus, + rank: row.githubStatus.ciStatus === "Failing" ? 20 : 50 + })) + ]; + + return { + query: trimmed, + results: results.sort((left, right) => left.rank - right.rank || left.title.localeCompare(right.title)).slice(0, 20) + }; +} +``` + +If `ilike` rejects nullable columns during typecheck, wrap nullable text fields with `sql` expressions in the implementation. + +- [ ] **Step 5: Run search tests** + +Run: + +```bash +node --import tsx --test tests/phase8-project-search.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/server/projects/search.ts tests/phase8-project-search.test.mjs +git commit -m "feat: add project readiness search service" +``` + +--- + +### Task 5: Add Search API Route + +**Files:** +- Create: `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` +- Create: `tests/phase8-search-api.test.mjs` + +- [ ] **Step 1: Write failing API tests** + +Create `tests/phase8-search-api.test.mjs` with tests for: + +- authenticated workspace member can search +- query shorter than 2 characters returns empty results +- non-member receives 403 +- missing project receives 404 + +Use route-level request calls by importing `GET` from the route file and stubbing session through the same pattern used in existing API tests. + +- [ ] **Step 2: Run the failing API test** + +Run: + +```bash +node --import tsx --test tests/phase8-search-api.test.mjs +``` + +Expected: FAIL because the route file does not exist. + +- [ ] **Step 3: Implement the route** + +Create `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts`: + +```ts +import { getAppSession } from "@/server/auth"; +import { createProjectRepository } from "@/server/projects/repository"; +import { searchProjectForUser } from "@/server/projects/search"; +import { WorkspaceError } from "@/server/workspaces/core"; + +const dependencies = { + getSession: getAppSession, + projectRepository: createProjectRepository() +}; + +function json(data: unknown, status = 200) { + return Response.json(data, { status }); +} + +function handleError(error: unknown) { + if (error instanceof WorkspaceError) { + return json({ error: error.message }, error.status); + } + + throw error; +} + +export async function GET( + request: Request, + context: { + params: Promise<{ + slug: string; + key: string; + }>; + } +) { + const session = await dependencies.getSession(); + if (!session) { + return json({ error: "authentication required." }, 401); + } + + const params = await context.params; + const url = new URL(request.url); + const query = url.searchParams.get("q") ?? ""; + + try { + const results = await searchProjectForUser( + { projectRepository: dependencies.projectRepository }, + session, + params.slug, + params.key, + query + ); + return json(results); + } catch (error) { + return handleError(error); + } +} +``` + +- [ ] **Step 4: Run API tests** + +Run: + +```bash +node --import tsx --test tests/phase8-search-api.test.mjs +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +Run: + +```bash +git add apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts tests/phase8-search-api.test.mjs +git commit -m "feat: add readiness search api" +``` + +--- + +### Task 6: Add Scoped Search UI to Overview + +**Files:** +- Create: `apps/web/src/features/overview/readiness-search.tsx` +- Modify: `apps/web/src/features/overview/overview-view.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` + +- [ ] **Step 1: Extend Overview UI tests for search states** + +In `overview-view.test.tsx`, add a test that stubs `fetch`, types `pipeline`, and expects grouped results: + +```tsx +it("searches readiness signals from the overview", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + query: "pipeline", + results: [ + { + id: "work-item-1", + type: "work_item", + title: "OPS-1 Fix release pipeline", + snippet: "Pipeline blocks the release.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Risk", + rank: 0 + } + ] + }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByPlaceholderText("Search blockers, PRs, comments..."), { + target: { value: "pipeline" } + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/workspaces/platform-ops/projects/OPS/search?q=pipeline"); + }); + expect(await screen.findByRole("link", { name: /OPS-1 Fix release pipeline/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS?selected=OPS-1" + ); +}); +``` + +Import `fireEvent`, `waitFor`, and `vi` at the top of the test file. + +- [ ] **Step 2: Run the failing Overview search test** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: FAIL because `OverviewView` does not accept `workspaceSlug` and `projectKey`, and `ReadinessSearch` does not exist. + +- [ ] **Step 3: Create `ReadinessSearch`** + +Create `apps/web/src/features/overview/readiness-search.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { useState, useTransition } from "react"; + +import type { ProjectSearchResult } from "@/server/projects/search"; + +interface ReadinessSearchProps { + workspaceSlug: string; + projectKey: string; +} + +export function ReadinessSearch({ workspaceSlug, projectKey }: ReadinessSearchProps) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function updateQuery(value: string) { + setQuery(value); + setError(null); + + if (value.trim().length < 2) { + setResults([]); + return; + } + + startTransition(async () => { + const response = await fetch( + `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(value.trim())}` + ); + + if (!response.ok) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + const body = (await response.json()) as { results: ProjectSearchResult[] }; + setResults(body.results); + }); + } + + return ( +
+

Readiness search

+ updateQuery(event.target.value)} + /> + {isPending ?

Searching readiness signals...

: null} + {error ?

{error}

: null} +
+ {results.map((result) => ( + +
+

{result.title}

+ + {result.chip} + +
+

{result.snippet}

+ + ))} + {query.trim().length >= 2 && !isPending && !error && results.length === 0 ? ( +

+ No readiness signals found for "{query.trim()}". +

+ ) : null} +
+
+ ); +} +``` + +- [ ] **Step 4: Wire search into `OverviewView`** + +Change `OverviewView` props: + +```ts +export function OverviewView({ + brief, + overview, + workspaceSlug, + projectKey +}: { + brief: string; + overview: OverviewViewModel; + workspaceSlug: string; + projectKey: string; +}) { + // render ReadinessSearch in the lower right section +} +``` + +Update `apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx` to pass `workspace.slug` and `project.key` from `loadProjectPageData`. + +- [ ] **Step 5: Run Overview UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/features/overview/readiness-search.tsx apps/web/src/features/overview/overview-view.tsx apps/web/src/features/overview/__tests__/overview-view.test.tsx apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx +git commit -m "feat: add readiness search UI" +``` + +--- + +### Task 7: Add Readiness-Critical Polish States + +**Files:** +- Modify: `apps/web/src/features/overview/readiness-action-list.tsx` +- Modify: `apps/web/src/features/overview/readiness-search.tsx` +- Modify: `apps/web/src/features/engineering/engineering-view.tsx` +- Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` +- Modify: `apps/web/src/features/engineering/__tests__/engineering-view.test.tsx` + +- [ ] **Step 1: Add UI tests for empty and no-repository states** + +In `overview-view.test.tsx`, assert empty actions are explicit: + +```tsx +const readyOverview = { + ...overview, + readiness: { + ...overview.readiness, + status: "Ready" as const, + tone: "success" as const, + narrative: "Ready: current stage has no blocking work and engineering signals are stable.", + actions: [] + } +}; + +render(); + +expect(screen.getByText("No readiness actions. The current stage has no blocking work or high-priority signals.")).toBeInTheDocument(); +``` + +In `engineering-view.test.tsx`, assert no repository connected renders a clear setup state: + +```tsx +expect(screen.getByText("Connect GitHub to populate engineering readiness signals.")).toBeInTheDocument(); +``` + +- [ ] **Step 2: Run failing UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: FAIL if the engineering no-repository state is not explicit. + +- [ ] **Step 3: Improve Engineering empty states** + +In `apps/web/src/features/engineering/engineering-view.tsx`, when `engineering.connectionStatus === "Setup required"`, render: + +```tsx +
+ Connect GitHub to populate engineering readiness signals. +
+``` + +Keep current linked PR, failing checks, deployments, and issue summary sections intact. + +- [ ] **Step 4: Improve search empty and error copy** + +In `ReadinessSearch`, keep three distinct states: + +- query shorter than 2 characters: `Search across blockers, PRs, comments, plan items, and notifications.` +- no results: `No readiness signals found for "{query}".` +- failed request: `Search failed. Try again from the project overview.` + +- [ ] **Step 5: Run UI tests** + +Run: + +```bash +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +Run: + +```bash +git add apps/web/src/features/overview apps/web/src/features/engineering +git commit -m "feat: polish readiness empty states" +``` + +--- + +### Task 8: Final Documentation and Verification + +**Files:** +- Modify: `docs/product/idea-brief.md` +- Modify: `docs/product/decision-brief.md` +- Modify: `docs/product/prd.md` +- Modify: `docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md` + +- [ ] **Step 1: Update product docs for Phase 8** + +Update product docs so they identify Phase 8 as: + +- Readiness Command Center +- lead-first Overview reporting +- deterministic team action list +- scoped readiness search +- readiness-critical polish + +Keep Phase 8 non-goals explicit: + +- no global portfolio dashboard +- no analytics warehouse +- no AI-generated recommendations +- no full-text infrastructure +- no global command palette + +- [ ] **Step 2: Mark completed plan tasks** + +In this plan file, check off completed task steps as implementation progresses. + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +node --import tsx --test tests/phase8-readiness-projection.test.mjs tests/phase8-project-search.test.mjs tests/phase8-search-api.test.mjs +npm run test --workspace @the-platform/web -- overview-view engineering-view +``` + +Expected: PASS. + +- [ ] **Step 4: Run full verification** + +Run: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +Expected: PASS. + +- [ ] **Step 5: Browser smoke check** + +Start the app from the Phase 8 worktree: + +```bash +npm run dev +``` + +Open the local Next.js URL and verify: + +- Overview shows `Readiness command center`. +- The readiness status matches seeded project signals. +- Team action list links navigate to existing project surfaces. +- Search query with a known identifier returns a work item result. +- Search query with no result shows the no-result state. +- Engineering no-repository projects show the setup state. + +- [ ] **Step 6: Commit final docs** + +Run: + +```bash +git add docs/product/idea-brief.md docs/product/decision-brief.md docs/product/prd.md docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md +git commit -m "docs: finalize phase 8 readiness plan" +``` From ec459c3cdfddf4bf6c1c3e67dc4e64fc4014708d Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 10:27:48 -0600 Subject: [PATCH 03/20] feat: add project readiness projection --- apps/web/src/server/projects/readiness.ts | 514 +++++++++++++++++++++ tests/phase8-readiness-projection.test.mjs | 389 ++++++++++++++++ 2 files changed, 903 insertions(+) create mode 100644 apps/web/src/server/projects/readiness.ts create mode 100644 tests/phase8-readiness-projection.test.mjs diff --git a/apps/web/src/server/projects/readiness.ts b/apps/web/src/server/projects/readiness.ts new file mode 100644 index 0000000..55e24d5 --- /dev/null +++ b/apps/web/src/server/projects/readiness.ts @@ -0,0 +1,514 @@ +import type { + NotificationInboxItem, + PlanItemRecord, + ProjectStageRecord, + TaskGithubStatusRecord, + WorkItemRecord, +} from "@the-platform/shared"; + +export type ProjectReadinessStatus = "Ready" | "Ready with risk" | "Blocked"; +export type ProjectReadinessTone = "success" | "warning" | "danger" | "neutral"; +export type ProjectReadinessActionSource = + | "work_item" + | "plan" + | "github" + | "notification"; + +export interface ProjectReadinessMetric { + label: string; + value: string; + detail: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessDecisionCue { + label: string; + value: string; + tone: ProjectReadinessTone; +} + +export interface ProjectReadinessAction { + id: string; + title: string; + detail: string; + href: string; + sourceType: ProjectReadinessActionSource; + priority: "high" | "medium" | "low"; +} + +export interface ProjectReadinessEngineeringSignal { + taskId: string; + identifier: string; + checkLabel: string; + checkUrl: string | null; + pullRequestLabel: string; + pullRequestUrl: string | null; +} + +export interface ProjectReadinessView { + status: ProjectReadinessStatus; + tone: ProjectReadinessTone; + narrative: string; + metrics: ProjectReadinessMetric[]; + decisionCues: ProjectReadinessDecisionCue[]; + actions: ProjectReadinessAction[]; +} + +export interface BuildProjectReadinessInput { + baseProjectHref?: string; + currentStage: ProjectStageRecord | null; + stages: ProjectStageRecord[]; + planItems: PlanItemRecord[]; + tasks: WorkItemRecord[]; + githubStatuses: TaskGithubStatusRecord[]; + engineeringItems: ProjectReadinessEngineeringSignal[]; + notificationInbox: NotificationInboxItem[]; +} + +interface RankedReadinessAction extends ProjectReadinessAction { + rank: number; +} + +function pluralize(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function hasBlockedReason(task: WorkItemRecord) { + return Boolean(task.blockedReason?.trim()); +} + +function isActiveTask(task: WorkItemRecord) { + return task.status !== "Done"; +} + +function isIncompletePlanItem(planItem: PlanItemRecord) { + return planItem.status !== "Done"; +} + +function taskLabel(task: WorkItemRecord) { + return task.identifier ? `${task.identifier}: ${task.title}` : task.title; +} + +function stableHref(input: BuildProjectReadinessInput, suffix = "") { + return input.baseProjectHref + ? `${input.baseProjectHref.replace(/\/+$/, "")}${suffix}` + : "#"; +} + +function workItemHref(input: BuildProjectReadinessInput, task: WorkItemRecord) { + return task.identifier + ? stableHref(input, `/items/${task.identifier}`) + : stableHref(input); +} + +function planHref(input: BuildProjectReadinessInput) { + return stableHref(input, "/plan"); +} + +function engineeringHref(input: BuildProjectReadinessInput) { + return stableHref(input, "/engineering"); +} + +function findEngineeringItem( + engineeringItems: ProjectReadinessEngineeringSignal[], + githubStatus: TaskGithubStatusRecord +) { + return engineeringItems.find((item) => item.taskId === githubStatus.taskId); +} + +function buildMetrics( + input: BuildProjectReadinessInput +): ProjectReadinessMetric[] { + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const urgentTaskCount = activeTasks.filter( + (task) => task.priority === "urgent" + ).length; + const failingCheckCount = input.githubStatuses.filter( + (status) => status.ciStatus === "Failing" + ).length; + const unreadNotificationCount = input.notificationInbox.filter( + (notification) => notification.isUnread + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const completedCurrentStagePlanItems = currentStagePlanItems.filter( + (planItem) => planItem.status === "Done" + ).length; + + return [ + { + label: "Work items", + value: pluralize(blockedTaskCount, "blocked"), + detail: `${pluralize(urgentTaskCount, "urgent item")} in scope`, + tone: + blockedTaskCount > 0 + ? "danger" + : urgentTaskCount > 0 + ? "warning" + : "success", + }, + { + label: "Checks", + value: pluralize(failingCheckCount, "failing check"), + detail: `${pluralize(input.githubStatuses.length, "GitHub signal")} tracked`, + tone: failingCheckCount > 0 ? "danger" : "success", + }, + { + label: "Current plan", + value: `${completedCurrentStagePlanItems}/${currentStagePlanItems.length} complete`, + detail: input.currentStage?.title ?? "No current stage selected", + tone: currentStagePlanItems.some(isIncompletePlanItem) + ? "warning" + : "success", + }, + { + label: "Notifications", + value: `${unreadNotificationCount} unread`, + detail: `${pluralize( + input.notificationInbox.filter( + (notification) => + notification.isUnread && notification.event.priority === "high" + ).length, + "high-priority notification" + )}`, + tone: input.notificationInbox.some( + (notification) => + notification.isUnread && notification.event.priority === "high" + ) + ? "warning" + : "neutral", + }, + ]; +} + +function buildDecisionCues( + input: BuildProjectReadinessInput, + status: ProjectReadinessStatus +): ProjectReadinessDecisionCue[] { + const failingCheckCount = input.githubStatuses.filter( + (githubStatus) => githubStatus.ciStatus === "Failing" + ).length; + const stageBlockerCount = input.currentStage?.status === "Blocked" ? 1 : 0; + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const reviewPrCount = input.githubStatuses.filter( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const incompletePlanItemCount = + currentStagePlanItems.filter(isIncompletePlanItem).length; + + return [ + { + label: "Ship gate", + value: + status === "Blocked" + ? `${pluralize( + failingCheckCount + stageBlockerCount + blockedTaskCount, + "blocker" + )} to clear` + : status === "Ready with risk" + ? "Proceed with watch items" + : "Clear", + tone: + status === "Blocked" + ? "danger" + : status === "Ready with risk" + ? "warning" + : "success", + }, + { + label: "Review load", + value: pluralize(reviewPrCount, "PR needing attention"), + tone: reviewPrCount > 0 ? "warning" : "success", + }, + { + label: "Plan confidence", + value: pluralize(incompletePlanItemCount, "open plan item"), + tone: incompletePlanItemCount > 0 ? "warning" : "success", + }, + ]; +} + +function buildActions( + input: BuildProjectReadinessInput +): ProjectReadinessAction[] { + const actions: RankedReadinessAction[] = []; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + + for (const githubStatus of input.githubStatuses.filter( + (status) => status.ciStatus === "Failing" + )) { + const engineeringItem = findEngineeringItem( + input.engineeringItems, + githubStatus + ); + actions.push({ + id: `github-check-${githubStatus.id}`, + title: engineeringItem + ? `Fix failing checks for ${engineeringItem.identifier}` + : "Fix failing checks", + detail: + engineeringItem?.checkLabel ?? "CI is failing and blocks readiness.", + href: engineeringItem?.checkUrl ?? engineeringHref(input), + sourceType: "github", + priority: "high", + rank: 0, + }); + } + + for (const task of input.tasks.filter( + (item) => + isActiveTask(item) && + (item.status === "Blocked" || hasBlockedReason(item)) + )) { + actions.push({ + id: `blocked-work-item-${task.id}`, + title: `Unblock ${taskLabel(task)}`, + detail: task.blockedReason?.trim() || "Work item is blocked.", + href: workItemHref(input, task), + sourceType: "work_item", + priority: "high", + rank: 1, + }); + } + + if (input.currentStage?.status === "Blocked") { + actions.push({ + id: `stage-gate-${input.currentStage.id}`, + title: `Resolve blocked stage gate: ${input.currentStage.title}`, + detail: input.currentStage.gateStatus || input.currentStage.goal, + href: planHref(input), + sourceType: "plan", + priority: "high", + rank: 2, + }); + } + + for (const task of input.tasks.filter( + (item) => isActiveTask(item) && item.priority === "urgent" + )) { + actions.push({ + id: `urgent-work-item-${task.id}`, + title: `Resolve urgent work: ${taskLabel(task)}`, + detail: "Urgent priority is a readiness risk.", + href: workItemHref(input, task), + sourceType: "work_item", + priority: "medium", + rank: 3, + }); + } + + for (const githubStatus of input.githubStatuses.filter( + (status) => + status.prStatus === "Open PR" || status.prStatus === "Review requested" + )) { + const engineeringItem = findEngineeringItem( + input.engineeringItems, + githubStatus + ); + actions.push({ + id: `github-pr-${githubStatus.id}`, + title: engineeringItem + ? `Review ${engineeringItem.identifier}` + : "Review pull request", + detail: engineeringItem?.pullRequestLabel ?? githubStatus.prStatus, + href: engineeringItem?.pullRequestUrl ?? engineeringHref(input), + sourceType: "github", + priority: "medium", + rank: 4, + }); + } + + for (const notification of input.notificationInbox.filter( + (item) => item.isUnread && item.event.priority === "high" + )) { + actions.push({ + id: `notification-${notification.recipient.id}`, + title: notification.event.title, + detail: notification.event.body ?? "High-priority unread notification.", + href: notification.event.url, + sourceType: "notification", + priority: "medium", + rank: 5, + }); + } + + for (const planItem of currentStagePlanItems.filter(isIncompletePlanItem)) { + actions.push({ + id: `plan-${planItem.id}`, + title: `Finish plan item: ${planItem.title}`, + detail: planItem.blocker?.trim() || planItem.outcome, + href: planHref(input), + sourceType: "plan", + priority: "low", + rank: 6, + }); + } + + return actions + .sort( + (left, right) => left.rank - right.rank || left.id.localeCompare(right.id) + ) + .slice(0, 5) + .map(({ rank: _rank, ...action }) => action); +} + +function buildNarrative( + input: BuildProjectReadinessInput, + status: ProjectReadinessStatus +) { + const failingCheckCount = input.githubStatuses.filter( + (githubStatus) => githubStatus.ciStatus === "Failing" + ).length; + const isCurrentStageBlocked = input.currentStage?.status === "Blocked"; + const activeTasks = input.tasks.filter(isActiveTask); + const blockedTaskCount = activeTasks.filter( + (task) => task.status === "Blocked" || hasBlockedReason(task) + ).length; + const urgentTaskCount = activeTasks.filter( + (task) => task.priority === "urgent" + ).length; + const reviewPrCount = input.githubStatuses.filter( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ).length; + const highPriorityUnreadCount = input.notificationInbox.filter( + (notification) => + notification.isUnread && notification.event.priority === "high" + ).length; + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const incompletePlanItemCount = + currentStagePlanItems.filter(isIncompletePlanItem).length; + const hasStagingDeployWithoutProduction = + input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Staging" + ) && + !input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Production" + ); + + if (status === "Blocked") { + const blockers = [ + isCurrentStageBlocked ? "current stage is blocked" : null, + failingCheckCount > 0 + ? pluralize(failingCheckCount, "failing check") + : null, + blockedTaskCount > 0 + ? pluralize(blockedTaskCount, "blocked work item") + : null, + ].filter((blocker): blocker is string => Boolean(blocker)); + + return `Readiness is blocked by ${ + blockers.length > 0 ? blockers.join(", ") : "a readiness blocker" + }.`; + } + + if (status === "Ready with risk") { + const risks = [ + urgentTaskCount > 0 + ? pluralize(urgentTaskCount, "urgent work item") + : null, + reviewPrCount > 0 + ? pluralize(reviewPrCount, "PR awaiting review", "PRs awaiting review") + : null, + highPriorityUnreadCount > 0 + ? pluralize(highPriorityUnreadCount, "high-priority notification") + : null, + hasStagingDeployWithoutProduction + ? "staging deploy without production" + : null, + incompletePlanItemCount > 0 + ? pluralize(incompletePlanItemCount, "incomplete plan item") + : null, + ].filter((risk): risk is string => Boolean(risk)); + + return `Ready with risk: ${ + risks.length > 0 ? risks.join(", ") : "readiness watch item" + } need attention.`; + } + + return "Ready: no blocking work, no urgent risks, and release signals are stable."; +} + +export function buildProjectReadiness( + input: BuildProjectReadinessInput +): ProjectReadinessView { + const hasProductionDeploy = input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Production" + ); + const currentStagePlanItems = input.currentStage + ? input.planItems.filter( + (planItem) => planItem.stageId === input.currentStage?.id + ) + : []; + const isBlocked = + input.currentStage?.status === "Blocked" || + input.tasks + .filter(isActiveTask) + .some((task) => task.status === "Blocked" || hasBlockedReason(task)) || + input.githubStatuses.some( + (githubStatus) => githubStatus.ciStatus === "Failing" + ); + const hasRisk = + input.tasks + .filter(isActiveTask) + .some((task) => task.priority === "urgent") || + input.githubStatuses.some( + (githubStatus) => + githubStatus.prStatus === "Open PR" || + githubStatus.prStatus === "Review requested" + ) || + (input.githubStatuses.some( + (githubStatus) => githubStatus.deployStatus === "Staging" + ) && + !hasProductionDeploy) || + input.notificationInbox.some( + (notification) => + notification.isUnread && notification.event.priority === "high" + ) || + currentStagePlanItems.some(isIncompletePlanItem); + const status: ProjectReadinessStatus = isBlocked + ? "Blocked" + : hasRisk + ? "Ready with risk" + : "Ready"; + const tone: ProjectReadinessTone = + status === "Blocked" + ? "danger" + : status === "Ready with risk" + ? "warning" + : "success"; + + return { + status, + tone, + narrative: buildNarrative(input, status), + metrics: buildMetrics(input), + decisionCues: buildDecisionCues(input, status), + actions: buildActions(input), + }; +} diff --git a/tests/phase8-readiness-projection.test.mjs b/tests/phase8-readiness-projection.test.mjs new file mode 100644 index 0000000..6ab0191 --- /dev/null +++ b/tests/phase8-readiness-projection.test.mjs @@ -0,0 +1,389 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectReadiness } from "../apps/web/src/server/projects/readiness.ts"; + +const now = new Date("2026-04-26T12:00:00.000Z").toISOString(); + +function task(overrides = {}) { + return { + id: overrides.id ?? "task-1", + projectId: "project-1", + workspaceId: "workspace-1", + identifier: Object.hasOwn(overrides, "identifier") + ? overrides.identifier + : "OPS-1", + title: overrides.title ?? "Ship the readiness view", + description: overrides.description ?? "", + status: overrides.status ?? "Doing", + type: "task", + parentId: null, + assigneeId: overrides.assigneeId ?? "user-2", + priority: overrides.priority ?? "medium", + labels: overrides.labels ?? null, + workflowStateId: null, + stageId: overrides.stageId ?? "stage-current", + planItemId: overrides.planItemId ?? "plan-current", + position: overrides.position ?? 0, + blockedReason: overrides.blockedReason ?? null, + dueDate: null, + completedAt: null, + createdAt: now, + updatedAt: now, + }; +} + +function stage(overrides = {}) { + return { + id: overrides.id ?? "stage-current", + projectId: "project-1", + slug: "current", + title: overrides.title ?? "Phase 8: Readiness", + goal: "Make project readiness clear.", + status: overrides.status ?? "In Progress", + gateStatus: overrides.gateStatus ?? "In review", + sortOrder: overrides.sortOrder ?? 0, + }; +} + +function planItem(overrides = {}) { + return { + id: overrides.id ?? "plan-current", + stageId: overrides.stageId ?? "stage-current", + title: overrides.title ?? "Finish readiness projection", + outcome: "Overview can explain readiness.", + status: overrides.status ?? "Todo", + blocker: overrides.blocker ?? null, + sortOrder: overrides.sortOrder ?? 0, + }; +} + +function githubStatus(overrides = {}) { + return { + id: overrides.id ?? "github-1", + taskId: overrides.taskId ?? "task-1", + prStatus: overrides.prStatus ?? "No PR", + ciStatus: overrides.ciStatus ?? "Unknown", + deployStatus: overrides.deployStatus ?? "Not deployed", + }; +} + +function inbox(overrides = {}) { + return { + event: { + id: "event-1", + workspaceId: "workspace-1", + projectId: "project-1", + workItemId: "task-1", + sourceType: "work_item", + sourceId: "task-1", + eventType: "priority_raised", + actorId: "user-2", + priority: overrides.priority ?? "high", + title: "OPS-1 needs attention", + body: "Priority was raised.", + url: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + metadata: null, + createdAt: now, + }, + recipient: { + id: "recipient-1", + eventId: "event-1", + workspaceId: "workspace-1", + recipientId: "user-1", + reason: "participant", + readAt: overrides.readAt ?? null, + dismissedAt: null, + createdAt: now, + }, + workItemIdentifier: "OPS-1", + projectKey: "OPS", + workspaceSlug: "platform-ops", + isUnread: overrides.readAt === undefined || overrides.readAt === null, + }; +} + +test("readiness projection marks blocked projects and explains the primary blockers", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + identifier: "OPS-12", + title: "Fix release pipeline", + status: "Blocked", + blockedReason: "CI is failing", + }), + ], + githubStatuses: [ + githubStatus({ + taskId: "task-1", + ciStatus: "Failing", + prStatus: "Open PR", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /1 failing check/); + assert.match(readiness.narrative, /1 blocked work item/); + assert.equal(readiness.decisionCues[0]?.label, "Ship gate"); + assert.equal(readiness.actions[0]?.sourceType, "github"); + assert.equal(readiness.actions[1]?.sourceType, "work_item"); +}); + +test("readiness projection marks projects ready with risk when work remains but blockers are absent", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "In Review" })], + tasks: [task({ identifier: "OPS-7", priority: "urgent" })], + githubStatuses: [ + githubStatus({ + prStatus: "Review requested", + ciStatus: "Passing", + deployStatus: "Staging", + }), + ], + engineeringItems: [], + notificationInbox: [inbox()], + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /urgent/); + assert.ok( + readiness.metrics.some( + (metric) => + metric.label === "Notifications" && metric.value === "1 unread" + ) + ); + assert.ok( + readiness.actions.every((action) => !action.id.startsWith("github-deploy-")) + ); + assert.ok( + readiness.actions.some((action) => action.sourceType === "notification") + ); +}); + +test("readiness projection marks stable projects ready", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "medium" })], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.match(readiness.narrative, /no blocking work/); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection ignores completed blocked work items", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [ + task({ + status: "Done", + blockedReason: "Old blocker should not count", + completedAt: now, + }), + ], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection ignores completed urgent work items", () => { + const readiness = buildProjectReadiness({ + currentStage: stage({ gateStatus: "Passed" }), + stages: [stage({ gateStatus: "Passed" })], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done", priority: "urgent", completedAt: now })], + githubStatuses: [ + githubStatus({ + prStatus: "Merged", + ciStatus: "Passing", + deployStatus: "Production", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready"); + assert.equal(readiness.actions.length, 0); +}); + +test("readiness projection builds stable action hrefs when base project href is provided", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS/", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem()], + tasks: [ + task({ + id: "task-blocked", + identifier: "OPS-12", + status: "Blocked", + blockedReason: "Waiting on approvals", + }), + task({ + id: "task-missing-identifier", + identifier: null, + priority: "urgent", + }), + ], + githubStatuses: [ + githubStatus({ + id: "github-failing", + taskId: "task-blocked", + ciStatus: "Failing", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal( + readiness.actions.find( + (action) => action.id === "blocked-work-item-task-blocked" + )?.href, + "/workspaces/platform-ops/projects/OPS/items/OPS-12" + ); + assert.equal( + readiness.actions.find( + (action) => action.id === "urgent-work-item-task-missing-identifier" + )?.href, + "/workspaces/platform-ops/projects/OPS" + ); + assert.equal( + readiness.actions.find( + (action) => action.id === "github-check-github-failing" + )?.href, + "/workspaces/platform-ops/projects/OPS/engineering" + ); + assert.equal( + readiness.actions.find((action) => action.id === "plan-plan-current")?.href, + "/workspaces/platform-ops/projects/OPS/plan" + ); +}); + +test("readiness projection explains stage-only blocked projects", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [planItem({ status: "Done" })], + tasks: [], + githubStatuses: [], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Blocked"); + assert.match(readiness.narrative, /current stage is blocked/); + assert.equal(readiness.actions[0]?.sourceType, "plan"); + assert.equal( + readiness.actions[0]?.title, + "Resolve blocked stage gate: Phase 8: Readiness" + ); + assert.equal( + readiness.actions[0]?.href, + "/workspaces/platform-ops/projects/OPS/plan" + ); +}); + +test("readiness projection explains staging-only risk", () => { + const readiness = buildProjectReadiness({ + currentStage: stage(), + stages: [stage()], + planItems: [planItem({ status: "Done" })], + tasks: [task({ status: "Done" })], + githubStatuses: [ + githubStatus({ + ciStatus: "Passing", + deployStatus: "Staging", + }), + ], + engineeringItems: [], + notificationInbox: [], + }); + + assert.equal(readiness.status, "Ready with risk"); + assert.match(readiness.narrative, /staging deploy without production/); +}); + +test("readiness projection orders action categories and caps actions at five", () => { + const readiness = buildProjectReadiness({ + baseProjectHref: "/workspaces/platform-ops/projects/OPS", + currentStage: stage({ status: "Blocked", gateStatus: "Blocked" }), + stages: [stage({ status: "Blocked", gateStatus: "Blocked" })], + planItems: [ + planItem({ id: "plan-1" }), + planItem({ id: "plan-2", title: "Second current-stage plan item" }), + ], + tasks: [ + task({ + id: "task-blocked", + identifier: "OPS-12", + status: "Blocked", + blockedReason: "Dependency unavailable", + }), + task({ + id: "task-urgent", + identifier: "OPS-13", + priority: "urgent", + }), + ], + githubStatuses: [ + githubStatus({ + id: "github-failing", + taskId: "task-blocked", + ciStatus: "Failing", + }), + githubStatus({ + id: "github-review", + taskId: "task-urgent", + prStatus: "Review requested", + ciStatus: "Passing", + }), + ], + engineeringItems: [], + notificationInbox: [inbox()], + }); + + assert.deepEqual( + readiness.actions.map((action) => action.id), + [ + "github-check-github-failing", + "blocked-work-item-task-blocked", + "stage-gate-stage-current", + "urgent-work-item-task-urgent", + "github-pr-github-review", + ] + ); +}); From 150acb8f3adc2db864754f629c2a616ce7563d66 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 10:53:34 -0600 Subject: [PATCH 04/20] feat: wire readiness into project overview data --- .../projects/[key]/project-page-data.ts | 15 ++-- apps/web/src/server/projects/workspace.ts | 38 ++++++++- tests/phase4-project-workspace.test.mjs | 82 +++++++++++++++++++ 3 files changed, 126 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts index f3e7253..74a3837 100644 --- a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts +++ b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts @@ -32,11 +32,13 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str try { const membership = await requireWorkspaceMembership(workspaceRepository, session, workspace.id, "viewer"); - const [workspaces, project] = await Promise.all([ + const [workspaces, project, notificationRows, notificationPreferences] = await Promise.all([ listWorkspacesForUser(workspaceRepository, session), - getProjectForUser(projectRepository, session, workspaceSlug, projectKey) + getProjectForUser(projectRepository, session, workspaceSlug, projectKey), + listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), + getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) ]); - const [workspaceView, projectStages, planItems, notificationRows, notificationPreferences] = await Promise.all([ + const [workspaceView, projectStages, planItems] = await Promise.all([ getProjectWorkspaceForUser( { projectRepository, @@ -44,12 +46,11 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str }, session, workspaceSlug, - projectKey + projectKey, + { notificationInbox: notificationRows } ), projectRepository.listProjectStages(project.id), - projectRepository.listPlanItems(project.id), - listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), - getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) + projectRepository.listPlanItems(project.id) ]); const notificationInbox = { notifications: notificationRows, diff --git a/apps/web/src/server/projects/workspace.ts b/apps/web/src/server/projects/workspace.ts index faf171c..a5fa375 100644 --- a/apps/web/src/server/projects/workspace.ts +++ b/apps/web/src/server/projects/workspace.ts @@ -1,4 +1,5 @@ import type { + NotificationInboxItem, PlanItemRecord, ProjectRecord, ProjectStageRecord, @@ -14,6 +15,7 @@ import { resolveWorkspaceContext } from "../work-management/utils"; import { WorkspaceError } from "../workspaces/core"; import type { AppSession } from "../workspaces/types"; +import { buildProjectReadiness, type ProjectReadinessView } from "./readiness"; import type { ProjectRepository, ProjectWorkItemEngineeringRecord } from "./types"; import type { WorkItemRepository } from "../work-items/types"; @@ -32,6 +34,10 @@ interface ProjectWorkspaceDependencies { workItemRepository: Pick; } +interface ProjectWorkspaceOptions { + notificationInbox?: NotificationInboxItem[]; +} + export interface ProjectWorkspaceCurrentStage { label: string; title: string; @@ -84,6 +90,7 @@ export interface ProjectWorkspaceView { currentStage: string; health: string[]; milestones: ProjectWorkspaceOverviewMilestone[]; + readiness: ProjectReadinessView; }; engineering: ProjectWorkspaceEngineeringView; } @@ -290,6 +297,20 @@ function buildHealthSummary(tasks: WorkItemRecord[], githubStatuses: TaskGithubS ]; } +function normalizeWorkspaceReadinessMetricLabels(readiness: ProjectReadinessView): ProjectReadinessView { + return { + ...readiness, + metrics: readiness.metrics.map((metric) => + metric.label === "Checks" + ? { + ...metric, + label: "GitHub" + } + : metric + ) + }; +} + function buildEngineeringItems( tasks: WorkItemRecord[], stageById: Map, @@ -342,7 +363,8 @@ export async function getProjectWorkspaceForUser( dependencies: ProjectWorkspaceDependencies, session: AppSession, workspaceSlug: string, - projectKey: string + projectKey: string, + options: ProjectWorkspaceOptions = {} ): Promise { const { project } = await resolveProjectContext(dependencies.projectRepository, session, workspaceSlug, projectKey); @@ -437,7 +459,19 @@ export async function getProjectWorkspaceForUser( overview: { currentStage: currentStageCard.title, health: buildHealthSummary(sortedTasks, githubStatuses), - milestones: buildOverviewMilestones(sortedStages, currentStageIndex) + milestones: buildOverviewMilestones(sortedStages, currentStageIndex), + readiness: normalizeWorkspaceReadinessMetricLabels( + buildProjectReadiness({ + currentStage, + stages: sortedStages, + planItems, + tasks: sortedTasks, + githubStatuses, + engineeringItems, + notificationInbox: options.notificationInbox ?? [], + baseProjectHref: `/workspaces/${workspaceSlug}/projects/${projectKey}` + }) + ) }, engineering: { repository: githubConnection?.repository.fullName ?? "No repository connected", diff --git a/tests/phase4-project-workspace.test.mjs b/tests/phase4-project-workspace.test.mjs index 45d51c7..418a8d0 100644 --- a/tests/phase4-project-workspace.test.mjs +++ b/tests/phase4-project-workspace.test.mjs @@ -268,6 +268,9 @@ test("project workspace view maps real stage, plan, and engineering records", as assert.equal(workspaceView.plan.items[1]?.linkedIssues.join(", "), `${drawerTask.identifier}, ${approvalTask.identifier}`); assert.equal(workspaceView.overview.currentStage, "Phase 2: Execution Surface"); assert.ok(workspaceView.overview.health.includes("Engineering risk: 1 failing check")); + assert.equal(workspaceView.overview.readiness.status, "Blocked"); + assert.ok(workspaceView.overview.readiness.metrics.some((metric) => metric.label === "GitHub")); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "github")); assert.equal(workspaceView.engineering.pullRequests, "2 open"); assert.equal(workspaceView.engineering.checks, "1 failing"); assert.ok( @@ -450,6 +453,85 @@ test("project workspace overview marks completed stages distinctly from upcoming ); }); +test("project workspace overview includes readiness signals from the current user's inbox", async () => { + const session = createNamedSession("readiness-owner"); + const project = { + id: "project-readiness", + workspaceId: "workspace-readiness", + key: "OPS3", + itemCounter: 1, + title: "Ops Readiness", + description: "Readiness notification harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "In review", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-readiness", + workspaceId: project.workspaceId, + projectId: project.id, + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Webhook failed", + body: "GitHub webhook failed processing.", + url: "/workspaces/alpha-workspace/projects/OPS3/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-readiness", + eventId: "event-readiness", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: project.key, + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready with risk"); + assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "notification")); +}); + test("task github status rejects invalid enum values at the database boundary", async (t) => { const harness = createPersistedHarness(t); const owner = createNamedSession("owner-enum"); From 3da9fa3a9e8a288889c4e4db361e588f4ed094bc Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 11:03:29 -0600 Subject: [PATCH 05/20] fix: scope project readiness notifications --- .../projects/[key]/project-page-data.ts | 12 ++- .../src/server/notifications/repository.ts | 21 ++--- apps/web/src/server/notifications/service.ts | 4 +- apps/web/src/server/notifications/types.ts | 4 +- apps/web/src/server/projects/readiness.ts | 2 +- apps/web/src/server/projects/workspace.ts | 39 +++------ tests/phase4-project-workspace.test.mjs | 79 +++++++++++++++++ tests/phase7-notifications-service.test.mjs | 87 +++++++++++++++++++ tests/phase8-readiness-projection.test.mjs | 1 + 9 files changed, 209 insertions(+), 40 deletions(-) diff --git a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts index 74a3837..fb8bc3a 100644 --- a/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts +++ b/apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts @@ -38,6 +38,16 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str listNotificationsForUser(notificationRepository, session, workspaceSlug, { limit: 20 }), getNotificationPreferencesForUser(notificationRepository, session, workspaceSlug) ]); + const projectReadinessNotificationRows = await listNotificationsForUser( + notificationRepository, + session, + workspaceSlug, + { + projectId: project.id, + unreadOnly: true, + limit: null + } + ); const [workspaceView, projectStages, planItems] = await Promise.all([ getProjectWorkspaceForUser( { @@ -47,7 +57,7 @@ export async function loadProjectPageData(workspaceSlug: string, projectKey: str session, workspaceSlug, projectKey, - { notificationInbox: notificationRows } + { notificationInbox: projectReadinessNotificationRows } ), projectRepository.listProjectStages(project.id), projectRepository.listPlanItems(project.id) diff --git a/apps/web/src/server/notifications/repository.ts b/apps/web/src/server/notifications/repository.ts index 6acfdfd..d5910dd 100644 --- a/apps/web/src/server/notifications/repository.ts +++ b/apps/web/src/server/notifications/repository.ts @@ -234,7 +234,14 @@ export function createNotificationRepository(): NotificationRepository { }, async listInboxForUser(input) { - const rows = await db + const filters = [ + eq(notificationRecipients.workspaceId, input.workspaceId), + eq(notificationRecipients.recipientId, input.recipientId), + isNull(notificationRecipients.dismissedAt), + ...(input.projectId ? [eq(notificationEvents.projectId, input.projectId)] : []), + ...(input.unreadOnly ? [isNull(notificationRecipients.readAt)] : []) + ]; + const query = db .select({ event: notificationEvents, recipient: notificationRecipients, @@ -247,15 +254,9 @@ export function createNotificationRepository(): NotificationRepository { .innerJoin(workspaces, eq(notificationRecipients.workspaceId, workspaces.id)) .leftJoin(projects, eq(notificationEvents.projectId, projects.id)) .leftJoin(tasks, eq(notificationEvents.workItemId, tasks.id)) - .where( - and( - eq(notificationRecipients.workspaceId, input.workspaceId), - eq(notificationRecipients.recipientId, input.recipientId), - isNull(notificationRecipients.dismissedAt) - ) - ) - .orderBy(desc(notificationRecipients.createdAt)) - .limit(input.limit ?? 30); + .where(and(...filters)) + .orderBy(desc(notificationRecipients.createdAt)); + const rows = await (input.limit === null ? query : query.limit(input.limit ?? 30)); return rows.map( (row): NotificationInboxItem => ({ diff --git a/apps/web/src/server/notifications/service.ts b/apps/web/src/server/notifications/service.ts index ddf6147..284acfe 100644 --- a/apps/web/src/server/notifications/service.ts +++ b/apps/web/src/server/notifications/service.ts @@ -159,13 +159,15 @@ export async function listNotificationsForUser( repository: NotificationRepository, session: AppSession, workspaceSlug: string, - options: { limit?: number } = {} + options: { projectId?: string; unreadOnly?: boolean; limit?: number | null } = {} ) { const { workspace } = await resolveWorkspaceContext(repository, session, workspaceSlug, "viewer"); return repository.listInboxForUser({ workspaceId: workspace.id, recipientId: session.userId, + ...(options.projectId !== undefined ? { projectId: options.projectId } : {}), + ...(options.unreadOnly !== undefined ? { unreadOnly: options.unreadOnly } : {}), ...(options.limit !== undefined ? { limit: options.limit } : {}) }); } diff --git a/apps/web/src/server/notifications/types.ts b/apps/web/src/server/notifications/types.ts index 7e9f08a..23384ef 100644 --- a/apps/web/src/server/notifications/types.ts +++ b/apps/web/src/server/notifications/types.ts @@ -65,7 +65,9 @@ export interface NotificationRepository listInboxForUser(input: { workspaceId: string; recipientId: string; - limit?: number; + projectId?: string; + unreadOnly?: boolean; + limit?: number | null; }): Promise; markNotificationReadForUser(input: { workspaceId: string; diff --git a/apps/web/src/server/projects/readiness.ts b/apps/web/src/server/projects/readiness.ts index 55e24d5..d4297ff 100644 --- a/apps/web/src/server/projects/readiness.ts +++ b/apps/web/src/server/projects/readiness.ts @@ -154,7 +154,7 @@ function buildMetrics( : "success", }, { - label: "Checks", + label: "GitHub", value: pluralize(failingCheckCount, "failing check"), detail: `${pluralize(input.githubStatuses.length, "GitHub signal")} tracked`, tone: failingCheckCount > 0 ? "danger" : "success", diff --git a/apps/web/src/server/projects/workspace.ts b/apps/web/src/server/projects/workspace.ts index a5fa375..699de01 100644 --- a/apps/web/src/server/projects/workspace.ts +++ b/apps/web/src/server/projects/workspace.ts @@ -297,20 +297,6 @@ function buildHealthSummary(tasks: WorkItemRecord[], githubStatuses: TaskGithubS ]; } -function normalizeWorkspaceReadinessMetricLabels(readiness: ProjectReadinessView): ProjectReadinessView { - return { - ...readiness, - metrics: readiness.metrics.map((metric) => - metric.label === "Checks" - ? { - ...metric, - label: "GitHub" - } - : metric - ) - }; -} - function buildEngineeringItems( tasks: WorkItemRecord[], stageById: Map, @@ -434,6 +420,9 @@ export async function getProjectWorkspaceForUser( engineeringRecords, githubConnection ); + const projectNotificationInbox = (options.notificationInbox ?? []).filter( + (notification) => notification.event.projectId === project.id + ); return { project: { @@ -460,18 +449,16 @@ export async function getProjectWorkspaceForUser( currentStage: currentStageCard.title, health: buildHealthSummary(sortedTasks, githubStatuses), milestones: buildOverviewMilestones(sortedStages, currentStageIndex), - readiness: normalizeWorkspaceReadinessMetricLabels( - buildProjectReadiness({ - currentStage, - stages: sortedStages, - planItems, - tasks: sortedTasks, - githubStatuses, - engineeringItems, - notificationInbox: options.notificationInbox ?? [], - baseProjectHref: `/workspaces/${workspaceSlug}/projects/${projectKey}` - }) - ) + readiness: buildProjectReadiness({ + currentStage, + stages: sortedStages, + planItems, + tasks: sortedTasks, + githubStatuses, + engineeringItems, + notificationInbox: projectNotificationInbox, + baseProjectHref: `/workspaces/${workspaceSlug}/projects/${projectKey}` + }) }, engineering: { repository: githubConnection?.repository.fullName ?? "No repository connected", diff --git a/tests/phase4-project-workspace.test.mjs b/tests/phase4-project-workspace.test.mjs index 418a8d0..62441b3 100644 --- a/tests/phase4-project-workspace.test.mjs +++ b/tests/phase4-project-workspace.test.mjs @@ -532,6 +532,85 @@ test("project workspace overview includes readiness signals from the current use assert.ok(workspaceView.overview.readiness.actions.some((action) => action.sourceType === "notification")); }); +test("project workspace overview ignores unread notifications from other projects", async () => { + const session = createNamedSession("readiness-scoped-owner"); + const project = { + id: "project-readiness-current", + workspaceId: "workspace-readiness-scoped", + key: "OPS4", + itemCounter: 1, + title: "Scoped Readiness", + description: "Readiness notification scope harness.", + stage: "Active", + dueDate: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + const stages = [ + { + id: "stage-current-scoped", + projectId: project.id, + slug: "readiness", + title: "Phase 8: Readiness", + goal: "Expose project readiness.", + status: "In Progress", + gateStatus: "Passed", + sortOrder: 0 + } + ]; + const notificationInbox = [ + { + event: { + id: "event-other-project", + workspaceId: project.workspaceId, + projectId: "project-readiness-other", + workItemId: null, + sourceType: "system", + sourceId: "readiness", + eventType: "github_webhook_failed", + actorId: null, + priority: "high", + title: "Other project webhook failed", + body: "Another project has a GitHub webhook failure.", + url: "/workspaces/alpha-workspace/projects/OTHER/engineering", + metadata: null, + createdAt: new Date().toISOString() + }, + recipient: { + id: "recipient-other-project", + eventId: "event-other-project", + workspaceId: project.workspaceId, + recipientId: session.userId, + reason: "system", + readAt: null, + dismissedAt: null, + createdAt: new Date().toISOString() + }, + workItemIdentifier: null, + projectKey: "OTHER", + workspaceSlug: "alpha-workspace", + isUnread: true + } + ]; + + const workspaceView = await getProjectWorkspaceForUser( + createProjectionDependencies({ + project, + stages, + planItems: [], + tasks: [], + githubStatuses: [] + }), + session, + "alpha-workspace", + project.key, + { notificationInbox } + ); + + assert.equal(workspaceView.overview.readiness.status, "Ready"); + assert.equal(workspaceView.overview.readiness.actions.length, 0); +}); + test("task github status rejects invalid enum values at the database boundary", async (t) => { const harness = createPersistedHarness(t); const owner = createNamedSession("owner-enum"); diff --git a/tests/phase7-notifications-service.test.mjs b/tests/phase7-notifications-service.test.mjs index d1a9ae6..626c0b8 100644 --- a/tests/phase7-notifications-service.test.mjs +++ b/tests/phase7-notifications-service.test.mjs @@ -314,3 +314,90 @@ test("notification service marks only the current user's recipient rows read", a ); assert.equal(unreadAfterMarkAll.filter((entry) => entry.isUnread).length, 0); }); + +test("notification service lists project-scoped unread rows without workspace pagination", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-project-scope"); + const teammate = createNamedSession("teammate-project-scope"); + const workspace = await harness.createWorkspace(owner, "notify-project-scope"); + await harness.addMembership(workspace.id, teammate, "member"); + const currentProject = await createProjectWithItem(harness, owner, workspace); + const otherProject = await createProjectWithItem(harness, owner, workspace); + + const currentUnread = await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: currentProject.item.projectId, + workItemId: currentProject.item.id, + sourceType: "work_item", + sourceId: `${currentProject.item.id}:unread`, + eventType: "priority_raised", + actorId: owner.userId, + priority: "high", + title: "Current project unread", + url: `/workspaces/${workspace.slug}/projects/${currentProject.projectKey}/items/${currentProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + const currentRead = await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: currentProject.item.projectId, + workItemId: currentProject.item.id, + sourceType: "work_item", + sourceId: `${currentProject.item.id}:read`, + eventType: "state_changed", + actorId: owner.userId, + priority: "high", + title: "Current project read", + url: `/workspaces/${workspace.slug}/projects/${currentProject.projectKey}/items/${currentProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + await createNotificationForSource( + harness.repositories.notificationRepository, + owner, + workspace.slug, + { + projectId: otherProject.item.projectId, + workItemId: otherProject.item.id, + sourceType: "work_item", + sourceId: `${otherProject.item.id}:unread`, + eventType: "priority_raised", + actorId: owner.userId, + priority: "high", + title: "Other project unread", + url: `/workspaces/${workspace.slug}/projects/${otherProject.projectKey}/items/${otherProject.item.identifier}`, + recipients: [{ recipientId: teammate.userId, reason: "participant" }] + } + ); + + await markNotificationReadForUser( + harness.repositories.notificationRepository, + teammate, + workspace.slug, + currentRead.recipients[0].id + ); + + const projectInbox = await listNotificationsForUser( + harness.repositories.notificationRepository, + teammate, + workspace.slug, + { + projectId: currentProject.item.projectId, + unreadOnly: true, + limit: null + } + ); + + assert.deepEqual( + projectInbox.map((entry) => entry.recipient.id), + [currentUnread.recipients[0].id] + ); + assert.ok(projectInbox.every((entry) => entry.event.projectId === currentProject.item.projectId)); + assert.ok(projectInbox.every((entry) => entry.isUnread)); +}); diff --git a/tests/phase8-readiness-projection.test.mjs b/tests/phase8-readiness-projection.test.mjs index 6ab0191..43b21d6 100644 --- a/tests/phase8-readiness-projection.test.mjs +++ b/tests/phase8-readiness-projection.test.mjs @@ -187,6 +187,7 @@ test("readiness projection marks stable projects ready", () => { assert.equal(readiness.status, "Ready"); assert.match(readiness.narrative, /no blocking work/); + assert.ok(readiness.metrics.some((metric) => metric.label === "GitHub")); assert.equal(readiness.actions.length, 0); }); From 03457bac1f141ad15ee599b23bfcefe72e7b1f85 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 11:15:44 -0600 Subject: [PATCH 06/20] feat: add readiness overview UI --- .../overview/__tests__/overview-view.test.tsx | 52 ++++++++++++++----- .../src/features/overview/overview-view.tsx | 17 ++++-- .../overview/readiness-action-list.tsx | 51 ++++++++++++++++++ .../overview/readiness-signal-card.tsx | 18 +++++++ .../features/overview/readiness-summary.tsx | 49 +++++++++++++++++ 5 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/features/overview/readiness-action-list.tsx create mode 100644 apps/web/src/features/overview/readiness-signal-card.tsx create mode 100644 apps/web/src/features/overview/readiness-summary.tsx diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index 11d0caf..a85b527 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -6,17 +6,41 @@ import { render } from "../../../test/render"; import { OverviewView } from "../overview-view"; const overview = { - currentStage: "Phase 2: Execution Surface", - health: ["Scope: stable", "Delivery risk: low", "Engineering risk: 1 failing check"], + currentStage: "Phase 8: Readiness", + health: ["Scope: stable", "Delivery risk: low", "Engineering risk: stable"], milestones: [ - { label: "Foundation Alignment", monthStart: 0, monthSpan: 2, tone: "completed" as const }, - { label: "Execution Surface", monthStart: 2, monthSpan: 2, tone: "current" as const }, - { label: "Engineering Signals", monthStart: 4, monthSpan: 2, tone: "upcoming" as const } - ] + { label: "Notifications", monthStart: 0, monthSpan: 2, tone: "completed" as const }, + { label: "Readiness", monthStart: 2, monthSpan: 2, tone: "current" as const } + ], + readiness: { + status: "Ready with risk" as const, + tone: "warning" as const, + narrative: "Ready with risk: 1 PR is awaiting review.", + metrics: [ + { label: "Plan", value: "4/5 done", detail: "1 current-stage item remains", tone: "warning" as const }, + { label: "Issues", value: "1 urgent", detail: "No blocked work", tone: "warning" as const }, + { label: "GitHub", value: "1 review", detail: "Checks are passing", tone: "warning" as const }, + { label: "Notifications", value: "2 unread", detail: "1 high priority", tone: "warning" as const } + ], + decisionCues: [ + { label: "Ship gate", value: "In review", tone: "warning" as const }, + { label: "Primary blocker", value: "None", tone: "success" as const } + ], + actions: [ + { + id: "review-pr-task-1", + title: "Review OPS-4 pull request", + detail: "Review requested before release.", + href: "/workspaces/platform-ops/projects/OPS/engineering", + sourceType: "github" as const, + priority: "medium" as const + } + ] + } }; describe("OverviewView", () => { - it("renders a lightweight milestone roadmap and current project health", () => { + it("renders readiness as the primary overview while keeping the milestone roadmap visible", () => { render( { /> ); + expect(screen.getByText("Readiness command center")).toBeInTheDocument(); + expect(screen.getByText("Ready with risk")).toBeInTheDocument(); + expect(screen.getByText("Ready with risk: 1 PR is awaiting review.")).toBeInTheDocument(); + expect(screen.getByText("4/5 done")).toBeInTheDocument(); + expect(screen.getByText("Decision cues")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Review OPS-4 pull request/i })).toHaveAttribute( + "href", + "/workspaces/platform-ops/projects/OPS/engineering" + ); expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); - expect(screen.getAllByText("Foundation Alignment")).toHaveLength(2); - expect(screen.getAllByText("Execution Surface")).toHaveLength(2); - expect(screen.getAllByText("Engineering Signals")).toHaveLength(2); - expect(screen.getByText("Phase 2: Execution Surface")).toBeInTheDocument(); - expect(screen.getByText("Engineering risk: 1 failing check")).toBeInTheDocument(); }); }); diff --git a/apps/web/src/features/overview/overview-view.tsx b/apps/web/src/features/overview/overview-view.tsx index 2e196a7..6bd5b31 100644 --- a/apps/web/src/features/overview/overview-view.tsx +++ b/apps/web/src/features/overview/overview-view.tsx @@ -1,4 +1,8 @@ +import type { ProjectReadinessView } from "@/server/projects/readiness"; + import { MilestoneRoadmap } from "./milestone-roadmap"; +import { ReadinessActionList } from "./readiness-action-list"; +import { ReadinessSummary } from "./readiness-summary"; interface OverviewMilestone { label: string; @@ -11,17 +15,20 @@ interface OverviewViewModel { currentStage: string; health: string[]; milestones: OverviewMilestone[]; + readiness: ProjectReadinessView; } export function OverviewView({ brief, overview }: { brief: string; overview: OverviewViewModel }) { return ( -
+
-
+ + +

Overview

-

Project alignment

+

Project alignment

{brief}

-
+
@@ -29,6 +36,8 @@ export function OverviewView({ brief, overview }: { brief: string; overview: Ove
); diff --git a/apps/web/src/features/overview/readiness-search.tsx b/apps/web/src/features/overview/readiness-search.tsx new file mode 100644 index 0000000..f059caf --- /dev/null +++ b/apps/web/src/features/overview/readiness-search.tsx @@ -0,0 +1,78 @@ +"use client"; + +import Link from "next/link"; +import { useState, useTransition } from "react"; + +import type { ProjectSearchResult } from "@/server/projects/search"; + +export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: string; projectKey: string }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [error, setError] = useState(""); + const [isPending, startTransition] = useTransition(); + + function updateQuery(value: string) { + setQuery(value); + setError(""); + + const trimmedQuery = value.trim(); + if (trimmedQuery.length < 2) { + setResults([]); + return; + } + + startTransition(async () => { + const response = await fetch( + `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(trimmedQuery)}` + ); + + if (!response.ok) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + const body = (await response.json()) as { results: ProjectSearchResult[] }; + setResults(body.results); + }); + } + + const trimmedQuery = query.trim(); + + return ( +
+

Readiness search

+ updateQuery(event.target.value)} + placeholder="Search blockers, PRs, comments..." + value={query} + /> + + {isPending ?

Searching readiness signals...

: null} + {error ?

{error}

: null} + +
+ {results.map((result) => ( + +
+

{result.title}

+ + {result.chip} + +
+

{result.snippet}

+ + ))} +
+ + {trimmedQuery.length >= 2 && !isPending && !error && results.length === 0 ? ( +

No readiness signals found for "{trimmedQuery}".

+ ) : null} +
+ ); +} From 1953cc103dd6c2512a1fc154767072e0b4fdd8ff Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 12:15:20 -0600 Subject: [PATCH 13/20] fix: harden readiness search UI --- .../overview/__tests__/overview-view.test.tsx | 138 ++++++++++++++++++ .../features/overview/readiness-search.tsx | 68 +++++++-- 2 files changed, 195 insertions(+), 11 deletions(-) diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index 65270f2..2fc7b80 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -5,6 +5,15 @@ import { render } from "../../../test/render"; import { OverviewView } from "../overview-view"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { promise, resolve }; +} + const overview = { currentStage: "Phase 8: Readiness", health: ["Scope: stable", "Delivery risk: low", "Engineering risk: stable"], @@ -90,6 +99,8 @@ describe("OverviewView", () => { render(); + expect(screen.getByRole("searchbox", { name: "Readiness search" })).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("Search blockers, PRs, comments..."), { target: { value: "pipeline" } }); @@ -102,4 +113,131 @@ describe("OverviewView", () => { "/workspaces/platform-ops/projects/OPS?selected=OPS-1" ); }); + + it("shows an error instead of crashing when readiness search fails", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("network failed")) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ query: "pipeline" }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "network" } + }); + + expect(await screen.findByText("Search failed. Try again from the project overview.")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "pipeline" } + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith("/api/workspaces/platform-ops/projects/OPS/search?q=pipeline"); + }); + expect(await screen.findByText("Search failed. Try again from the project overview.")).toBeInTheDocument(); + }); + + it("keeps stale readiness search responses from replacing newer results", async () => { + const firstSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const secondSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(firstSearch.promise).mockReturnValueOnce(secondSearch.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipe" } }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + secondSearch.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "latest-result", + type: "work_item", + title: "OPS-2 Latest pipeline result", + snippet: "Newest search result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-2", + chip: "Current", + rank: 0 + } + ] + }) + }); + + expect(await screen.findByRole("link", { name: /OPS-2 Latest pipeline result/i })).toBeInTheDocument(); + + firstSearch.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "stale-result", + type: "work_item", + title: "OPS-1 Stale pipeline result", + snippet: "Old search result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Stale", + rank: 0 + } + ] + }) + }); + + await waitFor(() => { + expect(screen.queryByRole("link", { name: /OPS-1 Stale pipeline result/i })).not.toBeInTheDocument(); + }); + expect(screen.getByRole("link", { name: /OPS-2 Latest pipeline result/i })).toBeInTheDocument(); + }); + + it("does not repopulate readiness search results after the query is cleared", async () => { + const search = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(search.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + fireEvent.change(input, { target: { value: "" } }); + + search.resolve({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "late-result", + type: "work_item", + title: "OPS-3 Late pipeline result", + snippet: "Late result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-3", + chip: "Late", + rank: 0 + } + ] + }) + }); + + await waitFor(() => { + expect(screen.queryByRole("link", { name: /OPS-3 Late pipeline result/i })).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/web/src/features/overview/readiness-search.tsx b/apps/web/src/features/overview/readiness-search.tsx index f059caf..3564f08 100644 --- a/apps/web/src/features/overview/readiness-search.tsx +++ b/apps/web/src/features/overview/readiness-search.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import type { ProjectSearchResult } from "@/server/projects/search"; @@ -10,42 +10,88 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: const [results, setResults] = useState([]); const [error, setError] = useState(""); const [isPending, startTransition] = useTransition(); + const requestIdRef = useRef(0); + const latestQueryRef = useRef(""); function updateQuery(value: string) { setQuery(value); setError(""); const trimmedQuery = value.trim(); + latestQueryRef.current = trimmedQuery; + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + if (trimmedQuery.length < 2) { setResults([]); return; } + function isCurrentRequest() { + return requestIdRef.current === requestId && latestQueryRef.current === trimmedQuery; + } + startTransition(async () => { - const response = await fetch( - `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(trimmedQuery)}` - ); + try { + const response = await fetch( + `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(trimmedQuery)}` + ); + + if (!isCurrentRequest()) { + return; + } + + if (!response.ok) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + const body = (await response.json()) as { results?: unknown }; + const nextResults = Array.isArray(body.results) ? (body.results as ProjectSearchResult[]) : null; + + if (!isCurrentRequest()) { + return; + } + + if (!nextResults) { + setError("Search failed. Try again from the project overview."); + setResults([]); + return; + } + + setResults(nextResults); + } catch { + if (!isCurrentRequest()) { + return; + } - if (!response.ok) { setError("Search failed. Try again from the project overview."); setResults([]); - return; } - - const body = (await response.json()) as { results: ProjectSearchResult[] }; - setResults(body.results); }); } const trimmedQuery = query.trim(); return ( -
-

Readiness search

+
+

+ Readiness search +

updateQuery(event.target.value)} placeholder="Search blockers, PRs, comments..." + type="search" value={query} /> From ed02891fd95a5f84333fd4ad020ed3dc98286592 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 12:27:19 -0600 Subject: [PATCH 14/20] feat: polish readiness empty states --- .../__tests__/engineering-view.test.tsx | 17 +++++ .../features/engineering/engineering-view.tsx | 5 ++ .../overview/__tests__/overview-view.test.tsx | 64 +++++++++++++++++++ .../features/overview/readiness-search.tsx | 9 ++- 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx b/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx index 701009c..98e4aeb 100644 --- a/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx +++ b/apps/web/src/features/engineering/__tests__/engineering-view.test.tsx @@ -83,4 +83,21 @@ describe("EngineeringView", () => { ); expect(screen.getByText("OPS-1 ยท merged ยท passing ยท phase 2")).toBeInTheDocument(); }); + + it("shows setup guidance when repository signals need a GitHub connection", () => { + render( + + ); + + expect(screen.getByText("Connect GitHub to populate engineering readiness signals.")).toBeInTheDocument(); + expect(screen.getByText("Linked pull requests")).toBeInTheDocument(); + expect(screen.getByText("Failing checks")).toBeInTheDocument(); + expect(screen.getByText("Deployments")).toBeInTheDocument(); + expect(screen.getByText("OPS-1 ยท merged ยท passing ยท phase 2")).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/features/engineering/engineering-view.tsx b/apps/web/src/features/engineering/engineering-view.tsx index 99987c4..c451b42 100644 --- a/apps/web/src/features/engineering/engineering-view.tsx +++ b/apps/web/src/features/engineering/engineering-view.tsx @@ -31,6 +31,11 @@ export function EngineeringView({ engineering }: { engineering: ProjectWorkspace {engineering.connectionStatus} {engineering.defaultBranch ?? "no branch"}
+ {engineering.connectionStatus === "Setup required" ? ( +
+ Connect GitHub to populate engineering readiness signals. +
+ ) : null}

Pull requests

diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index 2fc7b80..0bf49eb 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -76,6 +76,30 @@ describe("OverviewView", () => { expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); }); + it("shows explicit empty readiness actions when the current stage has no blocking work", () => { + render( + + ); + + expect( + screen.getByText("No readiness actions. The current stage has no blocking work or high-priority signals.") + ).toBeInTheDocument(); + }); + it("searches readiness signals from the overview", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, @@ -114,6 +138,42 @@ describe("OverviewView", () => { ); }); + it("shows readiness search guidance before a valid query is entered", () => { + render(); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "p" } + }); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + }); + + it("shows an empty readiness search state for valid queries without results", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ results: [] }) + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { + target: { value: "missing" } + }); + + expect(await screen.findByText('No readiness signals found for "missing".')).toBeInTheDocument(); + expect( + screen.queryByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Search failed. Try again from the project overview.")).not.toBeInTheDocument(); + }); + it("shows an error instead of crashing when readiness search fails", async () => { const fetchMock = vi .fn() @@ -131,6 +191,10 @@ describe("OverviewView", () => { }); expect(await screen.findByText("Search failed. Try again from the project overview.")).toBeInTheDocument(); + expect( + screen.queryByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).not.toBeInTheDocument(); + expect(screen.queryByText('No readiness signals found for "network".')).not.toBeInTheDocument(); fireEvent.change(screen.getByRole("searchbox", { name: "Readiness search" }), { target: { value: "pipeline" } diff --git a/apps/web/src/features/overview/readiness-search.tsx b/apps/web/src/features/overview/readiness-search.tsx index 3564f08..e9ce19f 100644 --- a/apps/web/src/features/overview/readiness-search.tsx +++ b/apps/web/src/features/overview/readiness-search.tsx @@ -73,6 +73,8 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: } const trimmedQuery = query.trim(); + const isShortQuery = trimmedQuery.length < 2; + const showNoResults = trimmedQuery.length >= 2 && !isPending && !error && results.length === 0; return (
Searching readiness signals...

: null} {error ?

{error}

: null} + {isShortQuery ? ( +

+ Search across blockers, PRs, comments, plan items, and notifications. +

+ ) : null}
{results.map((result) => ( @@ -116,7 +123,7 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: ))}
- {trimmedQuery.length >= 2 && !isPending && !error && results.length === 0 ? ( + {showNoResults ? (

No readiness signals found for "{trimmedQuery}".

) : null}
From 0349f9915d5b00b5c71563509c40447bfbf42895 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 12:33:26 -0600 Subject: [PATCH 15/20] fix: harden readiness search polish states --- .../overview/__tests__/overview-view.test.tsx | 63 +++++++++++++++++++ .../features/overview/readiness-search.tsx | 21 +++++-- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index 0bf49eb..a5a5c0e 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -268,6 +268,69 @@ describe("OverviewView", () => { expect(screen.getByRole("link", { name: /OPS-2 Latest pipeline result/i })).toBeInTheDocument(); }); + it("clears existing readiness search results as soon as a different valid query starts", async () => { + const nextSearch = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "pipeline-result", + type: "work_item", + title: "OPS-1 Pipeline result", + snippet: "Pipeline result.", + href: "/workspaces/platform-ops/projects/OPS?selected=OPS-1", + chip: "Current", + rank: 0 + } + ] + }) + }) + .mockReturnValueOnce(nextSearch.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + expect(await screen.findByRole("link", { name: /OPS-1 Pipeline result/i })).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "release" } }); + + expect(screen.queryByRole("link", { name: /OPS-1 Pipeline result/i })).not.toBeInTheDocument(); + expect(screen.getByText("Searching readiness signals...")).toBeInTheDocument(); + }); + + it("shows only short-query guidance when an in-flight search is shortened", async () => { + const search = deferred<{ + ok: boolean; + json: () => Promise<{ results: unknown[] }>; + }>(); + const fetchMock = vi.fn().mockReturnValueOnce(search.promise); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const input = screen.getByRole("searchbox", { name: "Readiness search" }); + fireEvent.change(input, { target: { value: "pipeline" } }); + + expect(screen.getByText("Searching readiness signals...")).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: "p" } }); + + expect( + screen.getByText("Search across blockers, PRs, comments, plan items, and notifications.") + ).toBeInTheDocument(); + expect(screen.queryByText("Searching readiness signals...")).not.toBeInTheDocument(); + }); + it("does not repopulate readiness search results after the query is cleared", async () => { const search = deferred<{ ok: boolean; diff --git a/apps/web/src/features/overview/readiness-search.tsx b/apps/web/src/features/overview/readiness-search.tsx index e9ce19f..e7b4174 100644 --- a/apps/web/src/features/overview/readiness-search.tsx +++ b/apps/web/src/features/overview/readiness-search.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useRef, useState, useTransition } from "react"; +import { useRef, useState } from "react"; import type { ProjectSearchResult } from "@/server/projects/search"; @@ -9,7 +9,7 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [error, setError] = useState(""); - const [isPending, startTransition] = useTransition(); + const [pendingQuery, setPendingQuery] = useState(""); const requestIdRef = useRef(0); const latestQueryRef = useRef(""); @@ -23,15 +23,19 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: requestIdRef.current = requestId; if (trimmedQuery.length < 2) { + setPendingQuery(""); setResults([]); return; } + setPendingQuery(trimmedQuery); + setResults([]); + function isCurrentRequest() { return requestIdRef.current === requestId && latestQueryRef.current === trimmedQuery; } - startTransition(async () => { + void (async () => { try { const response = await fetch( `/api/workspaces/${workspaceSlug}/projects/${projectKey}/search?q=${encodeURIComponent(trimmedQuery)}` @@ -68,13 +72,18 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: { workspaceSlug: setError("Search failed. Try again from the project overview."); setResults([]); + } finally { + if (isCurrentRequest()) { + setPendingQuery(""); + } } - }); + })(); } const trimmedQuery = query.trim(); const isShortQuery = trimmedQuery.length < 2; - const showNoResults = trimmedQuery.length >= 2 && !isPending && !error && results.length === 0; + const isSearching = pendingQuery === trimmedQuery && !isShortQuery; + const showNoResults = trimmedQuery.length >= 2 && !isSearching && !error && results.length === 0; return (
- {isPending ?

Searching readiness signals...

: null} + {isSearching ?

Searching readiness signals...

: null} {error ?

{error}

: null} {isShortQuery ? (

From 830ae84492fcb2e5be06cfefe2e9ea5e4b1cd383 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 14:03:11 -0600 Subject: [PATCH 16/20] fix: resolve phase 8 lint issues --- .../features/overview/__tests__/overview-view.test.tsx | 2 +- apps/web/src/server/projects/readiness.ts | 9 ++++++++- apps/web/src/server/projects/search.ts | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/overview/__tests__/overview-view.test.tsx b/apps/web/src/features/overview/__tests__/overview-view.test.tsx index a5a5c0e..6fcf164 100644 --- a/apps/web/src/features/overview/__tests__/overview-view.test.tsx +++ b/apps/web/src/features/overview/__tests__/overview-view.test.tsx @@ -308,7 +308,7 @@ describe("OverviewView", () => { expect(screen.getByText("Searching readiness signals...")).toBeInTheDocument(); }); - it("shows only short-query guidance when an in-flight search is shortened", async () => { + it("shows only short-query guidance when an in-flight search is shortened", () => { const search = deferred<{ ok: boolean; json: () => Promise<{ results: unknown[] }>; diff --git a/apps/web/src/server/projects/readiness.ts b/apps/web/src/server/projects/readiness.ts index d4297ff..bcfe2b7 100644 --- a/apps/web/src/server/projects/readiness.ts +++ b/apps/web/src/server/projects/readiness.ts @@ -369,7 +369,14 @@ function buildActions( (left, right) => left.rank - right.rank || left.id.localeCompare(right.id) ) .slice(0, 5) - .map(({ rank: _rank, ...action }) => action); + .map((action) => ({ + id: action.id, + title: action.title, + detail: action.detail, + href: action.href, + sourceType: action.sourceType, + priority: action.priority, + })); } function buildNarrative( diff --git a/apps/web/src/server/projects/search.ts b/apps/web/src/server/projects/search.ts index 14f748d..a544ec5 100644 --- a/apps/web/src/server/projects/search.ts +++ b/apps/web/src/server/projects/search.ts @@ -1,4 +1,5 @@ -import { and, eq, ilike, isNotNull, isNull, or, sql } from "drizzle-orm"; +import { and, eq, isNotNull, isNull, or, sql } from "drizzle-orm"; +import type { SQLWrapper } from "drizzle-orm"; import { comments, @@ -45,7 +46,7 @@ function escapeLikeQuery(query: string) { return query.replace(/[\\%_]/g, (character) => `\\${character}`); } -function ilikeLiteral(column: Parameters[0], pattern: string) { +function ilikeLiteral(column: SQLWrapper, pattern: string) { return sql`${column} ilike ${pattern} escape '\\'`; } From 0bfa3bc0dd78da84f516230488d2d7ed1bbf138c Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 14:03:23 -0600 Subject: [PATCH 17/20] docs: finalize phase 8 readiness plan --- docs/product/decision-brief.md | 99 +++++---- docs/product/idea-brief.md | 87 ++++---- docs/product/prd.md | 191 +++++++----------- ...6-04-26-phase8-readiness-command-center.md | 98 ++++----- 4 files changed, 215 insertions(+), 260 deletions(-) diff --git a/docs/product/decision-brief.md b/docs/product/decision-brief.md index 365fcb9..421887b 100644 --- a/docs/product/decision-brief.md +++ b/docs/product/decision-brief.md @@ -1,87 +1,80 @@ -# Decision Brief: Phase 7 - Notifications and Collaboration Foundation +# Decision Brief: Phase 8 - Readiness Command Center ## Context -Phase 6 made GitHub engineering status durable and visible in the project workspace. Phase 7 adds the attention layer: users should know when comments, mentions, assignments, workflow changes, and GitHub engineering events need their response. - The product direction remains: Jira for execution structure, GitHub for engineering truth, and Notion-like lightness for collaboration. -## Key Decisions - -### 1. Durable notification events plus recipient rows +By the end of Phase 7, the project workspace has execution views, implementation plans, comments, GitHub engineering state, and in-app notifications. Phase 8 turns those surfaces into a lead-first decision layer so project owners and teammates can understand readiness without manually reconciling every tab. -Notification events describe the source change. Recipient rows describe each user's delivery/read state. - -**Why:** A single event can fan out to multiple users, and each user needs independent read state, preferences, and repair semantics. +## Key Decisions -### 2. Domain services emit notifications after successful mutations +### 1. Overview becomes the Readiness Command Center -Comment, work item, and GitHub services call the notification service after their primary write succeeds. +Overview now leads with readiness status, narrative, signal metrics, decision cues, team actions, scoped search, and milestone context. -**Why:** Notification creation should be layered onto existing behavior without changing response shapes or ownership of domain writes. +**Why:** Project owners need a fast decision surface before drilling into board, plan, engineering, docs, or notifications. -### 3. Local in-app inbox before external delivery +### 2. Readiness stays deterministic and local-first -The project shell contains the first notification surface: bell, unread count, inbox panel, mark-read controls, and preferences. +Readiness is derived by a server-side projection from project stage, plan items, work items, GitHub-derived engineering state, and notification inbox rows. -**Why:** The product needs an internal attention loop before adding email, Slack, push, or digest channels. +**Why:** Phase 8 needs explainable team alignment, not opaque recommendations or external analytics infrastructure. -### 4. Workspace-level coarse preferences +### 3. Team actions are source-linked -Users can toggle comments, mentions, assignments, GitHub engineering changes, and state changes. +Each action points to the project surface that owns the underlying signal: plan, work item, engineering, or notification context. -**Why:** This gives immediate noise control without prematurely designing per-project or per-issue preference complexity. +**Why:** A readiness surface is only useful if teams can immediately resolve the work that caused the signal. -### 5. Self-notification and preference filtering in one service +### 4. Search is scoped to readiness work -The notification service suppresses actor self-notifications, filters invalid workspace members, applies preferences, and inserts recipient rows idempotently. +Project search covers readiness-relevant work items, plan items, comments, GitHub signals, and notifications for the current project and current workspace member. -**Why:** Centralizing these rules avoids drift between comment, work item, GitHub, API, and worker paths. +**Why:** This improves discovery without committing to a global command palette or full-text search platform. -### 6. Worker-backed notification repair +### 5. Polish is limited to readiness-critical states -The worker has a `repair-notifications` mode for missing recipients and an explicitly enabled recent-activity backfill path. +Phase 8 hardens empty action states, search short-query/no-result/error states, and the no-GitHub-repository engineering setup state. -**Why:** Webhooks, request handlers, and deployments can fail. Repair must be safe to re-run and auditable from worker output. +**Why:** These are the states that directly affect whether the readiness surface can be trusted during real project use. ## Non-Goals -- Email, Slack, push, SMS, or mobile delivery -- Real-time websocket transport -- Cross-workspace global notification center -- Per-project or per-issue preferences -- Manual ambiguous GitHub link management -- Reporting/search dashboards +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting, BI, email, Slack, push, or websocket delivery ## RBAC Rules -| Role | Read Own Inbox | Mark Own Notifications Read | Update Own Preferences | Receive Notifications | Repair Recipients | -|------|----------------|-----------------------------|------------------------|-----------------------|-------------------| -| Viewer | Yes | Yes | Yes | Yes | No | -| Member | Yes | Yes | Yes | Yes | No | -| Admin | Yes | Yes | Yes | Yes | No | -| Owner | Yes | Yes | Yes | Yes | No | -| Worker/System | No UI access | No | No | Derived writes only | Yes | +| Role | Read Project Readiness | Use Project Search | Open Source-Linked Actions | Mutate Readiness Rules | +|------|------------------------|--------------------|----------------------------|------------------------| +| Viewer | Yes | Yes | Yes, for allowed surfaces | No | +| Member | Yes | Yes | Yes, for allowed surfaces | No | +| Admin | Yes | Yes | Yes, for allowed surfaces | No | +| Owner | Yes | Yes | Yes, for allowed surfaces | No | +| Worker/System | Derived writes only | No UI access | No UI access | No | ## Success Criteria -1. Notification source, event, priority, recipient reason, record, and inbox contracts are shared. -2. Durable notification tables exist with event uniqueness, recipient uniqueness, preferences, and inbox indexes. -3. Comment creation notifies valid mentions and relevant participants without notifying the actor. -4. Work item assignment, state change, and urgent-priority updates notify the right users. -5. GitHub PR/check/deploy changes and webhook failures create relevant notifications and remain replay-safe. -6. Notification API routes enforce workspace membership and current-user recipient scoping. -7. The project shell renders a compact unread badge and inbox panel. -8. Users can mark one notification read, mark all read, and update coarse preferences. -9. Worker repair can rebuild missing notification recipients and explicitly backfill recent activity safely. -10. Full repo lint, typecheck, test, and build pass. +1. Overview identifies Phase 8 as the Readiness Command Center. +2. Readiness status is deterministic and can represent `Ready`, `Ready with risk`, and `Blocked`. +3. Readiness metrics explain plan, issue, GitHub, and notification signals. +4. Decision cues summarize ship gate and primary blocker state. +5. Team actions are deterministic, source-linked, and empty-state aware. +6. Project-scoped readiness search covers work items, plan items, comments, GitHub signals, and notifications. +7. Search enforces workspace/project membership and handles short-query, empty, stale, malformed, and failed responses. +8. Engineering shows a clear setup state when no GitHub repository is connected. +9. Phase 8 product docs keep non-goals explicit. +10. Full repo lint, typecheck, test, build, targeted tests, and browser smoke validation pass. ## Next Phase -Phase 8 should focus on product readiness and discovery surfaces: +After Phase 8 closes, the next work should be selected from validated product gaps rather than extending this phase by default. Strong candidates are: -- reporting and portfolio-level summaries -- search across work items, docs, comments, and engineering signals -- manual polish for GitHub linking and project operations -- UX hardening for empty states, loading states, and navigation edges -- performance and build/deployment readiness +- merge and PR hardening +- performance and seed-data reliability +- deeper GitHub setup/linking workflows +- portfolio-level views, only after the single-project readiness loop proves useful diff --git a/docs/product/idea-brief.md b/docs/product/idea-brief.md index d03f5a8..bc23b93 100644 --- a/docs/product/idea-brief.md +++ b/docs/product/idea-brief.md @@ -1,82 +1,79 @@ -# Idea Brief: Phase 7 - Notifications and Collaboration Foundation +# Idea Brief: Phase 8 - Readiness Command Center ## Problem -The project workspace now has Jira-like execution, plan alignment, comments, and live GitHub engineering status. The remaining collaboration gap is attention: teammates still need to scan boards, comments, assignments, and engineering views to know what changed for them. +The project workspace now has Jira-like execution, plan alignment, lightweight comments, live GitHub engineering status, and in-app notifications. The remaining product gap is decision clarity: teams can see many surfaces, but they still need to interpret whether the current project is ready to move forward and what the next team action should be. -Without a durable notification layer, the product can show project truth but cannot reliably direct each teammate to the work that needs their response. +Without a lead-first readiness surface, project owners must synthesize plan progress, blocked work, engineering signals, and notifications manually. ## Goals -1. **Durable notification model** - Store notification source events separately from per-user recipient rows. +1. **Readiness Command Center** - Make Overview the primary project decision surface. -2. **Source-driven notification creation** - Emit notifications from comments, mentions, assignments, work item state or priority changes, GitHub PR/check/deploy changes, and webhook failures. +2. **Lead-first Overview reporting** - Show readiness status, narrative, signal metrics, decision cues, and milestone context before detailed execution views. -3. **In-app inbox** - Add a compact notification bell, unread count, inbox panel, mark-read, and mark-all-read controls to the project workspace shell. +3. **Deterministic team action list** - Convert local project, plan, work item, GitHub, and notification signals into explainable source-linked actions. -4. **Coarse preferences** - Let users tune comments, mentions, assignments, GitHub, and state-change notification categories per workspace. +4. **Scoped readiness search** - Add project-scoped search across readiness-relevant work items, plan items, comments, engineering signals, and notifications. -5. **Worker repair path** - Add a safe worker mode that rebuilds missing recipient rows and can explicitly backfill recent missed activity. - -6. **Local-first reads** - Keep the UI reading local Postgres notification state instead of relying on real-time transport in this phase. +5. **Readiness-critical polish** - Cover empty actions, search short-query/no-result/error states, and no-GitHub-repository engineering setup guidance. ## Constraints -- Users can only read or mutate notification rows for workspaces where they are members. -- Users can only mark their own recipient rows read. -- Notification creation must not change existing comment, work item, or GitHub response shapes. -- Self-notifications are suppressed. -- Recipient creation must remain idempotent across webhook replay and worker repair. -- Phase 7 does not add email, Slack, push, or global cross-workspace notification delivery. +- Readiness is derived from local project state and deterministic rules. +- Readiness search stays project-scoped and workspace-member scoped. +- Overview must remain lightweight enough for team alignment, not become a portfolio analytics product. +- Existing board, list, plan, engineering, docs, and notification surfaces remain the source-linked detail views. -## Decisions +## Non-Goals -1. **Event plus recipient tables** - Notification events capture what happened; recipient rows capture who should see it and read state. +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting or BI integrations -2. **Domain services emit events** - Comments, work items, and GitHub services call the notification service after successful mutations. They do not write notification tables directly. +## Decisions -3. **In-app first** - Phase 7 ships a project-workspace inbox before external delivery channels. +1. **Overview leads with readiness** - The project landing surface answers "can we move forward?" before showing lower-level detail. -4. **Coarse preference model** - Workspace-level category switches are enough for this phase. Per-project and per-issue preferences remain out of scope. +2. **Server-side readiness projection** - `apps/web/src/server/projects/readiness.ts` owns deterministic readiness derivation from local state. -5. **Repair worker** - The worker owns deterministic repair for missing recipients and optional recent activity backfill. +3. **Source-linked actions** - Actions link back to plan, work item, engineering, or notification surfaces so teams can resolve the signal directly. -## Non-Goals +4. **Simple Postgres-backed search** - Phase 8 uses scoped matching without introducing global search infrastructure. -- Email, Slack, push, SMS, or mobile notifications -- Real-time websocket delivery -- Global cross-workspace notification center -- Per-project or per-issue notification preferences -- Manual ambiguous GitHub link management -- Portfolio reporting or executive dashboards +5. **Polish only where readiness depends on it** - Empty, error, and setup states are tightened where they affect readiness comprehension. ## Success Criteria -- Shared notification contracts exist for source type, event type, priority, recipient reason, events, recipients, preferences, and inbox items. -- Postgres has durable notification event, recipient, and preference tables with uniqueness and inbox lookup indexes. -- Comments notify valid mentions, assignees, and prior participants without notifying the actor. -- Work item assignment, state change, and urgent-priority changes notify the right users. -- GitHub PR, check, deploy, and webhook failure events create relevant notifications without duplicate recipients on replay. -- API routes list the current user's inbox, mark one notification read, mark all read, and update preferences with workspace scoping. -- The project shell shows an unread badge and inbox panel with source, title, context, work item identifier, timestamp, read state, link, and preferences. -- Worker notification repair can rebuild missing recipients safely and idempotently. +- Overview renders a Readiness Command Center with status, narrative, metrics, decision cues, and milestone context. +- Readiness status can be `Ready`, `Ready with risk`, or `Blocked`, derived from deterministic local rules. +- The team action list is source-linked and explains blockers, review needs, urgent work, unread high-priority notifications, and plan gaps. +- Project-scoped readiness search returns relevant work item, plan, comment, GitHub, and notification results with RBAC enforced. +- Search has distinct short-query guidance, no-result copy, and failure copy. +- Engineering shows a clear setup state when no GitHub repository is connected. +- Phase 8 final verification passes: targeted tests, lint, typecheck, full tests, build, and browser smoke check. ## Technical Direction -- Shared contracts: `packages/shared/src/constants.ts`, `packages/shared/src/types.ts` -- Durable schema: `notification_events`, `notification_recipients`, `notification_preferences` -- Web notification service: `apps/web/src/server/notifications/` -- API routes: `apps/web/src/app/api/workspaces/[slug]/notifications/*` -- UI components: `apps/web/src/components/notification-bell.tsx`, `notification-inbox.tsx`, `notification-preferences.tsx` -- Worker repair: `apps/worker/src/notification-repair.ts` +- Readiness projection: `apps/web/src/server/projects/readiness.ts` +- Workspace integration: `apps/web/src/server/projects/workspace.ts` +- Search service: `apps/web/src/server/projects/search.ts` +- Search API: `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` +- Overview UI: `apps/web/src/features/overview/` +- Engineering polish: `apps/web/src/features/engineering/engineering-view.tsx` +- Coverage: `tests/phase8-*.test.mjs`, Overview UI tests, Engineering UI tests ## Phase Position -Phase 7 of 8. Builds on: +Phase 8 of 8. Builds on: - Phase 2: Auth and workspace membership - Phase 3: Projects and work items - Phase 4: Board/list execution views - Phase 5: Detail, comments, plan, overview, docs, and project workspace shell - Phase 6: Live GitHub engineering integration +- Phase 7: In-app notifications and collaboration foundation -Next: Phase 8 should shift to reporting, search, and product polish now that execution, engineering status, and in-app notification foundations are in place. +Next: close Phase 8 with full verification, browser smoke validation, review, and PR/merge readiness. diff --git a/docs/product/prd.md b/docs/product/prd.md index b50f384..6fdbcad 100644 --- a/docs/product/prd.md +++ b/docs/product/prd.md @@ -1,158 +1,121 @@ -# PRD: Phase 7 - Notifications and Collaboration Foundation +# PRD: Phase 8 - Readiness Command Center ## Overview -Add a durable in-app notification foundation for comments, mentions, assignments, work item changes, and GitHub engineering updates. Phase 7 makes collaboration actionable without adding external delivery channels or real-time transport. +Add a lead-first Readiness Command Center to the project workspace Overview. Phase 8 synthesizes plan progress, work item state, GitHub engineering signals, and notification attention into deterministic readiness reporting, source-linked team actions, scoped readiness search, and readiness-critical polish. ## Scope Sections -### 7.1 Shared Notification Contracts +### 8.1 Readiness Projection -Notification concepts must be shared across web, DB, worker, and tests. +Readiness must be derived from local project state using deterministic server-side rules. **Requirements:** -- Define source types for comment, work item, GitHub, and system events. -- Define event types for comments, mentions, assignments, state changes, priority raises, GitHub PR/check/deploy changes, and webhook failures. -- Define priorities and recipient reasons. -- Export shared record types for events, recipients, preferences, and inbox items. +- Add a pure readiness projection for project stage, plan items, work items, GitHub signals, engineering state, and inbox rows. +- Return readiness status as `Ready`, `Ready with risk`, or `Blocked`. +- Return a plain-language narrative explaining the status. +- Return metrics for plan, issues, GitHub, and notifications. +- Return decision cues including ship gate and primary blocker state. +- Return source-linked actions for blockers, review needs, urgent work, high-priority notifications, and incomplete current-stage plan work. -### 7.2 Durable Notification Schema +### 8.2 Workspace Integration -Persist notifications locally before rendering or repairing them. +The project workspace loader must include readiness data in the Overview view model. **Requirements:** -- Add `notification_events` with workspace, project, work item, source identity, event type, actor, priority, title, body, URL, metadata, and created timestamp. -- Add `notification_recipients` with event id, workspace id, recipient id, reason, read timestamp, dismiss timestamp, and created timestamp. -- Add `notification_preferences` keyed by workspace id and user id. -- Enforce event uniqueness by workspace, source type, source id, and event type. -- Enforce recipient uniqueness by event, recipient, and reason. -- Add indexes for current-user inbox reads. +- Add readiness data to `ProjectWorkspaceView["overview"]`. +- Use project-scoped notification inbox rows for readiness signals. +- Preserve existing board, list, plan, engineering, docs, and notification shell behavior. +- Keep readiness links stable for project-scoped routes. -### 7.3 Notification Service and API +### 8.3 Overview Readiness UI -Centralize notification rules and expose current-user operations. +Overview should become the primary Phase 8 decision surface. **Requirements:** -- Create notifications from source events with recipient resolution and preference filtering. -- Suppress actor self-notifications. -- Keep recipient insertion idempotent. -- Enforce workspace membership for inbox reads and preference reads/writes. -- Add API routes to list inbox notifications, mark one notification read, mark all read, read preferences, and update preferences. -- Return 404 when a user tries to mark another user's recipient row. +- Render `Readiness command center`. +- Show readiness status, tone, and narrative prominently. +- Show signal cards for plan, issues, GitHub, and notifications. +- Show decision cues. +- Show deterministic team actions with source labels and links. +- Keep milestone roadmap visible as supporting context. +- Avoid duplicate page-level headings inside the project shell. -### 7.4 Comment and Mention Notifications +### 8.4 Project-Scoped Readiness Search -Comments should direct attention to valid teammates. +Project search should support readiness work without introducing global search infrastructure. **Requirements:** -- Parse deterministic `@userId` mentions from comment content. -- Notify valid mentioned workspace members with reason `mention`. -- Notify assignees and prior participants with reason `participant` when not already mentioned. -- Ignore unknown mentions and actor self-notifications. -- Do not emit notifications for comment edits or deletes in Phase 7. +- Search work items, current-stage plan items, comments, GitHub engineering signals, and notification signals. +- Enforce workspace membership and project scoping. +- Ignore queries shorter than two characters at the service/API level. +- Escape SQL wildcard characters for literal matching. +- Return stable result types, titles, snippets, hrefs, chips, and ranks. +- Deduplicate engineering results deterministically per task. -### 7.5 Work Item Change Notifications +### 8.5 Search API -Important execution changes should notify affected users. +Expose readiness search through a project-scoped API route. **Requirements:** -- Notify newly assigned users on assignment changes. -- Notify assignees and participants on workflow state changes. -- Notify assignees and participants when priority is raised to urgent. -- Resolve participants from assignee, creator activity, and prior commenters. -- Preserve existing activity log behavior. +- Add `GET /api/workspaces/[slug]/projects/[key]/search?q=...`. +- Return 401 when no session exists. +- Return 403 for non-members. +- Return 404 when the project is missing or inaccessible. +- Return an empty result set for short queries. +- Return malformed-body and failed-request errors safely to the UI. -### 7.6 GitHub Engineering Notifications +### 8.6 Scoped Search UI -Engineering changes should reach users attached to linked work items. +Overview should provide a lightweight readiness search box. **Requirements:** -- Notify linked item followers when PRs open, request review, merge, or close. -- Notify linked item followers when check rollups fail or recover. -- Notify linked item followers when deployments reach staging or production. -- Notify project owners/admins when a connected repository webhook delivery fails processing. -- Rely on notification event uniqueness to keep webhook replay duplicate-safe. +- Add an accessible `Readiness search` searchbox. +- Fetch from the project-scoped search API. +- Guard stale responses from replacing newer results. +- Clear stale results when a new valid query starts. +- Show short-query guidance: `Search across blockers, PRs, comments, plan items, and notifications.` +- Show no-result copy: `No readiness signals found for "{query}".` +- Show failure copy: `Search failed. Try again from the project overview.` -### 7.7 In-App Notification UI +### 8.7 Readiness-Critical Polish -The project shell should expose a lightweight inbox. +Polish is limited to states that affect readiness comprehension. **Requirements:** -- Extend the project page loader with recent inbox rows, unread count, and current preferences. -- Add a compact notification bell to the project shell header. -- Show unread count as a badge. -- Open an inbox panel with recent notifications. -- Render source label, title, context/body, work item identifier, timestamp, unread state, and link. -- Add mark-read and mark-all-read controls using the notification API. -- Add preference checkboxes for comments, mentions, assignments, GitHub, and state changes. +- Show explicit empty action copy when no readiness actions exist. +- Show a no-GitHub-repository engineering setup state: `Connect GitHub to populate engineering readiness signals.` +- Keep linked PR, failing checks, deployment, and issue summary sections intact. +- Keep search short-query, pending, no-result, and error states mutually clear. -### 7.8 Worker Notification Repair +### 8.8 Verification -The worker should repair notification gaps safely. +Automated and browser coverage must prove readiness behavior and phase stability. **Requirements:** -- Add a `repair-notifications` worker mode. -- Find notification events missing recipient rows. -- Rebuild recipients using the same membership, preference, self-notification, and participant semantics as the web notification service. -- Add an explicitly enabled recent-activity backfill path. -- Keep repair idempotent through event and recipient uniqueness. -- Emit summary logs with repaired events and inserted recipient counts. - -### 7.9 Verification +- Readiness projection tests pass. +- Project search service tests pass. +- Search API tests pass. +- Overview and Engineering UI tests pass. +- Full repo verification passes: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. +- Browser smoke check verifies the Overview readiness surface, action links, search states, and engineering setup state. -Automated coverage must prove notification contracts, domain behavior, UI behavior, and worker repair. +## Data Model -**Requirements:** -- Shared contract tests pass. -- DB schema tests pass. -- Notification service tests cover idempotency, preference filtering, self-notification suppression, and cross-workspace isolation. -- Comment, work item, GitHub, and API integration tests pass. -- UI tests cover unread badge, inbox rows, mark-read, mark-all-read, and preference toggles. -- Worker tests cover missing recipients, preference filtering, duplicate-safe re-run, and recent activity backfill. -- Full repo lint, typecheck, test, and build pass. +Phase 8 does not add durable readiness tables. Readiness is a server-side projection over existing project, stage, plan, work item, GitHub, engineering, comment, and notification data. -## Data Model +## Non-Goals -``` -notification_events: - id: uuid PK - workspace_id: uuid FK - project_id: uuid FK nullable - work_item_id: uuid FK nullable - source_type: enum - source_id: text - event_type: enum - actor_id: text nullable - priority: enum - title: text - body: text nullable - url: text - metadata: jsonb nullable - created_at: timestamp - -notification_recipients: - id: uuid PK - event_id: uuid FK - workspace_id: uuid FK - recipient_id: text - reason: enum - read_at: timestamp nullable - dismissed_at: timestamp nullable - created_at: timestamp - -notification_preferences: - workspace_id: uuid FK - user_id: text - comments_enabled: boolean - mentions_enabled: boolean - assignments_enabled: boolean - github_enabled: boolean - state_changes_enabled: boolean -``` +- Global portfolio dashboard +- Analytics warehouse +- AI-generated recommendations +- Full-text infrastructure +- Global command palette +- External reporting or BI integrations ## Exit Criteria -- All 10 success criteria from the decision brief are met. -- Phase 7 tests pass without external delivery services. -- Full repo verification passes: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. -- Product docs identify Phase 8 as reporting, search, and polish. +- All success criteria from the Phase 8 decision brief are met. +- Product docs identify Phase 8 as the Readiness Command Center with lead-first Overview reporting, deterministic team actions, scoped readiness search, and readiness-critical polish. +- Phase 8 non-goals remain explicit. +- Targeted tests, full repo verification, and browser smoke check pass. diff --git a/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md index 23d7841..f622c82 100644 --- a/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md +++ b/docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md @@ -34,7 +34,7 @@ - Create: `apps/web/src/server/projects/readiness.ts` - Create: `tests/phase8-readiness-projection.test.mjs` -- [ ] **Step 1: Write failing tests for readiness status rules** +- [x] **Step 1: Write failing tests for readiness status rules** Add `tests/phase8-readiness-projection.test.mjs` with three pure projection tests: @@ -202,7 +202,7 @@ test("readiness projection marks stable projects ready", () => { }); ``` -- [ ] **Step 2: Run the failing test** +- [x] **Step 2: Run the failing test** Run: @@ -212,7 +212,7 @@ node --import tsx --test tests/phase8-readiness-projection.test.mjs Expected: FAIL because `apps/web/src/server/projects/readiness.ts` does not exist. -- [ ] **Step 3: Implement readiness types and projection** +- [x] **Step 3: Implement readiness types and projection** Create `apps/web/src/server/projects/readiness.ts` with these exported types and functions: @@ -281,7 +281,7 @@ Implement `buildProjectReadiness(input: BuildProjectReadinessInput): ProjectRead - Sort actions by priority order: failing checks, blocked work items, urgent work, review PRs, unread high-priority notifications, incomplete current-stage plan items. - Cap `actions` at five entries. -- [ ] **Step 4: Run the readiness projection test** +- [x] **Step 4: Run the readiness projection test** Run: @@ -291,7 +291,7 @@ node --import tsx --test tests/phase8-readiness-projection.test.mjs Expected: PASS with 3 tests passing. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** Run: @@ -309,7 +309,7 @@ git commit -m "feat: add project readiness projection" - Modify: `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts` - Modify: `tests/phase4-project-workspace.test.mjs` -- [ ] **Step 1: Extend workspace projection tests** +- [x] **Step 1: Extend workspace projection tests** In `tests/phase4-project-workspace.test.mjs`, update the first workspace projection test after the existing `workspaceView.overview.health` assertion: @@ -402,7 +402,7 @@ test("project workspace overview includes readiness signals from the current use }); ``` -- [ ] **Step 2: Run the failing workspace projection test** +- [x] **Step 2: Run the failing workspace projection test** Run: @@ -412,7 +412,7 @@ node --import tsx --test tests/phase4-project-workspace.test.mjs Expected: FAIL because `overview.readiness` is not defined and `getProjectWorkspaceForUser` does not accept notification inbox options. -- [ ] **Step 3: Update workspace view model** +- [x] **Step 3: Update workspace view model** In `apps/web/src/server/projects/workspace.ts`: @@ -450,7 +450,7 @@ readiness: buildProjectReadiness({ }) ``` -- [ ] **Step 4: Pass inbox rows from the project page loader** +- [x] **Step 4: Pass inbox rows from the project page loader** In `apps/web/src/app/workspaces/[slug]/projects/[key]/project-page-data.ts`, fetch notifications before calling `getProjectWorkspaceForUser`: @@ -480,7 +480,7 @@ const [workspaceView, projectStages, planItems] = await Promise.all([ Keep the existing `notificationInbox` object shape returned from `loadProjectPageData`. -- [ ] **Step 5: Run workspace projection tests** +- [x] **Step 5: Run workspace projection tests** Run: @@ -490,7 +490,7 @@ node --import tsx --test tests/phase4-project-workspace.test.mjs Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** Run: @@ -510,7 +510,7 @@ git commit -m "feat: wire readiness into project overview data" - Create: `apps/web/src/features/overview/readiness-action-list.tsx` - Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` -- [ ] **Step 1: Replace the Overview UI test with readiness expectations** +- [x] **Step 1: Replace the Overview UI test with readiness expectations** Update `apps/web/src/features/overview/__tests__/overview-view.test.tsx` so the fixture includes `overview.readiness`: @@ -565,7 +565,7 @@ expect(screen.getByRole("link", { name: /Review OPS-4 pull request/i })).toHaveA expect(screen.getByText("Milestone roadmap")).toBeInTheDocument(); ``` -- [ ] **Step 2: Run the failing UI test** +- [x] **Step 2: Run the failing UI test** Run: @@ -575,7 +575,7 @@ npm run test --workspace @the-platform/web -- overview-view Expected: FAIL because the readiness components are not rendered. -- [ ] **Step 3: Add `ReadinessSignalCard`** +- [x] **Step 3: Add `ReadinessSignalCard`** Create `apps/web/src/features/overview/readiness-signal-card.tsx`: @@ -600,7 +600,7 @@ export function ReadinessSignalCard({ metric }: { metric: ProjectReadinessMetric } ``` -- [ ] **Step 4: Add `ReadinessActionList`** +- [x] **Step 4: Add `ReadinessActionList`** Create `apps/web/src/features/overview/readiness-action-list.tsx`: @@ -656,7 +656,7 @@ export function ReadinessActionList({ actions }: { actions: ProjectReadinessActi } ``` -- [ ] **Step 5: Add `ReadinessSummary`** +- [x] **Step 5: Add `ReadinessSummary`** Create `apps/web/src/features/overview/readiness-summary.tsx`: @@ -710,7 +710,7 @@ export function ReadinessSummary({ readiness }: { readiness: ProjectReadinessVie } ``` -- [ ] **Step 6: Compose the new Overview** +- [x] **Step 6: Compose the new Overview** Modify `apps/web/src/features/overview/overview-view.tsx`: @@ -720,7 +720,7 @@ Modify `apps/web/src/features/overview/overview-view.tsx`: - Render `ReadinessActionList` in the right/lower area on desktop. - Keep the current milestone roadmap visible. -- [ ] **Step 7: Run the Overview UI test** +- [x] **Step 7: Run the Overview UI test** Run: @@ -730,7 +730,7 @@ npm run test --workspace @the-platform/web -- overview-view Expected: PASS. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** Run: @@ -747,7 +747,7 @@ git commit -m "feat: add readiness overview UI" - Create: `apps/web/src/server/projects/search.ts` - Create: `tests/phase8-project-search.test.mjs` -- [ ] **Step 1: Write failing search tests** +- [x] **Step 1: Write failing search tests** Create `tests/phase8-project-search.test.mjs` that: @@ -772,7 +772,7 @@ assert.ok(results.results.every((result) => result.href.startsWith(`/workspaces/ assert.ok(!results.results.some((result) => result.title.includes("Other workspace"))); ``` -- [ ] **Step 2: Run the failing search test** +- [x] **Step 2: Run the failing search test** Run: @@ -782,7 +782,7 @@ node --import tsx --test tests/phase8-project-search.test.mjs Expected: FAIL because `apps/web/src/server/projects/search.ts` does not exist. -- [ ] **Step 3: Implement search result types** +- [x] **Step 3: Implement search result types** Create `apps/web/src/server/projects/search.ts` with: @@ -828,7 +828,7 @@ interface SearchProjectDependencies { } ``` -- [ ] **Step 4: Implement `searchProjectForUser`** +- [x] **Step 4: Implement `searchProjectForUser`** Add: @@ -959,7 +959,7 @@ export async function searchProjectForUser( If `ilike` rejects nullable columns during typecheck, wrap nullable text fields with `sql` expressions in the implementation. -- [ ] **Step 5: Run search tests** +- [x] **Step 5: Run search tests** Run: @@ -969,7 +969,7 @@ node --import tsx --test tests/phase8-project-search.test.mjs Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** Run: @@ -986,7 +986,7 @@ git commit -m "feat: add project readiness search service" - Create: `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts` - Create: `tests/phase8-search-api.test.mjs` -- [ ] **Step 1: Write failing API tests** +- [x] **Step 1: Write failing API tests** Create `tests/phase8-search-api.test.mjs` with tests for: @@ -997,7 +997,7 @@ Create `tests/phase8-search-api.test.mjs` with tests for: Use route-level request calls by importing `GET` from the route file and stubbing session through the same pattern used in existing API tests. -- [ ] **Step 2: Run the failing API test** +- [x] **Step 2: Run the failing API test** Run: @@ -1007,7 +1007,7 @@ node --import tsx --test tests/phase8-search-api.test.mjs Expected: FAIL because the route file does not exist. -- [ ] **Step 3: Implement the route** +- [x] **Step 3: Implement the route** Create `apps/web/src/app/api/workspaces/[slug]/projects/[key]/search/route.ts`: @@ -1067,7 +1067,7 @@ export async function GET( } ``` -- [ ] **Step 4: Run API tests** +- [x] **Step 4: Run API tests** Run: @@ -1077,7 +1077,7 @@ node --import tsx --test tests/phase8-search-api.test.mjs Expected: PASS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** Run: @@ -1095,7 +1095,7 @@ git commit -m "feat: add readiness search api" - Modify: `apps/web/src/features/overview/overview-view.tsx` - Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` -- [ ] **Step 1: Extend Overview UI tests for search states** +- [x] **Step 1: Extend Overview UI tests for search states** In `overview-view.test.tsx`, add a test that stubs `fetch`, types `pipeline`, and expects grouped results: @@ -1139,7 +1139,7 @@ it("searches readiness signals from the overview", async () => { Import `fireEvent`, `waitFor`, and `vi` at the top of the test file. -- [ ] **Step 2: Run the failing Overview search test** +- [x] **Step 2: Run the failing Overview search test** Run: @@ -1149,7 +1149,7 @@ npm run test --workspace @the-platform/web -- overview-view Expected: FAIL because `OverviewView` does not accept `workspaceSlug` and `projectKey`, and `ReadinessSearch` does not exist. -- [ ] **Step 3: Create `ReadinessSearch`** +- [x] **Step 3: Create `ReadinessSearch`** Create `apps/web/src/features/overview/readiness-search.tsx`: @@ -1235,7 +1235,7 @@ export function ReadinessSearch({ workspaceSlug, projectKey }: ReadinessSearchPr } ``` -- [ ] **Step 4: Wire search into `OverviewView`** +- [x] **Step 4: Wire search into `OverviewView`** Change `OverviewView` props: @@ -1257,7 +1257,7 @@ export function OverviewView({ Update `apps/web/src/app/workspaces/[slug]/projects/[key]/overview/page.tsx` to pass `workspace.slug` and `project.key` from `loadProjectPageData`. -- [ ] **Step 5: Run Overview UI tests** +- [x] **Step 5: Run Overview UI tests** Run: @@ -1267,7 +1267,7 @@ npm run test --workspace @the-platform/web -- overview-view Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** Run: @@ -1287,7 +1287,7 @@ git commit -m "feat: add readiness search UI" - Modify: `apps/web/src/features/overview/__tests__/overview-view.test.tsx` - Modify: `apps/web/src/features/engineering/__tests__/engineering-view.test.tsx` -- [ ] **Step 1: Add UI tests for empty and no-repository states** +- [x] **Step 1: Add UI tests for empty and no-repository states** In `overview-view.test.tsx`, assert empty actions are explicit: @@ -1314,7 +1314,7 @@ In `engineering-view.test.tsx`, assert no repository connected renders a clear s expect(screen.getByText("Connect GitHub to populate engineering readiness signals.")).toBeInTheDocument(); ``` -- [ ] **Step 2: Run failing UI tests** +- [x] **Step 2: Run failing UI tests** Run: @@ -1324,7 +1324,7 @@ npm run test --workspace @the-platform/web -- overview-view engineering-view Expected: FAIL if the engineering no-repository state is not explicit. -- [ ] **Step 3: Improve Engineering empty states** +- [x] **Step 3: Improve Engineering empty states** In `apps/web/src/features/engineering/engineering-view.tsx`, when `engineering.connectionStatus === "Setup required"`, render: @@ -1336,7 +1336,7 @@ In `apps/web/src/features/engineering/engineering-view.tsx`, when `engineering.c Keep current linked PR, failing checks, deployments, and issue summary sections intact. -- [ ] **Step 4: Improve search empty and error copy** +- [x] **Step 4: Improve search empty and error copy** In `ReadinessSearch`, keep three distinct states: @@ -1344,7 +1344,7 @@ In `ReadinessSearch`, keep three distinct states: - no results: `No readiness signals found for "{query}".` - failed request: `Search failed. Try again from the project overview.` -- [ ] **Step 5: Run UI tests** +- [x] **Step 5: Run UI tests** Run: @@ -1354,7 +1354,7 @@ npm run test --workspace @the-platform/web -- overview-view engineering-view Expected: PASS. -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** Run: @@ -1373,7 +1373,7 @@ git commit -m "feat: polish readiness empty states" - Modify: `docs/product/prd.md` - Modify: `docs/superpowers/plans/2026-04-26-phase8-readiness-command-center.md` -- [ ] **Step 1: Update product docs for Phase 8** +- [x] **Step 1: Update product docs for Phase 8** Update product docs so they identify Phase 8 as: @@ -1391,11 +1391,11 @@ Keep Phase 8 non-goals explicit: - no full-text infrastructure - no global command palette -- [ ] **Step 2: Mark completed plan tasks** +- [x] **Step 2: Mark completed plan tasks** In this plan file, check off completed task steps as implementation progresses. -- [ ] **Step 3: Run targeted tests** +- [x] **Step 3: Run targeted tests** Run: @@ -1406,7 +1406,7 @@ npm run test --workspace @the-platform/web -- overview-view engineering-view Expected: PASS. -- [ ] **Step 4: Run full verification** +- [x] **Step 4: Run full verification** Run: @@ -1419,7 +1419,7 @@ npm run build Expected: PASS. -- [ ] **Step 5: Browser smoke check** +- [x] **Step 5: Browser smoke check** Start the app from the Phase 8 worktree: @@ -1436,7 +1436,9 @@ Open the local Next.js URL and verify: - Search query with no result shows the no-result state. - Engineering no-repository projects show the setup state. -- [ ] **Step 6: Commit final docs** +Smoke note: root `npm run dev` started web but the worker exited because local GitHub worker tokens are not configured (`GITHUB_TOKEN` or `GITHUB_APP_INSTALLATION_TOKEN`). Browser smoke used the Phase 8 worktree web server directly with `npm run dev --workspace @the-platform/web -- -p 3001`, reusing local Postgres/Redis. No DB reset was run; a local demo membership and `SETUP` no-repository smoke project were added to the existing development workspace. + +- [x] **Step 6: Commit final docs** Run: From f4e5f0fe1224d9b6918144feea44712aabea6876 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 14:10:13 -0600 Subject: [PATCH 18/20] fix: complete readiness search scope --- apps/web/src/server/projects/search.ts | 66 +++++++++- tests/phase8-project-search.test.mjs | 163 +++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 5 deletions(-) diff --git a/apps/web/src/server/projects/search.ts b/apps/web/src/server/projects/search.ts index a544ec5..ede0a4c 100644 --- a/apps/web/src/server/projects/search.ts +++ b/apps/web/src/server/projects/search.ts @@ -5,6 +5,8 @@ import { comments, db, githubPullRequests, + notificationEvents, + notificationRecipients, planItems, projectStages, taskGithubStatus, @@ -50,6 +52,14 @@ function ilikeLiteral(column: SQLWrapper, pattern: string) { return sql`${column} ilike ${pattern} escape '\\'`; } +function projectScopedHref(url: string | null, baseHref: string) { + if (!url) { + return baseHref; + } + + return url === baseHref || url.startsWith(`${baseHref}/`) || url.startsWith(`${baseHref}?`) ? url : baseHref; +} + function engineeringScore(row: { prTitle: string | null; headBranch: string | null; @@ -127,7 +137,9 @@ export async function searchProjectForUser( title: tasks.title, description: tasks.description, status: tasks.status, - priority: tasks.priority + priority: tasks.priority, + labels: tasks.labels, + blockedReason: tasks.blockedReason }) .from(tasks) .where( @@ -136,7 +148,11 @@ export async function searchProjectForUser( or( ilikeLiteral(tasks.identifier, pattern), ilikeLiteral(tasks.title, pattern), - ilikeLiteral(tasks.description, pattern) + ilikeLiteral(tasks.description, pattern), + ilikeLiteral(sql`${tasks.status}::text`, pattern), + ilikeLiteral(sql`${tasks.priority}::text`, pattern), + ilikeLiteral(sql`array_to_string(${tasks.labels}, ' ')`, pattern), + ilikeLiteral(tasks.blockedReason, pattern) ) ) ); @@ -186,6 +202,9 @@ export async function searchProjectForUser( identifier: tasks.identifier, title: tasks.title, prTitle: githubPullRequests.title, + prBody: githubPullRequests.body, + prNumber: githubPullRequests.number, + baseBranch: githubPullRequests.baseBranch, headBranch: githubPullRequests.headBranch, branchName: workItemGithubLinks.branchName, prStatus: taskGithubStatus.prStatus, @@ -204,12 +223,40 @@ export async function searchProjectForUser( ilikeLiteral(tasks.identifier, pattern), ilikeLiteral(tasks.title, pattern), ilikeLiteral(githubPullRequests.title, pattern), + ilikeLiteral(githubPullRequests.body, pattern), + ilikeLiteral(sql`${githubPullRequests.number}::text`, pattern), + ilikeLiteral(githubPullRequests.baseBranch, pattern), ilikeLiteral(githubPullRequests.headBranch, pattern), - ilikeLiteral(workItemGithubLinks.branchName, pattern) + ilikeLiteral(workItemGithubLinks.branchName, pattern), + ilikeLiteral(sql`${taskGithubStatus.prStatus}::text`, pattern), + ilikeLiteral(sql`${taskGithubStatus.ciStatus}::text`, pattern), + ilikeLiteral(sql`${taskGithubStatus.deployStatus}::text`, pattern) ) ) ); + const notificationRows = await db + .select({ + id: notificationEvents.id, + title: notificationEvents.title, + body: notificationEvents.body, + url: notificationEvents.url, + sourceType: notificationEvents.sourceType, + eventType: notificationEvents.eventType, + priority: notificationEvents.priority + }) + .from(notificationEvents) + .innerJoin(notificationRecipients, eq(notificationRecipients.eventId, notificationEvents.id)) + .where( + and( + eq(notificationEvents.workspaceId, workspace.id), + eq(notificationEvents.projectId, project.id), + eq(notificationRecipients.workspaceId, workspace.id), + eq(notificationRecipients.recipientId, session.userId), + or(ilikeLiteral(notificationEvents.title, pattern), ilikeLiteral(notificationEvents.body, pattern)) + ) + ); + const results: ProjectSearchResult[] = [ ...workItemRows.map((item): ProjectSearchResult => { const selected = item.identifier ?? item.id; @@ -218,7 +265,7 @@ export async function searchProjectForUser( id: `work-item-${item.id}`, type: "work_item", title: workItemTitle(item), - snippet: item.description || item.status, + snippet: item.description || item.blockedReason || item.labels?.join(", ") || item.status, href: `${baseHref}?selected=${selected}`, chip: item.priority === "urgent" || item.status === "Blocked" ? "Risk" : item.status, rank: item.identifier?.toLowerCase() === trimmed.toLowerCase() ? 0 : 10 @@ -254,7 +301,16 @@ export async function searchProjectForUser( chip: engineering.ciStatus === "Failing" ? "CI failing" : (engineering.deployStatus ?? engineering.prStatus ?? "Engineering"), rank: engineering.ciStatus === "Failing" ? 20 : 50 }; - }) + }), + ...notificationRows.map((notification): ProjectSearchResult => ({ + id: `notification-${notification.id}`, + type: "notification", + title: notification.title, + snippet: notification.body ?? notification.eventType, + href: projectScopedHref(notification.url, baseHref), + chip: notification.priority === "high" ? "High notification" : notification.sourceType, + rank: notification.priority === "high" ? 20 : 60 + })) ]; return { diff --git a/tests/phase8-project-search.test.mjs b/tests/phase8-project-search.test.mjs index b704862..5c40dcd 100644 --- a/tests/phase8-project-search.test.mjs +++ b/tests/phase8-project-search.test.mjs @@ -420,6 +420,169 @@ test("project readiness search returns one engineering result per multi-link tas assert.equal(new Set(engineeringResults.map((result) => result.id)).size, engineeringResults.length); }); +test("project readiness search matches scoped work item metadata and risk fields", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-risk-fields"); + const workspace = await harness.createWorkspace(owner, "search-risk-fields"); + const otherWorkspace = await harness.createWorkspace(owner, "search-risk-fields-other"); + + const { project, item } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "RSK", + "Validate launch handoff" + ); + await createProjectWithWorkItemOnly(harness, owner, otherWorkspace, "RSK", "Validate launch handoff"); + + await sql` + update tasks + set + status = 'Blocked', + priority = 'urgent', + labels = array['customer-escalation']::text[], + blocked_reason = 'customer-escalation requires launch approval' + where id = ${item.id} + `; + + const blockedResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "blocked" + ); + const urgentResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "urgent" + ); + const labelResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "customer-escalation" + ); + + for (const results of [blockedResults, urgentResults, labelResults]) { + assert.ok(results.results.some((result) => result.type === "work_item" && result.title === "RSK-1 Validate launch handoff")); + assert.ok( + results.results.every((result) => + result.href.startsWith(`/workspaces/${workspace.slug}/projects/${project.key}`) + ) + ); + } +}); + +test("project readiness search matches engineering GitHub status fields", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-github-status"); + const workspace = await harness.createWorkspace(owner, "search-github-status"); + const { project, item } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "GST", + "Validate neutral handoff" + ); + + await addPullRequestLink(workspace, item, "Neutral"); + await sql` + insert into task_github_status (task_id, pr_status, ci_status, deploy_status) + values (${item.id}, 'Review requested', 'Failing', 'Production') + `; + + const results = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "failing" + ); + const engineeringResult = results.results.find((result) => result.type === "engineering"); + + assert.ok(engineeringResult); + assert.equal(engineeringResult.chip, "CI failing"); + assert.equal(engineeringResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); +}); + +test("project readiness search returns current-user project notifications only", async (t) => { + const harness = createPersistedHarness(t); + const owner = createNamedSession("owner-notification"); + const member = createNamedSession("member-notification"); + const workspace = await harness.createWorkspace(owner, "search-notification"); + const { project } = await createProjectWithWorkItemOnly( + harness, + owner, + workspace, + "NTF", + "Validate neutral notice" + ); + + await sql` + insert into workspace_members (workspace_id, user_id, role, joined_at) + values (${workspace.id}, ${member.userId}, 'member', now()) + `; + + const [event] = await sql` + insert into notification_events ( + workspace_id, + project_id, + source_type, + source_id, + event_type, + actor_id, + priority, + title, + body, + url + ) + values ( + ${workspace.id}, + ${project.id}, + 'github', + ${`notice-${uniqueSuffix()}`}, + 'github_check_changed', + ${member.userId}, + 'high', + 'Deployment escalation notice', + 'Body says escalation follow-up is required.', + ${`/workspaces/${workspace.slug}/projects/${project.key}/engineering`} + ) + returning id + `; + + await sql` + insert into notification_recipients (event_id, workspace_id, recipient_id, reason) + values (${event.id}, ${workspace.id}, ${owner.userId}, 'owner') + `; + + const ownerResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "escalation" + ); + const memberResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + member, + workspace.slug, + project.key, + "escalation" + ); + + const notificationResult = ownerResults.results.find((result) => result.type === "notification"); + + assert.ok(notificationResult); + assert.equal(notificationResult.title, "Deployment escalation notice"); + assert.equal(notificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + assert.ok(!memberResults.results.some((result) => result.type === "notification")); +}); + test("project readiness search treats LIKE wildcards as literal query text", async (t) => { const harness = createPersistedHarness(t); const owner = createNamedSession("owner-wildcard"); From 429ee3cbd9e8063309e371d918aff59182c1ae65 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 14:16:02 -0600 Subject: [PATCH 19/20] test: cover readiness search scope fields --- tests/phase8-project-search.test.mjs | 52 ++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/tests/phase8-project-search.test.mjs b/tests/phase8-project-search.test.mjs index 5c40dcd..26080e8 100644 --- a/tests/phase8-project-search.test.mjs +++ b/tests/phase8-project-search.test.mjs @@ -274,6 +274,8 @@ async function addPullRequestLink(workspace, item, label) { ${`${label.toLowerCase()}-rollout-${suffix}`} ) `; + + return { repository, pullRequest }; } test.after(async () => { @@ -440,8 +442,8 @@ test("project readiness search matches scoped work item metadata and risk fields set status = 'Blocked', priority = 'urgent', - labels = array['customer-escalation']::text[], - blocked_reason = 'customer-escalation requires launch approval' + labels = array['launch-label-only']::text[], + blocked_reason = 'release-council-only-token requires launch approval' where id = ${item.id} `; @@ -464,10 +466,17 @@ test("project readiness search matches scoped work item metadata and risk fields owner, workspace.slug, project.key, - "customer-escalation" + "launch-label-only" + ); + const blockedReasonResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "release-council-only-token" ); - for (const results of [blockedResults, urgentResults, labelResults]) { + for (const results of [blockedResults, urgentResults, labelResults, blockedReasonResults]) { assert.ok(results.results.some((result) => result.type === "work_item" && result.title === "RSK-1 Validate launch handoff")); assert.ok( results.results.every((result) => @@ -489,24 +498,53 @@ test("project readiness search matches engineering GitHub status fields", async "Validate neutral handoff" ); - await addPullRequestLink(workspace, item, "Neutral"); + const { pullRequest } = await addPullRequestLink(workspace, item, "Neutral"); + await sql` + update github_pull_requests + set body = 'pr-body-only-token confirms rollout readiness.' + where id = ${pullRequest.id} + `; await sql` insert into task_github_status (task_id, pr_status, ci_status, deploy_status) values (${item.id}, 'Review requested', 'Failing', 'Production') `; - const results = await searchProjectForUser( + const failingResults = await searchProjectForUser( { projectRepository: harness.repositories.projectRepository }, owner, workspace.slug, project.key, "failing" ); - const engineeringResult = results.results.find((result) => result.type === "engineering"); + const reviewResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "review requested" + ); + const productionResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "production" + ); + const prBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "pr-body-only-token" + ); + const engineeringResult = failingResults.results.find((result) => result.type === "engineering"); assert.ok(engineeringResult); assert.equal(engineeringResult.chip, "CI failing"); assert.equal(engineeringResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + for (const results of [reviewResults, productionResults, prBodyResults]) { + assert.ok(results.results.some((result) => result.type === "engineering")); + } }); test("project readiness search returns current-user project notifications only", async (t) => { From 8f5a028e1f9c11ee88931a7e629abcd0f00bb617 Mon Sep 17 00:00:00 2001 From: Henry Wen Date: Sun, 26 Apr 2026 14:18:51 -0600 Subject: [PATCH 20/20] test: split notification search field coverage --- tests/phase8-project-search.test.mjs | 43 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/tests/phase8-project-search.test.mjs b/tests/phase8-project-search.test.mjs index 26080e8..7d1673d 100644 --- a/tests/phase8-project-search.test.mjs +++ b/tests/phase8-project-search.test.mjs @@ -586,8 +586,8 @@ test("project readiness search returns current-user project notifications only", 'github_check_changed', ${member.userId}, 'high', - 'Deployment escalation notice', - 'Body says escalation follow-up is required.', + 'Title-only deployment notice', + 'Body-only follow-up token is required.', ${`/workspaces/${workspace.slug}/projects/${project.key}/engineering`} ) returning id @@ -598,27 +598,46 @@ test("project readiness search returns current-user project notifications only", values (${event.id}, ${workspace.id}, ${owner.userId}, 'owner') `; - const ownerResults = await searchProjectForUser( + const ownerTitleResults = await searchProjectForUser( { projectRepository: harness.repositories.projectRepository }, owner, workspace.slug, project.key, - "escalation" + "title-only" ); - const memberResults = await searchProjectForUser( + const ownerBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + owner, + workspace.slug, + project.key, + "body-only" + ); + const memberTitleResults = await searchProjectForUser( { projectRepository: harness.repositories.projectRepository }, member, workspace.slug, project.key, - "escalation" + "title-only" + ); + const memberBodyResults = await searchProjectForUser( + { projectRepository: harness.repositories.projectRepository }, + member, + workspace.slug, + project.key, + "body-only" ); - const notificationResult = ownerResults.results.find((result) => result.type === "notification"); - - assert.ok(notificationResult); - assert.equal(notificationResult.title, "Deployment escalation notice"); - assert.equal(notificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); - assert.ok(!memberResults.results.some((result) => result.type === "notification")); + const titleNotificationResult = ownerTitleResults.results.find((result) => result.type === "notification"); + const bodyNotificationResult = ownerBodyResults.results.find((result) => result.type === "notification"); + + assert.ok(titleNotificationResult); + assert.ok(bodyNotificationResult); + assert.equal(titleNotificationResult.title, "Title-only deployment notice"); + assert.equal(bodyNotificationResult.title, "Title-only deployment notice"); + assert.equal(titleNotificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + assert.equal(bodyNotificationResult.href, `/workspaces/${workspace.slug}/projects/${project.key}/engineering`); + assert.ok(!memberTitleResults.results.some((result) => result.type === "notification")); + assert.ok(!memberBodyResults.results.some((result) => result.type === "notification")); }); test("project readiness search treats LIKE wildcards as literal query text", async (t) => {