From b03269fb37b0e40cf9c34f44ac5afa4adbcd1897 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 30 May 2026 15:20:29 +0300 Subject: [PATCH 1/7] feat(core): add collaboratedAt invite marker + isCollaborative predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a durable collaboratedAt?: number root field to ImprovementProject, set ONCE on the first invite (roster grows beyond the solo creator) at both ProjectsTabView onMembersChange set-sites and NEVER cleared on removal. Threads the existing component-stable now (no bare Date.now() in the testable path). Adds an isCollaborative(ip) = Boolean(ip.collaboratedAt) predicate that gates the Azure-only collaboration affordances. The patch type already permits the new optional root field, and the IMPROVEMENT_PROJECT_UPDATE reducers carry it through via ...patch — no action-union or IDB change. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/azure/src/components/ProjectsTabView.tsx | 15 +- .../__tests__/ProjectsTabView.test.tsx | 137 ++++++++++++++++++ apps/pwa/src/components/ProjectsTabView.tsx | 15 +- .../__tests__/ProjectsTabView.test.tsx | 132 +++++++++++++++++ .../__tests__/predicates.test.ts | 54 +++++++ packages/core/src/improvementProject/index.ts | 2 + .../core/src/improvementProject/predicates.ts | 15 ++ packages/core/src/improvementProject/types.ts | 8 + 8 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/improvementProject/__tests__/predicates.test.ts create mode 100644 packages/core/src/improvementProject/predicates.ts diff --git a/apps/azure/src/components/ProjectsTabView.tsx b/apps/azure/src/components/ProjectsTabView.tsx index 9ae444b4b..384f9dd4b 100644 --- a/apps/azure/src/components/ProjectsTabView.tsx +++ b/apps/azure/src/components/ProjectsTabView.tsx @@ -152,9 +152,18 @@ const ProjectsTabView: React.FC = ({ actions={approachInputs?.actions} now={now} currentUserId={currentUserId} - onMembersChange={(members: ProjectMember[]) => - applyProjectPatch(selected, { metadata: { ...selected.metadata, members } }) - } + onMembersChange={(members: ProjectMember[]) => { + const prevCount = selected.metadata.members?.length ?? 0; + // Set the durable collaboration marker ONCE, when the roster first + // grows beyond its solo creator (first invite). Never re-stamped and + // never cleared on removal — see ImprovementProject.collaboratedAt. + const markFirstInvite = + members.length > prevCount && !selected.collaboratedAt ? { collaboratedAt: now } : {}; + applyProjectPatch(selected, { + metadata: { ...selected.metadata, members }, + ...markFirstInvite, + }); + }} onRequestSignoff={() => applyProjectPatch(selected, { signoff: { diff --git a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx index 563d663ca..54c0b72e4 100644 --- a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx @@ -176,4 +176,141 @@ describe('ProjectsTabView', () => { ]) ); }); + + it('sets collaboratedAt once on the first invite (roster grows from solo)', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ status: 'draft', metadata: { title: 'First invite' } }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'lead@contoso.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'lead' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ collaboratedAt: expect.any(Number) }) + ); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toEqual( + expect.any(Number) + ); + }); + + it('does not re-stamp collaboratedAt on a second invite (idempotent)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Already collaborative', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'member@contoso.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'member' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + // The second invite patches members but must NOT carry a collaboratedAt key + // (the marker is set-once; re-stamping would move the durable timestamp). + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); + + it('does not clear collaboratedAt when a member is removed (durable marker)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Removal keeps marker', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + { + id: 'pm-member', + createdAt: 0, + deletedAt: null, + userId: 'member@contoso.com', + displayName: 'Member', + role: 'member', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); }); diff --git a/apps/pwa/src/components/ProjectsTabView.tsx b/apps/pwa/src/components/ProjectsTabView.tsx index a97c3a6e8..809750357 100644 --- a/apps/pwa/src/components/ProjectsTabView.tsx +++ b/apps/pwa/src/components/ProjectsTabView.tsx @@ -153,9 +153,18 @@ const ProjectsTabView: React.FC = ({ actions={approachInputs?.actions} now={now} currentUserId={PWA_USER_ID} - onMembersChange={(members: ProjectMember[]) => - applyProjectPatch(selected, { metadata: { ...selected.metadata, members } }) - } + onMembersChange={(members: ProjectMember[]) => { + const prevCount = selected.metadata.members?.length ?? 0; + // Set the durable collaboration marker ONCE, when the roster first + // grows beyond its solo creator (first invite). Never re-stamped and + // never cleared on removal — see ImprovementProject.collaboratedAt. + const markFirstInvite = + members.length > prevCount && !selected.collaboratedAt ? { collaboratedAt: now } : {}; + applyProjectPatch(selected, { + metadata: { ...selected.metadata, members }, + ...markFirstInvite, + }); + }} onRequestSignoff={() => applyProjectPatch(selected, { signoff: { diff --git a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx index 0355d4443..0e6b41b1e 100644 --- a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx @@ -170,4 +170,136 @@ describe('ProjectsTabView', () => { ]) ); }); + + it('sets collaboratedAt once on the first invite (roster grows from solo)', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ status: 'draft', metadata: { title: 'First invite' } }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'lead@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'lead' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ collaboratedAt: expect.any(Number) }) + ); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toEqual( + expect.any(Number) + ); + }); + + it('does not re-stamp collaboratedAt on a second invite (idempotent)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Already collaborative', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@local', + displayName: 'Analyst', + role: 'lead', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /invite team/i })); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'member@example.com' }, + }); + fireEvent.change(screen.getByLabelText(/role/i), { target: { value: 'member' } }); + fireEvent.click(screen.getByRole('button', { name: /^invite$/i })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); + + it('does not clear collaboratedAt when a member is removed (durable marker)', () => { + const onProjectPatch = vi.fn(); + const existingMarker = 1_700_000_000_000; + const hub: ProcessHub = { + ...baseHub, + improvementProject: makeIP({ + status: 'draft', + metadata: { + title: 'Removal keeps marker', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@local', + displayName: 'Analyst', + role: 'lead', + invitedAt: 0, + }, + { + id: 'pm-member', + createdAt: 0, + deletedAt: null, + userId: 'member@example.com', + displayName: 'Member', + role: 'member', + invitedAt: 0, + }, + ], + }, + collaboratedAt: existingMarker, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Remove Member' })); + + const lastPatch = onProjectPatch.mock.calls.at(-1)?.[1] ?? {}; + expect(lastPatch).not.toHaveProperty('collaboratedAt'); + expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.collaboratedAt).toBe( + existingMarker + ); + }); }); diff --git a/packages/core/src/improvementProject/__tests__/predicates.test.ts b/packages/core/src/improvementProject/__tests__/predicates.test.ts new file mode 100644 index 000000000..5b1970747 --- /dev/null +++ b/packages/core/src/improvementProject/__tests__/predicates.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import type { ImprovementProject } from '../types'; +import { isCollaborative } from '../predicates'; + +function makeIP(overrides: Partial = {}): ImprovementProject { + return { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { title: 'Cpk lift' }, + goal: { outcomeGoals: [] }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + ...overrides, + }; +} + +describe('isCollaborative', () => { + it('is false for a solo project with no collaboratedAt marker', () => { + expect(isCollaborative(makeIP())).toBe(false); + }); + + it('is true once collaboratedAt is set (first invite happened)', () => { + expect(isCollaborative(makeIP({ collaboratedAt: 1_700_000_000_000 }))).toBe(true); + }); + + it('stays true even when the roster is back to a single member (marker is durable)', () => { + const ip = makeIP({ + collaboratedAt: 1_700_000_000_000, + metadata: { + title: 'Cpk lift', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'lead@example.com', + displayName: 'Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + }); + expect(isCollaborative(ip)).toBe(true); + }); +}); diff --git a/packages/core/src/improvementProject/index.ts b/packages/core/src/improvementProject/index.ts index fb963aed5..9df8d0065 100644 --- a/packages/core/src/improvementProject/index.ts +++ b/packages/core/src/improvementProject/index.ts @@ -19,3 +19,5 @@ export type { DriftableSnapshot, DriftableCurrent } from './snapshot'; export { createNewIP } from './factories'; export type { CreateNewIPInput } from './factories'; + +export { isCollaborative } from './predicates'; diff --git a/packages/core/src/improvementProject/predicates.ts b/packages/core/src/improvementProject/predicates.ts new file mode 100644 index 000000000..640d74109 --- /dev/null +++ b/packages/core/src/improvementProject/predicates.ts @@ -0,0 +1,15 @@ +import type { ImprovementProject } from './types'; + +/** + * True once a project has ever been collaborated on — i.e. its durable + * `collaboratedAt` marker has been set by the first invite. The marker is + * never cleared (removing members does not flip this back to false), so this + * is distinct from a reversible `members.length > 1` check. + * + * Gates the Azure-only collaboration affordances (the optional, non-blocking + * sign-off section). A solo PWA investigation never sets the marker, so it + * stays in Mode-1 solo and the sign-off section stays hidden. (IM-7 §11 #6.) + */ +export function isCollaborative(ip: ImprovementProject): boolean { + return Boolean(ip.collaboratedAt); +} diff --git a/packages/core/src/improvementProject/types.ts b/packages/core/src/improvementProject/types.ts index 67839475e..cf87506b9 100644 --- a/packages/core/src/improvementProject/types.ts +++ b/packages/core/src/improvementProject/types.ts @@ -140,6 +140,14 @@ export interface ImprovementProject extends EntityBase { }; updatedAt: number; signoff?: ImprovementProjectSignoff; + /** Durable collaboration marker (Unix ms). Set ONCE when the project roster + * first grows beyond its solo creator (first invite), and NEVER cleared on + * member removal. Gates the Azure-only collaboration affordances (the + * optional, non-blocking sign-off section) via `isCollaborative(ip)`. A + * solo PWA investigation never sets it — the project stays in Mode-1 solo. + * Distinct from `metadata.members.length > 1`, which is derived + reversible; + * this marker records that collaboration *happened*. (IM-7 §11 #6.) */ + collaboratedAt?: number; /** Optional analyst-authored lessons-learned narrative. Authored in * Sections mode (Control or Handoff stages typically); surfaces in * the Report Overview "What we standardized + learned" section. */ From a70622cb8c7fc4f2d3503c8b41f3ecbe10b6a6cb Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 30 May 2026 15:27:27 +0300 Subject: [PATCH 2/7] feat(ui): hidden-solo non-blocking signoff, decouple processOwner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IPDetailTeamRail now gates the sign-off section behind isCollaborative(ip): solo projects (no collaboratedAt) hide it entirely — a solo investigation closes without sign-off. Sign-off is decoupled from the process owner and is never a hard gate to close: canApprove = pendingSignoff (no processOwner requirement), and the pending copy reads a generic 'Awaiting approval' rather than naming a gatekeeper. The approver identity is captured at the dispatch site: Azure ProjectsTabView resolves the acting user from project membership (actingApprover), falling back to a generic Reviewer ref — never the process owner. PWA wires no sign-off callbacks at all (Mode-1 solo, §9.2) and its nudge stub is removed. ProcessHub.processOwner is retained (independent uses). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/azure/src/components/ProjectsTabView.tsx | 27 ++++- .../__tests__/ProjectsTabView.test.tsx | 52 +++++++- apps/pwa/src/App.tsx | 5 - apps/pwa/src/components/ProjectsTabView.tsx | 29 +---- .../__tests__/ProjectsTabView.test.tsx | 20 +--- .../components/IPDetail/IPDetailTeamRail.tsx | 111 ++++++++++-------- .../__tests__/IPDetailTeamRail.test.tsx | 46 +++++++- 7 files changed, 189 insertions(+), 101 deletions(-) diff --git a/apps/azure/src/components/ProjectsTabView.tsx b/apps/azure/src/components/ProjectsTabView.tsx index 384f9dd4b..bab9d7914 100644 --- a/apps/azure/src/components/ProjectsTabView.tsx +++ b/apps/azure/src/components/ProjectsTabView.tsx @@ -1,5 +1,10 @@ import React from 'react'; -import type { ProcessHub, ControlRecord, ControlHandoff } from '@variscout/core'; +import type { + ProcessHub, + ControlRecord, + ControlHandoff, + ProcessParticipantRef, +} from '@variscout/core'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { HubAction } from '@variscout/core/actions'; import type { ProjectMember } from '@variscout/core/projectMembership'; @@ -35,6 +40,23 @@ function liveProjects(hub: ProcessHub | undefined): ImprovementProject[] { return p && p.deletedAt === null ? [p] : []; } +/** + * Resolve the acting user as the sign-off approver. Sign-off is decoupled from + * the process owner (IM-7 Task 3): the approver is whoever is acting now, + * resolved from project membership by the signed-in user id, with a generic + * fallback when the user is unknown (no auth context in tests). + */ +function actingApprover( + project: ImprovementProject, + currentUserId: string | undefined +): ProcessParticipantRef { + const me = project.metadata.members?.find(m => m.userId === currentUserId); + if (me) { + return { userId: me.userId, displayName: me.displayName }; + } + return { userId: currentUserId, displayName: 'Reviewer' }; +} + function mergeProjectPatch( project: ImprovementProject, patch: Extract['patch'], @@ -181,7 +203,8 @@ const ProjectsTabView: React.FC = ({ ...(selected.signoff ?? {}), requestedAt: selected.signoff?.requestedAt ?? Date.now(), approvedAt: Date.now(), - approvedBy: activeHub.processOwner ?? { displayName: 'Process owner' }, + // Acting user, not the process owner (sign-off decoupled, IM-7 Task 3). + approvedBy: actingApprover(selected, currentUserId), }, }) } diff --git a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx index 54c0b72e4..369e330c4 100644 --- a/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/azure/src/components/__tests__/ProjectsTabView.test.tsx @@ -82,7 +82,8 @@ describe('ProjectsTabView', () => { const hub: ProcessHub = { ...baseHub, processOwner: { displayName: 'Pat Process', upn: 'pat@example.com' }, - improvementProject: makeIP(), + // collaboratedAt makes the sign-off section visible (collaboration affordance). + improvementProject: makeIP({ collaboratedAt: 1_700_000_000_000 }), }; render( @@ -107,6 +108,55 @@ describe('ProjectsTabView', () => { ); }); + it('approves with the acting user (not the process owner) and works with no process owner set', () => { + const onProjectPatch = vi.fn(); + const hub: ProcessHub = { + ...baseHub, + // No processOwner on the hub — sign-off must still be approvable. + improvementProject: makeIP({ + collaboratedAt: 1_700_000_000_000, + signoff: { requestedAt: 1_700_000_100_000 }, + metadata: { + title: 'Approve without owner', + members: [ + { + id: 'pm-lead', + createdAt: 0, + deletedAt: null, + userId: 'analyst@contoso.com', + displayName: 'Analyst Lead', + role: 'lead', + invitedAt: 0, + }, + ], + }, + }), + }; + + render( + {}} + onProjectPatch={onProjectPatch} + currentUserId="analyst@contoso.com" + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + + // approvedBy is the acting user (resolved from membership), never the process owner. + expect(onProjectPatch).toHaveBeenCalledWith( + 'ip-1', + expect.objectContaining({ + signoff: expect.objectContaining({ + approvedAt: expect.any(Number), + approvedBy: expect.objectContaining({ displayName: 'Analyst Lead' }), + }), + }) + ); + }); + it('threads currentUserId into IPDetailPage — charter team section is visible', () => { // Use 'draft' status so charter is the default active stage (deriveStageState returns // charter: 'current' for drafts, approach: 'current' for active which shifts the default). diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 1dd6cfacb..aa15f15a2 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -1307,11 +1307,6 @@ function AppMain() { ); }); }} - onNudgeSignoff={projectId => { - console.info( - `[projects] Nudge signoff for ${projectId} — EngagementEvent webhook boundary` - ); - }} onStartNewProject={panels.showCharter} /> ) : panels.activeView === 'improvement' ? ( diff --git a/apps/pwa/src/components/ProjectsTabView.tsx b/apps/pwa/src/components/ProjectsTabView.tsx index 809750357..44b5515e8 100644 --- a/apps/pwa/src/components/ProjectsTabView.tsx +++ b/apps/pwa/src/components/ProjectsTabView.tsx @@ -28,7 +28,7 @@ interface ProjectsTabViewProps { projectId: ImprovementProject['id'], patch: Extract['patch'] ) => void; - onNudgeSignoff?: (projectId: ImprovementProject['id']) => void; + // PWA never exposes sign-off (IM-7 §9.2): no onNudgeSignoff / onApproveSignoff. onStartNewProject?: () => void; } @@ -86,7 +86,6 @@ const ProjectsTabView: React.FC = ({ onOpenLegacyControl, onNudgeProcessOwner, onProjectPatch, - onNudgeSignoff, onStartNewProject, }) => { const [now] = React.useState(() => Date.now()); @@ -165,27 +164,11 @@ const ProjectsTabView: React.FC = ({ ...markFirstInvite, }); }} - onRequestSignoff={() => - applyProjectPatch(selected, { - signoff: { - ...(selected.signoff ?? {}), - requestedAt: Date.now(), - approvedAt: undefined, - approvedBy: undefined, - }, - }) - } - onNudgeSignoff={() => onNudgeSignoff?.(selected.id)} - onApproveSignoff={() => - applyProjectPatch(selected, { - signoff: { - ...(selected.signoff ?? {}), - requestedAt: selected.signoff?.requestedAt ?? Date.now(), - approvedAt: Date.now(), - approvedBy: activeHub.processOwner ?? { displayName: 'Process owner' }, - }, - }) - } + // PWA never exposes sign-off (IM-7 §9.2): it is a single-user, Mode-1 + // solo surface. The collaboration affordance lives only in the Azure + // app, so no onRequestSignoff / onNudgeSignoff / onApproveSignoff is + // wired here. The team rail's sign-off section additionally self-hides + // for solo projects via isCollaborative(). /> ); } diff --git a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx index 0e6b41b1e..90cb6e75a 100644 --- a/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx +++ b/apps/pwa/src/components/__tests__/ProjectsTabView.test.tsx @@ -73,8 +73,7 @@ describe('ProjectsTabView', () => { expect(onStartNewProject).toHaveBeenCalledTimes(1); }); - it('updates the project store and emits a patch from detail signoff actions', () => { - const onProjectPatch = vi.fn(); + it('never exposes a sign-off section — PWA is a Mode-1 solo surface (IM-7 §9.2)', () => { const hub: ProcessHub = { ...baseHub, processOwner: { displayName: 'Pat Process', upn: 'pat@example.com' }, @@ -86,21 +85,14 @@ describe('ProjectsTabView', () => { activeHub={hub} selectedProjectId="ip-1" onSelectProject={() => {}} - onProjectPatch={onProjectPatch} + onProjectPatch={() => {}} /> ); - fireEvent.click(screen.getByRole('button', { name: /request approval/i })); - - expect(onProjectPatch).toHaveBeenCalledWith( - 'ip-1', - expect.objectContaining({ - signoff: expect.objectContaining({ requestedAt: expect.any(Number) }), - }) - ); - expect(useImprovementProjectStore.getState().getProjectForHub('hub-1')?.signoff).toEqual( - expect.objectContaining({ requestedAt: expect.any(Number) }) - ); + // Solo project has no collaboratedAt marker → the team-rail sign-off + // section self-hides, and PWA wires no sign-off callbacks at all. + expect(screen.queryByRole('button', { name: /request approval/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Signoff' })).not.toBeInTheDocument(); }); it('threads PWA_USER_ID into IPDetailPage — charter team section is visible', () => { diff --git a/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx b/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx index f9ecc0a18..b785b5f13 100644 --- a/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx +++ b/packages/ui/src/components/IPDetail/IPDetailTeamRail.tsx @@ -2,6 +2,7 @@ import React, { useMemo, useState } from 'react'; import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; import type { ControlHandoff, ProcessHub, ControlRecord } from '@variscout/core'; import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { isCollaborative } from '@variscout/core/improvementProject'; import type { ProjectMember, ProjectRole } from '@variscout/core/projectMembership'; import IPDetailAvatar from './IPDetailAvatar'; import { deriveIPActivityEvents, type IPActivityEvent } from './activityEvents'; @@ -88,7 +89,9 @@ function ActivityList({ events }: { events: IPActivityEvent[] }) { const IPDetailTeamRail: React.FC = ({ ip, raciOverrides = {}, - activeHub, + // `activeHub` stays on the props contract (callers still pass it) but the rail + // no longer reads `processOwner`: sign-off is decoupled from the process owner + // (IM-7 Task 3). Intentionally not destructured here. ideas = [], actions = [], controlRecord, @@ -115,9 +118,14 @@ const IPDetailTeamRail: React.FC = ({ [actions, controlHandoff, effectiveNow, ideas, ip, controlRecord] ); const recentEvents = events.slice(0, 5); - const approver = activeHub?.processOwner; const pendingSignoff = Boolean(ip.signoff?.requestedAt && !ip.signoff.approvedAt); - const canApprove = pendingSignoff && Boolean(approver); + // Sign-off is decoupled from the process owner and never blocks closure: + // any acting reviewer may approve while a request is pending. The approver + // identity is captured at the dispatch site (the acting user), not here. + const canApprove = pendingSignoff; + // Sign-off is a collaboration affordance: hidden entirely for solo projects + // (no collaboratedAt marker). A solo investigation closes without sign-off. + const collaborative = isCollaborative(ip); return (