From 6d21c648581e1cf35c6b0fea9bb384eaa02d4ba0 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 30 May 2026 15:15:27 +0300 Subject: [PATCH 1/5] feat(core): extend MeasurementPlan to the DCP shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename factor→primaryFactor, drop msaRequired, add outcome/neededFactors[]/ scope:ConditionLeaf[]/processLocation/opDef?/msaNote? per spec §7.1. ConditionLeaf imported from findings/hypothesisCondition (not redefined). Reducer in actions.ts is unchanged (pure spread-merge auto-widens). Update types.test.ts to assert the DCP shape + hypothesisId immutability guard. Update actions.test.ts basePlan fixture to the new shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../measurementPlan/__tests__/actions.test.ts | 13 ++-- .../measurementPlan/__tests__/types.test.ts | 64 ++++++++++++++++--- packages/core/src/measurementPlan/types.ts | 50 ++++++++++++++- 3 files changed, 111 insertions(+), 16 deletions(-) diff --git a/packages/core/src/measurementPlan/__tests__/actions.test.ts b/packages/core/src/measurementPlan/__tests__/actions.test.ts index ccf8c7730..c1ce7e605 100644 --- a/packages/core/src/measurementPlan/__tests__/actions.test.ts +++ b/packages/core/src/measurementPlan/__tests__/actions.test.ts @@ -7,11 +7,15 @@ const basePlan: MeasurementPlan = { createdAt: 100, deletedAt: null, hypothesisId: 'h-1', - factor: 'Nozzle temperature', + outcome: 'Fill Weight', + primaryFactor: 'Nozzle temperature', + neededFactors: [], method: 'sensor', sampleSize: 50, owner: 'pm-1', status: 'planned', + scope: [], + processLocation: '', }; describe('reduceMeasurementPlans — MEASUREMENT_PLAN_ADD', () => { @@ -45,7 +49,7 @@ describe('reduceMeasurementPlans — MEASUREMENT_PLAN_UPDATE', () => { }); it('leaves non-matching plans unchanged', () => { - const otherPlan: MeasurementPlan = { ...basePlan, id: 'mp-2', factor: 'Other' }; + const otherPlan: MeasurementPlan = { ...basePlan, id: 'mp-2', primaryFactor: 'Other' }; const next = reduceMeasurementPlans([basePlan, otherPlan], { kind: 'MEASUREMENT_PLAN_UPDATE', planId: 'mp-1', @@ -65,7 +69,7 @@ describe('reduceMeasurementPlans — MEASUREMENT_PLAN_REMOVE', () => { expect(next).toHaveLength(1); expect(next[0].deletedAt).toBe(200); expect(next[0].id).toBe('mp-1'); - expect(next[0].factor).toBe(basePlan.factor); + expect(next[0].primaryFactor).toBe(basePlan.primaryFactor); }); it('does not mutate input', () => { @@ -120,7 +124,8 @@ describe('MeasurementPlanPatch', () => { const _patch3: MeasurementPlanPatch = { deletedAt: 999 }; // @ts-expect-error hypothesisId in Omit list const _patch4: MeasurementPlanPatch = { hypothesisId: 'h-other' }; - // Allowed: status, factor, sampleSize, owner, method, linkedFindingIds, msaRequired + // Allowed: status, primaryFactor, outcome, neededFactors, sampleSize, owner, method, + // scope, processLocation, opDef, msaNote, linkedFindingIds const _patch5: MeasurementPlanPatch = { status: 'complete', sampleSize: 100 }; expect(true).toBe(true); }); diff --git a/packages/core/src/measurementPlan/__tests__/types.test.ts b/packages/core/src/measurementPlan/__tests__/types.test.ts index 34ffcfc0c..97235627b 100644 --- a/packages/core/src/measurementPlan/__tests__/types.test.ts +++ b/packages/core/src/measurementPlan/__tests__/types.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { MeasurementPlan, MeasurementMethod, MeasurementPlanStatus } from '../types'; +import type { ConditionLeaf } from '../../findings/hypothesisCondition'; import type { ProjectMember } from '../../projectMembership/types'; describe('MeasurementMethod', () => { @@ -22,42 +23,87 @@ describe('MeasurementPlanStatus', () => { }); }); -describe('MeasurementPlan', () => { - it('has the wedge spec §3.6.3 shape', () => { +describe('MeasurementPlan — DCP shape (spec §7.1)', () => { + it('has the full DCP field set', () => { const ownerId: ProjectMember['id'] = 'pm-1'; + const scope: ConditionLeaf[] = [{ kind: 'leaf', column: 'SHIFT', op: 'eq', value: 'night' }]; const plan: MeasurementPlan = { id: 'mp-1', createdAt: 100, deletedAt: null, hypothesisId: 'h-1', - factor: 'Nozzle temperature', - method: 'sensor', + outcome: 'Fill Weight (g)', + primaryFactor: 'Nozzle temperature', + neededFactors: ['SHIFT', 'Operator'], sampleSize: 50, + method: 'sensor', owner: ownerId, status: 'planned', + scope, + processLocation: 'step-fill-1', }; - expect(plan.factor).toBe('Nozzle temperature'); + expect(plan.primaryFactor).toBe('Nozzle temperature'); + expect(plan.outcome).toBe('Fill Weight (g)'); + expect(plan.neededFactors).toEqual(['SHIFT', 'Operator']); + expect(plan.scope).toEqual(scope); + expect(plan.processLocation).toBe('step-fill-1'); expect(plan.method).toBe('sensor'); expect(plan.sampleSize).toBe(50); expect(plan.status).toBe('planned'); expect(plan.hypothesisId).toBe('h-1'); }); - it('supports optional linkedFindingIds + msaRequired', () => { + it('allows empty scope array (no active drill chips)', () => { const plan: MeasurementPlan = { id: 'mp-2', createdAt: 100, deletedAt: null, hypothesisId: 'h-1', - factor: 'X', + outcome: 'Y', + primaryFactor: 'X', + neededFactors: [], + sampleSize: 10, method: 'gemba-walk', + owner: 'pm-1', + status: 'complete', + scope: [], + processLocation: '', + }; + expect(plan.scope).toEqual([]); + expect(plan.processLocation).toBe(''); + }); + + it('supports optional opDef, msaNote, and linkedFindingIds', () => { + const plan: MeasurementPlan = { + id: 'mp-3', + createdAt: 100, + deletedAt: null, + hypothesisId: 'h-1', + outcome: 'Y', + primaryFactor: 'X', + neededFactors: [], sampleSize: 10, + method: 'gemba-walk', owner: 'pm-1', status: 'complete', + scope: [], + processLocation: '', + opDef: 'Measure at station 3 before packaging', + msaNote: 'Gage R&R study planned Q3', linkedFindingIds: ['f-1', 'f-2'], - msaRequired: true, }; + expect(plan.opDef).toBe('Measure at station 3 before packaging'); + expect(plan.msaNote).toBe('Gage R&R study planned Q3'); expect(plan.linkedFindingIds).toEqual(['f-1', 'f-2']); - expect(plan.msaRequired).toBe(true); + }); + + it('hypothesisId is excluded from MeasurementPlanPatch (immutability at type level)', () => { + // The Omit<> in actions.ts enforces that hypothesisId cannot be patched. + // This test documents the contract; tsc catches violations at compile time. + // @ts-expect-error hypothesisId must not appear in MeasurementPlanPatch + const _bad: import('../actions').MeasurementPlanPatch = { hypothesisId: 'h-other' }; + // Allowed: any field except identity/lifecycle/hypothesisId + const _ok: import('../actions').MeasurementPlanPatch = { status: 'complete', sampleSize: 100 }; + expect(true).toBe(true); }); }); diff --git a/packages/core/src/measurementPlan/types.ts b/packages/core/src/measurementPlan/types.ts index 1b129a8d1..be78f2494 100644 --- a/packages/core/src/measurementPlan/types.ts +++ b/packages/core/src/measurementPlan/types.ts @@ -2,6 +2,7 @@ import type { EntityBase } from '../identity'; import type { Finding } from '../findings/types'; import type { Hypothesis } from '../findings/types'; import type { ProjectMember } from '../projectMembership/types'; +import type { ConditionLeaf } from '../findings/hypothesisCondition'; export type MeasurementMethod = | 'sensor' @@ -12,13 +13,56 @@ export type MeasurementMethod = export type MeasurementPlanStatus = 'planned' | 'in-progress' | 'complete' | 'skipped'; +/** + * DCP-aligned measurement plan attached to a Hypothesis on the Investigation Wall. + * + * Field notes: + * - `hypothesisId` is required + immutable (no plan without a Hypothesis; excluded from + * `MeasurementPlanPatch` via `Omit<>` — see actions.ts). + * - `neededFactors` = dataset column names (string[]). IM-3's column-overlap matcher + * joins on these names. Stratifiers/covariates to collect alongside `primaryFactor`. + * - `scope` is a COPY of the active WHERE leaves (drill chips) when the plan is created. + * It is NOT a reference to the `ProblemStatementScope` entity — it is a snapshot of the + * `ConditionLeaf[]` from `analysisScopeStore.categoricalFilters` at creation time. + * May be `[]` when no drill chips are active. + * - `processLocation` is the ProcessMap node id (`step-${slug}-${seq}`) this plan belongs + * to. `''` is allowed for a mapless project (ADR-087 tolerates orphaned stepIds pre-launch). + * - `opDef` and `msaNote` are informational free-text notes — they are NOT gates. + * Formal MSA / Gage R&R gates defer to V2. + */ export interface MeasurementPlan extends EntityBase { + /** Required + immutable. (§11 #2 decision-log 2026-05-30.) */ hypothesisId: Hypothesis['id']; - factor: string; - method: MeasurementMethod; + /** The Y being studied on this plan. */ + outcome: string; + /** RENAMED from `factor`. The primary X being measured. */ + primaryFactor: string; + /** + * Stratifiers / covariates to collect alongside `primaryFactor`. + * VALUES ARE DATASET COLUMN NAMES — IM-3's column-overlap matcher depends on this contract. + */ + neededFactors: string[]; sampleSize: number; + method: MeasurementMethod; owner: ProjectMember['id']; status: MeasurementPlanStatus; + /** + * Active WHERE drill-chip conditions captured at plan creation time. + * This is a COPY (snapshot), NOT a reference to the `ProblemStatementScope` entity. + * May be `[]` when no drill chips were active. + */ + scope: ConditionLeaf[]; + /** + * ProcessMap node id this plan is attached to. Resolves against canonical ProcessMap + * node ids (`step-${slug}-${seq}`). Empty string `''` is allowed for mapless projects. + */ + processLocation: string; + /** Optional operational-definition note (informational — not a maturity gate). */ + opDef?: string; + /** + * Optional MSA / Gage R&R comment (informational — not a gate). + * Replaces the removed `msaRequired: boolean` flag. + */ + msaNote?: string; linkedFindingIds?: Finding['id'][]; - msaRequired?: boolean; } From 8dfef7d6bd803bc7b9295a9e15221fc9284c3b03 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Sat, 30 May 2026 15:20:32 +0300 Subject: [PATCH 2/5] =?UTF-8?q?refactor(ui):=20rename=20MeasurementPlan.fa?= =?UTF-8?q?ctor=20=E2=86=92=20primaryFactor=20+=20AddPlanForm=20DCP=20fiel?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 (mechanical rename): factor→primaryFactor in AddPlanForm, MeasurementPlanChip, and all test fixtures (HypothesisCardWithPlans, WallCanvas, MeasurementPlanChip, both apps' applyAction makeMeasurementPlan helpers). Task 3 (AddPlanForm DCP rework): remove msaRequired checkbox/state; add outcome, neededFactors[] (comma-split), processLocation (select from stepOptions), opDef?, msaNote? textareas; new props defaultScope?/defaultOutcome?/stepOptions?. Update AddPlanForm.test.tsx with 15 tests covering all new fields + scope/stepOptions/outcome prop threading. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../persistence/__tests__/applyAction.test.ts | 6 +- .../persistence/__tests__/applyAction.test.ts | 6 +- .../components/AnalyzeWall/AddPlanForm.tsx | 158 +++++++++++++++--- .../AnalyzeWall/MeasurementPlanChip.tsx | 4 +- .../__tests__/AddPlanForm.test.tsx | 140 +++++++++++++++- .../HypothesisCardWithPlans.test.tsx | 30 ++-- .../__tests__/MeasurementPlanChip.test.tsx | 6 +- .../AnalyzeWall/__tests__/WallCanvas.test.tsx | 12 +- 8 files changed, 314 insertions(+), 48 deletions(-) diff --git a/apps/azure/src/persistence/__tests__/applyAction.test.ts b/apps/azure/src/persistence/__tests__/applyAction.test.ts index 6548648fe..b4f5808d2 100644 --- a/apps/azure/src/persistence/__tests__/applyAction.test.ts +++ b/apps/azure/src/persistence/__tests__/applyAction.test.ts @@ -679,11 +679,15 @@ function makeMeasurementPlan(id: string, hypothesisId: string): MeasurementPlan createdAt: NOW, deletedAt: null, hypothesisId, - factor: 'X', + outcome: 'Y', + primaryFactor: 'X', + neededFactors: [], method: 'sensor', sampleSize: 10, owner: 'pm-1', status: 'planned', + scope: [], + processLocation: '', }; } diff --git a/apps/pwa/src/persistence/__tests__/applyAction.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.test.ts index 26b3203ca..002afd8f8 100644 --- a/apps/pwa/src/persistence/__tests__/applyAction.test.ts +++ b/apps/pwa/src/persistence/__tests__/applyAction.test.ts @@ -639,11 +639,15 @@ function makeMeasurementPlan(id: string, hypothesisId: string): MeasurementPlan createdAt: NOW, deletedAt: null, hypothesisId, - factor: 'X', + outcome: 'Y', + primaryFactor: 'X', + neededFactors: [], method: 'sensor', sampleSize: 10, owner: 'pm-1', status: 'planned', + scope: [], + processLocation: '', }; } diff --git a/packages/ui/src/components/AnalyzeWall/AddPlanForm.tsx b/packages/ui/src/components/AnalyzeWall/AddPlanForm.tsx index 53a97f7af..ae220a486 100644 --- a/packages/ui/src/components/AnalyzeWall/AddPlanForm.tsx +++ b/packages/ui/src/components/AnalyzeWall/AddPlanForm.tsx @@ -3,18 +3,51 @@ * * Pure DOM component (no SVG). Props-based — no store access. Mounted inside * via foreignObject in Task 8. + * + * DCP-aligned fields (spec §7.1): + * - primaryFactor (required) — renamed from `factor` + * - outcome (optional pre-fill via defaultOutcome prop; else user-entered) + * - neededFactors[] — comma-separated tag entry; stored as dataset column names + * - processLocation — select from stepOptions ('' = no step assigned) + * - opDef? — optional operational-definition note + * - msaNote? — optional MSA/Gage R&R comment (replaces removed msaRequired checkbox) + * - scope — snapshot of active WHERE drill chips (passed as defaultScope; not user-editable) */ import { useState } from 'react'; import type { Hypothesis } from '@variscout/core'; import type { MeasurementPlan, MeasurementMethod } from '@variscout/core/measurementPlan'; +import type { ConditionLeaf } from '@variscout/core/findings'; import type { ProjectMember } from '@variscout/core/projectMembership'; +/** A process-step option for the processLocation picker. */ +export interface StepOption { + id: string; + label: string; +} + export interface AddPlanFormProps { hypothesisId: Hypothesis['id']; members: ProjectMember[]; onSave: (plan: Omit) => void; onCancel: () => void; + /** + * Process steps derived from `deriveProcessSteps(processMap)` at the WallCanvas level. + * When provided, renders a select for processLocation. + * When absent/empty, processLocation is set to `''` (no step). + */ + stepOptions?: StepOption[]; + /** + * Active WHERE drill-chip conditions captured at form-open time. + * Stored as a snapshot on the plan `scope` field — NOT user-editable in the form. + * Defaults to `[]` when absent. + */ + defaultScope?: ConditionLeaf[]; + /** + * Pre-fill for the outcome field (e.g. from the project/hypothesis outcome). + * User can override. Defaults to `''` when absent. + */ + defaultOutcome?: string; } const METHODS: ReadonlyArray = [ @@ -25,18 +58,30 @@ const METHODS: ReadonlyArray = [ 'other', ]; -export function AddPlanForm({ hypothesisId, members, onSave, onCancel }: AddPlanFormProps) { +export function AddPlanForm({ + hypothesisId, + members, + onSave, + onCancel, + stepOptions, + defaultScope, + defaultOutcome, +}: AddPlanFormProps) { const eligibleOwners = members.filter(m => m.role !== 'sponsor' && m.deletedAt === null); - const [factor, setFactor] = useState(''); + const [primaryFactor, setPrimaryFactor] = useState(''); + const [outcome, setOutcome] = useState(defaultOutcome ?? ''); + const [neededFactorsRaw, setNeededFactorsRaw] = useState(''); const [method, setMethod] = useState('sensor'); const [sampleSize, setSampleSize] = useState(30); const [owner, setOwner] = useState(eligibleOwners[0]?.id ?? ''); - const [msaRequired, setMsaRequired] = useState(false); + const [processLocation, setProcessLocation] = useState(''); + const [opDef, setOpDef] = useState(''); + const [msaNote, setMsaNote] = useState(''); const [error, setError] = useState(null); const handleSave = () => { - if (!factor.trim()) { - setError('Factor is required'); + if (!primaryFactor.trim()) { + setError('Primary factor is required'); return; } if (sampleSize < 1) { @@ -48,30 +93,70 @@ export function AddPlanForm({ hypothesisId, members, onSave, onCancel }: AddPlan return; } setError(null); + + // Parse comma-separated neededFactors into trimmed non-empty strings. + const neededFactors = neededFactorsRaw + .split(',') + .map(s => s.trim()) + .filter(Boolean); + onSave({ hypothesisId, - factor: factor.trim(), + outcome: outcome.trim() || defaultOutcome || '', + primaryFactor: primaryFactor.trim(), + neededFactors, method, sampleSize, owner, status: 'planned', + scope: defaultScope ?? [], + processLocation, + ...(opDef.trim() ? { opDef: opDef.trim() } : {}), + ...(msaNote.trim() ? { msaNote: msaNote.trim() } : {}), linkedFindingIds: [], - msaRequired, }); }; + const hasStepOptions = stepOptions && stepOptions.length > 0; + return (
-
+
+ setFactor(e.target.value)} + value={outcome} + onChange={e => setOutcome(e.target.value)} + placeholder={defaultOutcome ?? ''} + /> +
+
+ + setNeededFactorsRaw(e.target.value)} + placeholder="e.g. SHIFT, Operator" />
@@ -121,16 +206,49 @@ export function AddPlanForm({ hypothesisId, members, onSave, onCancel }: AddPlan ))}
-
- setMsaRequired(e.target.checked)} + {hasStepOptions && ( +
+ + +
+ )} +
+ +