diff --git a/apps/azure/src/components/ProjectsTabView.tsx b/apps/azure/src/components/ProjectsTabView.tsx index 740a63096..be578b06e 100644 --- a/apps/azure/src/components/ProjectsTabView.tsx +++ b/apps/azure/src/components/ProjectsTabView.tsx @@ -8,7 +8,7 @@ import { IPDetailPage } from '@variscout/ui/ipDetail'; import type { CauseProjectionInputs, CauseRow, - HandoffChecklistInputs, + SustainmentClosureInputs, } from '@variscout/ui/ipDetail'; interface ProjectsTabViewProps { @@ -22,9 +22,9 @@ interface ProjectsTabViewProps { onOpenCauseWorkbench?: (cause: CauseRow) => void; sustainmentRecord?: SustainmentRecord; controlHandoff?: ControlHandoff; - handoffInputs?: HandoffChecklistInputs; + /** Closure checklist derived from controlHandoff (folded in from former Handoff stage). */ + closureInputs?: SustainmentClosureInputs; onOpenLegacySustainment?: () => void; - onOpenLegacyHandoff?: () => void; onNudgeProcessOwner?: () => void; onProjectPatch?: ( projectId: ImprovementProject['id'], @@ -86,9 +86,8 @@ const ProjectsTabView: React.FC = ({ onOpenCauseWorkbench, sustainmentRecord, controlHandoff, - handoffInputs, + closureInputs, onOpenLegacySustainment, - onOpenLegacyHandoff, onNudgeProcessOwner, onProjectPatch, onNudgeSignoff, @@ -149,9 +148,8 @@ const ProjectsTabView: React.FC = ({ onOpenCauseWorkbench={onOpenCauseWorkbench} sustainmentRecord={sustainmentRecord} controlHandoff={controlHandoff} - handoffInputs={handoffInputs} + closureInputs={closureInputs} onOpenLegacySustainment={onOpenLegacySustainment} - onOpenLegacyHandoff={onOpenLegacyHandoff} onNudgeProcessOwner={onNudgeProcessOwner} activeHub={activeHub} ideas={approachInputs?.ideas} diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index dc59bbd26..fb249e654 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -25,22 +25,16 @@ import { AppHeader } from '../components/AppHeader'; import PasteScreen from '../components/data/PasteScreen'; import ManualEntry from '../components/data/ManualEntry'; import { - ImprovementWorkspaceBase, - ImprovementContextPanel, - WhatIfExplorerPage, - PrioritizationMatrix, - TrackView, VerificationPrompt, BrainstormModal, QuestionLinkPrompt, SurveyNotebookBase, - DEFAULT_PRESETS, type ColumnMappingConfirmPayload, - type MatrixDimension, StageFiveModal, MatchSummaryCard, ActiveIPLaunchpadCard, ActiveIPScopeRibbon, + ImproveTabRoot, deriveActiveIPCanvasFocus, deriveActiveIPLineageIds, deriveActiveIPScopeLabels, @@ -621,7 +615,7 @@ export const Editor: React.FC = ({ const projectsControlHandoff = _azureLiveControlHandoffs.find( h => h.investigationId === (projectsSustainmentRecord?.investigationId ?? '') ); - const projectsHandoffInputs = projectsControlHandoff + const projectsClosureInputs = projectsControlHandoff ? { controlPlanDocumented: false, trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy), @@ -1147,26 +1141,11 @@ export const Editor: React.FC = ({ // Improvement workspace const { handleConvertIdeasToActions, - handleOpenImprovementPopout, - handleSynthesisChange, - causeColors, - causeLabels, - causeSummaries, - matrixIdeas, aggregatedActions, - selectedIdeasForRecap, - projectionReferenceContext, - verificationData: improvVerificationData, hasVerification: improvHasVerification, - currentOutcome: improvCurrentOutcome, - outcomeNotes: improvOutcomeNotes, - handleOutcomeChange: improvHandleOutcomeChange, - handleOutcomeNotesChange: improvHandleOutcomeNotesChange, improvementQuestions, - improvementLinkedFindings, selectedIdeaIds, projectedCpkMap: improvementProjectedCpkMap, - convertedIdeaIds, } = useImprovementOrchestration({ questionsState, findingsState, @@ -1178,8 +1157,6 @@ export const Editor: React.FC = ({ specs, stagedStats, }); - const activeImprovementView = usePanelsStore(s => s.activeImprovementView); - const highlightedIdeaId = usePanelsStore(s => s.highlightedIdeaId); const scopedFindings = useMemo( () => activeIPContext.isIPScoped @@ -1212,13 +1189,6 @@ export const Editor: React.FC = ({ : questionsState.questions, [questionsState.questions, scopedQuestionIds] ); - const scopedImprovementQuestions = useMemo( - () => - scopedQuestionIds - ? improvementQuestions.filter(question => scopedQuestionIds.has(question.id)) - : improvementQuestions, - [improvementQuestions, scopedQuestionIds] - ); const scopedQuestionsState = useMemo( () => (scopedQuestionIds ? { ...questionsState, questions: scopedQuestions } : questionsState), [questionsState, scopedQuestionIds, scopedQuestions] @@ -1255,12 +1225,6 @@ export const Editor: React.FC = ({ // Verification prompt: show when new data is uploaded while findings are improving const [showVerificationPrompt, setShowVerificationPrompt] = useState(false); - // Matrix axis state (local — not persisted) - const [matrixXAxis, setMatrixXAxis] = useState('benefit'); - const [matrixYAxis, setMatrixYAxis] = useState('timeframe'); - const [matrixColorBy, setMatrixColorBy] = useState('cost'); - const [matrixPreset, setMatrixPreset] = useState('benefit-time'); - // Brainstorm modal state const [brainstormQuestionId, setBrainstormQuestionId] = useState(null); const brainstormQuestion = improvementQuestions.find(q => q.id === brainstormQuestionId); @@ -1886,17 +1850,12 @@ export const Editor: React.FC = ({ }} sustainmentRecord={projectsSustainmentRecord} controlHandoff={projectsControlHandoff} - handoffInputs={projectsHandoffInputs} + closureInputs={projectsClosureInputs} onOpenLegacySustainment={() => usePanelsStore .getState() .showSustainment(projectsSustainmentRecord?.investigationId ?? undefined) } - onOpenLegacyHandoff={() => - usePanelsStore - .getState() - .showHandoff(projectsControlHandoff?.investigationId ?? undefined) - } onNudgeProcessOwner={() => { // Plan 3 will emit EngagementEvent webhook here. console.info('[handoff] Nudge process owner — Plan 3 will wire EngagementEvent'); @@ -1920,165 +1879,25 @@ export const Editor: React.FC = ({ currentUserId={currentUser?.email ?? undefined} /> ) : activeView === 'improvement' ? ( -
- {activeIPScope ? ( - - ) : null} - questionsState.selectIdea(hId, iId, sel)} - onUpdateTimeframe={(hId, iId, timeframe) => - questionsState.updateIdea(hId, iId, { timeframe }) - } - onUpdateDirection={(hId, iId, dir) => - questionsState.updateIdea(hId, iId, { direction: dir }) - } - onUpdateCost={(hId, iId, cost) => questionsState.updateIdea(hId, iId, { cost })} - onOpenRisk={() => {}} - onRemoveIdea={questionsState.removeIdea} - onOpenWhatIf={(questionId, ideaId) => handleProjectIdea(questionId, ideaId, true)} - onAddIdea={(hId, text) => questionsState.addIdea(hId, text)} - onAskCoScout={aiOrch.handleAskCoScoutFromIdeas} - onConvertToActions={() => { - handleConvertIdeasToActions(); - usePanelsStore.getState().setActiveImprovementView('track'); - }} - onBack={() => usePanelsStore.getState().showAnalysis()} - onPopout={handleOpenImprovementPopout} - selectedIdeaIds={selectedIdeaIds} - convertedIdeaIds={convertedIdeaIds} - targetCpk={processContext?.targetValue} - activeView={activeImprovementView} - showLeftPanel={true} - renderLeftPanel={() => { - if (projectionTarget) { - return ( - clearProjectionTarget()} - cpkTarget={cpkTarget} - activeFactor={viewState?.boxplotFactor} - mode={analysisMode ?? 'standard'} - projectionContext={{ - ideaText: projectionTarget.ideaText, - questionText: projectionTarget.questionText, - }} - onSaveProjection={handleSaveIdeaProjection} - referenceContext={projectionReferenceContext} - /> - ); - } - return ( - - ); - }} - renderMatrix={() => ( -
- { - if (axis === 'x') setMatrixXAxis(value); - else if (axis === 'y') setMatrixYAxis(value); - else setMatrixColorBy(value); - }} - onToggleSelect={ideaId => { - const question = improvementQuestions.find(q => - q.ideas?.some((i: { id: string }) => i.id === ideaId) - ); - if (question) { - questionsState.selectIdea( - question.id, - ideaId, - !selectedIdeaIds.has(ideaId) - ); - } - }} - highlightedIdeaId={highlightedIdeaId ?? undefined} - onIdeaClick={ideaId => { - const card = document.querySelector(`[data-testid="idea-row-${ideaId}"]`); - card?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - usePanelsStore.getState().setHighlightedIdeaId(ideaId); - setTimeout( - () => usePanelsStore.getState().setHighlightedIdeaId(null), - 2000 - ); - }} - onGhostDotClick={ideaId => { - const question = improvementQuestions.find(q => - q.ideas?.some((i: { id: string }) => i.id === ideaId) - ); - if (question) { - handleProjectIdea(question.id, ideaId, true); - } - }} - /> -
- )} - onIdeaHover={ideaId => usePanelsStore.getState().setHighlightedIdeaId(ideaId)} - highlightedIdeaId={highlightedIdeaId} - onOpenBrainstorm={questionId => { - setBrainstormQuestionId(questionId); - setBrainstormIdeas([]); - }} - renderTrackView={() => ( - - usePanelsStore.getState().setActiveImprovementView('plan') - } - onBackToPlan={() => - usePanelsStore.getState().setActiveImprovementView('plan') - } - actions={aggregatedActions} - onToggleComplete={(actionId, findingId) => { - findingsState.toggleActionComplete(findingId, actionId); - }} - verification={improvVerificationData} - hasVerification={improvHasVerification} - selectedOutcome={ - improvCurrentOutcome - ? improvCurrentOutcome.effective === 'yes' - ? 'effective' - : improvCurrentOutcome.effective === 'partial' - ? 'partial' - : 'not-effective' - : undefined - } - outcomeNotes={improvOutcomeNotes} - onOutcomeChange={improvHandleOutcomeChange} - onOutcomeNotesChange={improvHandleOutcomeNotesChange} - /> - )} - /> -
+ usePanelsStore.getState().showDashboard()} + onActionAdd={action => + console.warn('[wedge V1] ACTION_ITEM_ADD not yet wired (PR-WV1-3 work):', action) + } + onActionUpdate={(id, patch) => + console.warn( + '[wedge V1] ACTION_ITEM_UPDATE not yet wired (PR-WV1-3 work):', + id, + patch + ) + } + onActionRemove={id => + console.warn('[wedge V1] ACTION_ITEM_REMOVE not yet wired (PR-WV1-3 work):', id) + } + /> ) : activeView === 'report' ? ( { db.sustainmentReviews.where('hubId').equals(hub.id).toArray(), db.controlHandoffs.where('hubId').equals(hub.id).toArray(), ]); - const liveIps = ips.filter(p => p.deletedAt === null); + const hydrateAt = Date.now(); + const liveIps = ips + .filter(p => p.deletedAt === null) + .map(p => migrateImprovementProjectMetadata(p, hydrateAt)); const liveSustainmentRecords = sustainmentRecords.filter(record => record.deletedAt === null); const liveSustainmentReviews = sortReviewsDescending( sustainmentReviews.filter(review => review.deletedAt === null) diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 98dedd890..9a6fc7f7d 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -84,7 +84,6 @@ import { useFindingsStore, groupFindingsByChart } from './features/findings/find import { useProjectionStore } from './features/projection/projectionStore'; import { useInvestigationOrchestration } from './features/investigation/useInvestigationOrchestration'; import { useCanvasViewportLifecycle } from './features/investigation/useCanvasViewportLifecycle'; -import { useImprovementOrchestration } from './features/improvement/useImprovementOrchestration'; import { useStatsWorker } from './workers/useStatsWorker'; import { useActiveIPContext } from './hooks/useActiveIPContext'; @@ -361,14 +360,6 @@ function AppMain() { stats, }); - const improvementOrch = useImprovementOrchestration({ - questionsState, - findingsState: { - findings: findingsState.findings, - addAction: findingsState.addAction, - }, - }); - // Stage 5 modal — opens after Mode B Stage 3 confirm and via on-demand button. const stageFive = useStageFiveOpener(); @@ -863,15 +854,6 @@ function AppMain() { : questions, [questions, scopedQuestionIds] ); - const scopedImprovementQuestions = useMemo( - () => - scopedQuestionIds - ? improvementOrch.improvementQuestions.filter(question => - scopedQuestionIds.has(question.id) - ) - : improvementOrch.improvementQuestions, - [improvementOrch.improvementQuestions, scopedQuestionIds] - ); const scopedQuestionsState = useMemo( () => (scopedQuestionIds ? { ...questionsState, questions: scopedQuestions } : questionsState), [questionsState, scopedQuestionIds, scopedQuestions] @@ -919,7 +901,7 @@ function AppMain() { const projectsControlHandoff = _liveControlHandoffs.find( h => h.investigationId === (projectsSustainmentRecord?.investigationId ?? '') ); - const projectsHandoffInputs = projectsControlHandoff + const projectsClosureInputs = projectsControlHandoff ? { controlPlanDocumented: false, trainingDelivered: Boolean(projectsControlHandoff.signoff?.approvedBy), @@ -1300,17 +1282,12 @@ function AppMain() { }} sustainmentRecord={projectsSustainmentRecord} controlHandoff={projectsControlHandoff} - handoffInputs={projectsHandoffInputs} + closureInputs={projectsClosureInputs} onOpenLegacySustainment={() => usePanelsStore .getState() .showSustainment(projectsSustainmentRecord?.investigationId ?? undefined) } - onOpenLegacyHandoff={() => - usePanelsStore - .getState() - .showHandoff(projectsControlHandoff?.investigationId ?? undefined) - } onNudgeProcessOwner={() => { // Plan 3 will emit EngagementEvent webhook here. console.info('[handoff] Nudge process owner — Plan 3 will wire EngagementEvent'); @@ -1335,17 +1312,9 @@ function AppMain() { ) : panels.activeView === 'improvement' ? ( ) : panels.activeView === 'report' ? ( void; sustainmentRecord?: SustainmentRecord; controlHandoff?: ControlHandoff; - handoffInputs?: HandoffChecklistInputs; + /** Closure checklist derived from controlHandoff (folded in from former Handoff stage). */ + closureInputs?: SustainmentClosureInputs; onOpenLegacySustainment?: () => void; - onOpenLegacyHandoff?: () => void; onNudgeProcessOwner?: () => void; onProjectPatch?: ( projectId: ImprovementProject['id'], @@ -88,9 +88,8 @@ const ProjectsTabView: React.FC = ({ onOpenCauseWorkbench, sustainmentRecord, controlHandoff, - handoffInputs, + closureInputs, onOpenLegacySustainment, - onOpenLegacyHandoff, onNudgeProcessOwner, onProjectPatch, onNudgeSignoff, @@ -150,9 +149,8 @@ const ProjectsTabView: React.FC = ({ onOpenCauseWorkbench={onOpenCauseWorkbench} sustainmentRecord={sustainmentRecord} controlHandoff={controlHandoff} - handoffInputs={handoffInputs} + closureInputs={closureInputs} onOpenLegacySustainment={onOpenLegacySustainment} - onOpenLegacyHandoff={onOpenLegacyHandoff} onNudgeProcessOwner={onNudgeProcessOwner} activeHub={activeHub} ideas={approachInputs?.ideas} diff --git a/apps/pwa/src/components/layout/AppHeader.tsx b/apps/pwa/src/components/layout/AppHeader.tsx index 46e3dbaec..a3c3e929f 100644 --- a/apps/pwa/src/components/layout/AppHeader.tsx +++ b/apps/pwa/src/components/layout/AppHeader.tsx @@ -99,7 +99,7 @@ const PHASE_TABS: { id: PhaseId; label?: string; labelKey?: keyof MessageCatalog { id: 'analysis', labelKey: 'workspace.analysis' }, { id: 'investigation', labelKey: 'workspace.investigation' }, { id: 'improvement', labelKey: 'workspace.improve' }, - { id: 'projects', labelKey: 'workspace.projects' }, + { id: 'projects', labelKey: 'workspace.project' }, { id: 'report', labelKey: 'workspace.report' }, ]; diff --git a/apps/pwa/src/components/layout/__tests__/AppHeader.test.tsx b/apps/pwa/src/components/layout/__tests__/AppHeader.test.tsx index 0afa24117..209cefc1e 100644 --- a/apps/pwa/src/components/layout/__tests__/AppHeader.test.tsx +++ b/apps/pwa/src/components/layout/__tests__/AppHeader.test.tsx @@ -20,7 +20,7 @@ vi.mock('@variscout/hooks', async () => { 'workspace.analysis': 'Analysis', 'workspace.investigation': 'Investigation', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Project', 'workspace.report': 'Report', }; return msgs[key] ?? key; diff --git a/apps/pwa/src/components/views/ImprovementView.tsx b/apps/pwa/src/components/views/ImprovementView.tsx index 056d467e5..d72f122e6 100644 --- a/apps/pwa/src/components/views/ImprovementView.tsx +++ b/apps/pwa/src/components/views/ImprovementView.tsx @@ -1,67 +1,36 @@ /** * ImprovementView - Improvement workspace for PWA * - * Wraps ImprovementWorkspaceBase from @variscout/ui with PWA-specific wiring. - * Receives improvement data as props from the orchestration hook (no store reads). + * Wraps ImproveTabRoot (wedge V1 action tracker) with PWA-specific wiring. * No AI (no onAskCoScout), no popout (no onPopout), no Teams. + * Active-IP branching is handled internally by ImproveTabRoot: + * - activeIP = null → NoActiveProjectGuidance + * - activeIP = → ImproveStage action tracker */ import React from 'react'; -import { ActiveIPScopeRibbon, ImprovementWorkspaceBase } from '@variscout/ui'; +import { ActiveIPScopeRibbon, ImproveTabRoot } from '@variscout/ui'; import type { ActiveIPScopeLabels } from '@variscout/ui'; -import type { UseQuestionsReturn } from '@variscout/hooks'; -import type { UseImprovementOrchestrationReturn } from '../../features/improvement/useImprovementOrchestration'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import type { ActionItem } from '@variscout/core/findings'; interface ImprovementViewProps { activeIPScope?: { title: string; labels: ActiveIPScopeLabels } | null; - questionsState: UseQuestionsReturn; - onBack: () => void; - handleConvertIdeasToActions: UseImprovementOrchestrationReturn['handleConvertIdeasToActions']; - improvementQuestions: UseImprovementOrchestrationReturn['improvementQuestions']; - improvementLinkedFindings: UseImprovementOrchestrationReturn['improvementLinkedFindings']; - selectedIdeaIds: UseImprovementOrchestrationReturn['selectedIdeaIds']; - convertedIdeaIds: UseImprovementOrchestrationReturn['convertedIdeaIds']; + /** Active IP for the wedge V1 action tracker. Null = no active project. */ + activeIP: ImprovementProject | null; + /** Action items scoped to the hub (filtered to activeIP inside ImproveTabRoot). */ + actions: ActionItem[]; + /** Navigate to Home tab (used by NoActiveProjectGuidance). */ + onGoHome: () => void; } +const PWA_USER_ID = 'analyst@local'; + const ImprovementView: React.FC = ({ activeIPScope, - questionsState, - onBack, - handleConvertIdeasToActions, - improvementQuestions, - improvementLinkedFindings, - selectedIdeaIds, - convertedIdeaIds, + activeIP, + actions, + onGoHome, }) => { - if (improvementQuestions.length === 0) { - return ( -
- {activeIPScope ? ( - - ) : null} -
-
-

Improvement Workspace

-

- No improvement ideas yet. Start by investigating findings in the Investigation - workspace, then add improvement ideas to your questions. Answered questions with ideas - will appear here. -

- -
-
-
- ); - } - return (
{activeIPScope ? ( @@ -71,23 +40,20 @@ const ImprovementView: React.FC = ({ surface="Improve" /> ) : null} - questionsState.selectIdea(qId, iId, selected)} - onUpdateTimeframe={(qId, iId, timeframe) => - questionsState.updateIdea(qId, iId, { timeframe }) + + console.warn('[wedge V1] ACTION_ITEM_ADD not yet wired (PR-WV1-3 work):', action) + } + onActionUpdate={(id, patch) => + console.warn('[wedge V1] ACTION_ITEM_UPDATE not yet wired (PR-WV1-3 work):', id, patch) } - onUpdateDirection={(qId, iId, direction) => - questionsState.updateIdea(qId, iId, { direction }) + onActionRemove={id => + console.warn('[wedge V1] ACTION_ITEM_REMOVE not yet wired (PR-WV1-3 work):', id) } - onUpdateCost={(qId, iId, cost) => questionsState.updateIdea(qId, iId, { cost })} - onRemoveIdea={questionsState.removeIdea} - onAddIdea={questionsState.addIdea} - onConvertToActions={handleConvertIdeasToActions} - onBack={onBack} - selectedIdeaIds={selectedIdeaIds} - convertedIdeaIds={convertedIdeaIds} />
); diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts index 5eafa1744..6f746b699 100644 --- a/apps/pwa/src/persistence/PwaHubRepository.ts +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -52,6 +52,7 @@ import type { HubAction } from '@variscout/core/actions'; import type { ProcessHub } from '@variscout/core/processHub'; import type { ProcessMap } from '@variscout/core/frame'; import type { ActionItem } from '@variscout/core/findings'; +import { migrateImprovementProjectMetadata } from '@variscout/core/improvementProject'; import { db, type HubRow } from '../db/schema'; import { applyAction } from './applyAction'; @@ -95,8 +96,11 @@ export class PwaHubRepository implements HubRepository { this.sustainmentReviews.listByHub(hubMeta.id), this.controlHandoffs.listByHub(hubMeta.id), ]); + const hydrateAt = Date.now(); const liveOutcomes = outcomes.filter(o => o.deletedAt === null); - const liveProjects = improvementProjects.filter(p => p.deletedAt === null); + const liveProjects = improvementProjects + .filter(p => p.deletedAt === null) + .map(p => migrateImprovementProjectMetadata(p, hydrateAt)); const canonicalProcessMap = canvasRow ? stripHubId(canvasRow) : undefined; return { ...hubMeta, diff --git a/docs/decision-log.md b/docs/decision-log.md index a8dff8f59..bd3f31445 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -30,6 +30,14 @@ Decisions we keep relitigating. Each entry: short statement, rationale, closing **Amendment 2026-05-16 — PR-WV1-1 shipped (project membership foundation).** 17 commits on `feat/wedge-pr-wv1-1-project-membership` deliver the wedge §4 membership data model + ACL pattern: `ProjectRole` (`lead | member | sponsor`), `ProjectMember` + `Invitation` types extending `EntityBase`, `MembershipAction` reducer (ADD/UPDATE/REMOVE — patches use `Omit` per `feedback_action_patch_omit_lifecycle`), pure `canAccess(userId, members, action)` ACL function, Annotation-per-user `useProjectMembershipStore`, `InviteModal` + `MemberList` UI primitives with role-gated remove + per-row `aria-label`, Charter stage integration via additive `members[]` on `ImprovementProjectMetadata`, IPDetailPage ACL guard (non-member → `NoAccessRedirect`, Sponsor → `sponsor-report-panel` placeholder pointing to top-level Report nav), legacy `team[]` → wedge `members[]` `migrateTeamToMembers` function, Azure `useInvitationSync` Graph API stub, and PWA/Azure `ProjectsTabView` wired with `currentUserId` (PWA: `'analyst@local'` constant for single-user mode; Azure: `currentUser?.email` from EasyAuth) + `onMembersChange` callback mirroring the legacy `onTeamChange` `applyProjectPatch` pattern. Architecture review (system-architect, Opus) confirmed patterns generalize to PR-WV1-3's `MeasurementPlan` (reducer-over-domain-slice + pure ACL lookup table + sibling field on parent + migration helper). Three architectural decisions deferred to PR-WV1-2 with explicit ownership: (a) **`INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds** — `Invitation.status` + `acceptedAt` + `revokedAt` fields ship but no code mutates them yet; lifecycle action kinds land in PR-WV1-2 alongside Inbox simplification (Inbox surfaces pending invites; acceptance is the user action that transitions status → emits a `PROJECT_MEMBER_ADD` via composite reducer). (b) **`team[]` deprecation cutover** — `migrateTeamToMembers` exists in `packages/core/src/improvementProject/migration.ts` but is invoked nowhere; legacy `team[]` and wedge `members[]` coexist on the same `ImprovementProjectMetadata` for the migration window. PR-WV1-2 (stage rename + Improve fold-in) owns eager one-shot migration during `.vrs`/Dexie round-trip so legacy Sponsors (`team[].role === 'sponsor'`) don't get caught by the wedge ACL guard's `isExplicitlyExcluded` branch. (c) **Per-user persistence key on `useProjectMembershipStore`** — currently uses static `'variscout:projectMembership'` localStorage key (Zustand `persist` middleware). Shared-workstation users (rare in Azure ICP but possible) would bleed pending invites across sessions. Refactor to `useActiveIPStore`-style dynamic-key pattern (no `persist` middleware, manual `localStorage` with `variscout:projectMembership:{userId}` key + scope-keyed `Record` state) lands in PR-WV1-2 alongside auth-wiring refinement. (d) **Wire `canAccess` at consumer call sites** — final Opus code review noted that `IPDetailPage.tsx:126-134` inlines role lookup (`members.find(m => m.userId === currentUserId)?.role`) instead of calling `canAccess`, and `CharterOverview.tsx:129` gates the Invite button on `onInvite` prop presence rather than `canAccess(currentUserId, members, 'manage-membership')`. Today's behavior is correct because the only consumer is `IPDetailPage` itself, but PR-WV1-2's stage editors will need `canAccess('edit-charter')` / `canAccess('edit-improve')` per surface; converging the ACL truth table on one entry point at that point prevents drift. First commit of PR-WV1-2 should add §"Wire `canAccess` at consumer call sites" before stage editors land. Verification: 8/9 packages green via Turbo; `@variscout/ui` full suite stalls on the known unchanged-Canvas hang (per existing memory entries) — touched suites (`IPDetail`, `projects`, `InviteModal`, `MemberList`, `CharterOverview`) run green in isolation. `bash scripts/pr-ready-check.sh` deferred until pre-PR. _Amendment pinned 2026-05-16._ + **Amendment 2026-05-16 — PR-WV1-2 shipped (Improve restored as top-level tab + Project singular).** Mid-execution of PR-WV1-2 (Improve workspace migration), user surfaced the asymmetry: wedge §3.1 kept Analyze + Investigation as top-level verbs but removed Improve. That collapsed the pre-wedge verb/noun split too aggressively. The amendment **restores Improve as a top-level verb tab** (active-IP-scoped via the existing `useActiveIPContext(sessionHub)` cascade from PR-PT-7), **renames Projects → Project (singular)** to match the active-IP-centric pattern of every cascading verb tab, **trims IP detail from 4 stages to 3** (`Charter / Approach / Sustainment` — the `'improve'` stage retires; Sustainment still absorbs Handoff close-logic per Task 5), and **reuses the production ``** as the Advanced toggle target inside `` (retiring the PR-WV1-2 Task 3 `` skeleton — production primitives over parallel-build). Improve tab empty state when no active IP: `` with "Go to Home" button — mirrors how PR-WV1-1's `NoAccessRedirect` handles the ACL empty state. Preserves wedge §(3) "idea board / action conversion retire" (Improve tab is single-IP-scoped, not free-roaming). + + Engineering shape: 17 commits on `feat/wedge-pr-wv1-2-improve-workspace`. Tasks 0-5 from the original sub-plan delivered (canAccess wiring, stage rename, migrateImprovementProjectMetadata, ImproveStage, ImproveStageAdvanced [now retired], Advanced toggle, Handoff→Sustainment fold). Amendment Tasks A-E delivered the wedge-spec amendment: StageName trim to 3 values, ImproveStage routing removed from IPDetailPage, NoActiveProjectGuidance + ImproveTabRoot built, Improve components moved to `packages/ui/src/components/Improve/`, PWA + Azure shells wired through `` in `` body, `workspace.projects` i18n key renamed to `workspace.project` across 32 locales. + + PR-WV1-1 deferred items disposition: (b) `team[] → members[]` eager cutover via `migrateImprovementProjectMetadata` at .vrs/Dexie hydration — **closed by this PR**. (d) `canAccess` wired at consumer call sites (IPDetailPage + CharterOverview + ImproveStage) — **closed by this PR**. (a) `INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds — **still owed to PR-WV1-3**. (c) Per-user persistence key on `useProjectMembershipStore` — **still owed to PR-WV1-5**. + + Canonical artifacts: spec [`docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md`](superpowers/specs/2026-05-16-improve-tab-amendment-design.md); original sub-plan [`docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md`](superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md); amendment plan [`docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md`](superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md). _Amendment pinned 2026-05-16._ + - **2026-05-14 — Projects tab + IP detail (Lifecycle) page + Home active-IP launchpad locked.** Brainstorm session resolved coherence spec §15's "IP detail page details" carry-forward and reframed the top-nav structure. Six locks: (1) **7-tab nav split** — coherence's locked 6-tab Path A becomes 7 tabs (`[Home] [Process] [Analyze] [Investigation] [Improve] [Projects] [Report]`), splitting the previous single "Improvement" tab into a verb tab (Improve = legacy `ImprovementView` / PDCA workbench, zero migration) and a noun tab (Projects = new IP lifecycle + detail page); deliberate verb/noun split because the two surfaces serve genuinely different jobs (casual ideation across IPs vs. tracked-project authoring). (2) **Home becomes the active-IP launchpad** for the Project Lead persona; picking an active IP at Home cascades scope to Process / Analyze / Investigation / Improve / Report tabs via the IP-context chip (Coherence §11 pattern #5). Process Owner doesn't pick (hub-level cadence work); SME auto-scopes when accepting a consult; Frontline lives in action cards. State persists per-Hub-per-user via `localStorage` (new `useActiveIPStore` Annotation-layer Zustand store). (3) **IP detail (Lifecycle) page anatomy** — header (back-link / status pill / title / goal summary / team avatars + Invite) + stage tabs (`Charter / Approach / Sustainment / Handoff`) + Overview/Sections segmented toggle (Coherence §11 pattern #2) + 280px right rail team workspace. (4) **Approach stage is SuspectedCause-anchored** — per-cause hierarchy (Hypothesis → ImprovementIdea → ActionItem) as the visual unit in both Overview and Sections modes. Sections mode shows read-only summary with per-cause "Open in Improve workbench" jump-out (avoids duplicating the legacy PDCA tooling). (5) **Report tab IP-scoped with Overview/Technical audience toggle** (fractal pattern with IP detail's mode toggle) — Overview renders a 7-section QC-Story-shaped narrative arc with plain-English UI copy ("Where we started" / "What we aimed for" / "What we found + what we did" / "Did it work?" / "What we standardized + learned" / "What's next"); methodology lineage stays internal per RPS V1 D2 (`feedback_drop_methodology_bridges`). Technical layer renders the full analytical chart suite. Free-roaming Report = Hub-level portfolio. (6) **Single data-model addition** — optional `ImprovementProject.reflection?: string` field (sibling of `signoff`) for analyst lessons-learned narrative; backward-compatible, no `.vrs` format version bump. Spec [`docs/superpowers/specs/2026-05-14-projects-tab-design.md`](superpowers/specs/2026-05-14-projects-tab-design.md) written + committed as `2f4cff53`. Coherence spec amendments applied: §4 (nav: 6 → 7 tabs + Improve/Projects rows in table + amendment block), §15 (carry-forward marked resolved), §16 (new row in spec contributions matrix). Implementation plans next (4 sub-plans recommended: Projects tab + IP detail / Home + cascade / Team rail / Report). Tier behavior: V1 features in PWA free + Azure paid; V2 collaboration features (threaded comments, @-mentions, signoff queue UI, RACI per-section, change notifications) stay deferred + tier-gated when shipped per `feedback_tier_gate_inside_surface`. _Pinned 2026-05-14._ **Amendment 2026-05-15 — Plan 2 PR-PT-6 shipped (PR #177).** Squash-merged as `e7794a21`, first PR of Plan 2 (`docs/superpowers/plans/2026-05-14-projects-tab-launchpad-cascade.md`). Delivered the ADR-078 Annotation-layer `useActiveIPStore` in `@variscout/stores` with per-hub/per-user `localStorage` key `variscout:activeIP:${encodeURIComponent(hubId)}:${encodeURIComponent(userId)}`; no Document-layer persistence, HubAction dispatch, Dexie table, or migration. Added shared `@variscout/ui` Active-IP primitives: Mira's Home launchpad card, presentation helpers, and the shared IP-context chip with the spec's indigo inline style. Wired PWA and Azure Home/Projects/header flows: Project Lead can start/select/switch/exit active IP, Projects list selection sets active IP before opening detail, one-IP session auto-activation honors §4.3, and Exit preserves free-roaming. Added component and app integration tests for store behavior, Home card branches, chip text/actions/style, and PWA/Azure header consumption. Verification: touched-surface Vitest suites + `pnpm docs:check` + `pnpm build` passed; `bash scripts/pr-ready-check.sh` docs failure was fixed by linking the Plan 2 plan from the Projects Tab spec. Full `@variscout/ui` suite still stalls on unchanged `Canvas.test.tsx` in this environment; isolated branch and temp-main repros point to a pre-existing Canvas/Vitest runner issue, not the PR-PT-6 diff. PR final review subagent approved after mandatory checkout STEP 0. _Amendment pinned 2026-05-15._ diff --git a/docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md b/docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md new file mode 100644 index 000000000..2efd7c49a --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md @@ -0,0 +1,1124 @@ +--- +title: 'PR-WV1-2 amendment — restore Improve as top-level verb tab + Project singular (bite-sized plan)' +status: draft +last-reviewed: 2026-05-16 +related: + - docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md + - docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md + - docs/superpowers/plans/2026-05-16-wedge-implementation.md + - docs/07-decisions/adr-082-wedge-architecture.md +--- + +# PR-WV1-2 amendment — restore Improve as top-level verb tab + Project singular 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. **TDD IS NON-NEGOTIABLE** per user's explicit reminder: every code-touching step follows red→green→commit. + +**Goal:** Amend the in-flight PR-WV1-2 branch to (a) trim `StageName` from 4 values to 3 (drop `'improve'`), (b) remove `` routing from `IPDetailPage`, (c) wire the existing top-level Improve tab to render `` which mounts `` (with active IP) or `` (without), (d) rename the `workspace.projects` i18n key to `workspace.project` across 32 locale files, (e) amend the decision-log to reflect the restored 7-tab nav. + +**Architecture:** The existing `` in PWA + the Azure equivalent currently render `` as the legacy Improve panel. The amendment replaces ``'s body with ``, which uses active-IP context (via `useActiveIPContext(sessionHub)`) to choose between the simple tracker (`` from Task 2) and the guidance state. `` continues to back the Advanced toggle inside `` (replacing the Task 3 `` skeleton, which retires). The `'improve'` stage is removed from IP detail per the amendment spec; consumers in `IPDetailPage` are cleaned up. + +**Tech Stack:** TypeScript + React 18 + Zustand + Vitest + React Testing Library. + +**Canonical spec:** [`docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md`](../specs/2026-05-16-improve-tab-amendment-design.md). + +**Parent sub-plan (historical):** `docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md` — Tasks 0-5 stand verbatim per the amendment spec; this plan covers the rework needed on top. + +--- + +## Branch setup + +The branch `feat/wedge-pr-wv1-2-improve-workspace` already exists in the worktree at `/Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace`, 16 commits ahead of PR-WV1-1's HEAD (`7f7d21ea`), top commit `01e65326` (the spec amendment). + +Verify: + +```bash +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace branch --show-current +# expect: feat/wedge-pr-wv1-2-improve-workspace + +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace log --oneline 7f7d21ea..HEAD | head -20 +# expect (top to bottom): 01e65326 spec amendment, 6de849c3 Task 5 Sustainment fold, 80876352 Task 4 toggle, 09e03a37 Task 3 ImproveStageAdvanced, 7b391a9a Task 2 ImproveStage, c6e5872c + 6dba97c4 Task 1 stage rename, d16b7655 + b45a24a2 Task 0 canAccess. +``` + +--- + +## Foundation in place + +Already shipped on this branch: + +- PR-WV1-2 Task 0 — `canAccess` wired at `IPDetailPage` + `CharterOverview` Invite gate. +- PR-WV1-2 Task 1 — stage rename to 4 stages (`StageName = 'charter' | 'approach' | 'improve' | 'sustainment'`) + `migrateImprovementProjectMetadata` helper. The amendment trims this to 3 stages. +- PR-WV1-2 Task 2 — `` component at `packages/ui/src/components/IPDetail/stages/ImproveStage.tsx`. Currently routed from `IPDetailPage`. The amendment moves it. +- PR-WV1-2 Task 3 — `` skeleton at `packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx`. Mounts existing PDCA primitives. **The amendment retires this** — the production Advanced surface is `` (already wired in ``), not this skeleton. +- PR-WV1-2 Task 4 — Advanced toggle on `` that swaps in ``. The amendment re-points the toggle target. +- PR-WV1-2 Task 5 — Handoff close-logic folded into Sustainment closure; `HandoffOverview`/`HandoffSections` deleted. + +Scout findings to anchor file paths: + +- **PWA Improve tab handler:** `apps/pwa/src/features/panels/panelsStore.ts:113` — `showImprovement: () => set({ activeView: 'improvement' })`. PWA `App.tsx` lines 1330-1344 render `` when `activeView === 'improvement'`. +- **Azure Improve tab handler:** `apps/azure/src/pages/Editor.tsx:561` — `ps.showImprovement()`. Equivalent panel-store-driven render path in Editor. +- **Active-IP cascade hook:** `useActiveIPContext(sessionHub)` — returns `{ activeIP, activeIPScope, setActiveIP, clearActiveIP, ... }`. Canonical consumer pattern: `const { activeIP } = useActiveIPContext(sessionHub)`. +- **ActionItem fetch pattern:** `pwaHubRepository.actionItems.listByHub(activeHubId)` returns `ActionItem[]`. Used in `FrameView.tsx:150` and similar. +- **i18n locale count:** 32 files in `packages/core/src/i18n/messages/`. The `workspace.projects` key exists in every locale with literal value `'Projects'` (singular semantics in some languages, but English value is plural). +- **i18n test:** `packages/core/src/i18n/__tests__/index.test.ts:228` — "every locale defines every wall.\* key" pattern. The amendment adds `workspace.project` to every locale; the key-coverage test will fail if any locale is missed. +- **Nav consumer:** `apps/pwa/src/components/layout/AppHeader.tsx:101-102` — tab config has `{ id: 'projects', labelKey: 'workspace.projects' }`. Azure equivalent in the analogous header file. +- **`ImproveStage` callers:** Only `packages/ui/src/components/IPDetail/IPDetailPage.tsx:22` imports `ImproveStage`. `ImproveStage.tsx:4` imports `ImproveStageAdvanced` internally. + +--- + +## File structure (final state after amendment) + +**Created:** + +- `packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx` — guidance panel with role="alert" + heading + body + "Go to Home" button. +- `packages/ui/src/components/Improve/NoActiveProjectGuidance.test.tsx` — RTL tests. +- `packages/ui/src/components/Improve/ImproveTabRoot.tsx` — switches between `` (active IP) and `` (no active IP). +- `packages/ui/src/components/Improve/ImproveTabRoot.test.tsx` — RTL tests covering both branches. +- `packages/ui/src/components/Improve/index.ts` — barrel exporting both components. + +**Moved (via `git mv` to preserve history):** + +- `packages/ui/src/components/IPDetail/stages/ImproveStage.tsx` → `packages/ui/src/components/Improve/ImproveStage.tsx` +- `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx` → `packages/ui/src/components/Improve/ImproveStage.test.tsx` + +**Deleted:** + +- `packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx` — the Task 3 skeleton retires; `` is the production Advanced surface. +- `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx` + +**Modified:** + +- `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx` — `StageName` becomes `'charter' | 'approach' | 'sustainment'`; `STAGE_ORDER` becomes 3 values; `LABEL` drops `'improve'` entry. +- `packages/ui/src/components/IPDetail/stageState.ts` — `StageStateInputs.improveComplete` retires; `deriveStageState` simplifies per amendment spec table. +- `packages/ui/src/components/IPDetail/IPDetailPage.tsx` — remove `import { ImproveStage } from './stages/ImproveStage'`; remove `'improve'` case from stage router; remove `onActionAdd`/`Update`/`Remove` from `IPDetailPageProps`. +- `packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx` + `stageState.test.ts` + `IPDetailPage.test.tsx` — trim assertions to 3 stages. +- `packages/ui/src/components/Improve/ImproveStage.tsx` (after move) — Advanced toggle's `` import switches to `` from `@variscout/ui` (verify the actual import path during implementation). +- `packages/ui/src/components/Improve/ImproveStage.test.tsx` (after move) — Advanced-toggle test updated to assert the production Advanced surface renders (not the deleted skeleton). +- `packages/ui/src/index.ts` — export new `Improve/` barrel. +- `packages/core/src/i18n/messages/*.ts` (32 files) — drop `'workspace.projects'` line, add `'workspace.project'` line. +- `apps/pwa/src/components/layout/AppHeader.tsx` — update tab config: `labelKey: 'workspace.project'`. +- `apps/azure/src/components/layout/AppHeader.tsx` (or equivalent) — same update. +- `apps/pwa/src/App.tsx` — `` body replaced by `` wiring (or `` itself updated to compose `` internally). +- `apps/azure/src/pages/Editor.tsx` — same. +- `docs/decision-log.md` — amendment under the existing 2026-05-16 wedge entry. + +**Sub-path exports:** No new sub-path needed. `Improve/` directory exports flow through the existing `packages/ui/src/index.ts` barrel. Sub-path-export pair (`package.json#exports` + `tsconfig.json#paths`) does NOT need updating. + +--- + +## Task A — Trim `StageName` to 3 values + +**Goal:** Remove `'improve'` from `StageName` and downstream. After this, IP detail has 3 stage tabs. + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx` +- Modify: `packages/ui/src/components/IPDetail/stageState.ts` +- Test: `packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx` +- Test: `packages/ui/src/components/IPDetail/__tests__/stageState.test.ts` + +- [ ] **Step 1: Write the failing test asserting 3 stages** + +Add to `packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx`: + +```typescript +describe('STAGE_ORDER (amendment — 3 stages)', () => { + it('contains exactly charter, approach, sustainment', () => { + // STAGE_ORDER is module-internal; assert via the rendered tab list. + const ip: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { title: 'Test' }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', baseline: 0.5, target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, + }; + render( + {}} + /> + ); + expect(screen.getByTestId('stage-tab-charter')).toBeInTheDocument(); + expect(screen.getByTestId('stage-tab-approach')).toBeInTheDocument(); + expect(screen.getByTestId('stage-tab-sustainment')).toBeInTheDocument(); + expect(screen.queryByTestId('stage-tab-improve')).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run to verify FAIL** + +```bash +pnpm --filter @variscout/ui test -- IPDetailStageTabs +``` + +Expected: FAIL — `stage-tab-improve` still present (Task 1 added it). + +- [ ] **Step 3: Trim `StageName` and `STAGE_ORDER` in `IPDetailStageTabs.tsx`** + +Replace the existing declarations: + +```typescript +export type StageName = 'charter' | 'approach' | 'sustainment'; + +const STAGE_ORDER: StageName[] = ['charter', 'approach', 'sustainment']; +``` + +Update the `LABEL` map: drop the `'improve'` entry entirely. Keep the existing `'charter'`, `'approach'`, `'sustainment'` entries verbatim. + +- [ ] **Step 4: Trim `deriveStageState` in `stageState.ts`** + +Open `packages/ui/src/components/IPDetail/stageState.ts`. Remove the `improveComplete` field from `StageStateInputs`. Update `deriveStageState` to return the new 3-key shape per the amendment spec table: + +```typescript +export interface StageStateInputs { + status: ImprovementProject['status']; + sustainmentConfirmed: boolean; +} + +export interface StageStateMap { + charter: StageState; + approach: StageState; + sustainment: StageState; +} + +export function deriveStageState({ + status, + sustainmentConfirmed, +}: StageStateInputs): StageStateMap { + if (sustainmentConfirmed) { + return { charter: 'done', approach: 'done', sustainment: 'done' }; + } + switch (status) { + case 'draft': + return { charter: 'current', approach: 'upcoming', sustainment: 'upcoming' }; + case 'active': + return { charter: 'done', approach: 'current', sustainment: 'upcoming' }; + case 'closed': + return { charter: 'done', approach: 'done', sustainment: 'current' }; + } +} +``` + +- [ ] **Step 5: Update `stageState.test.ts` to assert the new 3-key shape** + +Open `packages/ui/src/components/IPDetail/__tests__/stageState.test.ts`. Remove every reference to `improveComplete` (the input field) and to the `improve` key (in the output). For every test case, replace the 4-key `expect(...).toEqual({ charter, approach, improve, sustainment })` with the 3-key form `{ charter, approach, sustainment }`. Mirror the table above. + +If existing tests covered the `improveComplete` signal explicitly (e.g., "when improveComplete is true, sustainment is current"), delete those tests — the signal no longer exists; `status === 'closed'` is the new "sustainment is current" trigger. + +- [ ] **Step 6: Update `IPDetailStageTabs.test.tsx` existing tests to match 3 stages** + +Find every assertion that pinned 4 tabs and trim to 3. Existing tests that asserted `stage-tab-improve` should now assert it's NOT present (covered by Step 1) or be deleted if redundant. + +- [ ] **Step 7: Run all stage-related tests to verify PASS** + +```bash +pnpm --filter @variscout/ui test -- "IPDetailStageTabs|stageState" +``` + +Expected: green across both suites. + +- [ ] **Step 8: Run the full IPDetail suite to catch downstream breakage** + +```bash +pnpm --filter @variscout/ui test -- IPDetail +``` + +Expected: green. If `IPDetailPage.test.tsx`'s "Improve stage routing" test (added in PR-WV1-2 Task 2) fails because `stage-tab-improve` is gone, that's expected — Task B deletes that test. Note the failure but proceed to commit; Task B fixes it. + +If the failure count is bigger than the one improve-routing test, STOP and report — there's more downstream coupling than the amendment spec anticipated. + +- [ ] **Step 9: Commit** + +```bash +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace add packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx packages/ui/src/components/IPDetail/stageState.ts packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx packages/ui/src/components/IPDetail/__tests__/stageState.test.ts +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace commit -m "refactor(ui): trim StageName to 3 values — drop 'improve' stage per amendment" +``` + +--- + +## Task B — Remove `ImproveStage` routing from `IPDetailPage` + +**Goal:** `IPDetailPage` no longer renders `` or accepts the three `onAction*` props. Stage router has 3 cases. + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/IPDetailPage.tsx` +- Modify: `packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx` + +- [ ] **Step 1: Write the failing test asserting the props are gone** + +In `packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx`, add (near the top of the file, after the existing fixture imports): + +```typescript +describe('IPDetailPageProps (amendment — no onAction* props)', () => { + it('does not accept onActionAdd / onActionUpdate / onActionRemove props', () => { + // @ts-expect-error onActionAdd should be removed from IPDetailPageProps + const props = { ip, onBackToList: () => {}, onActionAdd: () => {} } as IPDetailPageProps; + expect(props).toBeDefined(); + }); +}); +``` + +If the `IPDetailPageProps` type is not currently exported (it may be local), export it from `IPDetailPage.tsx` for this test: + +```typescript +export interface IPDetailPageProps { ... } +``` + +- [ ] **Step 2: Run to verify FAIL (the @ts-expect-error fires red when types still accept the prop)** + +```bash +pnpm --filter @variscout/ui test -- IPDetailPage +``` + +Expected: the test file fails to type-check OR the @ts-expect-error directive itself raises a tsc warning about being unused. (Vitest will surface the type error if its TS integration is enabled; otherwise this test pins via the type system at build time.) + +If the @ts-expect-error pattern doesn't fire in the vitest run, switch to a runtime assertion: after removing the prop from `IPDetailPageProps`, the existing "Improve stage routing" test (added in Task 2) will fail because the component no longer routes `'improve'`. That failure IS the red-light gate. Use whichever signal is firmer. + +- [ ] **Step 3: Remove `ImproveStage` import + props from `IPDetailPage.tsx`** + +In `packages/ui/src/components/IPDetail/IPDetailPage.tsx`: + +- Delete the line `import { ImproveStage } from './stages/ImproveStage';` (currently around line 22). +- Delete the three optional props from `IPDetailPageProps`: `onActionAdd?`, `onActionUpdate?`, `onActionRemove?`. They retire entirely. +- Delete any destructured references to these props in the component body. +- Delete the `case 'improve':` branch from the stage routing switch (if it survived Task A — it shouldn't, since `StageName` no longer includes `'improve'`, but verify). + +- [ ] **Step 4: Delete the "Improve stage routing" describe block from `IPDetailPage.test.tsx`** + +The test added by PR-WV1-2 Task 2: + +```typescript +describe('Improve stage routing', () => { + it('renders ImproveStage when activeStage = improve', () => { ... }); +}); +``` + +Delete this describe block entirely. No replacement — the Improve stage no longer exists in IP detail. + +- [ ] **Step 5: Run the IPDetailPage suite to verify PASS** + +```bash +pnpm --filter @variscout/ui test -- IPDetailPage +``` + +Expected: all remaining tests pass. The Sponsor placeholder test, the canAccess routing test, the Charter team section test, the ACL guard tests — all should remain green. + +- [ ] **Step 6: Update the app-side callers if they pass `onActionAdd`/`Update`/`Remove`** + +```bash +grep -rn "onActionAdd\|onActionUpdate\|onActionRemove" apps/pwa/src apps/azure/src --include="*.ts" --include="*.tsx" +``` + +If any consumer (likely `ProjectsTabView.tsx` in both apps) passes these props to ``, remove the prop assignments. The callbacks themselves (if they reference any other code path) can stay for now — Task C reuses them for the Improve tab wiring. + +- [ ] **Step 7: Run app-side tests** + +```bash +pnpm --filter @variscout/pwa test -- ProjectsTabView +pnpm --filter @variscout/azure-app test -- ProjectsTabView +``` + +Expected: green. If a test asserted `` was called with specific props, update the test to drop those expectations. + +- [ ] **Step 8: Commit** + +```bash +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace add packages/ui/src/components/IPDetail/IPDetailPage.tsx packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx apps/pwa/src apps/azure/src +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace commit -m "refactor(ui): remove ImproveStage routing from IPDetailPage per amendment" +``` + +--- + +## Task C — Build `ImproveTabRoot` + `NoActiveProjectGuidance`, retire `ImproveStageAdvanced`, move files, wire apps + +**Goal:** Replace the existing top-level Improve tab body (`` content) with ``, which switches between `` (active IP) and `` (no active IP). Reuse `` as the Advanced-toggle target inside ``, retiring ``. Move the Improve components to `packages/ui/src/components/Improve/`. + +This is the largest task — split into 4 substantive sub-commits. + +### Sub-task C.1 — `NoActiveProjectGuidance` component + +**Files:** + +- Create: `packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx` +- Create: `packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx` + +- [ ] **Step 1: Write the failing test** + +```typescript +// packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { NoActiveProjectGuidance } from '../NoActiveProjectGuidance'; + +describe('NoActiveProjectGuidance', () => { + it('renders the "No active project" heading + body copy', () => { + render( {}} />); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /no active project/i })).toBeInTheDocument(); + expect(screen.getByText(/improvement work happens inside a chartered project/i)).toBeInTheDocument(); + }); + + it('renders a "Go to Home" button', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /go to home/i })).toBeInTheDocument(); + }); + + it('calls onGoHome when the button is clicked', () => { + const onGoHome = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /go to home/i })); + expect(onGoHome).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify FAIL** + +```bash +pnpm --filter @variscout/ui test -- NoActiveProjectGuidance +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the component** + +```tsx +// packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx +export interface NoActiveProjectGuidanceProps { + onGoHome: () => void; +} + +export function NoActiveProjectGuidance({ onGoHome }: NoActiveProjectGuidanceProps) { + return ( +
+

No active project

+

+ Improvement work happens inside a chartered project. Pick a project from Home, or create a + new one to start tracking actions and ideating with the PDCA workbench. +

+ +
+ ); +} +``` + +- [ ] **Step 4: Run to verify PASS** + +```bash +pnpm --filter @variscout/ui test -- NoActiveProjectGuidance +``` + +Expected: 3/3 pass. + +- [ ] **Step 5: Commit** + +```bash +git -C add packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx +git -C commit -m "feat(ui): add NoActiveProjectGuidance empty state for Improve tab" +``` + +### Sub-task C.2 — Move `ImproveStage` + retire `ImproveStageAdvanced`; re-point Advanced toggle to `` + +**Files:** + +- Move: `packages/ui/src/components/IPDetail/stages/ImproveStage.tsx` → `packages/ui/src/components/Improve/ImproveStage.tsx` +- Move: `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx` → `packages/ui/src/components/Improve/__tests__/ImproveStage.test.tsx` +- Delete: `packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx` +- Delete: `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx` + +- [ ] **Step 1: `git mv` ImproveStage and its test to the new location** + +```bash +cd /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace +git mv packages/ui/src/components/IPDetail/stages/ImproveStage.tsx packages/ui/src/components/Improve/ImproveStage.tsx +git mv packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx packages/ui/src/components/Improve/__tests__/ImproveStage.test.tsx +``` + +(`__tests__/` directory will be auto-created by `git mv` if it doesn't exist; if it doesn't, create it first with `mkdir`.) + +- [ ] **Step 2: Delete `ImproveStageAdvanced`** + +```bash +git rm packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx +git rm packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx +``` + +- [ ] **Step 3: Find the production `ImprovementWorkspaceBase` import path** + +```bash +grep -rn "ImprovementWorkspaceBase\b" packages/ui/src --include="*.tsx" --include="*.ts" | head -10 +``` + +Note the export path (likely `from '@variscout/ui'` or `from './ImprovementPlan/ImprovementWorkspaceBase'`). Use it in step 4. + +- [ ] **Step 4: Update `ImproveStage.tsx` (now at new path) to use `ImprovementWorkspaceBase` for the Advanced view** + +Replace the `import { ImproveStageAdvanced } from './ImproveStageAdvanced';` line with: + +```typescript +import { ImprovementWorkspaceBase } from ''; +``` + +Replace the `` JSX with ``. The actual prop shape of `ImprovementWorkspaceBase` may differ — read its component declaration to identify required props. Likely needs: an `ip` reference, members, action callbacks. If `ImprovementWorkspaceBase` has 10+ required props that aren't reachable from `ImproveStage`'s current prop set, accept them on `ImproveStageProps` as a passthrough bundle (`advancedProps?: ComponentProps`). + +- [ ] **Step 5: Update `ImproveStage.test.tsx` (at new path)** + +The test "switches to Advanced workbench when toggle clicked" currently asserts `screen.getByLabelText(/context/i)` (from the deleted `ImproveStageAdvanced` skeleton's region label). After the swap to `ImprovementWorkspaceBase`, the toggle target's observable identity changes. Update the assertion: + +```typescript +it('switches to Advanced workbench when toggle clicked', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /advanced/i })); + // ImprovementWorkspaceBase renders an identifiable region — adapt to its actual structure + expect(screen.getByTestId('improvement-workspace-base')).toBeInTheDocument(); +}); +``` + +The exact selector depends on what `ImprovementWorkspaceBase` renders. If it doesn't have a stable test ID, add one (`data-testid="improvement-workspace-base"`) at its root in a tiny separate edit, OR pick a unique always-visible string from its content. Note the choice in the commit message. + +- [ ] **Step 6: Update import path in `IPDetailPage.tsx`** (it was removed in Task B, but verify no orphan imports remain) + +```bash +grep -n "ImproveStage\b\|ImproveStageAdvanced\b" packages/ui/src/components/IPDetail/IPDetailPage.tsx +``` + +Expected: zero matches (Task B already removed the import). + +- [ ] **Step 7: Run tests to verify PASS** + +```bash +pnpm --filter @variscout/ui test -- "ImproveStage|NoActiveProjectGuidance" +``` + +Expected: all green. ImproveStage's 8 tests (incl. the updated Advanced toggle test) + the 3 NoActiveProjectGuidance tests = 11 passing. + +- [ ] **Step 8: Commit** + +```bash +git -C add packages/ui/src/components/Improve/ packages/ui/src/components/IPDetail/stages/ +git -C commit -m "refactor(ui): move ImproveStage to Improve/; retire ImproveStageAdvanced; reuse ImprovementWorkspaceBase as Advanced view" +``` + +### Sub-task C.3 — `ImproveTabRoot` orchestration component + +**Files:** + +- Create: `packages/ui/src/components/Improve/ImproveTabRoot.tsx` +- Create: `packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx` +- Create: `packages/ui/src/components/Improve/index.ts` (barrel) +- Modify: `packages/ui/src/index.ts` (export the new barrel) + +- [ ] **Step 1: Write the failing tests for both branches** + +```typescript +// packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ImproveTabRoot } from '../ImproveTabRoot'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import type { ProjectMember } from '@variscout/core/projectMembership'; +import type { ActionItem } from '@variscout/core/findings'; + +const ip: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { + title: 'Test IP', + members: [ + { + id: 'pm-1', + createdAt: 1, + deletedAt: null, + userId: 'lead@org', + displayName: 'Lead', + role: 'lead', + invitedAt: 1, + } satisfies ProjectMember, + ], + }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', baseline: 0.5, target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, +}; + +const actions: ActionItem[] = []; + +describe('ImproveTabRoot', () => { + it('renders NoActiveProjectGuidance when activeIP is null', () => { + render( + {}} + onActionAdd={() => {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /no active project/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /actions/i })).not.toBeInTheDocument(); + }); + + it('renders ImproveStage scoped to activeIP when set', () => { + render( + {}} + onActionAdd={() => {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /no active project/i })).not.toBeInTheDocument(); + }); + + it('passes onGoHome from NoActiveProjectGuidance through correctly', () => { + const onGoHome = vi.fn(); + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /go to home/i })); + expect(onGoHome).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run to verify FAIL** + +```bash +pnpm --filter @variscout/ui test -- ImproveTabRoot +``` + +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `ImproveTabRoot`** + +```tsx +// packages/ui/src/components/Improve/ImproveTabRoot.tsx +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import type { ActionItem } from '@variscout/core/findings'; +import { ImproveStage } from './ImproveStage'; +import { NoActiveProjectGuidance } from './NoActiveProjectGuidance'; + +export interface ImproveTabRootProps { + activeIP: ImprovementProject | null; + actions: ActionItem[]; + currentUserId?: string; + onGoHome: () => void; + onActionAdd: (action: Pick) => void; + onActionUpdate: ( + actionId: string, + patch: Partial> + ) => void; + onActionRemove: (actionId: string) => void; +} + +export function ImproveTabRoot({ + activeIP, + actions, + currentUserId, + onGoHome, + onActionAdd, + onActionUpdate, + onActionRemove, +}: ImproveTabRootProps) { + if (activeIP === null) { + return ; + } + const members = activeIP.metadata.members ?? []; + const scopedActions = actions.filter(a => a.parentImprovementProjectId === activeIP.id); + return ( + + ); +} +``` + +- [ ] **Step 4: Create the barrel** + +```typescript +// packages/ui/src/components/Improve/index.ts +export { ImproveTabRoot, type ImproveTabRootProps } from './ImproveTabRoot'; +export { ImproveStage, type ImproveStageProps } from './ImproveStage'; +export { + NoActiveProjectGuidance, + type NoActiveProjectGuidanceProps, +} from './NoActiveProjectGuidance'; +``` + +- [ ] **Step 5: Update `packages/ui/src/index.ts` to re-export the new barrel** + +Find an existing `export *` line near the project surfaces (e.g., `export * from './components/projects'` from PR-WV1-1 Task 6's app wiring) and add: + +```typescript +export * from './components/Improve'; +``` + +Place it alphabetically near other re-exports. + +- [ ] **Step 6: Run tests to verify PASS** + +```bash +pnpm --filter @variscout/ui test -- ImproveTabRoot +``` + +Expected: 3/3 pass. + +- [ ] **Step 7: Commit** + +```bash +git -C add packages/ui/src/components/Improve/ImproveTabRoot.tsx packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx packages/ui/src/components/Improve/index.ts packages/ui/src/index.ts +git -C commit -m "feat(ui): add ImproveTabRoot orchestration component" +``` + +### Sub-task C.4 — Wire `ImproveTabRoot` into PWA + Azure shells + +**Files:** + +- Modify: `apps/pwa/src/components/ImprovementView.tsx` (or wherever the top-level Improve panel body lives) +- Modify: `apps/azure/src/components/ImprovementView.tsx` (or equivalent) +- Modify: existing app-shell test files that exercise the Improve tab + +- [ ] **Step 1: Read the existing `ImprovementView` in both apps** + +```bash +find apps/pwa/src apps/azure/src -name "ImprovementView*" -type f +``` + +Read each to understand the current component shape, what it renders, and what props it accepts. The amendment replaces its body with `` but preserves the outer wrapper (which likely handles header, layout, etc.). + +- [ ] **Step 2: Discover the active-IP cascade pattern + ActionItem fetch pattern at the call site** + +```bash +grep -n "useActiveIPContext\|activeIP\|actionItems.*listByHub" apps/pwa/src/App.tsx apps/azure/src/pages/Editor.tsx | head -15 +``` + +Find: + +- How the existing app shell reads `activeIP` from `useActiveIPContext(sessionHub)`. +- How it fetches ActionItems for the active hub (likely `pwaHubRepository.actionItems.listByHub(activeHubId)` in PWA; similar in Azure). +- How it threads these into `` today (or if it doesn't yet). + +- [ ] **Step 3: Write the failing test for PWA** + +In the existing test file for `App.tsx` or `ImprovementView.test.tsx` (whichever exists), add: + +```typescript +it('renders NoActiveProjectGuidance when Improve tab is opened with no active IP', () => { + // Setup: render the PWA shell with no active IP (use the existing pattern for shell tests) + render(); + fireEvent.click(screen.getByRole('tab', { name: /improve/i })); + expect(screen.getByRole('heading', { name: /no active project/i })).toBeInTheDocument(); +}); + +it('renders ImproveStage when Improve tab is opened with an active IP', () => { + // Setup: pre-activate an IP via useActiveIPStore.setActiveIP({ hubId, userId }, ip.id) + render(); + fireEvent.click(screen.getByRole('tab', { name: /improve/i })); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); +}); +``` + +Adapt the test scaffolding to the actual PWA shell test pattern. If integration testing at the App-level is heavy, write a simpler test against `ImprovementView` directly, with stub `activeIP` props. + +- [ ] **Step 4: Run to verify FAIL** + +```bash +pnpm --filter @variscout/pwa test -- "ImprovementView|App" +``` + +Expected: FAIL on the two new assertions. + +- [ ] **Step 5: Update `apps/pwa/src/components/ImprovementView.tsx` to render ``** + +Pseudocode (adapt to actual shape): + +```tsx +import { ImproveTabRoot } from '@variscout/ui'; + +export function ImprovementView(props: ImprovementViewProps) { + const { activeIP } = useActiveIPContext(props.sessionHub); + const actions = useActionItems(props.sessionHub); // hook that lists actions for the active hub + const currentUserId = PWA_USER_ID; // from PR-WV1-1's app wiring + + return ( + props.onTabChange('home')} + onActionAdd={action => + actionItemDispatch({ + kind: 'ACTION_ITEM_ADD', + actionItem: { + ...action, + id: generateDeterministicId(), + createdAt: Date.now(), + deletedAt: null, + }, + }) + } + onActionUpdate={(id, patch) => { + /* PR-WV1-3 will wire — for now log a console warning */ + }} + onActionRemove={id => { + /* PR-WV1-3 will wire */ + }} + /> + ); +} +``` + +`onActionUpdate` and `onActionRemove` lack live dispatch (per PR-WV1-2 Task 2's scoping decision — `ACTION_ITEM_UPDATE`/`REMOVE` action kinds don't exist yet). Wire them as no-op-with-console-warning stubs for V1; PR-WV1-3 adds the action kinds. + +`useActionItems` may not exist; if not, write a small hook that wraps `pwaHubRepository.actionItems.listByHub(activeHubId)` using the same pattern `FrameView.tsx:150` uses. Keep it in `apps/pwa/src/features/improvement/` or similar. + +- [ ] **Step 6: Run PWA tests to verify PASS** + +```bash +pnpm --filter @variscout/pwa test -- "ImprovementView|App" +``` + +Expected: green. + +- [ ] **Step 7: Repeat Steps 3-6 for Azure** + +`apps/azure/src/components/ImprovementView.tsx` gets the same treatment, sourcing `currentUserId` from `currentUser?.email` (EasyAuth, per PR-WV1-1's app wiring) and using the Azure `azureHubRepository.actionItems.listByHub` fetch. + +- [ ] **Step 8: Commit** + +```bash +git -C add apps/pwa/src apps/azure/src +git -C commit -m "feat(apps): wire ImproveTabRoot into PWA + Azure Improve tab" +``` + +--- + +## Task D — Rename `workspace.projects` i18n key to `workspace.project` + +**Goal:** Singular noun. 32 locale files updated; the nav consumer reads the new key. + +**Files:** + +- Modify: `packages/core/src/i18n/messages/*.ts` (32 files) +- Modify: `apps/pwa/src/components/layout/AppHeader.tsx` (tab config) +- Modify: `apps/azure/src/components/layout/AppHeader.tsx` (or equivalent) +- Test: `packages/core/src/i18n/__tests__/index.test.ts` (already exists; will guard the transition) + +- [ ] **Step 1: Write the failing test asserting the new key exists in every locale** + +In `packages/core/src/i18n/__tests__/index.test.ts`, add: + +```typescript +describe('workspace.project key (amendment — replaces workspace.projects)', () => { + it('is defined in every locale', () => { + for (const [localeName, messages] of Object.entries(ALL_LOCALES)) { + expect(messages, `${localeName} missing workspace.project`).toHaveProperty( + 'workspace.project' + ); + } + }); + + it('workspace.projects is no longer present in any locale', () => { + for (const [localeName, messages] of Object.entries(ALL_LOCALES)) { + expect(messages, `${localeName} still has workspace.projects`).not.toHaveProperty( + 'workspace.projects' + ); + } + }); +}); +``` + +`ALL_LOCALES` should be the existing aggregate the test file uses for the wall.\* key-coverage test (read `index.test.ts:228` for the pattern). Mirror it. + +- [ ] **Step 2: Run to verify FAIL** + +```bash +pnpm --filter @variscout/core test -- i18n +``` + +Expected: FAIL — `workspace.project` not defined yet. + +- [ ] **Step 3: Inventory all 32 locale files + their current `workspace.projects` line** + +```bash +grep -n "'workspace\.projects'" packages/core/src/i18n/messages/*.ts | head -40 +``` + +Confirm 32 hits, one per file. Note each file's path. + +- [ ] **Step 4: Apply the rename across all 32 locales** + +For each locale file, replace the single line: + +```typescript +'workspace.projects': '', +``` + +with: + +```typescript +'workspace.project': '', +``` + +The translated value stays the same string verbatim (the singular form in each language). For English (`en.ts`), the value goes from `'Projects'` to `'Project'`. For other locales, judge case-by-case: + +- Finnish (`fi.ts`): `'Projektit'` → `'Projekti'` +- Swedish (`sv.ts`): `'Projekt'` (already singular in Swedish for both) — change key, keep value +- Japanese (`ja.ts`): `'プロジェクト'` (no plural in Japanese) — change key, keep value +- Simplified Chinese (`zhHans.ts`): `'项目'` (no plural in Chinese) — change key, keep value +- German (`de.ts`): `'Projekte'` → `'Projekt'` + +For each locale, the convention is: if the language distinguishes singular/plural, switch to singular. If not, keep the existing word. Use a single `sed -i` invocation per file if confident, OR open each file and edit manually for the 3-4 locales you're least confident in. + +Verify your edits for at least these 5 locales before committing: + +- `en.ts` +- `fi.ts` +- `de.ts` +- `ja.ts` +- `zhHans.ts` + +- [ ] **Step 5: Update the nav consumer in PWA** + +In `apps/pwa/src/components/layout/AppHeader.tsx:101-102`, change: + +```typescript +{ id: 'projects', labelKey: 'workspace.projects' } +``` + +to: + +```typescript +{ id: 'projects', labelKey: 'workspace.project' } +``` + +The tab `id` stays `'projects'` (internal identifier; not user-visible). Only the `labelKey` changes. + +- [ ] **Step 6: Update the Azure equivalent** + +```bash +grep -rn "labelKey: 'workspace.projects'" apps/azure/src --include="*.ts" --include="*.tsx" +``` + +Apply the same edit at every hit. + +- [ ] **Step 7: Run tests to verify PASS** + +```bash +pnpm --filter @variscout/core test -- i18n +pnpm --filter @variscout/pwa test -- AppHeader +pnpm --filter @variscout/azure-app test -- AppHeader +``` + +Expected: green on all three. + +- [ ] **Step 8: Commit** + +```bash +git -C add packages/core/src/i18n/ apps/pwa/src/components apps/azure/src +git -C commit -m "refactor(i18n): rename workspace.projects to workspace.project (singular)" +``` + +--- + +## Task E — Final verification + decision-log amendment + +**Goal:** Run pre-PR check suite. Browser walk. Decision-log entry. + +- [ ] **Step 1: Run targeted test sweep** + +```bash +cd /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace +pnpm --filter @variscout/core test 2>&1 | tail -5 +pnpm --filter @variscout/stores test 2>&1 | tail -5 +pnpm --filter @variscout/pwa test 2>&1 | tail -5 +pnpm --filter @variscout/azure-app test 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- IPDetail 2>&1 | tail -10 +pnpm --filter @variscout/ui test -- Improve 2>&1 | tail -10 +pnpm --filter @variscout/ui test -- projects 2>&1 | tail -10 +pnpm --filter @variscout/ui test -- Charter 2>&1 | tail -10 +pnpm --filter @variscout/ui test -- Sustainment 2>&1 | tail -10 +``` + +Expected: all green. The full `@variscout/ui` suite has a documented Canvas hang per prior memory entries — touched suites in isolation are the gate. + +- [ ] **Step 2: Run full build** + +```bash +pnpm build 2>&1 | tail -10 +``` + +Expected: 5/5 packages/apps green. + +- [ ] **Step 3: Run pr-ready-check** + +```bash +bash scripts/pr-ready-check.sh 2>&1 | tail -30 +``` + +Expected: green. + +- [ ] **Step 4: Browser walk via `claude --chrome`** + +Verify each scenario in the amendment spec §"Acceptance criteria": + +1. App loads → nav shows 7 tabs in order `[Home] [Project] [Process] [Analyze] [Investigation] [Improve] [Report]`. +2. Tab labels show "Project" (singular) in English. +3. Switch locale to Finnish → tab shows "Projekti" (singular). +4. Pick a project from Home (sets active IP). +5. Click Project tab → 3 stage tabs visible (Charter / Approach / Sustainment); no Improve stage. +6. Click Improve tab → simple action tracker renders for the active IP; "Add action" button visible. +7. Click "Advanced" toggle → `` renders (PDCA workbench with Brainstorm + IdeaCard + Prioritization + What-If). +8. Click "Simple view" → tracker returns. +9. Exit IP (clear active IP from Home or chip) → click Improve tab → "No active project" guidance renders with "Go to Home" button. +10. Click "Go to Home" → lands on Home tab. + +Capture any failures and decide: block PR or fold to follow-up. + +- [ ] **Step 5: Amend `docs/decision-log.md` under the existing 2026-05-16 wedge entry** + +Add a new "Amendment 2026-05-16 — PR-WV1-2 shipped (Improve restored as top-level tab + Project singular)" block. Cover: + +- 7-tab nav reinstated: `[Home] [Project] [Process] [Analyze] [Investigation] [Improve] [Report]`. Reverses decision-log §(1) "Improve tab removed as top-level". +- "Projects" → "Project" singular rename across the i18n surface. +- Project detail trimmed from 4 stages to 3 (`Charter / Approach / Sustainment`); the `'improve'` stage retires. +- Improve tab content: `` mounts `` (simple action tracker) with the Advanced toggle re-pointed to the production ``. `` skeleton from PR-WV1-2 Task 3 retires. +- `` and `` live in `packages/ui/src/components/Improve/`. +- Improve tab activates active-IP cascade pattern from PR-PT-7: empty state when no active IP, scoped tracker when active. +- Preserves decision-log §(3): cross-IP idea board / action conversion still retire. The Improve tab is single-IP-scoped, not free-roaming. +- Remaining PR-WV1-1 followups: (a) `INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds → still owed to PR-WV1-3. (c) Per-user persistence key on `useProjectMembershipStore` → still owed to PR-WV1-5. +- Canonical artifact: `docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md`. + +- [ ] **Step 6: Commit the decision-log amendment** + +```bash +git -C add docs/decision-log.md +git -C commit -m "docs(wedge): log PR-WV1-2 delivery + Improve-tab amendment" +``` + +- [ ] **Step 7: Push + open PR** + +```bash +git -C push -u origin feat/wedge-pr-wv1-2-improve-workspace +gh pr create --title "feat(wedge): PR-WV1-2 Improve workspace migration (amended)" \ + --body "$(cat <<'EOF' +## Summary + +Implements wedge V1 spec §3.2 + §3.3 with the 2026-05-16 amendment (Improve restored as top-level verb tab; Projects → Project singular). 4-stage IP detail collapses to 3 (`Charter / Approach / Sustainment`) with Handoff folded into Sustainment closure. Top-level Improve tab renders simple action tracker (default) + production `` PDCA workbench (Advanced toggle), scoped to active IP via PR-PT-7 cascade. Empty state guides to Home when no active IP. + +Also absorbs two of PR-WV1-1's deferred items: +- (b) `team[] → members[]` eager cutover at .vrs / Dexie hydration via `migrateImprovementProjectMetadata` +- (d) `canAccess` wired at consumer call sites (IPDetailPage + CharterOverview Invite gate) + +## Test plan + +- [x] pnpm test (per-package targeted; full @variscout/ui suite has documented Canvas hang) +- [x] pnpm build green +- [x] bash scripts/pr-ready-check.sh +- [ ] `--chrome` browser walk per amendment spec §Acceptance criteria (10 scenarios) + +## Spec amendment + +`docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md` + +## Master plan + +`docs/superpowers/plans/2026-05-16-wedge-implementation.md` PR-WV1-2 row. + +## Sub-plans + +`docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md` (original) +`docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md` (amendment) +EOF +)" +``` + +GitHub will show the PR's base as `feat/wedge-pr-wv1-1-project-membership` until PR-WV1-1 (#183) merges; then auto-updates to main. + +--- + +## Verification + +End-to-end: + +1. **Schema:** `StageName` is 3 values; legacy `'handoff'` removed earlier; `'improve'` removed by this amendment. `migrateImprovementProjectMetadata` still folds team[] → members[] at hydration. +2. **Stage flow:** Project detail renders 3 stage tabs. Sustainment closure absorbs Handoff close-action. No `'improve'` stage anywhere. +3. **ACL:** `canAccess` is the single ACL entry point. `` gates Add/Mark-done/Remove via `canAccess('edit-improve')`. +4. **Nav:** 7 tabs in order. `workspace.project` key live; `workspace.projects` gone. +5. **Improve tab:** `` switches between `` (active IP) and `` (no active IP). Advanced toggle reaches ``. +6. **Apps:** PWA + Azure shells render `` inside `` body. + +--- + +## Self-review checklist + +- [ ] **Spec coverage**: each numbered item in `2026-05-16-improve-tab-amendment-design.md` §"Acceptance criteria" maps to a task above. +- [ ] **Placeholder scan**: no TBD / TODO. Every code block has actual code. +- [ ] **Type consistency**: `StageName`, `IPDetailPageProps`, `ImproveTabRootProps`, `ImproveStageProps`, `NoActiveProjectGuidanceProps` used consistently. +- [ ] **No `Math.random`** in code or tests. +- [ ] **No "root cause"** language anywhere (P5 amended). +- [ ] **No `.toFixed()`** on stat values. +- [ ] **TDD compliance**: every code-touching task has 5-step rhythm (test → fail → impl → pass → commit). +- [ ] **Sub-path exports paired**: no new sub-path needed — `Improve/` flows through existing `packages/ui` barrel. +- [ ] **Patch types**: `ActionItem` UPDATE patch uses `Omit` per `feedback_action_patch_omit_lifecycle`. +- [ ] **`canAccess` is the single ACL entry point** after this amendment. +- [ ] **No inline role-string comparisons** in IPDetailPage / CharterOverview / ImproveStage / ImproveTabRoot. + +--- + +## Deferred to later PRs (still applies, unchanged from PR-WV1-2 original plan) + +- **PR-WV1-1 deferred item (a) — `INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds** — still owed to PR-WV1-3 (Investigation Wall + Inbox simplification). +- **PR-WV1-1 deferred item (c) — Per-user persistence key on `useProjectMembershipStore`** — still owed to PR-WV1-5 (tier-gating retirement + nav reorder). +- **ADR-080 auto-fire trigger** — still out of scope. Data model preserved; trigger pending. + +--- + +## Execution handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-16-pr-wv1-2-amendment-improve-tab-restore.md`. Per `feedback_subagent_driven_default`, dispatching `superpowers:subagent-driven-development` next without asking — fresh implementer subagent per task (A through E), Sonnet workhorses, spec + quality reviewer pair per task, final architecture review (system-architect Opus) + code-review skill (Opus) at end. diff --git a/docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md b/docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md new file mode 100644 index 000000000..a90143e72 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md @@ -0,0 +1,1387 @@ +--- +title: 'PR-WV1-2 — Improve Workspace Migration (bite-sized plan)' +status: draft +last-reviewed: 2026-05-16 +related: + - docs/superpowers/specs/2026-05-16-wedge-architecture-design.md + - docs/superpowers/plans/2026-05-16-wedge-implementation.md + - docs/superpowers/plans/2026-05-16-pr-wv1-1-project-membership.md + - docs/07-decisions/adr-082-wedge-architecture.md + - docs/07-decisions/adr-080-sustainment-auto-fire-pattern.md +--- + +# PR-WV1-2 — Improve Workspace Migration 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:** Migrate the legacy top-level Improve workspace into the Projects detail page as a 4-stage flow (`Charter / Approach / Improve / Sustainment` — Handoff folded into Sustainment closure). Introduce a simple `ActionItem`-backed tracker as the default Improve stage UI; expose the existing PDCA primitives behind an Advanced toggle. Cut the top-level Improve tab from the app shell, and absorb the two decision-log followups that are tightly coupled to this slice: (b) eager `team[] → members[]` migration and (d) `canAccess` wiring at consumer call sites. + +**Architecture:** Stage-list refactor in `@variscout/ui`'s `IPDetailStageTabs` (the canonical 4-stage source), a new `ImproveStage` + `ImproveStageAdvanced` pair under `packages/ui/src/components/IPDetail/stages/`, fold of `HandoffOverview`/`HandoffSections` into `SustainmentOverview`/`SustainmentSections`, and route-handler/i18n cleanup in the PWA + Azure shells. Stage rename ships with a `migrateImprovementProjectMetadata` helper in `@variscout/core/improvementProject` that performs the legacy → wedge migration once at .vrs / Dexie hydration time, calling the already-shipped `migrateTeamToMembers` from PR-WV1-1. + +**Tech Stack:** TypeScript + React 18 + Zustand + Dexie + Vitest + React Testing Library. + +**Parent plan:** [`docs/superpowers/plans/2026-05-16-wedge-implementation.md`](2026-05-16-wedge-implementation.md) (PR-WV1-2 row). + +**Canonical spec:** [`docs/superpowers/specs/2026-05-16-wedge-architecture-design.md`](../specs/2026-05-16-wedge-architecture-design.md) §3.2 (4-stage IP detail) + §3.3 (Improve = simple tracker by default). + +**PR-WV1-1 deferred items absorbed here:** (b) `team[]` eager cutover (Task 1) + (d) `canAccess` wiring (Task 0). Items (a) invitation lifecycle and (c) per-user persistence key are explicitly **NOT** absorbed — see "Deferred to later PRs" section at the bottom. + +--- + +## PR sizing — single PR off this branch + +Per `feedback_slice_size_cap` ("Cap slices at ~6–8 tasks/PR; multi-PR off one branch when larger"), this plan is 8 tasks. The decision-log fold-ins (canAccess wiring, team[] cutover) compose cleanly with the master-plan content because: + +- Task 0 (canAccess wiring) MUST land before Task 2 builds ImproveStage — the new stage gates its edit affordances via `canAccess('edit-improve')`, so converging the ACL truth table FIRST keeps Task 2 from inventing a parallel inline check. +- Task 1's stage rename already touches the `.vrs`/Dexie migration path; folding `team[] → members[]` here is one extra function call, not a new task. + +Recommendation: **single PR**. If reviewers ask for a split, the natural seam is at the end of Task 1 (canAccess + stage rename + migrations all coherent foundation work; remaining tasks are pure Improve-UI build-out). + +--- + +## Branch setup + +Branch already exists: `feat/wedge-pr-wv1-2-improve-workspace`, based on PR-WV1-1's HEAD (`7f7d21ea`). Worktree at `.worktrees/feat/wedge-pr-wv1-2-improve-workspace`. `pnpm install` already completed. + +Verify: + +```bash +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace branch --show-current +# expect: feat/wedge-pr-wv1-2-improve-workspace +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace log --oneline -5 +# expect top three commits to be PR-WV1-1 work (7f7d21ea, fce353ff, 695091e3) +``` + +--- + +## Foundation already in place (from PR-WV1-1) + +- `@variscout/core/projectMembership`: `ProjectRole` (`'lead' | 'member' | 'sponsor'`), `ProjectMember`, `Invitation`, `MembershipAction`, `canAccess(userId, members, action)`, `ProjectAction` (`'edit-charter' | 'edit-approach' | 'edit-improve' | 'edit-sustainment' | 'manage-membership' | 'view-report'`). +- `@variscout/core/improvementProject/migration.ts`: `migrateTeamToMembers(legacyTeam, migrationTimestamp): ProjectMember[]` — exists but currently invoked nowhere. +- `ImprovementProjectMetadata` has BOTH `team?` (legacy) and `members?: ProjectMember[]` (wedge) — coexisting during the migration window. +- `IPDetailPage` renders the Sponsor placeholder (`data-testid="sponsor-report-panel"`) and `NoAccessRedirect` for non-members when `currentUserId` + populated `members[]` are both present; inline role lookup is used today (will be replaced by Task 0). + +--- + +## Surfaces touched (from Explore scout 2026-05-16) + +| Concern | Canonical file | +| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Stage type + `STAGE_ORDER` | `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx` | +| Stage routing in IP detail | `packages/ui/src/components/IPDetail/IPDetailPage.tsx` | +| Sustainment + Handoff stage views | `packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx`, `SustainmentSections.tsx`, `HandoffOverview.tsx`, `HandoffSections.tsx` | +| Existing PDCA primitives | `packages/ui/src/components/ImprovementPlan/{PrioritizationMatrix,BrainstormModal,IdeaGroupCard,ImprovementContextPanel}.tsx`, `packages/ui/src/components/WhatIfExplorer/WhatIfExplorer.tsx` | +| Top-level Improve tab handler | `apps/pwa/src/App.tsx:398` (`else if (tab === 'improve') panels.showImprovement()`), `apps/azure/src/pages/Editor.tsx:561` (`ps.showImprovement()`) | +| Tab i18n keys | `packages/core/src/i18n/messages/*.ts` (key `workspace.improve`) | +| ActionItem entity (drives simple tracker) | `packages/core/src/findings/types.ts:161` | +| ImprovementProject types | `packages/core/src/improvementProject/types.ts` | + +--- + +## Task 0 — Wire `canAccess` at consumer call sites + +**Goal:** Replace the inline `members.find(m => m.userId === currentUserId)?.role` lookup in `IPDetailPage` and the `onInvite`-prop-presence gating in `CharterOverview` with calls to `canAccess` from `@variscout/core/projectMembership`. This converges the ACL truth table on one entry point BEFORE Task 2 adds new consumers. + +**Why first:** Per PR-WV1-1's final Opus review: "if PR-WV1-2 adds a 4th role or refines `ProjectAction` semantics, drift between `canAccess` and `IPDetailPage` is silent." + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/IPDetailPage.tsx` +- Modify: `packages/ui/src/components/IPDetail/stages/CharterOverview.tsx` +- Modify: `packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx` +- Modify: `packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx` + +### Steps + +- [ ] **Step 1: Read both files end-to-end first.** Verify the current inline-lookup shape and the `onInvite`-prop gate location. Note the line numbers; don't write any code yet. + +- [ ] **Step 2: Write a failing test asserting `IPDetailPage` uses `canAccess` for the Sponsor placeholder branch** + +Add to `packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx` (inside the existing `describe('ACL guard', ...)` block): + +```typescript +it('uses canAccess view-report for Sponsor placeholder gating', () => { + // Sponsor's only allowed action is 'view-report'; the placeholder must render + // and stage tabs must be hidden — same observable behavior as before, but + // routed through canAccess. + render( + {}} + currentUserId="sponsor@org" + /> + ); + expect(screen.getByTestId('sponsor-report-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('stage-tab-charter')).not.toBeInTheDocument(); +}); +``` + +- [ ] **Step 3: Run to verify the test passes today (it tests current observable behavior)** + +```bash +pnpm --filter @variscout/ui test -- IPDetailPage +``` + +Expected: PASS. This test pins the observable contract; the refactor below must preserve it. + +- [ ] **Step 4: Refactor `IPDetailPage.tsx` to use `canAccess`** + +Replace the inline lookups (around the `isExplicitlyExcluded` / `isSponsor` derivations) with: + +```tsx +import { canAccess } from '@variscout/core/projectMembership'; + +// ... inside the component, after `const members = ip.metadata.members ?? [];` +const hasIdentity = currentUserId !== undefined && members.length > 0; +const isExplicitlyExcluded = hasIdentity && !canAccess(currentUserId, members, 'view-report'); +const isSponsor = + hasIdentity && + canAccess(currentUserId, members, 'view-report') && + !canAccess(currentUserId, members, 'edit-charter'); +``` + +This preserves the existing two-branch logic but routes every role decision through `canAccess`. Delete any leftover `userRole` const if it's only used by these branches; if it's used elsewhere, leave it. + +- [ ] **Step 5: Run the full IPDetailPage suite to verify all 6 ACL-guard tests + the new one pass** + +```bash +pnpm --filter @variscout/ui test -- IPDetailPage +``` + +Expected: all green (existing 6 ACL tests + Sponsor placeholder test from step 2). + +- [ ] **Step 6: Write a failing test asserting CharterOverview gates Invite on `canAccess('manage-membership')`** + +Add to `packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx` (inside the existing `describe('Team section', ...)` block): + +```typescript +it('hides the Invite button for non-Lead viewers even when onInvite is provided', () => { + // Member-role current user; should NOT see Invite even though onInvite handler is wired + render( + {}} + onMemberRemove={() => {}} + /> + ); + expect(screen.queryByRole('button', { name: /invite team/i })).not.toBeInTheDocument(); +}); +``` + +(Use the existing test's members fixture — it should include a Member-role entry with `userId: 'member@org'`. If not, extend the fixture in the same step.) + +- [ ] **Step 7: Run the test to verify it fails today** (Member currently sees the Invite button because gating uses `onInvite` prop presence, not role) + +```bash +pnpm --filter @variscout/ui test -- CharterOverview +``` + +Expected: FAIL on the new test; the existing 6 tests still pass. + +- [ ] **Step 8: Refactor `CharterOverview.tsx` to gate the Invite button via `canAccess`** + +Inside the component (where the Team section is rendered), replace the `{onInvite ? ... : null}` gating with: + +```tsx +import { canAccess } from '@variscout/core/projectMembership'; + +// inside the component body, after destructuring props: +const canManageMembership = + currentUserId !== undefined && canAccess(currentUserId, members, 'manage-membership'); + +// in JSX, render the Team section only when membership data exists, +// and render the Invite button only when canManageMembership is true: +{ + members !== undefined && ( +
+ ... + {canManageMembership && onInvite && ( + + )} + {})} + /> + ... +
+ ); +} +``` + +If `members` is typed as optional, narrow it explicitly. Keep `onInvite` as a continued requirement for actually opening the modal — `canManageMembership` is a strictly tighter gate. + +- [ ] **Step 9: Run the test to verify it now passes** + +```bash +pnpm --filter @variscout/ui test -- CharterOverview +``` + +Expected: all 7 Team-section tests pass (6 existing + new Member-hidden Invite test). + +- [ ] **Step 10: Commit** + +```bash +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace add packages/ui/src/components/IPDetail/IPDetailPage.tsx packages/ui/src/components/IPDetail/stages/CharterOverview.tsx packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx +git -C /Users/jukka-mattiturtiainen/Projects/VariScout_lite/.worktrees/feat/wedge-pr-wv1-2-improve-workspace commit -m "refactor(ui): route IPDetailPage + Charter ACL through canAccess" +``` + +--- + +## Task 1 — Stage type rename + .vrs migration + team[] → members[] eager cutover + +**Goal:** Rename the IP stage list from `['charter', 'approach', 'sustainment', 'handoff']` to `['charter', 'approach', 'improve', 'sustainment']`. Add a single migration helper (`migrateImprovementProjectMetadata`) that runs at `.vrs` load / Dexie rehydration and folds (a) the legacy stage rename + (b) the `team[]` → `members[]` cutover from PR-WV1-1's deferred item (b). + +**Why combined:** the `.vrs` / Dexie migration codepath is a single seam. Adding two separate migration passes here would be ceremony; folding them into one helper keeps the migration window short and the migration logic discoverable. + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx` (StageName type, STAGE_ORDER, LABEL map) +- Modify: `packages/ui/src/components/IPDetail/IPDetailPage.tsx` (stage routing — if any inline references to `'handoff'` exist) +- Modify: `packages/ui/src/components/IPDetail/index.ts` (barrel — verify the new `StageName` shape still flows through) +- Create: `packages/core/src/improvementProject/migrateMetadata.ts` — `migrateImprovementProjectMetadata(ip, now)` helper +- Create: `packages/core/src/improvementProject/__tests__/migrateMetadata.test.ts` +- Modify: `packages/core/src/improvementProject/index.ts` — export `migrateImprovementProjectMetadata` +- Modify: `apps/azure/src/services/localDb.ts` AND/OR `apps/azure/src/db/schema.ts` (find via grep — the .vrs / Dexie hydration call site) +- Modify: `apps/pwa/src/services/` equivalent (find via grep — PWA hydration is likely in-memory only via `useProjectStore`) +- Modify: `packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx` (if it pins `'handoff'`) + +### Steps + +- [ ] **Step 1: Read `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx`** to confirm the exact shape of `StageName`, `STAGE_ORDER`, and the `LABEL` map. Also read `packages/ui/src/components/IPDetail/IPDetailPage.tsx` to identify every `'handoff'` / `'sustainment'` reference (active-stage default, persistence of last-viewed stage, etc.). + +- [ ] **Step 2: Find the .vrs / Dexie hydration call site** + +```bash +grep -rn "improvementProjects\|ImprovementProject\b" apps/azure/src/services apps/azure/src/db apps/pwa/src/services 2>/dev/null | grep -v test | head -20 +``` + +Identify the function(s) that load `ImprovementProject` records from storage (Dexie `.toArray()` / `.get()` reads in Azure; localStorage/Zustand hydration in PWA). The migration call should hook here — every load goes through `migrateImprovementProjectMetadata`. + +If unclear after 5 minutes, STOP and report. Don't guess at the call site. + +- [ ] **Step 3: Write the failing migration helper test** + +```typescript +// packages/core/src/improvementProject/__tests__/migrateMetadata.test.ts +import { describe, it, expect } from 'vitest'; +import { migrateImprovementProjectMetadata } from '../migrateMetadata'; +import type { ImprovementProject } from '../types'; + +describe('migrateImprovementProjectMetadata', () => { + const baseIP: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { title: 'Test IP' }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', baseline: 0.5, target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, + }; + + it('migrates legacy team[] to members[] when members is absent', () => { + const legacy: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [ + { role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }, + { role: 'teamMember', person: { displayName: 'Mira' } }, + ], + }, + }; + const out = migrateImprovementProjectMetadata(legacy, 1234); + expect(out.metadata.members).toBeDefined(); + expect(out.metadata.members).toHaveLength(2); + expect(out.metadata.members?.[0].role).toBe('lead'); + expect(out.metadata.members?.[1].role).toBe('member'); + // Legacy team[] preserved for backward compat readers in this PR + expect(out.metadata.team).toBeDefined(); + }); + + it('does not migrate when members[] already populated', () => { + const alreadyMigrated: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [{ role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }], + members: [ + { + id: 'pm-existing', + createdAt: 100, + deletedAt: null, + userId: 'someone-else@org', + displayName: 'Someone Else', + role: 'lead', + invitedAt: 100, + }, + ], + }, + }; + const out = migrateImprovementProjectMetadata(alreadyMigrated, 1234); + expect(out.metadata.members).toHaveLength(1); + expect(out.metadata.members?.[0].userId).toBe('someone-else@org'); + }); + + it('is a no-op when neither team nor members exist', () => { + const out = migrateImprovementProjectMetadata(baseIP, 1234); + expect(out.metadata.members).toBeUndefined(); + expect(out).toEqual(baseIP); + }); + + it('returns a new object (does not mutate input)', () => { + const legacy: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [{ role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }], + }, + }; + const out = migrateImprovementProjectMetadata(legacy, 1234); + expect(out).not.toBe(legacy); + expect(legacy.metadata.members).toBeUndefined(); + }); +}); +``` + +- [ ] **Step 4: Run to verify it fails** (module not found) + +```bash +pnpm --filter @variscout/core test -- improvementProject/__tests__/migrateMetadata +``` + +Expected: FAIL. + +- [ ] **Step 5: Implement `migrateImprovementProjectMetadata`** + +```typescript +// packages/core/src/improvementProject/migrateMetadata.ts +import type { ImprovementProject } from './types'; +import { migrateTeamToMembers } from './migration'; + +/** + * Idempotent migration applied at hydration time. Folds two PR-WV1-2 changes + * over the wedge V1 metadata shape: + * 1. legacy `team[]` → wedge `members[]` (via migrateTeamToMembers) + * + * Legacy `team[]` is preserved on the output for now; PR-WV1-5 (tier-gating + * retirement + nav reorder) drops it. + */ +export function migrateImprovementProjectMetadata( + ip: ImprovementProject, + now: number +): ImprovementProject { + const hasMembers = ip.metadata.members !== undefined; + const hasLegacyTeam = ip.metadata.team !== undefined && ip.metadata.team.length > 0; + + if (hasMembers || !hasLegacyTeam) { + return ip; + } + + const members = migrateTeamToMembers(ip.metadata.team, now); + + return { + ...ip, + metadata: { + ...ip.metadata, + members, + }, + }; +} +``` + +- [ ] **Step 6: Export from `improvementProject` barrel** + +In `packages/core/src/improvementProject/index.ts`, add: + +```typescript +export { migrateImprovementProjectMetadata } from './migrateMetadata'; +``` + +- [ ] **Step 7: Run tests to verify they pass** + +```bash +pnpm --filter @variscout/core test -- improvementProject +``` + +Expected: all four migration tests pass, plus all existing core/improvementProject tests. + +- [ ] **Step 8: Rename `StageName` + `STAGE_ORDER` in `IPDetailStageTabs.tsx`** + +Edit `packages/ui/src/components/IPDetail/IPDetailStageTabs.tsx`: + +```typescript +export type StageName = 'charter' | 'approach' | 'improve' | 'sustainment'; + +const STAGE_ORDER: StageName[] = ['charter', 'approach', 'improve', 'sustainment']; +``` + +Update the `LABEL` map: drop `'handoff'`, add `'improve'`. Use the existing `workspace.improve` i18n key — that key already exists in all locales (per Explore scout finding §3). + +- [ ] **Step 9: Update `IPDetailPage.tsx` to remove any hard-coded `'handoff'` references** + +Search the file: any `activeStage === 'handoff'` or `'handoff' as StageName` references should map to `'sustainment'` (since Handoff is folded into Sustainment closure in Task 5). + +Also update the Sponsor placeholder's stage-tab-absent assertion path if it referenced 4 named stages. + +- [ ] **Step 10: Fix existing tests that assert the legacy 4-stage shape** + +```bash +grep -rln "'handoff'\b" packages/ui/src/components/IPDetail/ apps/ --include="*.tsx" --include="*.ts" | head +``` + +Update each hit: + +- Test fixtures using `status: 'handoff'` should still be valid IP shapes (per `ImprovementProject.status: 'draft' | 'active' | 'closed'` — `'handoff'` is NOT a project status, it's a stage; confirm by checking `improvementProject/types.ts`). +- Any test assertion like `expect(screen.getByTestId('stage-tab-handoff')).toBeInTheDocument()` should become `'stage-tab-improve'`. + +- [ ] **Step 11: Wire the migration helper into Azure Dexie hydration** + +From Step 2's discovery, identify the Dexie `.toArray()` call that loads ImprovementProject records on app startup or hub-open. Wrap each loaded IP through `migrateImprovementProjectMetadata(ip, Date.now())` before handing to the store / UI. + +If the load happens in multiple places, prefer to add the migration at the lowest call site (closest to the Dexie read) — single seam. + +- [ ] **Step 12: Wire the migration helper into PWA hydration** + +PWA likely hydrates from `localStorage` + `useProjectStore`. The migration call should happen at the same boundary as the Azure one — wherever the raw stored shape becomes a typed `ImprovementProject` in the store. + +If both apps share a hydration helper (e.g., in `@variscout/stores` or a `useEditorDataFlow` hook), wire it once at that helper level. + +- [ ] **Step 13: Run the full UI + apps test suites for touched files** + +```bash +pnpm --filter @variscout/ui test -- IPDetail 2>&1 | tail -10 +pnpm --filter @variscout/azure-app test -- ProjectsTabView 2>&1 | tail -10 +pnpm --filter @variscout/pwa test -- ProjectsTabView 2>&1 | tail -10 +``` + +Expected: all green. + +- [ ] **Step 14: Commit** + +```bash +git -C add packages/core/src/improvementProject/ packages/ui/src/components/IPDetail/ apps/azure/src/ apps/pwa/src/ +git -C commit -m "feat(core): rename Handoff stage to Improve; eager-migrate legacy team[]" +``` + +If the diff is large and touches both core + UI + apps, split into two commits: `feat(core): ...` for the migration helper + stage type, and `refactor(ui,apps): wire stage rename + metadata migration` for everything else. Use judgment. + +--- + +## Task 2 — Build `ImproveStage` (simple ActionItem tracker) + +**Goal:** Render a focused list of `ActionItem` entities scoped to the current IP. Show title (`text`), owner (`assignedTo?.displayName`), due date (`dueAt`), status (`status`), and the linked suspected cause name (looked up via `parentImprovementIdeaId → ImprovementIdea`, falling back to "Unattributed"). Gate edit affordances via `canAccess(currentUserId, members, 'edit-improve')`. + +**Why an existing entity:** `ActionItem` already carries every field the simple tracker needs (`packages/core/src/findings/types.ts:161`). No new types — PR-WV1-2 is a UI/integration slice, not a data-modeling slice. + +**Files:** + +- Create: `packages/ui/src/components/IPDetail/stages/ImproveStage.tsx` +- Create: `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx` +- Modify: `packages/ui/src/components/IPDetail/IPDetailPage.tsx` (route `'improve'` stage to ``) + +### Steps + +- [ ] **Step 1: Discover the ActionItem data source** + +```bash +grep -rn "actionItems\b\|ActionItem\[\]\|HubAction.*ACTION_ITEM\|actionItemActions" packages/core/src packages/stores/src apps --include="*.ts" --include="*.tsx" | head -15 +``` + +Identify where `actionItems[]` lives in app state (likely on `ProcessHub.actionItems` per `findings/types.ts:161` `EntityBase` peers). Identify any reducer / dispatch helper that adds / updates / removes them. + +If ActionItems are NOT yet wired through a store / dispatch surface today, this task gets larger — STOP and report scope concern. (Master plan assumed they exist; verify.) + +- [ ] **Step 2: Write the failing component test** + +```typescript +// packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ImproveStage } from '../ImproveStage'; +import type { ActionItem } from '@variscout/core/findings'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +const leadMembers: ProjectMember[] = [ + { + id: 'pm-1', + createdAt: 1, + deletedAt: null, + userId: 'lead@org', + displayName: 'Lead', + role: 'lead', + invitedAt: 1, + }, +]; + +const actions: ActionItem[] = [ + { + id: 'ai-1', + createdAt: 1, + deletedAt: null, + text: 'Run a pilot on Line 3', + assignedTo: { displayName: 'Mira', upn: 'mira@org' }, + dueAt: '2026-06-01', + status: 'open', + parentImprovementProjectId: 'ip-1', + }, + { + id: 'ai-2', + createdAt: 2, + deletedAt: null, + text: 'Document the new SOP', + status: 'done', + parentImprovementProjectId: 'ip-1', + }, +]; + +describe('ImproveStage', () => { + it('renders the scoped ActionItem list', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText('Run a pilot on Line 3')).toBeInTheDocument(); + expect(screen.getByText('Document the new SOP')).toBeInTheDocument(); + }); + + it('shows owner display name when present', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText('Mira')).toBeInTheDocument(); + }); + + it('renders an Add Action affordance for users with edit-improve', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('button', { name: /add action/i })).toBeInTheDocument(); + }); + + it('hides Add Action for users without edit-improve (Sponsor)', () => { + const mixedMembers: ProjectMember[] = [ + ...leadMembers, + { + id: 'pm-2', + createdAt: 1, + deletedAt: null, + userId: 'sponsor@org', + displayName: 'Sponsor', + role: 'sponsor', + invitedAt: 1, + }, + ]; + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.queryByRole('button', { name: /add action/i })).not.toBeInTheDocument(); + }); + + it('calls onActionAdd with a typed payload when Add is submitted', () => { + const onActionAdd = vi.fn(); + render( + {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /add action/i })); + fireEvent.change(screen.getByLabelText(/title/i), { target: { value: 'New action' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onActionAdd).toHaveBeenCalledWith( + expect.objectContaining({ text: 'New action', parentImprovementProjectId: 'ip-1' }) + ); + }); + + it('renders an empty state when there are no actions', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText(/no actions yet/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run to verify the test fails** + +```bash +pnpm --filter @variscout/ui test -- ImproveStage +``` + +Expected: FAIL (module not found). + +- [ ] **Step 4: Implement `ImproveStage.tsx`** + +```tsx +// packages/ui/src/components/IPDetail/stages/ImproveStage.tsx +import { useState } from 'react'; +import type { ActionItem } from '@variscout/core/findings'; +import { canAccess, type ProjectMember } from '@variscout/core/projectMembership'; + +export interface ImproveStageProps { + projectId: string; + actions: ActionItem[]; + members: ProjectMember[]; + currentUserId?: string; + onActionAdd: (action: Pick) => void; + onActionUpdate: ( + actionId: string, + patch: Partial> + ) => void; + onActionRemove: (actionId: string) => void; +} + +export function ImproveStage({ + projectId, + actions, + members, + currentUserId, + onActionAdd, + onActionUpdate, + onActionRemove, +}: ImproveStageProps) { + const canEdit = currentUserId !== undefined && canAccess(currentUserId, members, 'edit-improve'); + const [addOpen, setAddOpen] = useState(false); + const [newTitle, setNewTitle] = useState(''); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + if (!trimmed) return; + onActionAdd({ text: trimmed, parentImprovementProjectId: projectId }); + setNewTitle(''); + setAddOpen(false); + }; + + return ( +
+
+

Actions

+ {canEdit && ( + + )} +
+ + {addOpen && canEdit && ( +
+ +
+ + +
+
+ )} + + {actions.length === 0 ? ( +

No actions yet.

+ ) : ( +
    + {actions.map(a => ( +
  • +
    + {a.text} + {a.status ?? 'open'} +
    +
    + {a.assignedTo?.displayName && {a.assignedTo.displayName}} + {a.dueAt && Due {a.dueAt}} +
    + {canEdit && ( +
    + + +
    + )} +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +pnpm --filter @variscout/ui test -- ImproveStage +``` + +Expected: 6/6 pass. + +- [ ] **Step 6: Route `'improve'` stage in `IPDetailPage.tsx` to render ``** + +Locate the stage-routing switch in `IPDetailPage.tsx` (the place that picks between CharterOverview / ApproachOverview / SustainmentOverview / HandoffOverview today). Add an `'improve'` case rendering `` with the right props plumbed: + +- `projectId={ip.id}` +- `actions={...}` — filtered from app state. Source: from Task 2 step 1's discovery. If actions live on `ProcessHub.actionItems`, get them via the existing hook / prop the page already uses. +- `members={ip.metadata.members ?? []}` +- `currentUserId={currentUserId}` +- `onActionAdd` / `onActionUpdate` / `onActionRemove` — callback props on `IPDetailPage` mirroring the `onMembersChange` pattern from PR-WV1-1. Add the three optional props to `IPDetailPageProps`. + +The PWA + Azure call sites in `apps/{pwa,azure}/src/components/ProjectsTabView.tsx` get the three new callbacks wired in Task 6. + +- [ ] **Step 7: Add an integration test in IPDetailPage.test.tsx for the Improve stage routing** + +Inside the existing test file, add: + +```typescript +describe('Improve stage routing', () => { + it('renders ImproveStage when activeStage = improve', () => { + // Configure default active stage to 'improve' (likely via a status that puts the IP in that stage, + // or by passing an explicit activeStage prop if IPDetailPage supports one — confirm via existing tests). + render( + {}} + currentUserId="anybody@org" + /> + ); + // Click the Improve stage tab + fireEvent.click(screen.getByTestId('stage-tab-improve')); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 8: Run all IPDetail tests** + +```bash +pnpm --filter @variscout/ui test -- IPDetail +``` + +Expected: green. + +- [ ] **Step 9: Commit** + +```bash +git -C add packages/ui/src/components/IPDetail/stages/ImproveStage.tsx packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx packages/ui/src/components/IPDetail/IPDetailPage.tsx packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx +git -C commit -m "feat(ui): add ImproveStage with canAccess-gated ActionItem tracker" +``` + +--- + +## Task 3 — Build `ImproveStageAdvanced` (PDCA primitives) + +**Goal:** Mount the four existing PDCA primitives (`PrioritizationMatrix`, `BrainstormModal`, `IdeaGroupCard`, `ImprovementContextPanel`) + `WhatIfExplorer` inside a single `ImproveStageAdvanced` wrapper. This wrapper renders when the user toggles the Improve stage to Advanced mode (Task 4 wires the toggle). + +**Why minimal:** the primitives are already modular (Explore scout §4). This task is composition, not new functionality. The wrapper just lays out the existing components in a sensible Advanced workspace shape — left rail = ContextPanel, main = BrainstormModal + IdeaGroupCard list, right rail = PrioritizationMatrix + WhatIfExplorer. + +**Files:** + +- Create: `packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx` +- Create: `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx` + +### Steps + +- [ ] **Step 1: Read each PDCA primitive's prop shape** + +```bash +grep -A 10 "interface PrioritizationMatrixProps\|interface BrainstormModalProps\|interface IdeaGroupCardProps\|interface ImprovementContextPanelProps\|interface WhatIfExplorerProps" packages/ui/src/components/ImprovementPlan/ packages/ui/src/components/WhatIfExplorer/ -r 2>/dev/null | head -40 +``` + +Note the required props for each. If any primitive's prop shape mismatches the Improve-stage data context (e.g., it expects data shapes that don't exist in `ImprovementProject`), STOP and report — task will need design adjustment. + +- [ ] **Step 2: Write the failing test** + +```typescript +// packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ImproveStageAdvanced } from '../ImproveStageAdvanced'; + +describe('ImproveStageAdvanced', () => { + it('renders all four PDCA workspace regions', () => { + render(); + expect(screen.getByLabelText(/context/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/ideas/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/prioritization/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/what-if/i)).toBeInTheDocument(); + }); +}); +``` + +This test pins region presence only — the primitives' own tests cover their internal behavior. Don't duplicate. + +- [ ] **Step 3: Run to verify the test fails** + +```bash +pnpm --filter @variscout/ui test -- ImproveStageAdvanced +``` + +Expected: FAIL (module not found). + +- [ ] **Step 4: Implement `ImproveStageAdvanced.tsx`** + +```tsx +// packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx +import { ImprovementContextPanel } from '../../ImprovementPlan/ImprovementContextPanel'; +import { BrainstormModal } from '../../ImprovementPlan/BrainstormModal'; +import { IdeaGroupCard } from '../../ImprovementPlan/IdeaGroupCard'; +import { PrioritizationMatrix } from '../../ImprovementPlan/PrioritizationMatrix'; +import { WhatIfExplorer } from '../../WhatIfExplorer/WhatIfExplorer'; + +export interface ImproveStageAdvancedProps { + projectId: string; + // Additional props flow in as the existing primitives reveal their requirements + // during Step 1 inspection. Add them here and propagate from IPDetailPage in Step 6. +} + +export function ImproveStageAdvanced({ projectId }: ImproveStageAdvancedProps) { + return ( +
+ +
+ + +
+ +
+ ); +} +``` + +Fill in the actual required props per Step 1. If any primitive requires substantive props that don't exist in the IP detail context (e.g., a whole `ProcessHub` reference), accept the prop on `ImproveStageAdvancedProps` and pass through. + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +pnpm --filter @variscout/ui test -- ImproveStageAdvanced +``` + +Expected: 1/1 pass. + +- [ ] **Step 6: Commit** + +```bash +git -C add packages/ui/src/components/IPDetail/stages/ImproveStageAdvanced.tsx packages/ui/src/components/IPDetail/stages/__tests__/ImproveStageAdvanced.test.tsx +git -C commit -m "feat(ui): add ImproveStageAdvanced mounting PDCA primitives" +``` + +--- + +## Task 4 — Advanced toggle inside `ImproveStage` + +**Goal:** Add a "Show advanced workbench" toggle to `ImproveStage` that swaps in `ImproveStageAdvanced`. State persists per-IP in `useViewStore` (View layer — transient is fine; per IP scope so a user's mode choice doesn't leak across projects within a session). + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/stages/ImproveStage.tsx` +- Modify: `packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx` +- Modify: `packages/stores/src/useViewStore.ts` — add a `improveAdvancedByIp: Record` field (or similar) if View store doesn't already track per-IP UI state + +### Steps + +- [ ] **Step 1: Read `packages/stores/src/useViewStore.ts`** to confirm its current shape + how per-IP state is keyed (if at all). Note the test pattern used for resetting the store. + +- [ ] **Step 2: Write a failing test for the toggle behavior** + +Append to `ImproveStage.test.tsx`: + +```typescript +describe('ImproveStage advanced toggle', () => { + it('renders simple tracker by default', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); + expect(screen.queryByLabelText(/context/i)).not.toBeInTheDocument(); + }); + + it('switches to Advanced workbench when toggle clicked', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /advanced/i })); + expect(screen.getByLabelText(/context/i)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run to verify the new tests fail** + +```bash +pnpm --filter @variscout/ui test -- ImproveStage +``` + +Expected: 2 new tests fail; 6 existing pass. + +- [ ] **Step 4: Add the toggle to `ImproveStage.tsx`** + +Add a state hook (or, if Step 1 found a per-IP view-store hook, use that): + +```tsx +import { ImproveStageAdvanced } from './ImproveStageAdvanced'; +import { useViewStore } from '@variscout/stores'; // if applicable + +// inside the component: +const [showAdvanced, setShowAdvanced] = useState(false); +// Optional: hydrate/persist via useViewStore per-IP if Step 1 confirmed the shape. + +// In the header (next to Add action button): + + +// In the body: +{showAdvanced ? ( + +) : ( + /* existing simple tracker JSX */ +)} +``` + +If a per-IP view-store slice gets added, keep it minimal: `improveAdvancedByIp: Record` + `setImproveAdvanced(projectId, advanced)` + a getter selector. Tests for the slice should follow the existing useViewStore test pattern. + +- [ ] **Step 5: Run the tests** + +```bash +pnpm --filter @variscout/ui test -- ImproveStage +``` + +Expected: 8/8 pass. + +- [ ] **Step 6: Commit** + +```bash +git -C add packages/ui/src/components/IPDetail/stages/ImproveStage.tsx packages/ui/src/components/IPDetail/stages/__tests__/ImproveStage.test.tsx packages/stores/ +git -C commit -m "feat(ui): add Advanced toggle on ImproveStage" +``` + +--- + +## Task 5 — Fold Handoff close-logic into Sustainment closure + +**Goal:** Move the Handoff stage's "close project" action into `SustainmentOverview` (or a new `SustainmentClosure` extraction inside it), and delete `HandoffOverview` + `HandoffSections`. Preserve every external observable behavior the existing Handoff tests verified, including any auto-fire path tied to ADR-080's pattern. + +**Note on ADR-080 auto-fire:** the Explore scout confirmed the data model exists (`sustainmentRecords[]`, `controlHandoffs[]`) but found NO live "auto-create on SuspectedCause confirmation" trigger in the current tree. Per the master plan: the requirement here is to **preserve whatever is shipped**, not to add the trigger. If shipped behavior doesn't include the auto-fire today, then there's nothing to preserve beyond the data model + close-action flow. + +**Files:** + +- Modify: `packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx` +- Modify: `packages/ui/src/components/IPDetail/stages/SustainmentSections.tsx` +- Delete: `packages/ui/src/components/IPDetail/stages/HandoffOverview.tsx` +- Delete: `packages/ui/src/components/IPDetail/stages/HandoffSections.tsx` +- Modify: existing `HandoffOverview.test.tsx` + `HandoffSections.test.tsx` — port their assertions into the Sustainment test files +- Modify: any consumer that imports `HandoffOverview` / `HandoffSections` (grep first) + +### Steps + +- [ ] **Step 1: Read `HandoffOverview.tsx`, `HandoffSections.tsx`, `SustainmentOverview.tsx`, `SustainmentSections.tsx` end-to-end.** Identify what Handoff does that Sustainment doesn't: + - Close-project action and its handler + - Any HubAction dispatched (search for `HANDOFF_` action kinds via `grep`) + - Activity-feed events emitted + - Any data-flow specific to Handoff (e.g., controlHandoffs[] CRUD) + +- [ ] **Step 2: Find every consumer of HandoffOverview / HandoffSections** + +```bash +grep -rln "HandoffOverview\|HandoffSections" packages/ui/src apps/ --include="*.ts" --include="*.tsx" | head +``` + +Expected: imports in `IPDetailPage.tsx` (stage routing switch) and possibly a barrel. + +- [ ] **Step 3: Port the close-project action into `SustainmentOverview.tsx`** + +Move the Handoff close-project button, modal, and submit handler into SustainmentOverview's UI. If the close-project action dispatched a `HANDOFF_CLOSE` HubAction kind, rename it to `SUSTAINMENT_CLOSE` (or absorb into an existing `IMPROVEMENT_PROJECT_UPDATE` patch — confirm via Step 1's discovery). If the action kind needs renaming, the dispatch-pattern follows `feedback_action_patch_omit_lifecycle` — verify the patch type uses the standard `Omit` shape. + +- [ ] **Step 4: Port the Handoff tests into Sustainment test files** + +Open `HandoffOverview.test.tsx` and `HandoffSections.test.tsx`. For each test: + +- If the test asserts Handoff-specific UI (close button, modal, signoff field), move the assertion into the matching Sustainment test file, adapting the rendered component. +- If the test asserts a Handoff-only data flow that's being collapsed, decide: keep the assertion (renaming to `Sustainment closure`) or drop it (and document why in the commit message). + +- [ ] **Step 5: Run all Sustainment + Handoff tests in their new shape** + +```bash +pnpm --filter @variscout/ui test -- "Sustainment|Handoff" +``` + +Expected: all ported tests pass; no dangling references to the old Handoff components. + +- [ ] **Step 6: Delete HandoffOverview.tsx + HandoffSections.tsx + their `__tests__` files** + +```bash +rm packages/ui/src/components/IPDetail/stages/HandoffOverview.tsx +rm packages/ui/src/components/IPDetail/stages/HandoffSections.tsx +rm packages/ui/src/components/IPDetail/stages/__tests__/HandoffOverview.test.tsx +rm packages/ui/src/components/IPDetail/stages/__tests__/HandoffSections.test.tsx +``` + +- [ ] **Step 7: Remove Handoff imports from `IPDetailPage.tsx`** (and any barrel). Update the stage-routing switch: there's no `'handoff'` case any more — that StageName value no longer exists per Task 1. + +- [ ] **Step 8: Run `pnpm build` to catch cross-package type-export gaps** + +```bash +pnpm build 2>&1 | tail -10 +``` + +Per `feedback_ui_build_before_merge`: build catches type-export gaps that per-package vitest misses. Expected: green. + +- [ ] **Step 9: Commit** + +```bash +git -C add packages/ui/src/components/IPDetail/stages/ +git -C commit -m "refactor(ui): fold Handoff close-logic into Sustainment closure" +``` + +--- + +## Task 6 — Remove top-level Improve tab (PWA + Azure) + +**Goal:** Remove the `'improve'` tab from the app shell. Both apps redirect users to `/projects` with a one-time toast when they hit the legacy URL. The 7→6 tab transition lands here; the **nav reorder** to wedge's `[Home] [Projects] [Process] [Analyze] [Investigation] [Report]` is explicitly deferred to PR-WV1-5. + +**Files:** + +- Modify: `apps/pwa/src/App.tsx` (remove `else if (tab === 'improve') panels.showImprovement()` and any tab list that includes 'improve') +- Modify: `apps/azure/src/pages/Editor.tsx` (same: remove `ps.showImprovement()` branch + tab list entry) +- Modify: `packages/core/src/i18n/messages/*.ts` — delete the `workspace.improve` key from every locale +- Modify: matching test files + +### Steps + +- [ ] **Step 1: Find every `tab === 'improve'` reference** + +```bash +grep -rn "tab === 'improve'\|'improve' as\|showImprovement\|workspace.improve" packages/ apps/ --include="*.ts" --include="*.tsx" | head -25 +``` + +Note each call site. + +- [ ] **Step 2: Write a failing test asserting the legacy `/improve` route redirects to `/projects` with a toast** (one test per app, in its existing app-shell test file) + +Adapt the redirect mechanism to whatever the app shell uses for routing (URL / hash / tab state). If the app shell has no current "navigate to tab X with toast Y" mechanism, document the workaround used. + +- [ ] **Step 3: Run the tests to verify they fail** + +```bash +pnpm --filter @variscout/pwa test -- App +pnpm --filter @variscout/azure-app test -- Editor +``` + +Expected: new tests fail. + +- [ ] **Step 4: Implement the redirect in both apps** + +In `apps/pwa/src/App.tsx`, replace: + +```typescript +} else if (tab === 'improve') { + panels.showImprovement(); +} +``` + +with: + +```typescript +} else if (tab === 'improve') { + // Wedge V1: Improve moved to a stage inside Projects detail (ADR-082, wedge spec §3.2). + showToast({ kind: 'info', message: 'Improve is now a stage in each project.' }); + setActiveTab('projects'); +} +``` + +(Adapt `showToast` / `setActiveTab` to the app's actual toast + nav helpers — read 1-2 existing tab-switch call sites to mirror the pattern.) Do the same in `apps/azure/src/pages/Editor.tsx`. + +- [ ] **Step 5: Remove the `'improve'` entry from the tab list array** + +If the apps construct their tab list from a hardcoded array (PWA scout found this in `App.tsx`; Azure equivalent in `Editor.tsx`), drop `'improve'` from the array. Drop the i18n key from every locale file. This is mechanical; use a single `sed` if all locales use the identical key shape, but verify the change in one locale first. + +- [ ] **Step 6: Run all tests** + +```bash +pnpm --filter @variscout/pwa test +pnpm --filter @variscout/azure-app test +``` + +Expected: both green. + +- [ ] **Step 7: Run `pnpm docs:check`** (i18n key deletions can cascade) + +```bash +pnpm docs:check 2>&1 | tail -10 +``` + +Expected: green. + +- [ ] **Step 8: Commit** + +```bash +git -C add apps/ packages/core/src/i18n/ +git -C commit -m "feat(apps): retire top-level Improve tab (now a project stage)" +``` + +--- + +## Task 7 — Final verification + decision-log amendment + +**Goal:** Run the full pre-PR check suite, perform a `--chrome` browser walk covering the wedge V1 spec §3.2 verification scenarios, and amend the decision-log to mark PR-WV1-2 shipped + explicitly defer the two remaining PR-WV1-1 follow-ups (invitation lifecycle to PR-WV1-3, per-user persistence key to PR-WV1-5). + +### Steps + +- [ ] **Step 1: Targeted test sweep** + +```bash +pnpm --filter @variscout/core test 2>&1 | tail -5 +pnpm --filter @variscout/stores test 2>&1 | tail -5 +pnpm --filter @variscout/pwa test 2>&1 | tail -5 +pnpm --filter @variscout/azure-app test 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- IPDetail 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- projects 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- Charter 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- Improve 2>&1 | tail -5 +pnpm --filter @variscout/ui test -- Sustainment 2>&1 | tail -5 +``` + +Expected: all green. (Full `@variscout/ui` suite has a documented unchanged-Canvas hang per prior memory entries — touched suites are run in isolation.) + +- [ ] **Step 2: Full build** + +```bash +pnpm build 2>&1 | tail -10 +``` + +Expected: 5/5 packages/apps green. + +- [ ] **Step 3: pr-ready-check** + +```bash +bash scripts/pr-ready-check.sh 2>&1 | tail -30 +``` + +Expected: green (architecture grep, ADR-074 boundary, lint, docs). + +- [ ] **Step 4: Browser walk (`claude --chrome`)** + +Verify each wedge V1 §3.2 + §3.3 scenario end-to-end: + +1. Create a new project as Lead → IP detail shows 4 stage tabs: Charter, Approach, Improve, Sustainment (no Handoff). +2. Open the Improve stage → simple ActionItem tracker shows; "Add action" button visible; "Advanced" toggle in the header. +3. Click "Add action" → enter title → save → action appears in list. +4. Click "Advanced" → ImproveStageAdvanced workspace shows Context / Ideas / Prioritization / What-If regions. +5. Click "Simple view" → returns to tracker. +6. Sign in as a Sponsor → IPDetailPage shows the Sponsor placeholder pointing to the top Report nav tab; Improve stage isn't reachable from this view. +7. Sign in as a Member → can see + edit Improve actions; cannot see the Invite-team button on Charter. +8. Open the Sustainment stage → closure UI present; absorb Handoff close-action invokes correctly (project status moves to `closed`). +9. Navigate to the legacy `/improve` URL → redirected to Projects list with toast "Improve is now a stage in each project." +10. Load an existing customer `.vrs` with legacy `team[]` populated and no `members[]` → after hydration, `members[]` is auto-populated via `migrateImprovementProjectMetadata`; the Team rail on Charter shows entries; legacy `team[]` is still readable by the legacy `IPDetailTeamRail`. + +If any step fails, capture a screenshot, log to `docs/investigations.md`, and decide whether to block the PR or fold into a follow-up. Document the choice in the PR description. + +- [ ] **Step 5: Amend `docs/decision-log.md` 2026-05-16 wedge entry** + +Add a new "Amendment 2026-05-16 — PR-WV1-2 shipped" block under the existing wedge entry. Cover: + +- 4-stage rename (`Sustainment+Handoff` → `Improve+Sustainment`); Handoff stage views deleted; `SustainmentOverview` absorbs close-action. +- New `ImproveStage` (simple ActionItem tracker) + `ImproveStageAdvanced` (PDCA primitives mounted) + Advanced toggle. +- `migrateImprovementProjectMetadata` helper folds (a) stage rename + (b) `team[] → members[]` eager cutover; invoked at `.vrs` / Dexie hydration in both apps. +- Top-level Improve tab retired (7 → 6 tabs; reorder still deferred to PR-WV1-5). +- `canAccess` wired at `IPDetailPage` (Sponsor placeholder + non-member gate) and `CharterOverview` Invite-button gating. PR-WV1-1 deferred item (d) closed. +- PR-WV1-1 deferred item (b) closed. +- **Remaining PR-WV1-1 followups:** (a) `INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds — re-owned by **PR-WV1-3** (Investigation Wall + MeasurementPlans is the natural carrier; Inbox simplification is part of that slice). (c) Per-user persistence key on `useProjectMembershipStore` — re-owned by **PR-WV1-5** (tier-gating retirement + nav reorder, where auth-wiring refinement naturally lands). + +- [ ] **Step 6: Commit the decision-log amendment** + +```bash +git -C add docs/decision-log.md +git -C commit -m "docs(wedge): log PR-WV1-2 delivery + re-owner remaining followups" +``` + +- [ ] **Step 7: Push + open PR** + +```bash +git -C push -u origin feat/wedge-pr-wv1-2-improve-workspace +gh pr create --title "feat(wedge): PR-WV1-2 Improve workspace migration" \ + --body "$(cat <<'EOF' +## Summary + +Implements wedge V1 spec §3.2 + §3.3 — IP detail flattens to 4 stages (Charter / Approach / Improve / Sustainment with Handoff folded into Sustainment closure), top-level Improve tab retires, simple ActionItem tracker is the default Improve stage UI with PDCA primitives behind an Advanced toggle. + +Also absorbs two of PR-WV1-1's deferred items per decision-log 2026-05-16 amendment: +- (b) `team[]` → `members[]` eager cutover at .vrs / Dexie hydration via new `migrateImprovementProjectMetadata` +- (d) `canAccess` wired at consumer call sites (IPDetailPage + CharterOverview Invite gate) + +## Test plan + +- [x] pnpm test (per-package; full @variscout/ui suite has documented unchanged-Canvas hang) +- [x] pnpm build green +- [x] bash scripts/pr-ready-check.sh +- [ ] `--chrome` browser walk per wedge §3.2 + §3.3 (10 scenarios in plan §Task 7 Step 4) + +## Master plan + +`docs/superpowers/plans/2026-05-16-wedge-implementation.md` (PR-WV1-2 row). + +## Sub-plan + +`docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md` +EOF +)" +``` + +If the PR is opened while PR-WV1-1 (#183) is still open, GitHub will show this PR's base as `feat/wedge-pr-wv1-1-project-membership`. When #183 squash-merges to main, GitHub auto-updates this PR's base to main. + +--- + +## Verification (end-to-end) + +1. **Schema:** legacy `team[]` and wedge `members[]` coexist on `ImprovementProjectMetadata`; `migrateImprovementProjectMetadata` populates `members[]` from `team[]` at hydration only when `members` is absent. +2. **Stage flow:** 4 named stages render in `IPDetailStageTabs`. The Improve stage routes to `` by default; Sustainment closure absorbs Handoff. No `'handoff'` StageName anywhere. +3. **ACL:** `canAccess` is the single ACL entry point. IPDetailPage Sponsor / non-member branches route through it. CharterOverview Invite button is gated on `canAccess('manage-membership')`. ImproveStage edit affordances gate on `canAccess('edit-improve')`. +4. **Apps:** PWA + Azure shells retire the `'improve'` tab handler with a redirect-to-Projects + toast; existing `currentUserId` + `onMembersChange` wiring from PR-WV1-1 propagates unchanged. +5. **Test sweep:** core + stores + PWA + Azure green; UI touched suites (IPDetail, projects, Charter, Improve, Sustainment) green. +6. **Build:** `pnpm build` green across all 5 packages/apps. + +--- + +## Self-review checklist + +- [ ] **Spec coverage**: every wedge spec §3.2 (4-stage IP detail) + §3.3 (simple-by-default Improve + Advanced toggle) commitment lands in a task. +- [ ] **Placeholder scan**: no TBD / TODO / placeholder code; every step has the actual content. +- [ ] **Type consistency**: `StageName`, `ProjectAction`, `MembershipAction`, `ImproveStageProps`, `ImproveStageAdvancedProps` used consistently across tasks. +- [ ] **No `Math.random`** in code or tests (per `packages/core/CLAUDE.md` hard rule). +- [ ] **No "root cause" language** anywhere (per P5 amended). +- [ ] **Sub-path exports paired**: no new sub-path added in this PR; if Task 1's migration helper goes through the existing `improvementProject` barrel (it should), no change to `package.json#exports` or `tsconfig.json#paths` is needed. Verify before committing. +- [ ] **Patch types**: any new `*_UPDATE` HubAction added (e.g., a renamed `SUSTAINMENT_CLOSE`) uses `Omit` per `feedback_action_patch_omit_lifecycle`. +- [ ] **`canAccess` is the single ACL entry point** after this PR. No inline role-string comparisons remain in IPDetailPage / CharterOverview / ImproveStage. + +--- + +## Deferred to later PRs + +- **PR-WV1-1 deferred item (a) — `INVITATION_ACCEPT` / `INVITATION_REVOKE` action kinds** — moves to PR-WV1-3 (Investigation Wall + Measurement Plans). The Inbox simplification context lands there; acceptance is the user action that transitions an `Invitation.status` → emits a `PROJECT_MEMBER_ADD` via composite reducer. +- **PR-WV1-1 deferred item (c) — Per-user persistence key on `useProjectMembershipStore`** — moves to PR-WV1-5 (tier-gating retirement + nav reorder). That PR refines auth wiring (single-user PWA vs. multi-user Azure) and is the natural place to adopt the `useActiveIPStore`-style dynamic-key pattern. +- **ADR-080 auto-fire trigger** — Out of scope for this PR. The data model is preserved; the auto-creation hook (SuspectedCause confirmed + matching improvement implemented → Sustainment record) does not yet exist in the current tree per the Explore scout. Track as a separate workstream once the canonical trigger source is decided. + +--- + +## Execution handoff + +Plan complete. Recommended approach: + +**Subagent-Driven Development** (recommended): + +- Fresh implementer subagent per task (8 tasks) +- Per-task spec reviewer + quality reviewer pair +- Final architecture review (`system-architect` Opus) + code review (`superpowers:requesting-code-review` Opus) at end of branch +- Sonnet for implementer + per-task reviewers (~70%+ of dispatches per CLAUDE.md memory) + +Invoke `superpowers:subagent-driven-development` with this plan as input. diff --git a/docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md b/docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md new file mode 100644 index 000000000..da7bd48fe --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md @@ -0,0 +1,182 @@ +--- +title: 'Wedge V1 amendment — Improve as top-level verb tab, Project as singular noun' +status: draft +last-reviewed: 2026-05-16 +category: design-spec +audience: [designer, engineer] +related: + - docs/superpowers/specs/2026-05-16-wedge-architecture-design.md + - docs/superpowers/specs/2026-05-14-variscout-coherence-design.md + - docs/superpowers/specs/2026-05-14-projects-tab-design.md + - docs/07-decisions/adr-082-wedge-architecture.md + - docs/superpowers/plans/2026-05-16-wedge-implementation.md + - docs/superpowers/plans/2026-05-16-pr-wv1-2-improve-workspace.md +--- + +# Wedge V1 amendment — Improve as top-level verb tab, Project as singular noun + +## Context + +The wedge V1 spec (`2026-05-16-wedge-architecture-design.md` §3.1–3.3) locked a 6-tab workflow nav — `[Home] [Projects] [Process] [Analyze] [Investigation] [Report]` — and folded the legacy Improve workspace into Projects detail as a stage. PR-WV1-2 was mid-execution to deliver that fold-in: Tasks 0–5 had shipped (canAccess wiring, stage rename `Sustainment+Handoff → Improve+Sustainment`, `ImproveStage` simple action tracker, `ImproveStageAdvanced` PDCA workbench, Advanced toggle, Handoff close-logic folded into Sustainment closure). Task 6 was about to retire the top-level Improve tab. + +During mid-execution review, the user surfaced a sharp design objection: the wedge V1 nav keeps **Analyze** and **Investigation** as top-level verb tabs (with active-IP cascade per PR-PT-7), but removes **Improve** entirely. That asymmetry is hard to defend — improvement work is the _doing_ verb of a chartered project; it deserves the same first-class verb treatment as data exploration and question-driven inquiry. Removing it forces specialists to drill into Project detail every time they want to update an action — even though the IP-context chip already tells them which project they're in. + +This amendment restores Improve as a top-level verb tab, makes the Project tab singular (active-IP-scoped, matching the cascade pattern), removes the Improve stage from Project detail (since the same UI now lives at the top level), and preserves every other wedge V1 decision unchanged. + +## Decision summary + +1. **7-tab nav order:** `[Home] [Project] [Process] [Analyze] [Investigation] [Improve] [Report]`. Wedge V1 §3.1's 6-tab list is superseded. +2. **Project tab is singular.** Active-IP-centric — it shows ONE project's lifecycle, matching how Analyze / Investigation / Improve / Report behave under the PR-PT-7 cascade. The full project _list_ lives on the Home launchpad (already shipped per PR-PT-6). +3. **Project detail has 3 stage tabs**, not 4: `Charter / Approach / Sustainment`. The `'improve'` stage is removed. Sustainment continues to absorb Handoff close-logic per Task 5. +4. **Improve tab is a top-level verb workspace.** With an active IP, it renders the simple action tracker (default) + Advanced PDCA workbench (toggle). Without an active IP, it renders a guidance state directing the user to Home to pick or charter a project. Implementations of `` + `` from PR-WV1-2 Tasks 2-4 are reused verbatim — only the routing surface changes. +5. **Improvement actions are project-scoped data.** `ActionItem.parentImprovementProjectId` continues to anchor each action to one IP. The Improve tab filters to the active IP's actions via the same cascade that PR-PT-7 uses for other verb tabs. Cross-IP action views are out of V1 scope. + +## Surface responsibilities + +| Tab | Role | Active-IP behavior | +| ----------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| **Home** | Project picker + launchpad (Mira's day-start) | Sets active IP via launchpad cards; full list lives here | +| **Project** | Active project's lifecycle authoring with 3 stage tabs (Charter / Approach / Sustainment) | Required — empty state directs to Home | +| **Process** | Process Hub view scoped to active IP | Required — empty state directs to Home | +| **Analyze** | Data analysis surface, scoped to active IP | Required — empty state directs to Home | +| **Investigation** | Question-driven EDA scoped to active IP | Required — empty state directs to Home | +| **Improve** | Daily execution surface — simple action tracker (default) + Advanced PDCA workbench (toggle) | Required — empty state directs to Home | +| **Report** | Overview / Technical reporting, scoped to active IP (or Hub portfolio when no IP set, per existing PR-PT-9 behavior) | Required for IP-scoped views | + +Every non-Home tab follows the same pattern: with active IP, show the scoped working surface; without active IP, show a brief guidance state pointing back to Home. Improve adopts this pattern; it does NOT get a free-roaming "all projects" or "all ideas" view. + +## Improve tab UX + +### With active IP + +Renders `` (the component built in PR-WV1-2 Task 2): + +- **Default view (simple tracker):** Action list scoped to active IP. Shows title (`text`), owner (`assignedTo?.displayName`), due date (`dueAt`), status (`open | in-progress | done`), and the linked suspected cause name where derivable from `parentImprovementIdeaId`. +- **Add action button** gated by `canAccess(currentUserId, members, 'edit-improve')`. +- **Per-row Mark-done / Remove buttons** with the same canAccess gate. +- **"Advanced" toggle** in the header. Clicking it renders `` (the component from Task 3): `ImprovementContextPanel` (causes), `BrainstormModal` + `IdeaGroupCard` (ideas), `PrioritizationMatrix` + `WhatIfExplorer` (prioritization + projection). All five primitives are scoped to the active IP. +- **"Simple view" toggle** returns to the tracker. + +The IP-context chip at the top of the page surfaces which project is active (per PR-PT-7 pattern). Users can switch projects from Home or via the chip's switch affordance. + +### Without active IP + +Renders a guidance state — single section, single role="alert" panel: + +> **No active project** +> +> Improvement work happens inside a chartered project. Pick a project from Home, or create a new one to start tracking actions and ideating with the PDCA workbench. +> +> **[Go to Home]** + +This mirrors how PR-WV1-1's `NoAccessRedirect` handles the ACL empty state — informative, action-oriented, no functionality that could mislead the user into thinking free-roaming ideation is available. + +## Project tab structure + +Project detail (the surface that opens when the active IP is set and the user clicks the Project tab): + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ← Back to Home ●Active Project: Heads 5-8 Cpk shortfall │ +│ Goal · invited team avatars · Invite │ +├──────────────────────┬──────────────────────────────────────┤ +│ Stages: │ Overview / Sections toggle │ +│ • Charter │ │ +│ • Approach │ (stage body — render switch) │ +│ • Sustainment │ │ +└──────────────────────┴──────────────────────────────────────┘ +``` + +3 stage tabs. The `'improve'` stage is removed from `StageName`, `STAGE_ORDER`, the `LABEL` map, and `deriveStageState`. PR-WV1-2 Task 1's prior rename `Sustainment+Handoff → Improve+Sustainment` is replaced by a simpler `Sustainment+Handoff → Sustainment` (Handoff folds into Sustainment closure per Task 5). + +Stage progression semantics under the new 3-stage model: + +| IP state | Charter | Approach | Sustainment | +| ------------------------------- | --------- | ---------- | ----------- | +| `status === 'draft'` | `current` | `upcoming` | `upcoming` | +| `status === 'active'` | `done` | `current` | `upcoming` | +| `status === 'closed'` | `done` | `done` | `current` | +| `sustainmentConfirmed === true` | `done` | `done` | `done` | + +The previous `improveComplete` signal (introduced in Task 1) retires — there is no Improve stage to gate. + +## Impact on PR-WV1-2 in-flight work + +PR-WV1-2 was 5 tasks deep when this amendment landed. Disposition per task: + +| Task | Prior status | Disposition under amendment | +| --------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Task 0** — Wire `canAccess` at consumer call sites | Done | **Keep verbatim.** ACL wiring is unchanged. | +| **Task 1** — Stage rename + `migrateImprovementProjectMetadata` | Done (renamed to 4 stages incl. `'improve'`) | **Partial rework.** Stage list flattens further — `StageName` becomes `'charter' | 'approach' | 'sustainment'`. `STAGE_ORDER`is the same 3 values.`LABEL`map drops the`'improve'`entry.`StageStateInputs.improveComplete`retires.`deriveStageState`simplifies per the table above.`migrateImprovementProjectMetadata`is unchanged — it does`team[] → members[]` cutover only, never touched stage names. | +| **Task 2** — Build `ImproveStage` simple tracker | Done | **Keep component verbatim.** Routing moves: the component is rendered by the top-level Improve tab handler instead of by `IPDetailPage`. The Improve tab handler reads active IP from the cascade. | +| **Task 3** — Build `ImproveStageAdvanced` PDCA workspace | Done | **Keep verbatim.** Reused identically inside the Improve tab when Advanced toggle is on. | +| **Task 4** — Advanced toggle | Done | **Keep verbatim.** Toggle behavior unchanged. | +| **Task 5** — Fold Handoff into Sustainment closure | Done | **Keep verbatim.** Handoff fold stands. Sustainment is now the project's closure stage. | +| **Task 6** — _Was:_ retire top-level Improve tab | Pending | **Rewritten.** New scope: (a) wire the existing `'improve'` tab handler in both apps to render `` with the active IP's actions + members + currentUserId, OR the empty-state guidance when no active IP; (b) rename `workspace.projects` i18n key to `workspace.project` (singular) across all locale files; (c) update the i18n message text in each locale to "Project" (singular); (d) un-prefix `_handoffInputs` / `_onOpenLegacyHandoff` cleanup that Task 5 absorbed remains as-is. | +| **Task 7** — Final verification + decision-log amendment | Pending | **Updated copy.** Decision-log amendment captures this amendment-of-an-amendment honestly: wedge §3.1 6-tab nav is superseded by 7-tab nav with Improve restored; `'improve'` stage removed from Project detail; Project (singular) noun. | + +Specifically removed from Project detail: + +- `` rendering inside the `'improve'` case of `IPDetailPage.tsx`'s stage router (Task 2's wiring at the IP detail level is reverted). +- The 3 optional callback props `onActionAdd` / `onActionUpdate` / `onActionRemove` on `IPDetailPageProps` are removed (those props move to the top-level Improve tab handler). +- The new "Improve stage routing" integration test in `IPDetailPage.test.tsx` is removed (no `'improve'` stage to route). + +The `` component itself, its tests, ``, and the Advanced toggle all live in `packages/ui/src/components/IPDetail/stages/` today. Either leave them there (the path becomes archaeologically inaccurate but the file path is internal) OR move to `packages/ui/src/components/Improve/`. Recommendation: **move** during Task 6 to keep the codebase shape honest — these components no longer belong to IP detail. + +## Journey × persona × cognitive shape + +Per `feedback_journey_first_then_ui`, the asymmetry the wedge V1 created (Analyze + Investigation as top-level verbs, Improve folded inside) is justified only if Improve has no real journey outside a project. The brainstorm surfaced the opposite: improvement work IS the daily verb for a specialist. The Improve tab makes that daily journey first-class. + +**V1 Specialist daily journey, post-amendment:** + +1. Open app → Home → pick today's project (sets active IP) +2. Click Project tab → check stage status, edit Charter / Approach / Sustainment as needed +3. Click Analyze tab → run capability/control charts on the active IP's data +4. Click Investigation tab → drill into outstanding questions for the active IP +5. Click Improve tab → update action statuses, add new actions, ideate via the Advanced toggle when needed +6. Click Report tab → see how the active IP is tracking against goal + +Each tab is a lens on the _same_ project. The active-IP cascade (already wired in PR-PT-7) means switching tabs doesn't lose context — the chip carries the project's name across all surfaces. The user objection that drove this amendment ("we have analysis, investigate etc tabs still") was exactly right: those tabs already represent the verb-shaped daily journey for the specialist; Improve belongs alongside them. + +**Cross-IP work** (compare prioritization matrices across past projects, build a portfolio-level idea library) is **NOT** added by this amendment. The Improve tab is single-IP-scoped. If portfolio-level Improve patterns become real V2 work, the spec can add an Analyze-tab pivot (per wedge §3.3's existing note that "What-If may re-emerge in Analyze later") or a dedicated portfolio surface. + +## What this amendment supersedes + +- **Wedge V1 spec §3.1** 6-tab nav `[Home] [Projects] [Process] [Analyze] [Investigation] [Report]` → superseded by 7-tab nav with Improve restored + Project singular. +- **Wedge V1 spec §3.2** "Project detail = 4 stages — `Charter / Approach / Improve / Sustainment`" → superseded by 3 stages `Charter / Approach / Sustainment`. +- **Wedge V1 spec §3.3** "Improve stage UI = simple action tracker by default; PDCA workbench + What-If accessible via an 'Advanced' toggle (progressive disclosure)" → the toggle behavior is preserved verbatim, but it lives in the top-level Improve tab instead of inside the Improve stage of Project detail. +- **Decision-log 2026-05-16 wedge entry §(1)** "Improve tab removed as top-level; becomes a stage inside Projects detail" → reversed. Improve restored as a top-level tab; Projects renamed to Project (singular). +- **Decision-log 2026-05-16 wedge entry §(3)** "idea board / action conversion retire" → preserved. Free-roaming cross-IP idea board still retires. Improve tab is single-IP-scoped, not a free-roaming surface. Within an active IP, the PDCA workbench is fully available behind the Advanced toggle. + +The wedge meta-decisions (single-product specialist tool, Lead/Member/Sponsor membership ACLs, single €99 SKU, Hub internal-only, three response paths, ADR-080 sustainment auto-fire pattern) are **not** affected. ADR-082 stays in force. + +## What this amendment preserves + +- Coherence design 2026-05-14's verb/noun split is restored. The pre-wedge analysis ("Improve = legacy ImprovementView / PDCA workbench" + "Projects = new IP lifecycle + detail page" + "deliberate verb/noun split because the two surfaces serve genuinely different jobs") was correct; the wedge V1's collapse went too far. +- PR-PT-6 active-IP launchpad on Home — unchanged. +- PR-PT-7 active-IP cascade through verb tabs — extended to include the restored Improve tab. +- PR-PT-8 team workspace right rail — unchanged. Still rendered inside Project detail. +- PR-PT-9 Report tab IP-scoped vs. Hub portfolio behavior — unchanged. +- Wedge spec §4 project membership model + canAccess truth table — unchanged. The Improve tab uses `canAccess(currentUserId, members, 'edit-improve')` exactly as the Improve stage would have. +- Wedge spec §6 (single €99 SKU, Azure tenant-wide) — unchanged. + +## Open questions for V2 (not blockers for this amendment) + +1. **Process tab in 7-tab nav.** The wedge V1 spec brought "Process" as a tab for the canvas viewport (per ADR-081). This amendment preserves the Process tab. If wedge V2 redesigns Process navigation, it can be revisited then. +2. **What-If re-emergence in Analyze.** Per wedge §3.3, "What-If may re-emerge in Analyze later." The Improve tab's Advanced toggle still includes the `WhatIfExplorer` primitive. The Analyze tab does not yet surface What-If. Whether it should ever — and how that would relate to the Improve tab's What-If — is V2 work. +3. **Cross-IP improvement patterns.** Not addressed by V1. If specialists run multiple projects in parallel and want pattern-matching across them, a future Analyze pivot or portfolio surface may be needed. Track in `docs/investigations.md` if the question recurs. + +## Acceptance criteria + +This amendment lands when: + +1. PWA + Azure apps render 7 tabs in the order `[Home] [Project] [Process] [Analyze] [Investigation] [Improve] [Report]`. +2. The `workspace.project` i18n key is added (replacing the `workspace.projects` key); all 32 locale files show "Project" (singular) in their language. +3. Clicking the Improve tab with an active IP renders `` for that IP, with the Advanced toggle reaching ``. +4. Clicking the Improve tab WITHOUT an active IP renders the guidance state with a "Go to Home" button. +5. Project detail stage tabs show 3 stages — `Charter / Approach / Sustainment` — with no `'improve'` stage button visible anywhere. +6. The `migrateImprovementProjectMetadata` helper (already shipped) continues to fold legacy `team[] → members[]` at hydration without touching stage data. +7. Sustainment continues to absorb Handoff close-logic (Task 5's work stands). +8. All `canAccess(currentUserId, members, action)` gates continue to enforce Lead / Member / Sponsor permissions identically to PR-WV1-1. +9. Tests in `packages/core`, `packages/stores`, `packages/ui` (touched suites), `apps/pwa`, `apps/azure` all green; `pnpm build` green across all 5 packages/apps; `bash scripts/pr-ready-check.sh` green. +10. Browser walk covers: pick project from Home → see Project tab show 3 stages → click Improve tab → see scoped tracker → toggle Advanced → see PDCA workbench → exit IP → click Improve tab → see guidance state. diff --git a/docs/superpowers/specs/2026-05-16-wedge-architecture-design.md b/docs/superpowers/specs/2026-05-16-wedge-architecture-design.md index d86b25476..976cd6c5b 100644 --- a/docs/superpowers/specs/2026-05-16-wedge-architecture-design.md +++ b/docs/superpowers/specs/2026-05-16-wedge-architecture-design.md @@ -5,6 +5,7 @@ category: design-spec status: draft last-reviewed: 2026-05-16 related: + - docs/superpowers/specs/2026-05-16-improve-tab-amendment-design.md - docs/superpowers/specs/2026-05-14-variscout-coherence-design.md - docs/superpowers/specs/2026-05-14-projects-tab-design.md - docs/superpowers/specs/2026-05-03-variscout-vision-design.md diff --git a/packages/core/src/i18n/__tests__/index.test.ts b/packages/core/src/i18n/__tests__/index.test.ts index d94cb1f39..0bd363085 100644 --- a/packages/core/src/i18n/__tests__/index.test.ts +++ b/packages/core/src/i18n/__tests__/index.test.ts @@ -356,3 +356,21 @@ describe('detectLocale', () => { expect(detectLocale('FR-ca')).toBe('fr'); }); }); + +describe('workspace.project key (amendment — replaces workspace.projects)', () => { + it('is defined in every locale', () => { + for (const locale of LOCALES) { + const catalog = getMessages(locale); + expect(catalog, `${locale} missing workspace.project`).toHaveProperty('workspace.project'); + } + }); + + it('workspace.projects is no longer present in any locale', () => { + for (const locale of LOCALES) { + const catalog = getMessages(locale); + expect(catalog, `${locale} still has workspace.projects`).not.toHaveProperty( + 'workspace.projects' + ); + } + }); +}); diff --git a/packages/core/src/i18n/messages/ar.ts b/packages/core/src/i18n/messages/ar.ts index aee928aa4..bc6693cc6 100644 --- a/packages/core/src/i18n/messages/ar.ts +++ b/packages/core/src/i18n/messages/ar.ts @@ -594,7 +594,7 @@ export const ar: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'مشروع', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/bg.ts b/packages/core/src/i18n/messages/bg.ts index f9e6f6692..3223d00a7 100644 --- a/packages/core/src/i18n/messages/bg.ts +++ b/packages/core/src/i18n/messages/bg.ts @@ -602,7 +602,7 @@ export const bg: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Проект', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/cs.ts b/packages/core/src/i18n/messages/cs.ts index c4e92036c..e617dc4ab 100644 --- a/packages/core/src/i18n/messages/cs.ts +++ b/packages/core/src/i18n/messages/cs.ts @@ -514,7 +514,7 @@ export const cs: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/da.ts b/packages/core/src/i18n/messages/da.ts index b5d29d0cf..1e3f7de80 100644 --- a/packages/core/src/i18n/messages/da.ts +++ b/packages/core/src/i18n/messages/da.ts @@ -566,7 +566,7 @@ export const da: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/de.ts b/packages/core/src/i18n/messages/de.ts index ca7a89a09..9c7d5341b 100644 --- a/packages/core/src/i18n/messages/de.ts +++ b/packages/core/src/i18n/messages/de.ts @@ -606,7 +606,7 @@ export const de: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/el.ts b/packages/core/src/i18n/messages/el.ts index 6bfe09568..fefd2c667 100644 --- a/packages/core/src/i18n/messages/el.ts +++ b/packages/core/src/i18n/messages/el.ts @@ -604,7 +604,7 @@ export const el: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Έργο', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/en.ts b/packages/core/src/i18n/messages/en.ts index 763617227..54c993fa9 100644 --- a/packages/core/src/i18n/messages/en.ts +++ b/packages/core/src/i18n/messages/en.ts @@ -607,7 +607,7 @@ export const en: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Project', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/es.ts b/packages/core/src/i18n/messages/es.ts index 9d27ffb23..4f86968ec 100644 --- a/packages/core/src/i18n/messages/es.ts +++ b/packages/core/src/i18n/messages/es.ts @@ -607,7 +607,7 @@ export const es: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Proyecto', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/fi.ts b/packages/core/src/i18n/messages/fi.ts index a15a97f47..b467c3818 100644 --- a/packages/core/src/i18n/messages/fi.ts +++ b/packages/core/src/i18n/messages/fi.ts @@ -604,7 +604,7 @@ export const fi: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekti', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/fr.ts b/packages/core/src/i18n/messages/fr.ts index 3d43fa129..d0498cc83 100644 --- a/packages/core/src/i18n/messages/fr.ts +++ b/packages/core/src/i18n/messages/fr.ts @@ -610,7 +610,7 @@ export const fr: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projet', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/he.ts b/packages/core/src/i18n/messages/he.ts index 6e07a8aa2..afa25d4bd 100644 --- a/packages/core/src/i18n/messages/he.ts +++ b/packages/core/src/i18n/messages/he.ts @@ -593,7 +593,7 @@ export const he: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'פרויקט', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/hi.ts b/packages/core/src/i18n/messages/hi.ts index 158c4d18a..61ff16b2d 100644 --- a/packages/core/src/i18n/messages/hi.ts +++ b/packages/core/src/i18n/messages/hi.ts @@ -604,7 +604,7 @@ export const hi: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'परियोजना', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/hr.ts b/packages/core/src/i18n/messages/hr.ts index 4850cebb8..f72e7d522 100644 --- a/packages/core/src/i18n/messages/hr.ts +++ b/packages/core/src/i18n/messages/hr.ts @@ -600,7 +600,7 @@ export const hr: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/hu.ts b/packages/core/src/i18n/messages/hu.ts index 163685acd..5f921f728 100644 --- a/packages/core/src/i18n/messages/hu.ts +++ b/packages/core/src/i18n/messages/hu.ts @@ -518,7 +518,7 @@ export const hu: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/id.ts b/packages/core/src/i18n/messages/id.ts index 142874d02..73c804e4d 100644 --- a/packages/core/src/i18n/messages/id.ts +++ b/packages/core/src/i18n/messages/id.ts @@ -552,7 +552,7 @@ export const id: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Proyek', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/it.ts b/packages/core/src/i18n/messages/it.ts index 5ae734a56..a66e44f67 100644 --- a/packages/core/src/i18n/messages/it.ts +++ b/packages/core/src/i18n/messages/it.ts @@ -574,7 +574,7 @@ export const it: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Progetto', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/ja.ts b/packages/core/src/i18n/messages/ja.ts index e53091da7..d48eab14c 100644 --- a/packages/core/src/i18n/messages/ja.ts +++ b/packages/core/src/i18n/messages/ja.ts @@ -566,7 +566,7 @@ export const ja: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'プロジェクト', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/ko.ts b/packages/core/src/i18n/messages/ko.ts index 76814eb66..5607438b3 100644 --- a/packages/core/src/i18n/messages/ko.ts +++ b/packages/core/src/i18n/messages/ko.ts @@ -565,7 +565,7 @@ export const ko: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': '프로젝트', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/ms.ts b/packages/core/src/i18n/messages/ms.ts index a60a355d5..dc1a9b7ee 100644 --- a/packages/core/src/i18n/messages/ms.ts +++ b/packages/core/src/i18n/messages/ms.ts @@ -604,7 +604,7 @@ export const ms: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projek', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/nb.ts b/packages/core/src/i18n/messages/nb.ts index bcb08277b..3a8d0aba7 100644 --- a/packages/core/src/i18n/messages/nb.ts +++ b/packages/core/src/i18n/messages/nb.ts @@ -514,7 +514,7 @@ export const nb: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Prosjekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/nl.ts b/packages/core/src/i18n/messages/nl.ts index df58bd98f..d2e45220d 100644 --- a/packages/core/src/i18n/messages/nl.ts +++ b/packages/core/src/i18n/messages/nl.ts @@ -573,7 +573,7 @@ export const nl: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Project', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/pl.ts b/packages/core/src/i18n/messages/pl.ts index 677bb4f2d..02916307b 100644 --- a/packages/core/src/i18n/messages/pl.ts +++ b/packages/core/src/i18n/messages/pl.ts @@ -571,7 +571,7 @@ export const pl: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/pt.ts b/packages/core/src/i18n/messages/pt.ts index 88bbbdfbe..3e490164f 100644 --- a/packages/core/src/i18n/messages/pt.ts +++ b/packages/core/src/i18n/messages/pt.ts @@ -606,7 +606,7 @@ export const pt: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projeto', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/ro.ts b/packages/core/src/i18n/messages/ro.ts index a6382e7be..5f9939455 100644 --- a/packages/core/src/i18n/messages/ro.ts +++ b/packages/core/src/i18n/messages/ro.ts @@ -552,7 +552,7 @@ export const ro: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Proiect', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/sk.ts b/packages/core/src/i18n/messages/sk.ts index 67465b955..6ccfda67c 100644 --- a/packages/core/src/i18n/messages/sk.ts +++ b/packages/core/src/i18n/messages/sk.ts @@ -602,7 +602,7 @@ export const sk: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/sv.ts b/packages/core/src/i18n/messages/sv.ts index 6be264157..bc7620ebe 100644 --- a/packages/core/src/i18n/messages/sv.ts +++ b/packages/core/src/i18n/messages/sv.ts @@ -565,7 +565,7 @@ export const sv: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Projekt', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/th.ts b/packages/core/src/i18n/messages/th.ts index 8bc6806df..1aa104988 100644 --- a/packages/core/src/i18n/messages/th.ts +++ b/packages/core/src/i18n/messages/th.ts @@ -543,7 +543,7 @@ export const th: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'โครงการ', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/tr.ts b/packages/core/src/i18n/messages/tr.ts index fe49370cc..90fabb402 100644 --- a/packages/core/src/i18n/messages/tr.ts +++ b/packages/core/src/i18n/messages/tr.ts @@ -571,7 +571,7 @@ export const tr: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Proje', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/uk.ts b/packages/core/src/i18n/messages/uk.ts index 574b2fe5e..8f742673e 100644 --- a/packages/core/src/i18n/messages/uk.ts +++ b/packages/core/src/i18n/messages/uk.ts @@ -553,7 +553,7 @@ export const uk: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Проєкт', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/vi.ts b/packages/core/src/i18n/messages/vi.ts index 038647625..270326e63 100644 --- a/packages/core/src/i18n/messages/vi.ts +++ b/packages/core/src/i18n/messages/vi.ts @@ -551,7 +551,7 @@ export const vi: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': 'Dự án', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/zhHans.ts b/packages/core/src/i18n/messages/zhHans.ts index 5be272f61..94561276b 100644 --- a/packages/core/src/i18n/messages/zhHans.ts +++ b/packages/core/src/i18n/messages/zhHans.ts @@ -555,7 +555,7 @@ export const zhHans: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': '项目', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/messages/zhHant.ts b/packages/core/src/i18n/messages/zhHant.ts index 87f8bb950..d31e0f27b 100644 --- a/packages/core/src/i18n/messages/zhHant.ts +++ b/packages/core/src/i18n/messages/zhHant.ts @@ -555,7 +555,7 @@ export const zhHant: MessageCatalog = { 'workspace.findings': 'Findings', 'workspace.improvement': 'Improvement', 'workspace.improve': 'Improve', - 'workspace.projects': 'Projects', + 'workspace.project': '項目', 'workspace.report': 'Report', // Synthesis card diff --git a/packages/core/src/i18n/types.ts b/packages/core/src/i18n/types.ts index d7abcede2..33f9d2ad5 100644 --- a/packages/core/src/i18n/types.ts +++ b/packages/core/src/i18n/types.ts @@ -696,7 +696,7 @@ export interface MessageCatalog { 'workspace.investigation': string; 'workspace.improvement': string; 'workspace.improve': string; - 'workspace.projects': string; + 'workspace.project': string; 'workspace.report': string; 'workspace.findings': string; diff --git a/packages/core/src/improvementProject/__tests__/migrateMetadata.test.ts b/packages/core/src/improvementProject/__tests__/migrateMetadata.test.ts new file mode 100644 index 000000000..ce480576e --- /dev/null +++ b/packages/core/src/improvementProject/__tests__/migrateMetadata.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { migrateImprovementProjectMetadata } from '../migrateMetadata'; +import type { ImprovementProject } from '../types'; + +describe('migrateImprovementProjectMetadata', () => { + const baseIP: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { title: 'Test IP' }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', baseline: 0.5, target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, + }; + + it('migrates legacy team[] to members[] when members is absent', () => { + const legacy: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [ + { role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }, + { role: 'teamMember', person: { displayName: 'Mira' } }, + ], + }, + }; + const out = migrateImprovementProjectMetadata(legacy, 1234); + expect(out.metadata.members).toBeDefined(); + expect(out.metadata.members).toHaveLength(2); + expect(out.metadata.members?.[0].role).toBe('lead'); + expect(out.metadata.members?.[1].role).toBe('member'); + expect(out.metadata.team).toBeDefined(); // legacy preserved + }); + + it('does not migrate when members[] already populated', () => { + const alreadyMigrated: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [{ role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }], + members: [ + { + id: 'pm-existing', + createdAt: 100, + deletedAt: null, + userId: 'someone-else@org', + displayName: 'Someone Else', + role: 'lead', + invitedAt: 100, + }, + ], + }, + }; + const out = migrateImprovementProjectMetadata(alreadyMigrated, 1234); + expect(out.metadata.members).toHaveLength(1); + expect(out.metadata.members?.[0].userId).toBe('someone-else@org'); + }); + + it('is a no-op when neither team nor members exist', () => { + const out = migrateImprovementProjectMetadata(baseIP, 1234); + expect(out.metadata.members).toBeUndefined(); + expect(out).toEqual(baseIP); + }); + + it('returns a new object (does not mutate input)', () => { + const legacy: ImprovementProject = { + ...baseIP, + metadata: { + ...baseIP.metadata, + team: [{ role: 'projectLead', person: { displayName: 'Lead', upn: 'lead@org' } }], + }, + }; + const out = migrateImprovementProjectMetadata(legacy, 1234); + expect(out).not.toBe(legacy); + expect(legacy.metadata.members).toBeUndefined(); + }); +}); diff --git a/packages/core/src/improvementProject/index.ts b/packages/core/src/improvementProject/index.ts index bd3376982..90041d773 100644 --- a/packages/core/src/improvementProject/index.ts +++ b/packages/core/src/improvementProject/index.ts @@ -17,3 +17,4 @@ export { computeSourceHash, shouldShowDrift } from './snapshot'; export type { DriftableSnapshot, DriftableCurrent } from './snapshot'; export { migrateTeamToMembers } from './migration'; +export { migrateImprovementProjectMetadata } from './migrateMetadata'; diff --git a/packages/core/src/improvementProject/migrateMetadata.ts b/packages/core/src/improvementProject/migrateMetadata.ts new file mode 100644 index 000000000..354596588 --- /dev/null +++ b/packages/core/src/improvementProject/migrateMetadata.ts @@ -0,0 +1,32 @@ +import type { ImprovementProject } from './types'; +import { migrateTeamToMembers } from './migration'; + +/** + * Idempotent migration applied at hydration time. Folds PR-WV1-2 changes + * over the wedge V1 metadata shape: + * 1. legacy `team[]` → wedge `members[]` (via migrateTeamToMembers) + * + * Legacy `team[]` is preserved on the output for now; PR-WV1-5 (tier-gating + * retirement + nav reorder) drops it. + */ +export function migrateImprovementProjectMetadata( + ip: ImprovementProject, + now: number +): ImprovementProject { + const hasMembers = ip.metadata.members !== undefined; + const hasLegacyTeam = ip.metadata.team !== undefined && ip.metadata.team.length > 0; + + if (hasMembers || !hasLegacyTeam) { + return ip; + } + + const members = migrateTeamToMembers(ip.metadata.team, now); + + return { + ...ip, + metadata: { + ...ip.metadata, + members, + }, + }; +} diff --git a/packages/ui/src/components/IPDetail/IPDetailPage.tsx b/packages/ui/src/components/IPDetail/IPDetailPage.tsx index 912bbfac0..ad440a6d6 100644 --- a/packages/ui/src/components/IPDetail/IPDetailPage.tsx +++ b/packages/ui/src/components/IPDetail/IPDetailPage.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; import { usePreferencesStore } from '@variscout/stores'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { ProjectMember, ProjectRole } from '@variscout/core/projectMembership'; +import { canAccess } from '@variscout/core/projectMembership'; import { generateDeterministicId } from '@variscout/core'; import { reduceProjectMembers, type MembershipAction } from '@variscout/core/actions'; import IPDetailHeader from './IPDetailHeader'; @@ -16,10 +17,8 @@ import CharterOverview from './stages/CharterOverview'; import CharterSections from './stages/CharterSections'; import ApproachOverview from './stages/ApproachOverview'; import ApproachSections from './stages/ApproachSections'; -import SustainmentOverview from './stages/SustainmentOverview'; +import SustainmentOverview, { type SustainmentClosureInputs } from './stages/SustainmentOverview'; import SustainmentSections from './stages/SustainmentSections'; -import HandoffOverview, { type HandoffChecklistInputs } from './stages/HandoffOverview'; -import HandoffSections from './stages/HandoffSections'; import type { CauseProjectionInputs, CauseRow } from './stages/causeProjection'; import type { ImprovementProjectFormProps } from '../ImprovementProject/ImprovementProjectForm'; import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; @@ -56,14 +55,12 @@ export interface IPDetailPageProps { sustainmentRecord?: SustainmentRecord; /** Linked ControlHandoff. Present when handoff stage is active or beyond. */ controlHandoff?: ControlHandoff; - /** Inputs for Handoff checklist (derived from controlHandoff by caller). */ - handoffInputs?: HandoffChecklistInputs; + /** Closure checklist inputs for SustainmentOverview (folded in from former Handoff stage). */ + closureInputs?: SustainmentClosureInputs; /** Per-cause in-control rows for Sustainment Overview. */ sustainmentPerCauseRows?: Array<{ factor: string; inControl: boolean; observation?: string }>; /** "Open legacy Sustainment panel" handler. */ onOpenLegacySustainment?: () => void; - /** "Open legacy Handoff panel" handler. */ - onOpenLegacyHandoff?: () => void; /** "Nudge owner" handler (Plan 3 wires actual notification). */ onNudgeProcessOwner?: () => void; /** Activity/signoff inputs for the team rail. */ @@ -76,7 +73,6 @@ export interface IPDetailPageProps { } function defaultActiveStage(stages: ReturnType): StageName { - if (stages.handoff === 'current') return 'handoff'; if (stages.sustainment === 'current') return 'sustainment'; if (stages.approach === 'current') return 'approach'; return 'charter'; @@ -98,11 +94,10 @@ const IPDetailPage: React.FC = ({ onOpenCauseWorkbench, sustainmentRecord, controlHandoff, - handoffInputs, + closureInputs, sustainmentPerCauseRows, onOpenLegacySustainment, - onOpenLegacyHandoff, - onNudgeProcessOwner, + onNudgeProcessOwner: _onNudgeProcessOwner, ideas, actions, now, @@ -123,15 +118,12 @@ const IPDetailPage: React.FC = ({ // ACL guard: only apply when we have an identified user AND an explicit members list. // If currentUserId is absent OR members[] is empty/absent → backward-compatible open access. - const userRole = - currentUserId !== undefined && members.length > 0 - ? members.find(m => m.userId === currentUserId)?.role - : undefined; - - const isExplicitlyExcluded = - currentUserId !== undefined && members.length > 0 && userRole === undefined; - - const isSponsor = userRole === 'sponsor'; + const hasIdentity = currentUserId !== undefined && members.length > 0; + const isExplicitlyExcluded = hasIdentity && !canAccess(currentUserId, members, 'view-report'); + const isSponsor = + hasIdentity && + canAccess(currentUserId, members, 'view-report') && + !canAccess(currentUserId, members, 'edit-charter'); const handleInviteClick = () => { onInviteClick?.(); @@ -269,16 +261,20 @@ const IPDetailPage: React.FC = ({ {activeStage === 'sustainment' && mode === 'overview' && sustainmentRecord && ( setActiveStage('handoff')} + onStartHandoff={() => setActiveStage('sustainment')} onOpenProcess={() => onJumpOut?.('process')} onOpenAnalyze={() => onJumpOut?.('analyze')} perCauseRows={sustainmentPerCauseRows} + closureInputs={closureInputs} + onNudgeOwner={_onNudgeProcessOwner} + onOpenReport={() => onJumpOut?.('report')} /> )} {activeStage === 'sustainment' && mode === 'sections' && sustainmentRecord && ( onOpenLegacySustainment?.()} + controlHandoff={controlHandoff} /> )} {activeStage === 'sustainment' && !sustainmentRecord && ( @@ -287,29 +283,6 @@ const IPDetailPage: React.FC = ({ ADR-080.

)} - - {activeStage === 'handoff' && mode === 'overview' && handoffInputs && ( - onJumpOut?.('report')} - onExportPdf={() => { - /* Plan 4 wires PDF export */ - }} - onNudgeOwner={() => onNudgeProcessOwner?.()} - /> - )} - {activeStage === 'handoff' && mode === 'sections' && controlHandoff && ( - onOpenLegacyHandoff?.()} - /> - )} - {activeStage === 'handoff' && (!handoffInputs || !controlHandoff) && ( -

- No Handoff record linked yet. Confirm Sustainment (4 consecutive on-target ticks) to - start Handoff. -

- )}
= { done: '✓', current: '', 'not-started': '○', + upcoming: '○', locked: '⏸', }; @@ -20,10 +21,9 @@ const LABEL: Record = { charter: 'Charter', approach: 'Approach', sustainment: 'Sustainment', - handoff: 'Handoff', }; -const STAGE_ORDER: StageName[] = ['charter', 'approach', 'sustainment', 'handoff']; +const STAGE_ORDER: StageName[] = ['charter', 'approach', 'sustainment']; function stageClass(state: StageState, isActive: boolean): string { if (isActive) return 'border-b-2 border-[var(--vs-accent)] text-[var(--vs-accent)] font-semibold'; diff --git a/packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx b/packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx index 163c2d4b2..0b9b35b4d 100644 --- a/packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx +++ b/packages/ui/src/components/IPDetail/__tests__/IPDetailPage.test.tsx @@ -221,7 +221,6 @@ describe('IPDetailPage', () => { expect(screen.getByRole('tab', { name: /charter/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /approach/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /sustainment/i })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: /handoff/i })).toBeInTheDocument(); }); it('renders full tab list for Member', () => { @@ -235,7 +234,7 @@ describe('IPDetailPage', () => { expect(screen.queryByRole('tab', { name: /charter/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /approach/i })).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: /sustainment/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /handoff/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /improve/i })).not.toBeInTheDocument(); expect(screen.getByTestId('sponsor-report-panel')).toBeInTheDocument(); }); @@ -253,6 +252,12 @@ describe('IPDetailPage', () => { render( {}} currentUserId="anybody@org" />); expect(screen.getByRole('tab', { name: /charter/i })).toBeInTheDocument(); }); + + it('uses canAccess view-report for Sponsor placeholder gating', () => { + render( {}} currentUserId="sponsor@org" />); + expect(screen.getByTestId('sponsor-report-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('stage-tab-charter')).not.toBeInTheDocument(); + }); }); describe('Charter team section (wedge members[])', () => { diff --git a/packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx b/packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx index a05a4e14c..2872fc805 100644 --- a/packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx +++ b/packages/ui/src/components/IPDetail/__tests__/IPDetailStageTabs.test.tsx @@ -6,23 +6,15 @@ import type { StageStateMap } from '../stageState'; const stages: StageStateMap = { charter: 'done', approach: 'current', - sustainment: 'locked', - handoff: 'locked', + sustainment: 'upcoming', }; describe('IPDetailStageTabs', () => { - it('renders all 4 stage tabs with state-specific icons', () => { + it('renders all 3 stage tabs with state-specific icons', () => { render( {}} />); expect(screen.getByTestId('stage-tab-charter')).toHaveTextContent('Charter'); expect(screen.getByTestId('stage-tab-charter')).toHaveTextContent('✓'); - expect(screen.getByTestId('stage-tab-sustainment')).toHaveTextContent('⏸'); - }); - - it('does not call onStageChange when locked stage clicked', () => { - const onChange = vi.fn(); - render(); - fireEvent.click(screen.getByTestId('stage-tab-sustainment')); - expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByTestId('stage-tab-sustainment')).toHaveTextContent('○'); }); it('calls onStageChange with the clicked stage name', () => { @@ -32,3 +24,18 @@ describe('IPDetailStageTabs', () => { expect(onChange).toHaveBeenCalledWith('charter'); }); }); + +describe('STAGE_ORDER (amendment — 3 stages)', () => { + it('contains exactly charter, approach, sustainment', () => { + const threeStages: StageStateMap = { + charter: 'current', + approach: 'upcoming', + sustainment: 'upcoming', + }; + render( {}} />); + expect(screen.getByTestId('stage-tab-charter')).toBeInTheDocument(); + expect(screen.getByTestId('stage-tab-approach')).toBeInTheDocument(); + expect(screen.getByTestId('stage-tab-sustainment')).toBeInTheDocument(); + expect(screen.queryByTestId('stage-tab-improve')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/IPDetail/__tests__/stageState.test.ts b/packages/ui/src/components/IPDetail/__tests__/stageState.test.ts index 28a30266d..16ebaed50 100644 --- a/packages/ui/src/components/IPDetail/__tests__/stageState.test.ts +++ b/packages/ui/src/components/IPDetail/__tests__/stageState.test.ts @@ -15,12 +15,11 @@ const baseIP: ImprovementProject = { }; describe('deriveStageState', () => { - it('charter is current when IP is draft with no investigation linked', () => { + it('charter is current when IP is draft', () => { const state: StageStateMap = deriveStageState(baseIP); expect(state.charter).toBe('current'); - expect(state.approach).toBe('not-started'); - expect(state.sustainment).toBe('locked'); - expect(state.handoff).toBe('locked'); + expect(state.approach).toBe('upcoming'); + expect(state.sustainment).toBe('upcoming'); }); it('approach becomes current when IP is active', () => { @@ -28,28 +27,22 @@ describe('deriveStageState', () => { const state = deriveStageState(ip); expect(state.charter).toBe('done'); expect(state.approach).toBe('current'); - expect(state.sustainment).toBe('locked'); + expect(state.sustainment).toBe('upcoming'); }); - it('sustainment unlocks when IP is closed', () => { + it('sustainment becomes current when IP is closed', () => { const ip: ImprovementProject = { ...baseIP, status: 'closed' }; const state = deriveStageState(ip); expect(state.charter).toBe('done'); expect(state.approach).toBe('done'); expect(state.sustainment).toBe('current'); - expect(state.handoff).toBe('locked'); }); - it('handoff unlocks when sustainmentConfirmed flag passed', () => { + it('all stages done when sustainmentConfirmed', () => { const ip: ImprovementProject = { ...baseIP, status: 'closed' }; const state = deriveStageState(ip, { sustainmentConfirmed: true }); + expect(state.charter).toBe('done'); + expect(state.approach).toBe('done'); expect(state.sustainment).toBe('done'); - expect(state.handoff).toBe('current'); - }); - - it('all stages done when handoff is operational', () => { - const ip: ImprovementProject = { ...baseIP, status: 'closed' }; - const state = deriveStageState(ip, { sustainmentConfirmed: true, handoffOperational: true }); - expect(state.handoff).toBe('done'); }); }); diff --git a/packages/ui/src/components/IPDetail/index.ts b/packages/ui/src/components/IPDetail/index.ts index c29c2c1d0..3bcc0e577 100644 --- a/packages/ui/src/components/IPDetail/index.ts +++ b/packages/ui/src/components/IPDetail/index.ts @@ -6,4 +6,4 @@ export type { StageState, StageStateMap, StageStateInputs } from './stageState'; export { deriveStageState } from './stageState'; export type { CauseRow, CauseStatus, CauseProjectionInputs } from './stages/causeProjection'; export { projectCauses } from './stages/causeProjection'; -export type { HandoffChecklistInputs } from './stages/HandoffOverview'; +export type { SustainmentClosureInputs } from './stages/SustainmentOverview'; diff --git a/packages/ui/src/components/IPDetail/stageState.ts b/packages/ui/src/components/IPDetail/stageState.ts index 19ac417e6..d8ed4077c 100644 --- a/packages/ui/src/components/IPDetail/stageState.ts +++ b/packages/ui/src/components/IPDetail/stageState.ts @@ -1,48 +1,45 @@ import type { ImprovementProject } from '@variscout/core/improvementProject'; -export type StageState = 'done' | 'current' | 'not-started' | 'locked'; +export type StageState = 'done' | 'current' | 'not-started' | 'upcoming' | 'locked'; export interface StageStateMap { charter: StageState; approach: StageState; sustainment: StageState; - handoff: StageState; } export interface StageStateInputs { /** True when the linked SustainmentRecord has reached confirmed-sustained status. */ sustainmentConfirmed?: boolean; - /** True when the linked ControlHandoff has reached operational status. */ - handoffOperational?: boolean; } /** - * Pure derivation of the 4-stage state from an ImprovementProject + optional + * Pure derivation of the 3-stage state from an ImprovementProject + optional * linked-artifact signals. Used by IPDetailStageTabs to render the visual * state (✓ done / current with underline / ○ not-started / ⏸ locked). + * + * Stage order: Charter → Approach → Sustainment. + * Improve is a top-level tab (not a project detail stage) per wedge amendment 2026-05-16. + * Sustainment becomes current when IP is closed. */ export function deriveStageState( ip: ImprovementProject, inputs: StageStateInputs = {} ): StageStateMap { - const { sustainmentConfirmed = false, handoffOperational = false } = inputs; - - if (handoffOperational) { - return { charter: 'done', approach: 'done', sustainment: 'done', handoff: 'done' }; - } + const { sustainmentConfirmed = false } = inputs; if (sustainmentConfirmed) { - return { charter: 'done', approach: 'done', sustainment: 'done', handoff: 'current' }; + return { charter: 'done', approach: 'done', sustainment: 'done' }; } if (ip.status === 'closed') { - return { charter: 'done', approach: 'done', sustainment: 'current', handoff: 'locked' }; + return { charter: 'done', approach: 'done', sustainment: 'current' }; } if (ip.status === 'active') { - return { charter: 'done', approach: 'current', sustainment: 'locked', handoff: 'locked' }; + return { charter: 'done', approach: 'current', sustainment: 'upcoming' }; } // draft - return { charter: 'current', approach: 'not-started', sustainment: 'locked', handoff: 'locked' }; + return { charter: 'current', approach: 'upcoming', sustainment: 'upcoming' }; } diff --git a/packages/ui/src/components/IPDetail/stages/CharterOverview.tsx b/packages/ui/src/components/IPDetail/stages/CharterOverview.tsx index a22f41955..b7296f4b4 100644 --- a/packages/ui/src/components/IPDetail/stages/CharterOverview.tsx +++ b/packages/ui/src/components/IPDetail/stages/CharterOverview.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { ProjectRole } from '@variscout/core/projectMembership'; +import { canAccess } from '@variscout/core/projectMembership'; import { InviteModal } from '../../projects/InviteModal'; import { MemberList } from '../../projects/MemberList'; @@ -34,6 +35,11 @@ const CharterOverview: React.FC = ({ }) => { const [inviteOpen, setInviteOpen] = useState(false); const members = ip.metadata.members ?? []; + // Empty members[] is open-access (mirrors IPDetailPage hasIdentity escape): legacy IPs + // without wedge membership data fall back to pre-WV1-1 behavior where Invite was visible. + const canManageMembership = + currentUserId !== undefined && + (members.length === 0 || canAccess(currentUserId, members, 'manage-membership')); const issueSnapshot = ip.sections.background.snapshotText ?? '—'; const goalSet = isGoalSet(ip); const hypoCount = ip.sections.investigationLineage.hypothesisIds?.length ?? 0; @@ -132,13 +138,15 @@ const CharterOverview: React.FC = ({
Team
- + {canManageMembership && ( + + )}
{members.length > 0 && currentUserId !== undefined ? (
diff --git a/packages/ui/src/components/IPDetail/stages/HandoffOverview.tsx b/packages/ui/src/components/IPDetail/stages/HandoffOverview.tsx deleted file mode 100644 index cd7b63fc7..000000000 --- a/packages/ui/src/components/IPDetail/stages/HandoffOverview.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; - -export interface HandoffChecklistInputs { - controlPlanDocumented: boolean; - trainingDelivered: boolean; - cadenceAssigned: boolean; - processOwnerAcknowledged: boolean; - controlPlanRef?: string; - trainingRef?: string; - cadenceOwner?: string; - acknowledgmentReminder?: string; -} - -interface HandoffOverviewProps { - inputs: HandoffChecklistInputs; - onOpenReport: () => void; - onExportPdf: () => void; - onNudgeOwner: () => void; -} - -interface ChecklistItem { - key: keyof Pick< - HandoffChecklistInputs, - 'controlPlanDocumented' | 'trainingDelivered' | 'cadenceAssigned' | 'processOwnerAcknowledged' - >; - label: string; - description: (i: HandoffChecklistInputs) => string; -} - -const ITEMS: ChecklistItem[] = [ - { - key: 'controlPlanDocumented', - label: 'Control plan documented', - description: i => i.controlPlanRef ?? 'No control plan linked', - }, - { - key: 'trainingDelivered', - label: 'Training materials delivered', - description: i => i.trainingRef ?? 'No training acknowledgments on file', - }, - { - key: 'cadenceAssigned', - label: 'Monitoring cadence assigned', - description: i => i.cadenceOwner ?? 'No owner assigned', - }, - { - key: 'processOwnerAcknowledged', - label: 'Process Owner acknowledgment', - description: i => i.acknowledgmentReminder ?? 'Pending — not yet acknowledged', - }, -]; - -const HandoffOverview: React.FC = ({ - inputs, - onOpenReport, - onExportPdf, - onNudgeOwner, -}) => { - const completed = ITEMS.filter(it => inputs[it.key] === true).length; - - return ( -
-
-
- Handoff readiness · {completed} of {ITEMS.length} items complete -
-
- -
- {ITEMS.map(item => { - const done = inputs[item.key] === true; - return ( -
-
- - {done ? '✓' : '⏳'} - -
-
{item.label}
-
- {item.description(inputs)} -
-
- {!done && item.key === 'processOwnerAcknowledged' && ( - - )} -
-
- ); - })} -
- -
-
- Continue in -
-
- - -
-
-
- ); -}; - -export default HandoffOverview; diff --git a/packages/ui/src/components/IPDetail/stages/HandoffSections.tsx b/packages/ui/src/components/IPDetail/stages/HandoffSections.tsx deleted file mode 100644 index 474e5f3a4..000000000 --- a/packages/ui/src/components/IPDetail/stages/HandoffSections.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import type { ControlHandoff } from '@variscout/core'; - -interface HandoffSectionsProps { - handoff: ControlHandoff; - onOpenLegacy?: () => void; -} - -const HandoffSections: React.FC = ({ handoff, onOpenLegacy }) => { - const title = (handoff as { title?: string }).title ?? 'Control handoff'; - return ( -
-
- Control handoff · {title} -
-

- The Handoff authoring form (control plan text, owner FK, training FK, acknowledgment toggle) - is reachable today via the legacy Handoff activeView. This Sections-mode embedding will - inline the same form in a follow-up plan; for V1 we link out. -

- -
- ); -}; - -export default HandoffSections; diff --git a/packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx b/packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx index ee9f92918..cee4ec66a 100644 --- a/packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx +++ b/packages/ui/src/components/IPDetail/stages/SustainmentOverview.tsx @@ -1,6 +1,50 @@ import React from 'react'; import type { SustainmentRecord } from '@variscout/core'; +/** Closure checklist inputs derived from a ControlHandoff or caller state. */ +export interface SustainmentClosureInputs { + controlPlanDocumented: boolean; + trainingDelivered: boolean; + cadenceAssigned: boolean; + processOwnerAcknowledged: boolean; + controlPlanRef?: string; + trainingRef?: string; + cadenceOwner?: string; + acknowledgmentReminder?: string; +} + +interface ClosureChecklistItem { + key: keyof Pick< + SustainmentClosureInputs, + 'controlPlanDocumented' | 'trainingDelivered' | 'cadenceAssigned' | 'processOwnerAcknowledged' + >; + label: string; + description: (i: SustainmentClosureInputs) => string; +} + +const CLOSURE_ITEMS: ClosureChecklistItem[] = [ + { + key: 'controlPlanDocumented', + label: 'Control plan documented', + description: i => i.controlPlanRef ?? 'No control plan linked', + }, + { + key: 'trainingDelivered', + label: 'Training materials delivered', + description: i => i.trainingRef ?? 'No training acknowledgments on file', + }, + { + key: 'cadenceAssigned', + label: 'Monitoring cadence assigned', + description: i => i.cadenceOwner ?? 'No owner assigned', + }, + { + key: 'processOwnerAcknowledged', + label: 'Process Owner acknowledgment', + description: i => i.acknowledgmentReminder ?? 'Pending — not yet acknowledged', + }, +]; + interface SustainmentOverviewProps { record: SustainmentRecord; onStartHandoff: () => void; @@ -8,6 +52,14 @@ interface SustainmentOverviewProps { onOpenAnalyze: () => void; /** Optional: per-cause in-control rows from caller (Plan 4 wires real data). */ perCauseRows?: Array<{ factor: string; inControl: boolean; observation?: string }>; + /** Optional closure checklist inputs (folded in from former Handoff stage). */ + closureInputs?: SustainmentClosureInputs; + /** Called when user clicks "Nudge" on pending process-owner acknowledgment. */ + onNudgeOwner?: () => void; + /** Called when user clicks "Report · final summary". */ + onOpenReport?: () => void; + /** Called when user clicks "Export PDF for audit". */ + onExportPdf?: () => void; } const SUSTAINMENT_THRESHOLD = 4; @@ -18,6 +70,10 @@ const SustainmentOverview: React.FC = ({ onOpenProcess, onOpenAnalyze, perCauseRows = [], + closureInputs, + onNudgeOwner, + onOpenReport, + onExportPdf, }) => { const ticks = Math.max(0, record.consecutiveOnTargetTicks); const visibleTicks = Math.min(ticks, 8); @@ -72,6 +128,80 @@ const SustainmentOverview: React.FC = ({
)} + {closureInputs && ( +
+
+
+ Sustainment closure ·{' '} + {CLOSURE_ITEMS.filter(it => closureInputs[it.key] === true).length} of{' '} + {CLOSURE_ITEMS.length} items complete +
+
+ +
+ {CLOSURE_ITEMS.map(item => { + const done = closureInputs[item.key] === true; + return ( +
+
+ + {done ? '✓' : '⏳'} + +
+
{item.label}
+
+ {item.description(closureInputs)} +
+
+ {!done && item.key === 'processOwnerAcknowledged' && ( + + )} +
+
+ ); + })} +
+ + {(onOpenReport || onExportPdf) && ( +
+
+ Continue in +
+
+ {onOpenReport && ( + + )} + {onExportPdf && ( + + )} +
+
+ )} +
+ )} +
Continue in diff --git a/packages/ui/src/components/IPDetail/stages/SustainmentSections.tsx b/packages/ui/src/components/IPDetail/stages/SustainmentSections.tsx index 3a803c44f..09ab6612b 100644 --- a/packages/ui/src/components/IPDetail/stages/SustainmentSections.tsx +++ b/packages/ui/src/components/IPDetail/stages/SustainmentSections.tsx @@ -1,12 +1,25 @@ import React from 'react'; -import type { SustainmentRecord } from '@variscout/core'; +import type { SustainmentRecord, ControlHandoff } from '@variscout/core'; interface SustainmentSectionsProps { record: SustainmentRecord; onOpenLegacy?: () => void; + /** Optional ControlHandoff entity when closure is in progress. */ + controlHandoff?: ControlHandoff; + /** Called when user clicks "Open legacy Handoff panel" from closure section. */ + onOpenLegacyHandoff?: () => void; } -const SustainmentSections: React.FC = ({ record, onOpenLegacy }) => { +const SustainmentSections: React.FC = ({ + record, + onOpenLegacy, + controlHandoff, + onOpenLegacyHandoff, +}) => { + const handoffTitle = controlHandoff + ? ((controlHandoff as { title?: string }).title ?? 'Control handoff') + : null; + return (
@@ -25,6 +38,27 @@ const SustainmentSections: React.FC = ({ record, onOpe > Open legacy Sustainment panel + + {controlHandoff && ( +
+
+ Control handoff · {handoffTitle} +
+

+ The Handoff authoring form (control plan text, owner FK, training FK, acknowledgment + toggle) is reachable today via the legacy Handoff activeView. This Sections-mode + embedding will inline the same form in a follow-up plan; for V1 we link out. +

+ +
+ )}
); }; diff --git a/packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx b/packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx index f7b17c613..568748803 100644 --- a/packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx +++ b/packages/ui/src/components/IPDetail/stages/__tests__/CharterOverview.test.tsx @@ -155,5 +155,23 @@ describe('CharterOverview', () => { ); expect(screen.queryByRole('button', { name: /invite team/i })).not.toBeInTheDocument(); }); + + it('hides the Invite button for non-Lead viewers even when onInvite is provided', () => { + const charterIPWithMembers: ImprovementProject = { + ...baseIP, + metadata: { ...baseIP.metadata, members: twoMembers }, + }; + render( + {}} + onOpenAnalyze={() => {}} + currentUserId="member@org" + onInvite={() => {}} + onMemberRemove={() => {}} + /> + ); + expect(screen.queryByRole('button', { name: /invite team/i })).not.toBeInTheDocument(); + }); }); }); diff --git a/packages/ui/src/components/IPDetail/stages/__tests__/HandoffOverview.test.tsx b/packages/ui/src/components/IPDetail/stages/__tests__/HandoffOverview.test.tsx deleted file mode 100644 index 3bc18c328..000000000 --- a/packages/ui/src/components/IPDetail/stages/__tests__/HandoffOverview.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import HandoffOverview, { type HandoffChecklistInputs } from '../HandoffOverview'; - -const allDone: HandoffChecklistInputs = { - controlPlanDocumented: true, - trainingDelivered: true, - cadenceAssigned: true, - processOwnerAcknowledged: true, -}; - -const pendingAck: HandoffChecklistInputs = { - ...allDone, - processOwnerAcknowledged: false, -}; - -describe('HandoffOverview', () => { - it('shows 4 of 4 complete when all done', () => { - render( - {}} - onExportPdf={() => {}} - onNudgeOwner={() => {}} - /> - ); - expect(screen.getByText(/4 of 4 items complete/i)).toBeInTheDocument(); - }); - - it('shows 3 of 4 complete when one item is pending', () => { - render( - {}} - onExportPdf={() => {}} - onNudgeOwner={() => {}} - /> - ); - expect(screen.getByText(/3 of 4 items complete/i)).toBeInTheDocument(); - }); - - it('calls onNudgeOwner when nudge clicked on pending ack', () => { - const onNudge = vi.fn(); - render( - {}} - onExportPdf={() => {}} - onNudgeOwner={onNudge} - /> - ); - fireEvent.click(screen.getByTestId('handoff-nudge-owner')); - expect(onNudge).toHaveBeenCalled(); - }); -}); diff --git a/packages/ui/src/components/IPDetail/stages/__tests__/SustainmentOverview.test.tsx b/packages/ui/src/components/IPDetail/stages/__tests__/SustainmentOverview.test.tsx index c82489a19..75cd04514 100644 --- a/packages/ui/src/components/IPDetail/stages/__tests__/SustainmentOverview.test.tsx +++ b/packages/ui/src/components/IPDetail/stages/__tests__/SustainmentOverview.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import type { SustainmentRecord } from '@variscout/core'; -import SustainmentOverview from '../SustainmentOverview'; +import SustainmentOverview, { type SustainmentClosureInputs } from '../SustainmentOverview'; const record: SustainmentRecord = { id: 'sr-1', @@ -20,6 +20,18 @@ const record: SustainmentRecord = { lastEvaluatedSnapshotId: undefined, }; +const allDone: SustainmentClosureInputs = { + controlPlanDocumented: true, + trainingDelivered: true, + cadenceAssigned: true, + processOwnerAcknowledged: true, +}; + +const pendingAck: SustainmentClosureInputs = { + ...allDone, + processOwnerAcknowledged: false, +}; + describe('SustainmentOverview', () => { it('renders 4 cadence tick pills matching consecutiveOnTargetTicks', () => { render( @@ -72,3 +84,63 @@ describe('SustainmentOverview', () => { expect(onStart).toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// Sustainment closure panel (folded in from former Handoff stage) +// --------------------------------------------------------------------------- + +describe('SustainmentOverview — closure panel', () => { + it('shows 4 of 4 items complete when all done', () => { + render( + {}} + onOpenProcess={() => {}} + onOpenAnalyze={() => {}} + closureInputs={allDone} + /> + ); + expect(screen.getByText(/4 of 4 items complete/i)).toBeInTheDocument(); + }); + + it('shows 3 of 4 items complete when process-owner acknowledgment is pending', () => { + render( + {}} + onOpenProcess={() => {}} + onOpenAnalyze={() => {}} + closureInputs={pendingAck} + /> + ); + expect(screen.getByText(/3 of 4 items complete/i)).toBeInTheDocument(); + }); + + it('calls onNudgeOwner when Nudge clicked on pending acknowledgment', () => { + const onNudge = vi.fn(); + render( + {}} + onOpenProcess={() => {}} + onOpenAnalyze={() => {}} + closureInputs={pendingAck} + onNudgeOwner={onNudge} + /> + ); + fireEvent.click(screen.getByTestId('sustainment-closure-nudge-owner')); + expect(onNudge).toHaveBeenCalled(); + }); + + it('does not render closure panel when closureInputs is absent', () => { + render( + {}} + onOpenProcess={() => {}} + onOpenAnalyze={() => {}} + /> + ); + expect(screen.queryByText(/Sustainment closure/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Improve/ImproveStage.tsx b/packages/ui/src/components/Improve/ImproveStage.tsx new file mode 100644 index 000000000..93eab39f4 --- /dev/null +++ b/packages/ui/src/components/Improve/ImproveStage.tsx @@ -0,0 +1,152 @@ +import { useState, type FormEvent, type ComponentProps } from 'react'; +import type { ActionItem } from '@variscout/core/findings'; +import { canAccess, type ProjectMember } from '@variscout/core/projectMembership'; +import { ImprovementWorkspaceBase } from '../ImprovementPlan/ImprovementWorkspaceBase'; + +export interface ImproveStageProps { + projectId: string; + actions: ActionItem[]; + members: ProjectMember[]; + currentUserId?: string; + onActionAdd: (action: Pick) => void; + onActionUpdate: ( + actionId: string, + patch: Partial> + ) => void; + onActionRemove: (actionId: string) => void; + /** Optional pass-throughs for the Advanced PDCA workbench. + * Forwarded verbatim to ImprovementWorkspaceBase. */ + advancedProps?: Partial>; +} + +export function ImproveStage({ + projectId, + actions, + members, + currentUserId, + onActionAdd, + onActionUpdate, + onActionRemove, + advancedProps, +}: ImproveStageProps) { + // Empty members[] is open-access (mirrors IPDetailPage hasIdentity escape): legacy IPs + // without wedge membership data fall back to pre-WV1-1 behavior where edits were visible. + const canEdit = + currentUserId !== undefined && + (members.length === 0 || canAccess(currentUserId, members, 'edit-improve')); + const [showAdvanced, setShowAdvanced] = useState(false); + const [addOpen, setAddOpen] = useState(false); + const [newTitle, setNewTitle] = useState(''); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const trimmed = newTitle.trim(); + if (!trimmed) return; + onActionAdd({ text: trimmed, parentImprovementProjectId: projectId }); + setNewTitle(''); + setAddOpen(false); + }; + + return ( +
+
+

Actions

+
+ {canEdit && !showAdvanced && ( + + )} + +
+
+ + {showAdvanced ? ( + + ) : ( + <> + {addOpen && canEdit && ( +
+ +
+ + +
+
+ )} + + {actions.length === 0 ? ( +

No actions yet.

+ ) : ( +
    + {actions.map(a => ( +
  • +
    + {a.text} + {a.status ?? 'open'} +
    +
    + {a.assignedTo?.displayName && {a.assignedTo.displayName}} + {a.dueAt && Due {a.dueAt}} +
    + {canEdit && ( +
    + + +
    + )} +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/packages/ui/src/components/Improve/ImproveTabRoot.tsx b/packages/ui/src/components/Improve/ImproveTabRoot.tsx new file mode 100644 index 000000000..152da4215 --- /dev/null +++ b/packages/ui/src/components/Improve/ImproveTabRoot.tsx @@ -0,0 +1,44 @@ +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import type { ActionItem } from '@variscout/core/findings'; +import { ImproveStage } from './ImproveStage'; +import { NoActiveProjectGuidance } from './NoActiveProjectGuidance'; + +export interface ImproveTabRootProps { + activeIP: ImprovementProject | null; + actions: ActionItem[]; + currentUserId?: string; + onGoHome: () => void; + onActionAdd: (action: Pick) => void; + onActionUpdate: ( + actionId: string, + patch: Partial> + ) => void; + onActionRemove: (actionId: string) => void; +} + +export function ImproveTabRoot({ + activeIP, + actions, + currentUserId, + onGoHome, + onActionAdd, + onActionUpdate, + onActionRemove, +}: ImproveTabRootProps) { + if (activeIP === null) { + return ; + } + const members = activeIP.metadata.members ?? []; + const scopedActions = actions.filter(a => a.parentImprovementProjectId === activeIP.id); + return ( + + ); +} diff --git a/packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx b/packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx new file mode 100644 index 000000000..aedc9710f --- /dev/null +++ b/packages/ui/src/components/Improve/NoActiveProjectGuidance.tsx @@ -0,0 +1,22 @@ +export interface NoActiveProjectGuidanceProps { + onGoHome: () => void; +} + +export function NoActiveProjectGuidance({ onGoHome }: NoActiveProjectGuidanceProps) { + return ( +
+

No active project

+

+ Improvement work happens inside a chartered project. Pick a project from Home, or create a + new one to start tracking actions and ideating with the PDCA workbench. +

+ +
+ ); +} diff --git a/packages/ui/src/components/Improve/__tests__/ImproveStage.test.tsx b/packages/ui/src/components/Improve/__tests__/ImproveStage.test.tsx new file mode 100644 index 000000000..7ef3b9e0b --- /dev/null +++ b/packages/ui/src/components/Improve/__tests__/ImproveStage.test.tsx @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// vi.mock BEFORE component imports per testing.md rule. +// ImprovementWorkspaceBase uses useTranslation (i18n) and lucide icons that need mocking +// to keep this test hermetic. Runtime integration verified via --chrome browser walk. +vi.mock('../../../components/ImprovementPlan/ImprovementWorkspaceBase', () => ({ + ImprovementWorkspaceBase: () => ( +
ImprovementWorkspaceBase
+ ), +})); + +import { fireEvent, render, screen, cleanup } from '@testing-library/react'; +import { ImproveStage } from '../ImproveStage'; +import type { ActionItem } from '@variscout/core/findings'; +import type { ProjectMember } from '@variscout/core/projectMembership'; + +const leadMembers: ProjectMember[] = [ + { + id: 'pm-1', + createdAt: 1, + deletedAt: null, + userId: 'lead@org', + displayName: 'Lead', + role: 'lead', + invitedAt: 1, + }, +]; + +const actions: ActionItem[] = [ + { + id: 'ai-1', + createdAt: 1, + deletedAt: null, + text: 'Run a pilot on Line 3', + assignedTo: { displayName: 'Mira', upn: 'mira@org' }, + dueAt: '2026-06-01', + status: 'open', + parentImprovementProjectId: 'ip-1', + }, + { + id: 'ai-2', + createdAt: 2, + deletedAt: null, + text: 'Document the new SOP', + status: 'done', + parentImprovementProjectId: 'ip-1', + }, +]; + +describe('ImproveStage', () => { + it('renders the scoped ActionItem list', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText('Run a pilot on Line 3')).toBeInTheDocument(); + expect(screen.getByText('Document the new SOP')).toBeInTheDocument(); + }); + + it('shows owner display name when present', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText('Mira')).toBeInTheDocument(); + }); + + it('renders an Add Action affordance for users with edit-improve', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('button', { name: /add action/i })).toBeInTheDocument(); + }); + + it('hides Add Action for users without edit-improve (Sponsor)', () => { + const mixedMembers: ProjectMember[] = [ + ...leadMembers, + { + id: 'pm-2', + createdAt: 1, + deletedAt: null, + userId: 'sponsor@org', + displayName: 'Sponsor', + role: 'sponsor', + invitedAt: 1, + }, + ]; + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.queryByRole('button', { name: /add action/i })).not.toBeInTheDocument(); + }); + + it('calls onActionAdd with a typed payload when Add is submitted', () => { + const onActionAdd = vi.fn(); + render( + {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /add action/i })); + fireEvent.change(screen.getByLabelText(/title/i), { target: { value: 'New action' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onActionAdd).toHaveBeenCalledWith( + expect.objectContaining({ text: 'New action', parentImprovementProjectId: 'ip-1' }) + ); + }); + + it('renders an empty state when there are no actions', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByText(/no actions yet/i)).toBeInTheDocument(); + }); +}); + +describe('ImproveStage advanced toggle', () => { + beforeEach(() => { + cleanup(); + }); + + it('renders simple tracker by default', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); + expect(screen.queryByTestId('improvement-workspace-base')).not.toBeInTheDocument(); + }); + + it('switches to Advanced workbench (ImprovementWorkspaceBase) when toggle clicked', () => { + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /advanced/i })); + expect(screen.getByTestId('improvement-workspace-base')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx b/packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx new file mode 100644 index 000000000..0e417f43a --- /dev/null +++ b/packages/ui/src/components/Improve/__tests__/ImproveTabRoot.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; + +// vi.mock BEFORE component imports per testing.md rule. +vi.mock('../../../components/ImprovementPlan/ImprovementWorkspaceBase', () => ({ + ImprovementWorkspaceBase: () => ( +
ImprovementWorkspaceBase
+ ), +})); + +import { fireEvent, render, screen } from '@testing-library/react'; +import { ImproveTabRoot } from '../ImproveTabRoot'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import type { ProjectMember } from '@variscout/core/projectMembership'; +import type { ActionItem } from '@variscout/core/findings'; + +const ip: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + updatedAt: 0, + deletedAt: null, + status: 'active', + metadata: { + title: 'Test IP', + members: [ + { + id: 'pm-1', + createdAt: 1, + deletedAt: null, + userId: 'lead@org', + displayName: 'Lead', + role: 'lead', + invitedAt: 1, + } satisfies ProjectMember, + ], + }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', baseline: 0.5, target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, +}; + +const actions: ActionItem[] = []; + +describe('ImproveTabRoot', () => { + it('renders NoActiveProjectGuidance when activeIP is null', () => { + render( + {}} + onActionAdd={() => {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /no active project/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /actions/i })).not.toBeInTheDocument(); + }); + + it('renders ImproveStage scoped to activeIP when set', () => { + render( + {}} + onActionAdd={() => {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + expect(screen.getByRole('heading', { name: /actions/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /no active project/i })).not.toBeInTheDocument(); + }); + + it('passes onGoHome from NoActiveProjectGuidance through correctly', () => { + const onGoHome = vi.fn(); + render( + {}} + onActionUpdate={() => {}} + onActionRemove={() => {}} + /> + ); + fireEvent.click(screen.getByRole('button', { name: /go to home/i })); + expect(onGoHome).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx b/packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx new file mode 100644 index 000000000..df1abc592 --- /dev/null +++ b/packages/ui/src/components/Improve/__tests__/NoActiveProjectGuidance.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { NoActiveProjectGuidance } from '../NoActiveProjectGuidance'; + +describe('NoActiveProjectGuidance', () => { + it('renders the "No active project" heading + body copy', () => { + render( {}} />); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /no active project/i })).toBeInTheDocument(); + expect( + screen.getByText(/improvement work happens inside a chartered project/i) + ).toBeInTheDocument(); + }); + + it('renders a "Go to Home" button', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /go to home/i })).toBeInTheDocument(); + }); + + it('calls onGoHome when the button is clicked', () => { + const onGoHome = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /go to home/i })); + expect(onGoHome).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/components/Improve/index.ts b/packages/ui/src/components/Improve/index.ts new file mode 100644 index 000000000..719eb44ba --- /dev/null +++ b/packages/ui/src/components/Improve/index.ts @@ -0,0 +1,6 @@ +export { ImproveTabRoot, type ImproveTabRootProps } from './ImproveTabRoot'; +export { ImproveStage, type ImproveStageProps } from './ImproveStage'; +export { + NoActiveProjectGuidance, + type NoActiveProjectGuidanceProps, +} from './NoActiveProjectGuidance'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2734c5454..cead2e998 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -840,3 +840,6 @@ export type { // IP Detail workspace (Plan 2 — composition; Plans 3-5 fill content) export * from './components/IPDetail'; + +// Improve tab orchestration (wedge V1 top-level Improve tab) +export * from './components/Improve';