diff --git a/apps/azure/src/db/schema.ts b/apps/azure/src/db/schema.ts index 9a74095a4..fdb5d8314 100644 --- a/apps/azure/src/db/schema.ts +++ b/apps/azure/src/db/schema.ts @@ -35,6 +35,8 @@ export type { EvidenceSourceCursor }; export type ProcessHubRecord = import('@variscout/core').ProcessHub; export type EvidenceSourceRecord = import('@variscout/core').EvidenceSource; export type EvidenceSnapshotRecord = import('@variscout/core').EvidenceSnapshot; +export type ImprovementProjectRecord = + import('@variscout/core/improvementProject').ImprovementProject; export class VariScoutDatabase extends Dexie { projects!: Dexie.Table; @@ -47,6 +49,7 @@ export class VariScoutDatabase extends Dexie { sustainmentReviews!: Dexie.Table; controlHandoffs!: Dexie.Table; evidenceSourceCursors!: Dexie.Table; + improvementProjects!: Dexie.Table; constructor() { super('VaRiScoutAzure'); @@ -134,6 +137,13 @@ export class VariScoutDatabase extends Dexie { this.version(9).stores({ sustainmentRecords: 'id, investigationId, hubId, nextReviewDue, updatedAt, deletedAt', }); + + // Version 10: PR-RPS-5 — ImprovementProject dedicated table. + // Mirrors the sustainmentRecords pattern (dedicated table per entity, hubId-indexed). + // No upgrade callback needed — new empty table; existing data unaffected. + this.version(10).stores({ + improvementProjects: 'id, hubId, deletedAt, status, updatedAt', + }); } } diff --git a/apps/azure/src/persistence/AzureHubRepository.ts b/apps/azure/src/persistence/AzureHubRepository.ts index e6d6a9e9a..339a55e49 100644 --- a/apps/azure/src/persistence/AzureHubRepository.ts +++ b/apps/azure/src/persistence/AzureHubRepository.ts @@ -35,7 +35,24 @@ export class AzureHubRepository implements HubRepository { // full hub blob, so no existing hub needs to be loaded first. This matches // the PWA pattern and supports the null-hub bootstrap case (first hub save). if (action.kind === 'HUB_PERSIST_SNAPSHOT') { - await saveProcessHubToIndexedDB(action.hub); + // improvementProjects live in their own table; decompose them out of the + // hub blob before saving. Mirrors the PWA HUB_PERSIST_SNAPSHOT decomposition. + const { improvementProjects, ...hubWithoutIP } = action.hub; + await db.transaction('rw', [db.processHubs, db.improvementProjects], async () => { + await saveProcessHubToIndexedDB(hubWithoutIP); + // Drop stale rows for this hub, then bulk-put incoming snapshot rows. + const incomingProjectIds = new Set((improvementProjects ?? []).map(p => p.id)); + await db.improvementProjects + .where('hubId') + .equals(action.hub.id) + .filter(p => !incomingProjectIds.has(p.id)) + .delete(); + if (improvementProjects && improvementProjects.length > 0) { + await db.improvementProjects.bulkPut( + improvementProjects.map(p => ({ ...p, hubId: action.hub.id })) + ); + } + }); return; } @@ -49,13 +66,26 @@ export class AzureHubRepository implements HubRepository { // --------------------------------------------------------------------------- hubs: HubReadAPI = { - // hubs.get is unscoped — direct id lookup; hubs.list filters tombstones + // hubs.get is unscoped — direct id lookup; hydrates improvementProjects from dedicated table. async get(id) { - return db.processHubs.get(id); + const hub = await db.processHubs.get(id); + if (!hub) return undefined; + const ips = await db.improvementProjects.where('hubId').equals(id).toArray(); + const liveIps = ips.filter(p => p.deletedAt === null); + if (liveIps.length === 0) return hub; + return { ...hub, improvementProjects: liveIps }; }, async list() { const all = await db.processHubs.toArray(); - return all.filter(h => h.deletedAt === null); + const live = all.filter(h => h.deletedAt === null); + return Promise.all( + live.map(async hub => { + const ips = await db.improvementProjects.where('hubId').equals(hub.id).toArray(); + const liveIps = ips.filter(p => p.deletedAt === null); + if (liveIps.length === 0) return hub; + return { ...hub, improvementProjects: liveIps }; + }) + ); }, }; diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts index 19f4e00b0..0868f3a62 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts @@ -104,6 +104,7 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { await db.evidenceSources.clear(); await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); + await db.improvementProjects.clear(); }); afterEach(async () => { @@ -111,6 +112,7 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { await db.evidenceSources.clear(); await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); + await db.improvementProjects.clear(); }); // ---- hubs.get ---- diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts new file mode 100644 index 000000000..80932adfc --- /dev/null +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts @@ -0,0 +1,136 @@ +// apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts +// +// Real-Dexie integration tests for AzureHubRepository.dispatch HUB_PERSIST_SNAPSHOT. +// +// These tests verify the decomposition contract using the actual Dexie schema +// (not mocks) via the 'fake-indexeddb/auto' polyfill. Coverage: +// +// 1. Hub is saved WITHOUT improvementProjects embedded in the hub blob +// 2. ImprovementProjects are written to the dedicated improvementProjects table +// 3. Empty improvementProjects array — table stays empty +// 4. Hub with no improvementProjects field — table stays empty +// 5. Stale IPs (present in DB but absent from the new snapshot) are deleted +// +// fake-indexeddb/auto must be the first import so Dexie sees the IndexedDB +// polyfill before db.ts runs its module-load side effects. + +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ProcessHub } from '@variscout/core/processHub'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { AzureHubRepository } from '../AzureHubRepository'; +import { db } from '../../db/schema'; + +// --------------------------------------------------------------------------- +// Fixture helpers — deterministic literal values +// --------------------------------------------------------------------------- + +const NOW = 1_746_352_800_000; + +function makeIP( + id: string, + hubId: string, + overrides: Partial = {} +): ImprovementProject { + return { + id, + hubId, + status: 'draft', + createdAt: NOW, + deletedAt: null, + updatedAt: NOW, + metadata: { title: id }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + ...overrides, + }; +} + +function makeHub(id: string, ips: ImprovementProject[] = []): ProcessHub { + return { + id, + name: `Hub ${id}`, + createdAt: NOW, + deletedAt: null, + improvementProjects: ips, + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown — clear tables touched by dispatch before each test. +// --------------------------------------------------------------------------- + +beforeEach(async () => { + await db.processHubs.clear(); + await db.improvementProjects.clear(); +}); + +afterEach(async () => { + await db.processHubs.clear(); + await db.improvementProjects.clear(); +}); + +// --------------------------------------------------------------------------- +// Integration tests for HUB_PERSIST_SNAPSHOT decomposition +// --------------------------------------------------------------------------- + +describe('AzureHubRepository.dispatch HUB_PERSIST_SNAPSHOT — Dexie integration', () => { + it('strips improvementProjects from the hub blob and writes them to the dedicated table', async () => { + const repo = new AzureHubRepository(); + const ip = makeIP('ip-1', 'hub-1'); + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-1', [ip]) }); + + // The hub row in processHubs must NOT embed improvementProjects + const hubRow = await db.processHubs.get('hub-1'); + expect(hubRow).toBeDefined(); + expect((hubRow as Partial | undefined)?.improvementProjects).toBeUndefined(); + + // The IP must have been written to the dedicated table + const ipRows = await db.improvementProjects.where('hubId').equals('hub-1').toArray(); + expect(ipRows).toHaveLength(1); + expect(ipRows[0].id).toBe('ip-1'); + expect(ipRows[0].hubId).toBe('hub-1'); + }); + + it('persists a hub with empty improvementProjects array — IP table stays empty', async () => { + const repo = new AzureHubRepository(); + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-2', []) }); + + const hubRow = await db.processHubs.get('hub-2'); + expect(hubRow).toBeDefined(); + const ipRows = await db.improvementProjects.where('hubId').equals('hub-2').toArray(); + expect(ipRows).toHaveLength(0); + }); + + it('persists a hub with no improvementProjects field — IP table stays empty', async () => { + const repo = new AzureHubRepository(); + // Deliberately omit improvementProjects field + const hub: ProcessHub = { id: 'hub-3', name: 'Hub 3', createdAt: NOW, deletedAt: null }; + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub }); + + const hubRow = await db.processHubs.get('hub-3'); + expect(hubRow).toBeDefined(); + const ipRows = await db.improvementProjects.where('hubId').equals('hub-3').toArray(); + expect(ipRows).toHaveLength(0); + }); + + it('cleans up stale IPs absent from the new snapshot', async () => { + const repo = new AzureHubRepository(); + // Pre-seed a stale IP directly into the table (simulates a previously saved snapshot) + await db.improvementProjects.put(makeIP('ip-stale', 'hub-4')); + + // Dispatch a snapshot that has a different IP (ip-keep) but not ip-stale + await repo.dispatch({ + kind: 'HUB_PERSIST_SNAPSHOT', + hub: makeHub('hub-4', [makeIP('ip-keep', 'hub-4')]), + }); + + const ipRows = await db.improvementProjects.where('hubId').equals('hub-4').toArray(); + expect(ipRows.map(r => r.id)).toEqual(['ip-keep']); + }); +}); diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts index d50a0647e..41b7e7786 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts @@ -16,8 +16,9 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; // vi.hoisted + vi.mock must appear before the subject imports so that vitest // can hoist them above the import block at transform time. const mocks = vi.hoisted(() => ({ - saveProcessHubToIndexedDB: vi.fn<() => Promise>(), - applyAction: vi.fn<() => Promise>(), + saveProcessHubToIndexedDB: + vi.fn<(hub: import('@variscout/core/processHub').ProcessHub) => Promise>(), + applyAction: vi.fn<(action: import('@variscout/core/actions').HubAction) => Promise>(), })); vi.mock('../../services/localDb', () => ({ @@ -38,18 +39,33 @@ vi.mock('../applyAction', () => ({ // db/schema is not used in dispatch tests — db access is blocked by the mock. // We mock the db module as well to prevent Dexie from attempting to open IndexedDB. +// improvementProjects and transaction are included because HUB_PERSIST_SNAPSHOT now +// decomposes improvementProjects within a Dexie transaction. vi.mock('../../db/schema', () => ({ db: { processHubs: { get: vi.fn(), toArray: vi.fn(), put: vi.fn(), clear: vi.fn() }, evidenceSources: { get: vi.fn(), where: vi.fn(), toArray: vi.fn(), clear: vi.fn() }, evidenceSnapshots: { get: vi.fn(), where: vi.fn(), toArray: vi.fn(), clear: vi.fn() }, evidenceSourceCursors: { get: vi.fn(), clear: vi.fn() }, + improvementProjects: { + get: vi.fn(), + where: vi.fn(() => ({ + equals: vi.fn(() => ({ filter: vi.fn(() => ({ delete: vi.fn().mockResolvedValue(0) })) })), + })), + bulkPut: vi.fn().mockResolvedValue([]), + clear: vi.fn(), + }, + // transaction executes the callback immediately (no real transaction scope needed in mocks). + transaction: vi.fn((_mode: string, _tables: unknown[], callback: () => Promise) => + callback() + ), }, })); import { AzureHubRepository } from '../AzureHubRepository'; import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; import type { HubAction } from '@variscout/core/actions'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; // --------------------------------------------------------------------------- // Fixture helpers @@ -78,6 +94,25 @@ function makeOutcome(id: string, deletedAt: number | null = null): OutcomeSpec { }; } +function makeIP(id: string, hubId: string): ImprovementProject { + return { + id, + hubId, + status: 'draft', + createdAt: NOW, + deletedAt: null, + updatedAt: NOW, + metadata: { title: id }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + }; +} + // --------------------------------------------------------------------------- // Dispatch tests // --------------------------------------------------------------------------- @@ -93,12 +128,26 @@ describe('AzureHubRepository dispatch', () => { mocks.applyAction.mockResolvedValue(undefined); }); - it('HUB_PERSIST_SNAPSHOT calls saveProcessHubToIndexedDB with the action hub', async () => { - const hub = makeHub({ id: 'hub-azure-1', name: 'Bootstrap Hub' }); - await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub }); + it('HUB_PERSIST_SNAPSHOT calls saveProcessHubToIndexedDB with improvementProjects stripped', async () => { + // Fixture includes improvementProjects so the test actually exercises the + // decomposition path (without IPs the assertion would pass vacuously because + // hubWithoutIP === hub structurally when no IPs are present). + const hubWithIPs = makeHub({ + id: 'hub-azure-1', + name: 'Bootstrap Hub', + improvementProjects: [makeIP('ip-1', 'hub-azure-1')], + }); + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub: hubWithIPs }); expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledOnce(); - expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledWith(hub); + // saveProcessHubToIndexedDB must receive a hub WITHOUT improvementProjects + expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledWith( + expect.not.objectContaining({ improvementProjects: expect.anything() }) + ); + // The other hub fields must be preserved + const callArg = mocks.saveProcessHubToIndexedDB.mock.calls[0][0] as ProcessHub; + expect(callArg.id).toBe(hubWithIPs.id); + expect(callArg.name).toBe(hubWithIPs.name); }); it('HUB_PERSIST_SNAPSHOT returns undefined (bootstrap path)', async () => { @@ -107,10 +156,14 @@ describe('AzureHubRepository dispatch', () => { }); it('HUB_PERSIST_SNAPSHOT does not throw even when called multiple times', async () => { - const hub = makeHub(); + const hub = makeHub({ improvementProjects: [makeIP('ip-x', 'hub-azure-1')] }); await expect(repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub })).resolves.toBeUndefined(); await expect(repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub })).resolves.toBeUndefined(); expect(mocks.saveProcessHubToIndexedDB).toHaveBeenCalledTimes(2); + // Both calls must have received a hub without improvementProjects + for (const [callArg] of mocks.saveProcessHubToIndexedDB.mock.calls) { + expect(callArg).not.toHaveProperty('improvementProjects'); + } }); it('propagates errors from saveProcessHubToIndexedDB', async () => { diff --git a/apps/azure/src/persistence/__tests__/applyAction.improvementProject.test.ts b/apps/azure/src/persistence/__tests__/applyAction.improvementProject.test.ts new file mode 100644 index 000000000..a49d6b57c --- /dev/null +++ b/apps/azure/src/persistence/__tests__/applyAction.improvementProject.test.ts @@ -0,0 +1,285 @@ +// apps/azure/src/persistence/__tests__/applyAction.improvementProject.test.ts +// +// PR-RPS-5 T4 — tests for IMPROVEMENT_PROJECT_CREATE / UPDATE / ARCHIVE handlers +// in the Azure applyAction dispatcher. +// +// Mirrors apps/pwa/src/persistence/__tests__/applyAction.improvementProject.test.ts +// with the following Azure-specific differences: +// - Azure applyAction signature: applyAction(action) — no db arg (uses module-scoped db). +// - Hub table is db.processHubs (not db.hubs as in PWA's normalized schema). +// - beforeEach/afterEach clear both db.processHubs and db.improvementProjects. +// +// Coverage: +// 1. CREATE inserts the project row into db.improvementProjects +// 2. CREATE with a missing hub throws (loud failure) +// 3. UPDATE metadata-only — deep-merge preserves non-supplied metadata keys +// 4. UPDATE goal-only — outcomeGoal shallow-merge preserves outcomeSpecId; +// factorControls array replaces wholesale +// 5. UPDATE sections — partial supply preserves other sub-section keys and +// shallow-merges the supplied sub-section +// 6. UPDATE sets updatedAt to Date.now() (timing-safe) +// 7. UPDATE missing project — idempotent no-op +// 8. ARCHIVE sets deletedAt to non-null timestamp +// 9. ARCHIVE missing project — idempotent no-op +// 10. UPDATE financialImpact — patches amount, preserves currency (deep-merge bonus) +// +// fake-indexeddb/auto must be the first import so Dexie sees the IndexedDB +// polyfill before db.ts runs its module-load side effects. + +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProcessHub } from '@variscout/core/processHub'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { applyAction } from '../applyAction'; +import { db } from '../../db/schema'; + +// --------------------------------------------------------------------------- +// Fixture helpers — deterministic literal values (no Date.now() / crypto.randomUUID() +// in fixture-value positions; IDs and timestamps are hardcoded literals) +// --------------------------------------------------------------------------- + +const NOW = 1_746_352_800_000; + +function makeHub(id: string, overrides: Partial = {}): ProcessHub { + return { + id, + name: `Hub ${id}`, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeProject( + id: string, + hubId: string, + overrides: Partial = {} +): ImprovementProject { + return { + id, + hubId, + status: 'draft', + createdAt: NOW, + deletedAt: null, + updatedAt: NOW, + metadata: { + title: 'Default Title', + }, + goal: { + outcomeGoal: { + outcomeSpecId: 'out-default', + target: 1.33, + }, + }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown — clear tables touched by these handlers before each test. +// --------------------------------------------------------------------------- + +beforeEach(async () => { + await db.processHubs.clear(); + await db.improvementProjects.clear(); +}); + +afterEach(async () => { + await db.processHubs.clear(); + await db.improvementProjects.clear(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_CREATE +// --------------------------------------------------------------------------- + +describe('applyAction (Azure) — IMPROVEMENT_PROJECT_CREATE', () => { + it('inserts the project row and is retrievable from db.improvementProjects', async () => { + // Seed the parent hub directly into db.processHubs (Azure's hub table). + await db.processHubs.put(makeHub('hub-1')); + + const project = makeProject('proj-1', 'hub-1', { + metadata: { title: 'My Project', businessCase: 'Save money' }, + }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-1', project }); + + const rows = await db.improvementProjects.toArray(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('proj-1'); + expect(rows[0].hubId).toBe('hub-1'); + expect(rows[0].metadata.title).toBe('My Project'); + expect(rows[0].metadata.businessCase).toBe('Save money'); + }); + + it('throws (loud-fail) when the parent hub does not exist', async () => { + const project = makeProject('proj-orphan', 'ghost-hub'); + await expect( + applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'ghost-hub', project }) + ).rejects.toThrow(/ghost-hub/); + + // No row should have been created. + expect(await db.improvementProjects.get('proj-orphan')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_UPDATE — deep-merge contract +// --------------------------------------------------------------------------- + +describe('applyAction (Azure) — IMPROVEMENT_PROJECT_UPDATE', () => { + it('metadata-only patch preserves non-supplied metadata keys (deep-merge)', async () => { + await db.processHubs.put(makeHub('hub-2')); + const project = makeProject('proj-2', 'hub-2', { + metadata: { title: 'A', businessCase: 'orig' }, + }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-2', project }); + + await applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-2', + patch: { metadata: { title: 'B' } }, + }); + + const row = await db.improvementProjects.get('proj-2'); + expect(row?.metadata).toEqual({ title: 'B', businessCase: 'orig' }); + }); + + it('goal-only patch: outcomeGoal preserved; factorControls replaces wholesale', async () => { + await db.processHubs.put(makeHub('hub-3')); + const project = makeProject('proj-3', 'hub-3', { + goal: { + outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 }, + }, + }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-3', project }); + + await applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-3', + patch: { + goal: { + outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 }, // same outcomeGoal + factorControls: [{ factor: 'X', targetCondition: '95±2' }], + }, + }, + }); + + const row = await db.improvementProjects.get('proj-3'); + expect(row?.goal.outcomeGoal.outcomeSpecId).toBe('o-1'); + expect(row?.goal.factorControls).toEqual([{ factor: 'X', targetCondition: '95±2' }]); + }); + + it('sections partial supply: supplied sub-section merges; other sub-sections preserved', async () => { + await db.processHubs.put(makeHub('hub-4')); + const project = makeProject('proj-4', 'hub-4', { + sections: { + background: { manualNarrative: 'orig' }, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-4', project }); + + await applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-4', + patch: { + sections: { background: { snapshotText: 'snap' } }, + }, + }); + + const row = await db.improvementProjects.get('proj-4'); + // background shallow-merges: both keys present + expect(row?.sections.background).toEqual({ manualNarrative: 'orig', snapshotText: 'snap' }); + // Other sub-sections must not be dropped + expect(row?.sections.investigationLineage).toBeDefined(); + expect(row?.sections.approach).toBeDefined(); + expect(row?.sections.outcomeReference).toBeDefined(); + }); + + it('sets updatedAt to Date.now() after patch', async () => { + await db.processHubs.put(makeHub('hub-5')); + const project = makeProject('proj-5', 'hub-5', { updatedAt: 1 }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-5', project }); + + const before = Date.now(); + await applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-5', + patch: { status: 'active' }, + }); + + const row = await db.improvementProjects.get('proj-5'); + expect(row?.updatedAt).toBeGreaterThanOrEqual(before); + }); + + it('is idempotent (no-op) when projectId does not exist', async () => { + await expect( + applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'ghost-proj', + patch: { status: 'active' }, + }) + ).resolves.toBeUndefined(); + + expect(await db.improvementProjects.get('ghost-proj')).toBeUndefined(); + }); + + it('financialImpact patch deep-merges: amount update preserves existing currency', async () => { + await db.processHubs.put(makeHub('hub-7')); + const project = makeProject('proj-7', 'hub-7', { + metadata: { + title: 'FI Test', + businessCase: 'Cost reduction', + financialImpact: { amount: 10, currency: 'EUR' }, + }, + }); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-7', project }); + + // Patch: update amount (currency required by the type — must be supplied). + await applyAction({ + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-7', + patch: { metadata: { title: 'FI Test', financialImpact: { amount: 20, currency: 'EUR' } } }, + }); + + const row = await db.improvementProjects.get('proj-7'); + // financialImpact deep-merges: amount updated, currency preserved. + expect(row?.metadata.financialImpact).toEqual({ amount: 20, currency: 'EUR' }); + // businessCase must be preserved (metadata shallow-merge; financialImpact is the only + // nested-deep-merge key — the outer metadata spread preserves keys not in the patch). + expect(row?.metadata.businessCase).toBe('Cost reduction'); + }); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_ARCHIVE +// --------------------------------------------------------------------------- + +describe('applyAction (Azure) — IMPROVEMENT_PROJECT_ARCHIVE', () => { + it('soft-deletes by setting deletedAt to a non-null timestamp', async () => { + await db.processHubs.put(makeHub('hub-6')); + const project = makeProject('proj-6', 'hub-6'); + await applyAction({ kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-6', project }); + + await applyAction({ kind: 'IMPROVEMENT_PROJECT_ARCHIVE', projectId: 'proj-6' }); + + const row = await db.improvementProjects.get('proj-6'); + expect(row?.deletedAt).not.toBeNull(); + expect(row?.deletedAt).toBeGreaterThan(0); + }); + + it('is idempotent (no-op) when projectId does not exist', async () => { + await expect( + applyAction({ kind: 'IMPROVEMENT_PROJECT_ARCHIVE', projectId: 'ghost-proj' }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/azure/src/persistence/applyAction.ts b/apps/azure/src/persistence/applyAction.ts index f6a622cb1..3a2d9d729 100644 --- a/apps/azure/src/persistence/applyAction.ts +++ b/apps/azure/src/persistence/applyAction.ts @@ -223,6 +223,92 @@ export async function applyAction(action: HubAction): Promise { return; } + // ------------------------------------------------------------------------- + // Improvement Project — dedicated improvementProjects Dexie table. + // + // Mirrors the PWA handler byte-for-byte (apps/pwa/src/persistence/applyAction.ts). + // UPDATE applies the deep-merge contract documented on + // `improvementProjectActions.ts`: + // - objects shallow-merge one level (metadata, goal, signoff) + // - nested metadata.financialImpact + goal.outcomeGoal also shallow-merge + // - sections shallow-merge per sub-section key (missing keys preserved) + // - all arrays REPLACE wholesale + // - id, createdAt, hubId, deletedAt, updatedAt are not caller-controllable + // - updatedAt is set by THIS handler to Date.now() + // ------------------------------------------------------------------------- + + case 'IMPROVEMENT_PROJECT_CREATE': { + const hub = await db.processHubs.get(action.hubId); + if (!hub) { + throw new Error(`IMPROVEMENT_PROJECT_CREATE: parent hub ${action.hubId} does not exist`); + } + await db.improvementProjects.add(action.project); + return; + } + + case 'IMPROVEMENT_PROJECT_UPDATE': { + // Idempotent on missing. + const existing = await db.improvementProjects.get(action.projectId); + if (!existing) return; + const { patch } = action; + const merged = { + ...existing, + ...patch, + metadata: patch.metadata + ? { + ...existing.metadata, + ...patch.metadata, + ...(patch.metadata.financialImpact + ? { + financialImpact: { + ...(existing.metadata.financialImpact ?? {}), + ...patch.metadata.financialImpact, + }, + } + : {}), + } + : existing.metadata, + goal: patch.goal + ? { + ...existing.goal, + ...patch.goal, + ...(patch.goal.outcomeGoal + ? { outcomeGoal: { ...existing.goal.outcomeGoal, ...patch.goal.outcomeGoal } } + : {}), + } + : existing.goal, + sections: patch.sections + ? { + background: { ...existing.sections.background, ...(patch.sections.background ?? {}) }, + investigationLineage: { + ...existing.sections.investigationLineage, + ...(patch.sections.investigationLineage ?? {}), + }, + approach: { ...existing.sections.approach, ...(patch.sections.approach ?? {}) }, + outcomeReference: { + ...existing.sections.outcomeReference, + ...(patch.sections.outcomeReference ?? {}), + }, + } + : existing.sections, + signoff: patch.signoff + ? { ...(existing.signoff ?? {}), ...patch.signoff } + : existing.signoff, + updatedAt: Date.now(), + }; + await db.improvementProjects.put(merged); + return; + } + + case 'IMPROVEMENT_PROJECT_ARCHIVE': { + // Idempotent soft-delete. + await db.improvementProjects.update(action.projectId, { + deletedAt: Date.now(), + updatedAt: Date.now(), + }); + return; + } + // ------------------------------------------------------------------------- // Session-only — Azure has no dedicated Dexie table today; F3 normalizes. // ------------------------------------------------------------------------- diff --git a/apps/pwa/src/db/schema.ts b/apps/pwa/src/db/schema.ts index d2e711ffa..36103eb2c 100644 --- a/apps/pwa/src/db/schema.ts +++ b/apps/pwa/src/db/schema.ts @@ -36,6 +36,7 @@ import type { RowProvenanceTag, } from '@variscout/core'; import type { Finding, Question, CausalLink, Hypothesis } from '@variscout/core/findings'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { ProcessMap } from '@variscout/core/frame'; // --------------------------------------------------------------------------- @@ -78,6 +79,7 @@ export type FindingRow = Finding; export type QuestionRow = Question; export type CausalLinkRow = CausalLink; export type HypothesisRow = Hypothesis; +export type ImprovementProjectRow = ImprovementProject; // --------------------------------------------------------------------------- // Database @@ -95,6 +97,7 @@ export class PwaDatabase extends Dexie { questions!: Table; causalLinks!: Table; hypotheses!: Table; + improvementProjects!: Table; canvasState!: Table; meta!: Table; @@ -112,6 +115,7 @@ export class PwaDatabase extends Dexie { questions: '&id, investigationId, deletedAt', causalLinks: '&id, investigationId, deletedAt', hypotheses: '&id, investigationId, deletedAt', + improvementProjects: '&id, hubId, deletedAt, status, updatedAt', canvasState: '&hubId', meta: '&key', }); diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts index 76c9aab72..0a17e4bb1 100644 --- a/apps/pwa/src/persistence/PwaHubRepository.ts +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -80,16 +80,19 @@ export class PwaHubRepository implements HubRepository { // --------------------------------------------------------------------------- private async joinHub(hubMeta: HubRow): Promise { - const [outcomes, canvasRow] = await Promise.all([ + const [outcomes, canvasRow, improvementProjects] = await Promise.all([ db.outcomes.where('hubId').equals(hubMeta.id).toArray(), db.canvasState.get(hubMeta.id), + db.improvementProjects.where('hubId').equals(hubMeta.id).toArray(), ]); const liveOutcomes = outcomes.filter(o => o.deletedAt === null); + const liveProjects = improvementProjects.filter(p => p.deletedAt === null); const canonicalProcessMap = canvasRow ? stripHubId(canvasRow) : undefined; return { ...hubMeta, ...(liveOutcomes.length > 0 ? { outcomes: liveOutcomes } : {}), ...(canonicalProcessMap ? { canonicalProcessMap } : {}), + ...(liveProjects.length > 0 ? { improvementProjects: liveProjects } : {}), } as ProcessHub; } @@ -104,19 +107,27 @@ export class PwaHubRepository implements HubRepository { // reenter; joinHub stays a private helper that only touches the three // tables already declared in the transaction scope. get: async id => { - return db.transaction('r', [db.hubs, db.outcomes, db.canvasState], async () => { - const hubMeta = await db.hubs.get(id); - if (!hubMeta) return undefined; - if (hubMeta.deletedAt !== null) return undefined; - return this.joinHub(hubMeta); - }); + return db.transaction( + 'r', + [db.hubs, db.outcomes, db.canvasState, db.improvementProjects], + async () => { + const hubMeta = await db.hubs.get(id); + if (!hubMeta) return undefined; + if (hubMeta.deletedAt !== null) return undefined; + return this.joinHub(hubMeta); + } + ); }, list: async () => { - return db.transaction('r', [db.hubs, db.outcomes, db.canvasState], async () => { - const allHubs = await db.hubs.toArray(); - const liveHubs = allHubs.filter(h => h.deletedAt === null); - return Promise.all(liveHubs.map(h => this.joinHub(h))); - }); + return db.transaction( + 'r', + [db.hubs, db.outcomes, db.canvasState, db.improvementProjects], + async () => { + const allHubs = await db.hubs.toArray(); + const liveHubs = allHubs.filter(h => h.deletedAt === null); + return Promise.all(liveHubs.map(h => this.joinHub(h))); + } + ); }, }; diff --git a/apps/pwa/src/persistence/__tests__/applyAction.improvementProject.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.improvementProject.test.ts new file mode 100644 index 000000000..b942a1d11 --- /dev/null +++ b/apps/pwa/src/persistence/__tests__/applyAction.improvementProject.test.ts @@ -0,0 +1,284 @@ +// apps/pwa/src/persistence/__tests__/applyAction.improvementProject.test.ts +// +// PR-RPS-5 T3 — tests for IMPROVEMENT_PROJECT_CREATE / UPDATE / ARCHIVE handlers +// in the PWA applyAction dispatcher. +// +// Coverage: +// 1. CREATE inserts the project row +// 2. CREATE with a missing hub throws (loud failure) +// 3. UPDATE metadata-only — deep-merge preserves non-supplied metadata keys +// 4. UPDATE goal-only — outcomeGoal shallow-merge preserves outcomeSpecId; +// factorControls array replaces wholesale +// 5. UPDATE sections — partial supply preserves other sub-section keys and +// shallow-merges the supplied sub-section +// 6. UPDATE sets updatedAt to Date.now() (timing-safe) +// 7. UPDATE missing project — idempotent no-op +// 8. ARCHIVE sets deletedAt to non-null +// 9. ARCHIVE missing project — idempotent no-op +// +// fake-indexeddb/auto must be the first import so Dexie sees the IndexedDB +// polyfill before db.ts runs its module-load side effects. + +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProcessHub } from '@variscout/core/processHub'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { applyAction } from '../applyAction'; +import { db } from '../../db/schema'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +const NOW = 1_746_352_800_000; + +function makeHub(id: string, overrides: Partial = {}): ProcessHub { + return { + id, + name: `Hub ${id}`, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeProject( + id: string, + hubId: string, + overrides: Partial = {} +): ImprovementProject { + return { + id, + hubId, + status: 'draft', + createdAt: NOW, + deletedAt: null, + updatedAt: NOW, + metadata: { + title: 'Default Title', + }, + goal: { + outcomeGoal: { + outcomeSpecId: 'out-default', + target: 1.33, + }, + }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(async () => { + await db.hubs.clear(); + await db.improvementProjects.clear(); +}); + +afterEach(async () => { + await db.hubs.clear(); + await db.improvementProjects.clear(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_CREATE +// --------------------------------------------------------------------------- + +describe('applyAction — IMPROVEMENT_PROJECT_CREATE', () => { + it('inserts the project row and is retrievable from the table', async () => { + // Seed the parent hub. + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-1') }); + + const project = makeProject('proj-1', 'hub-1', { + metadata: { title: 'My Project', businessCase: 'Save money' }, + }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-1', project }); + + const rows = await db.improvementProjects.toArray(); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('proj-1'); + expect(rows[0].hubId).toBe('hub-1'); + expect(rows[0].metadata.title).toBe('My Project'); + expect(rows[0].metadata.businessCase).toBe('Save money'); + }); + + it('throws (loud-fail) when the parent hub does not exist', async () => { + const project = makeProject('proj-orphan', 'ghost-hub'); + await expect( + applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'ghost-hub', project }) + ).rejects.toThrow(/ghost-hub/); + + // No row should have been created. + expect(await db.improvementProjects.get('proj-orphan')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_UPDATE — deep-merge contract +// --------------------------------------------------------------------------- + +describe('applyAction — IMPROVEMENT_PROJECT_UPDATE', () => { + it('metadata-only patch preserves non-supplied metadata keys (deep-merge)', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-2') }); + const project = makeProject('proj-2', 'hub-2', { + metadata: { title: 'A', businessCase: 'orig' }, + }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-2', project }); + + await applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-2', + patch: { metadata: { title: 'B' } }, + }); + + const row = await db.improvementProjects.get('proj-2'); + expect(row?.metadata).toEqual({ title: 'B', businessCase: 'orig' }); + }); + + it('goal-only patch: outcomeGoal preserved; factorControls replaces wholesale', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-3') }); + const project = makeProject('proj-3', 'hub-3', { + goal: { + outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 }, + }, + }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-3', project }); + + await applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-3', + patch: { + goal: { + outcomeGoal: { outcomeSpecId: 'o-1', target: 1.33 }, // same outcomeGoal + factorControls: [{ factor: 'X', targetCondition: '95±2' }], + }, + }, + }); + + const row = await db.improvementProjects.get('proj-3'); + expect(row?.goal.outcomeGoal.outcomeSpecId).toBe('o-1'); + expect(row?.goal.factorControls).toEqual([{ factor: 'X', targetCondition: '95±2' }]); + }); + + it('sections partial supply: supplied sub-section merges; other sub-sections preserved', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-4') }); + const project = makeProject('proj-4', 'hub-4', { + sections: { + background: { manualNarrative: 'orig' }, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-4', project }); + + await applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-4', + patch: { + sections: { background: { snapshotText: 'snap' } }, + }, + }); + + const row = await db.improvementProjects.get('proj-4'); + // background shallow-merges: both keys present + expect(row?.sections.background).toEqual({ manualNarrative: 'orig', snapshotText: 'snap' }); + // Other sub-sections must not be dropped + expect(row?.sections.investigationLineage).toBeDefined(); + expect(row?.sections.approach).toBeDefined(); + expect(row?.sections.outcomeReference).toBeDefined(); + }); + + it('sets updatedAt to Date.now() after patch', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-5') }); + const project = makeProject('proj-5', 'hub-5', { updatedAt: 1 }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-5', project }); + + const before = Date.now(); + await applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-5', + patch: { status: 'active' }, + }); + + const row = await db.improvementProjects.get('proj-5'); + expect(row?.updatedAt).toBeGreaterThanOrEqual(before); + }); + + it('is idempotent (no-op) when projectId does not exist', async () => { + await expect( + applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'ghost-proj', + patch: { status: 'active' }, + }) + ).resolves.toBeUndefined(); + + expect(await db.improvementProjects.get('ghost-proj')).toBeUndefined(); + }); + + it('financialImpact patch deep-merges: amount update preserves existing currency', async () => { + // Note: ImprovementProjectMetadata.financialImpact types currency as required + // on every supply ({ amount?: number; currency: string }), so a patch must + // always include currency when it includes financialImpact. + // This test verifies that the deep-merge spreads existing before patch — + // i.e., supplying { amount: 20, currency: 'EUR' } when existing is + // { amount: 10, currency: 'EUR' } correctly yields { amount: 20, currency: 'EUR' }, + // and that other metadata keys (businessCase) are not clobbered. + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-7') }); + const project = makeProject('proj-7', 'hub-7', { + metadata: { + title: 'FI Test', + businessCase: 'Cost reduction', + financialImpact: { amount: 10, currency: 'EUR' }, + }, + }); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-7', project }); + + // Patch: update amount only (currency required by the type — must be supplied). + await applyAction(db, { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'proj-7', + patch: { metadata: { title: 'FI Test', financialImpact: { amount: 20, currency: 'EUR' } } }, + }); + + const row = await db.improvementProjects.get('proj-7'); + // financialImpact deep-merges: amount updated, currency preserved. + expect(row?.metadata.financialImpact).toEqual({ amount: 20, currency: 'EUR' }); + // businessCase must be preserved (metadata shallow-merge; financialImpact is the only + // nested-deep-merge key — the outer metadata spread preserves keys not in the patch). + expect(row?.metadata.businessCase).toBe('Cost reduction'); + }); +}); + +// --------------------------------------------------------------------------- +// IMPROVEMENT_PROJECT_ARCHIVE +// --------------------------------------------------------------------------- + +describe('applyAction — IMPROVEMENT_PROJECT_ARCHIVE', () => { + it('soft-deletes by setting deletedAt to a non-null timestamp', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-6') }); + const project = makeProject('proj-6', 'hub-6'); + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_CREATE', hubId: 'hub-6', project }); + + await applyAction(db, { kind: 'IMPROVEMENT_PROJECT_ARCHIVE', projectId: 'proj-6' }); + + const row = await db.improvementProjects.get('proj-6'); + expect(row?.deletedAt).not.toBeNull(); + expect(row?.deletedAt).toBeGreaterThan(0); + }); + + it('is idempotent (no-op) when projectId does not exist', async () => { + await expect( + applyAction(db, { kind: 'IMPROVEMENT_PROJECT_ARCHIVE', projectId: 'ghost-proj' }) + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index 32538ec9e..b437c31fb 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -79,32 +79,51 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { - await db.hubs.put(hubMeta); - // HUB_PERSIST_SNAPSHOT carries the hub's authoritative full state; rows - // that exist for this hub but are absent from the incoming snapshot are - // stale and must be removed inside the same transaction (preserves the - // F2 blob-replacement invariant in the normalized world). bulkPut/put - // alone are upserts and would leave dropped outcomes / a removed - // canonicalProcessMap visible on the next joinHub. - const incomingOutcomeIds = new Set((outcomes ?? []).map(o => o.id)); - await db.outcomes - .where('hubId') - .equals(hubMeta.id) - .filter(o => !incomingOutcomeIds.has(o.id)) - .delete(); - if (outcomes && outcomes.length > 0) { - await db.outcomes.bulkPut(outcomes.map(outcome => ({ ...outcome, hubId: hubMeta.id }))); - } - if (canonicalProcessMap) { - await db.canvasState.put({ hubId: hubMeta.id, ...canonicalProcessMap }); - } else { - // Snapshot lacks a canonical process map — clear any stale row so - // joinHub won't resurrect it. - await db.canvasState.delete(hubMeta.id); + // improvementProjects live in their own table; drop the embedded copy from the hub row. + const { canonicalProcessMap, outcomes, improvementProjects, ...hubMeta } = action.hub; + await db.transaction( + 'rw', + [db.hubs, db.outcomes, db.canvasState, db.improvementProjects], + async () => { + await db.hubs.put(hubMeta); + // HUB_PERSIST_SNAPSHOT carries the hub's authoritative full state; rows + // that exist for this hub but are absent from the incoming snapshot are + // stale and must be removed inside the same transaction (preserves the + // F2 blob-replacement invariant in the normalized world). bulkPut/put + // alone are upserts and would leave dropped outcomes / a removed + // canonicalProcessMap visible on the next joinHub. + const incomingOutcomeIds = new Set((outcomes ?? []).map(o => o.id)); + await db.outcomes + .where('hubId') + .equals(hubMeta.id) + .filter(o => !incomingOutcomeIds.has(o.id)) + .delete(); + if (outcomes && outcomes.length > 0) { + await db.outcomes.bulkPut(outcomes.map(outcome => ({ ...outcome, hubId: hubMeta.id }))); + } + // improvementProjects: drop stale rows for this hub, then bulk-put the + // incoming snapshot. bulkPut alone is upsert; we delete first to remove + // rows that are absent from the incoming snapshot (same invariant as outcomes). + const incomingProjectIds = new Set((improvementProjects ?? []).map(p => p.id)); + await db.improvementProjects + .where('hubId') + .equals(hubMeta.id) + .filter(p => !incomingProjectIds.has(p.id)) + .delete(); + if (improvementProjects && improvementProjects.length > 0) { + await db.improvementProjects.bulkPut( + improvementProjects.map(p => ({ ...p, hubId: hubMeta.id })) + ); + } + if (canonicalProcessMap) { + await db.canvasState.put({ hubId: hubMeta.id, ...canonicalProcessMap }); + } else { + // Snapshot lacks a canonical process map — clear any stale row so + // joinHub won't resurrect it. + await db.canvasState.delete(hubMeta.id); + } } - }); + ); return; } @@ -278,6 +297,91 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { expect(typeof _exhaustive).toBe('function'); }); }); + +describe('IMPROVEMENT_PROJECT actions', () => { + it('compile under the HubAction discriminated union', () => { + const create: HubAction = { + kind: 'IMPROVEMENT_PROJECT_CREATE', + hubId: 'hub-1', + project: { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + deletedAt: null, + status: 'draft', + metadata: { title: 't' }, + goal: { outcomeGoal: { outcomeSpecId: 'o-1', target: 1 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, + updatedAt: 0, + }, + }; + const update: HubAction = { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'ip-1', + patch: { metadata: { title: 't2' } }, + }; + const archive: HubAction = { kind: 'IMPROVEMENT_PROJECT_ARCHIVE', projectId: 'ip-1' }; + expect(create.kind).toBe('IMPROVEMENT_PROJECT_CREATE'); + expect(update.kind).toBe('IMPROVEMENT_PROJECT_UPDATE'); + expect(archive.kind).toBe('IMPROVEMENT_PROJECT_ARCHIVE'); + }); + + it('partial-sections patch is a valid UPDATE action (documented contract)', () => { + const partialSections: HubAction = { + kind: 'IMPROVEMENT_PROJECT_UPDATE', + projectId: 'ip-1', + patch: { sections: { background: { snapshotText: 'x' } } }, + }; + expect(partialSections.kind).toBe('IMPROVEMENT_PROJECT_UPDATE'); + }); +}); diff --git a/packages/core/src/actions/improvementProjectActions.ts b/packages/core/src/actions/improvementProjectActions.ts new file mode 100644 index 000000000..53a9ae4d0 --- /dev/null +++ b/packages/core/src/actions/improvementProjectActions.ts @@ -0,0 +1,46 @@ +import type { ProcessHub } from '../processHub'; +import type { ImprovementProject } from '../improvementProject'; + +/** + * Hub mutations for ImprovementProject entities (PR-RPS-5). + * + * `IMPROVEMENT_PROJECT_UPDATE` deep-merges the patch into the existing entity: + * - objects shallow-merge one level: `metadata`, `goal`, `signoff`, and the + * four `sections` are independently shallow-merged when supplied. + * - nested objects inside `metadata` (`financialImpact`) and `goal` (`outcomeGoal`) + * also shallow-merge if both sides are present. + * - all arrays REPLACE wholesale: callers pass the full new value for + * `metadata.team[]`, `goal.factorControls[]`, `goal.mechanismGoals[]`, and + * all FK arrays inside `sections.*`. + * - `id`, `createdAt`, `hubId` are immutable (excluded from patch typing). + * - `updatedAt` is set to `Date.now()` by the persistence handler, not by the caller. + * - `deletedAt` is managed exclusively by `IMPROVEMENT_PROJECT_ARCHIVE`; supplying it + * via UPDATE would create an unsupervised soft-delete path. + * - for `sections`: the four section keys shallow-merge independently when supplied + * (if patch.sections is present, missing sub-section keys are preserved from existing; + * only supplied sub-section keys are shallow-merged). + * + * Persistence handlers (apps/pwa, apps/azure) implement this contract identically. + */ +export type ImprovementProjectAction = + | { + kind: 'IMPROVEMENT_PROJECT_CREATE'; + hubId: ProcessHub['id']; + project: ImprovementProject; + } + | { + kind: 'IMPROVEMENT_PROJECT_UPDATE'; + projectId: ImprovementProject['id']; + patch: Partial< + Omit< + ImprovementProject, + 'id' | 'createdAt' | 'hubId' | 'updatedAt' | 'deletedAt' | 'sections' + > + > & { + sections?: Partial; + }; + } + | { + kind: 'IMPROVEMENT_PROJECT_ARCHIVE'; + projectId: ImprovementProject['id']; + }; diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 1222cdc6e..5b502a944 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -8,4 +8,5 @@ export type { CausalLinkAction } from './causalLinkActions'; export type { HypothesisAction } from './hypothesisActions'; export type { HubMetaAction } from './hubMetaActions'; export type { CanvasAction } from './canvasActions'; +export type { ImprovementProjectAction } from './improvementProjectActions'; export type { HubAction } from './HubAction'; diff --git a/packages/core/src/improvementProject/__tests__/snapshot.test.ts b/packages/core/src/improvementProject/__tests__/snapshot.test.ts new file mode 100644 index 000000000..97089503e --- /dev/null +++ b/packages/core/src/improvementProject/__tests__/snapshot.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { computeSourceHash, shouldShowDrift } from '../snapshot'; +import type { DriftableSnapshot, DriftableCurrent } from '../snapshot'; + +describe('computeSourceHash', () => { + it('is deterministic — same value produces the same hash on repeated calls', () => { + const value = { x: 1, y: [2, 3], z: 'hello' }; + const h1 = computeSourceHash(value); + const h2 = computeSourceHash(value); + expect(h1).toBe(h2); + }); + + it('produces different hashes for different values', () => { + const h1 = computeSourceHash({ a: 1 }); + const h2 = computeSourceHash({ a: 2 }); + expect(h1).not.toBe(h2); + }); + + it('returns a stable string for undefined', () => { + const h = computeSourceHash(undefined); + expect(typeof h).toBe('string'); + expect(h.length).toBeGreaterThan(0); + // deterministic + expect(h).toBe(computeSourceHash(undefined)); + }); + + it('returns a stable string for null', () => { + const h = computeSourceHash(null); + expect(typeof h).toBe('string'); + expect(h.length).toBeGreaterThan(0); + // deterministic + expect(h).toBe(computeSourceHash(null)); + }); +}); + +describe('shouldShowDrift', () => { + it('returns true when snapshot.sourceHash differs from current.hash', () => { + const snapshot: DriftableSnapshot = { value: 'old', sourceHash: 'abc-123' }; + const current: DriftableCurrent = { value: 'new', hash: 'xyz-456' }; + expect(shouldShowDrift(snapshot, current)).toBe(true); + }); + + it('returns false when hashes match', () => { + const hash = computeSourceHash({ x: 42 }); + const snapshot: DriftableSnapshot = { value: 42, sourceHash: hash }; + const current: DriftableCurrent = { value: 42, hash }; + expect(shouldShowDrift(snapshot, current)).toBe(false); + }); +}); diff --git a/packages/core/src/improvementProject/__tests__/types.test.ts b/packages/core/src/improvementProject/__tests__/types.test.ts new file mode 100644 index 000000000..28863deff --- /dev/null +++ b/packages/core/src/improvementProject/__tests__/types.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import type { ImprovementProject, ImprovementProjectStatus } from '../types'; + +describe('ImprovementProject', () => { + it('compiles with required title + multi-level Goal', () => { + const ip: ImprovementProject = { + id: 'ip-1', + hubId: 'hub-1', + createdAt: 0, + deletedAt: null, + status: 'draft', + metadata: { title: 'Heads 5-8 lift' }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 }, + factorControls: [ + { + factor: 'NOZZLE.TEMP', + targetCondition: 'in control 95±2°C', + linkedHypothesisId: 'h-1', + }, + ], + }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + updatedAt: 0, + }; + expect(ip.metadata.title).toBe('Heads 5-8 lift'); + }); + + it('status union covers draft | active | closed', () => { + const statuses: ImprovementProjectStatus[] = ['draft', 'active', 'closed']; + expect(statuses).toHaveLength(3); + }); +}); diff --git a/packages/core/src/improvementProject/index.ts b/packages/core/src/improvementProject/index.ts new file mode 100644 index 000000000..5a01555b5 --- /dev/null +++ b/packages/core/src/improvementProject/index.ts @@ -0,0 +1,17 @@ +export type { + ImprovementProject, + ImprovementProjectStatus, + ImprovementProjectMetadata, + ImprovementProjectOutcomeGoal, + ImprovementProjectFactorControl, + ImprovementProjectMechanismGoal, + ImprovementProjectGoal, + ImprovementProjectBackgroundSection, + ImprovementProjectInvestigationLineageSection, + ImprovementProjectApproachSection, + ImprovementProjectOutcomeReferenceSection, + ImprovementProjectSignoff, +} from './types'; + +export { computeSourceHash, shouldShowDrift } from './snapshot'; +export type { DriftableSnapshot, DriftableCurrent } from './snapshot'; diff --git a/packages/core/src/improvementProject/snapshot.ts b/packages/core/src/improvementProject/snapshot.ts new file mode 100644 index 000000000..61b007429 --- /dev/null +++ b/packages/core/src/improvementProject/snapshot.ts @@ -0,0 +1,43 @@ +/** + * D18 live-document state-machine helpers. + * + * `computeSourceHash(value)` produces a stable string fingerprint of a value + * (used to detect upstream change). Lightweight — not cryptographic; suitable + * only for change detection within a single browser session. + * + * `shouldShowDrift(snapshot, current)` returns true when the upstream source's + * hash has changed since the snapshot was taken — UI uses this to surface a + * "refresh from source" affordance per spec §11 D18. + */ + +export function computeSourceHash(value: unknown): string { + // JSON.stringify(undefined) returns undefined (not a string); normalise to a + // sentinel literal so the function never throws on undefined inputs. + const json = JSON.stringify(value) ?? '__undefined__'; + const lengthPart = json.length.toString(36); + const hash = stringHash(json); + return `${lengthPart}-${Math.abs(hash).toString(36)}`; +} + +function stringHash(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; + return h; +} + +export interface DriftableSnapshot { + value: T; + sourceHash: string; +} + +export interface DriftableCurrent { + value: T; + hash: string; +} + +export function shouldShowDrift( + snapshot: DriftableSnapshot, + current: DriftableCurrent +): boolean { + return snapshot.sourceHash !== current.hash; +} diff --git a/packages/core/src/improvementProject/types.ts b/packages/core/src/improvementProject/types.ts new file mode 100644 index 000000000..ad8da1e88 --- /dev/null +++ b/packages/core/src/improvementProject/types.ts @@ -0,0 +1,92 @@ +import type { EntityBase } from '../identity'; +import type { + ProcessHub, + OutcomeSpec, + ProcessParticipantRef, + ProcessHubInvestigation, +} from '../processHub'; +import type { Hypothesis, Finding, ImprovementIdea, ActionItem } from '../findings/types'; +import type { SustainmentRecord, ControlHandoff } from '../sustainment'; + +export type ImprovementProjectStatus = 'draft' | 'active' | 'closed'; + +export interface ImprovementProjectMetadata { + title: string; // required + businessCase?: string; + financialImpact?: { amount?: number; currency: string }; + team?: Array<{ + role: 'champion' | 'sponsor' | 'projectLead' | 'teamMember' | 'processOwner'; + person: ProcessParticipantRef; + }>; + investigationId?: ProcessHubInvestigation['id']; +} + +export interface ImprovementProjectOutcomeGoal { + outcomeSpecId: OutcomeSpec['id']; + baseline?: number; + target: number; + deadline?: string; +} + +export interface ImprovementProjectFactorControl { + factor: string; + targetCondition: string; + linkedHypothesisId?: Hypothesis['id']; +} + +export interface ImprovementProjectMechanismGoal { + description: string; + linkedFindingIds?: Finding['id'][]; +} + +export interface ImprovementProjectGoal { + outcomeGoal: ImprovementProjectOutcomeGoal; // Y-level required + factorControls?: ImprovementProjectFactorControl[]; // X-level + mechanismGoals?: ImprovementProjectMechanismGoal[]; // x-level + freeText?: string; // fallback when no OutcomeSpec available +} + +export interface ImprovementProjectBackgroundSection { + /** Snapshot copy of capability summary at IP open. Drift indicator triggers refresh. */ + snapshotText?: string; + snapshotSourceHash?: string; + snapshottedAt?: string; + manualNarrative?: string; +} + +export interface ImprovementProjectInvestigationLineageSection { + hypothesisIds?: Hypothesis['id'][]; + findingIds?: Finding['id'][]; +} + +export interface ImprovementProjectApproachSection { + improvementIdeaIds?: ImprovementIdea['id'][]; + actionItemIds?: ActionItem['id'][]; + narrative?: string; +} + +export interface ImprovementProjectOutcomeReferenceSection { + sustainmentRecordId?: SustainmentRecord['id']; + controlHandoffId?: ControlHandoff['id']; +} + +export interface ImprovementProjectSignoff { + requestedAt?: number; + approvedAt?: number; + approvedBy?: ProcessParticipantRef; +} + +export interface ImprovementProject extends EntityBase { + hubId: ProcessHub['id']; + status: ImprovementProjectStatus; + metadata: ImprovementProjectMetadata; + goal: ImprovementProjectGoal; + sections: { + background: ImprovementProjectBackgroundSection; + investigationLineage: ImprovementProjectInvestigationLineageSection; + approach: ImprovementProjectApproachSection; + outcomeReference: ImprovementProjectOutcomeReferenceSection; + }; + updatedAt: number; + signoff?: ImprovementProjectSignoff; +} diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index 7e9f8bda6..6360b3ebf 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -116,6 +116,15 @@ export interface ProcessHub extends EntityBase { * See `docs/05-technical/architecture/capability-target-cascade.md`. */ reviewSignal?: HubReviewSignal; + /** + * Improvement Project entities owned by this hub. In-memory hydrated list, + * loaded by the HubRepository on read. Mutations flow through + * `IMPROVEMENT_PROJECT_*` HubAction kinds (PR-RPS-5). + * + * Inline `import()` type avoids a top-level cycle (improvementProject/types.ts + * imports ProcessHub). + */ + improvementProjects?: import('./improvementProject').ImprovementProject[]; } export const DEFAULT_PROCESS_HUB: ProcessHub = { diff --git a/packages/core/src/serialization/__tests__/roundtrip.test.ts b/packages/core/src/serialization/__tests__/roundtrip.test.ts index ca18b77a2..c7fb99a7d 100644 --- a/packages/core/src/serialization/__tests__/roundtrip.test.ts +++ b/packages/core/src/serialization/__tests__/roundtrip.test.ts @@ -4,6 +4,7 @@ import { vrsExport } from '../vrsExport'; import { vrsImport } from '../vrsImport'; import { VRS_VERSION } from '../vrsFormat'; import { DEFAULT_PROCESS_HUB } from '../../processHub'; +import type { ImprovementProject } from '../../improvementProject'; describe('vrs roundtrip', () => { const hub = { @@ -56,3 +57,84 @@ describe('vrs roundtrip', () => { expect(imported.rawData).toBeUndefined(); }); }); + +describe('vrs roundtrip — improvementProjects', () => { + const richIP: ImprovementProject = { + id: 'ip-1', + hubId: DEFAULT_PROCESS_HUB.id, + createdAt: 1_714_000_000_000, + deletedAt: null, + status: 'active', + metadata: { + title: 'Heads 5-8 lift', + businessCase: 'Expected $80k/yr', + financialImpact: { amount: 80000, currency: 'USD' }, + team: [{ role: 'projectLead', person: { displayName: 'A. Lead' } }], + }, + goal: { + outcomeGoal: { + outcomeSpecId: 'outcome-1', + baseline: 0.91, + target: 1.33, + deadline: '2026-09-01', + }, + factorControls: [ + { factor: 'NOZZLE.TEMP', targetCondition: 'in control 95±2°C', linkedHypothesisId: 'h-1' }, + ], + }, + sections: { + background: { snapshotText: 'Cpk 0.91 over 12wk', snapshottedAt: '2026-05-01T00:00:00Z' }, + investigationLineage: { hypothesisIds: ['h-1', 'h-2'], findingIds: ['f-1'] }, + approach: { + improvementIdeaIds: ['idea-1'], + actionItemIds: ['ai-1'], + narrative: 'Replace thermostat', + }, + outcomeReference: {}, + }, + updatedAt: 1_714_500_000_000, + signoff: { requestedAt: 1_714_400_000_000 }, + }; + + const minimalIP: ImprovementProject = { + id: 'ip-2', + hubId: DEFAULT_PROCESS_HUB.id, + createdAt: 1_714_000_000_000, + deletedAt: null, + status: 'draft', + metadata: { title: 'Operator training' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 } }, + sections: { background: {}, investigationLineage: {}, approach: {}, outcomeReference: {} }, + updatedAt: 1_714_000_000_000, + }; + + it('round-trips hub carrying a populated improvementProjects array', () => { + const hub = { + ...DEFAULT_PROCESS_HUB, + improvementProjects: [richIP, minimalIP], + }; + const exported = vrsExport(hub); + const imported = vrsImport(exported); + expect(imported.hub.improvementProjects).toEqual([richIP, minimalIP]); + }); + + it('legacy .vrs without improvementProjects imports cleanly with field undefined', () => { + const legacyJson = JSON.stringify({ + version: VRS_VERSION, + exportedAt: new Date().toISOString(), + hub: { ...DEFAULT_PROCESS_HUB }, + }); + const imported = vrsImport(legacyJson); + expect(imported.hub.improvementProjects).toBeUndefined(); + }); + + it('empty improvementProjects array round-trips as [] (not undefined)', () => { + const hub = { + ...DEFAULT_PROCESS_HUB, + improvementProjects: [] as ImprovementProject[], + }; + const exported = vrsExport(hub); + const imported = vrsImport(exported); + expect(imported.hub.improvementProjects).toEqual([]); + }); +}); diff --git a/packages/hooks/src/__tests__/useLiveProjection.test.ts b/packages/hooks/src/__tests__/useLiveProjection.test.ts new file mode 100644 index 000000000..edacef361 --- /dev/null +++ b/packages/hooks/src/__tests__/useLiveProjection.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useLiveProjection } from '../useLiveProjection'; + +// Stable fetchBatch factory — returns a new Map from a plain object each call. +// Used for tests where fetchBatch reference is stable across rerenders. +function makeFetchBatch(store: Record) { + return (ids: ReadonlyArray): Map => { + const m = new Map(); + for (const id of ids) { + if (id in store) m.set(id, store[id]); + } + return m; + }; +} + +describe('useLiveProjection', () => { + it('returns resolved entities in fkList order when all IDs hit', () => { + const store = { a: { label: 'Alpha' }, b: { label: 'Beta' }, c: { label: 'Gamma' } }; + const fetchBatch = makeFetchBatch(store); + const fkList = ['a', 'b', 'c'] as const; + + const { result } = renderHook(() => useLiveProjection(fkList, fetchBatch)); + + expect(result.current).toEqual([{ label: 'Alpha' }, { label: 'Beta' }, { label: 'Gamma' }]); + }); + + it('silently drops missing FK entries', () => { + const store = { a: { label: 'Alpha' }, c: { label: 'Gamma' } }; + const fetchBatch = makeFetchBatch(store); + const fkList = ['a', 'b', 'c'] as const; // 'b' is not in store + + const { result } = renderHook(() => useLiveProjection(fkList, fetchBatch)); + + expect(result.current).toEqual([{ label: 'Alpha' }, { label: 'Gamma' }]); + expect(result.current).toHaveLength(2); + }); + + it('re-runs projection when fkList reference changes', () => { + const store = { a: { label: 'Alpha' }, b: { label: 'Beta' } }; + const fetchBatch = makeFetchBatch(store); + + let fkList: ReadonlyArray = ['a']; + const { result, rerender } = renderHook(() => useLiveProjection(fkList, fetchBatch)); + + expect(result.current).toEqual([{ label: 'Alpha' }]); + + // Change fkList to a new reference + fkList = ['a', 'b']; + rerender(); + + expect(result.current).toEqual([{ label: 'Alpha' }, { label: 'Beta' }]); + }); + + it('memoizes — returns the same array reference when neither input changes', () => { + const store = { x: { val: 1 } }; + const fetchBatch = makeFetchBatch(store); + const fkList: ReadonlyArray = ['x']; + + const { result, rerender } = renderHook(() => useLiveProjection(fkList, fetchBatch)); + + const firstRef = result.current; + + // Rerender with identical fkList and fetchBatch references + rerender(); + + expect(result.current).toBe(firstRef); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 52b4fb8dc..8e93f9553 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -652,3 +652,6 @@ export { type UseBoxplotSelectArgs, type UseBoxplotSelectReturn, } from './useBoxplotSelect'; + +// Live-document primitives (RPS V1 PR5 — spec §11 D18) +export { useLiveProjection } from './useLiveProjection'; diff --git a/packages/hooks/src/useLiveProjection.ts b/packages/hooks/src/useLiveProjection.ts new file mode 100644 index 000000000..954c79772 --- /dev/null +++ b/packages/hooks/src/useLiveProjection.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; + +/** + * Memoized FK-list → entity batch projection. Used by IP UI sections that + * carry FK arrays (e.g. sections.investigationLineage.hypothesisIds[]) to + * resolve them into rendered entities. + * + * `fetchBatch` is expected to be a referentially stable map-returning fn + * (e.g., a Zustand selector wrapped in useCallback). Re-projection runs + * only when fkList or fetchBatch identity changes. + * + * Returns the entities that resolved cleanly; missing FK entries are silently + * dropped from the output array — the consumer chooses whether to surface that. + * + * Per spec §11 D18 — live-document primitives. + */ +export function useLiveProjection( + fkList: ReadonlyArray, + fetchBatch: (ids: ReadonlyArray) => Map +): T[] { + return useMemo(() => { + const batch = fetchBatch(fkList); + return fkList.map(id => batch.get(id)).filter((v): v is T => v !== undefined); + }, [fkList, fetchBatch]); +} diff --git a/packages/stores/src/__tests__/improvementProjectStore.test.ts b/packages/stores/src/__tests__/improvementProjectStore.test.ts new file mode 100644 index 000000000..7a4d2b144 --- /dev/null +++ b/packages/stores/src/__tests__/improvementProjectStore.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + useImprovementProjectStore, + getImprovementProjectInitialState, +} from '../improvementProjectStore'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; + +function makeProject(id: string, hubId: string): ImprovementProject { + return { + id, + hubId, + status: 'draft', + metadata: { title: `Project ${id}` }, + goal: { + outcomeGoal: { + outcomeSpecId: `outcome-${id}`, + target: 1.33, + }, + }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + createdAt: 1714000000000, + updatedAt: 1714000000000, + deletedAt: null, + }; +} + +beforeEach(() => { + useImprovementProjectStore.setState(getImprovementProjectInitialState()); +}); + +describe('improvementProjectStore initial state', () => { + it('starts with an empty projectsByHub record', () => { + const { projectsByHub } = useImprovementProjectStore.getState(); + expect(projectsByHub).toEqual({}); + }); +}); + +describe('improvementProjectStore setProjectsForHub', () => { + it('populates the slot for a hub', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().setProjectsForHub('hub-1', [project]); + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toEqual([project]); + }); + + it('replaces the slot with an empty array', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().setProjectsForHub('hub-1', [project]); + useImprovementProjectStore.getState().setProjectsForHub('hub-1', []); + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toEqual([]); + }); +}); + +describe('improvementProjectStore getProjectsForHub', () => { + it('returns [] for an unknown hub', () => { + const result = useImprovementProjectStore.getState().getProjectsForHub('unknown-hub'); + expect(result).toEqual([]); + }); +}); + +describe('improvementProjectStore upsertProject', () => { + it('inserts a project into a fresh hub slot', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().upsertProject(project); + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toEqual([project]); + }); + + it('replaces an existing entry by id without duplicating', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().upsertProject(project); + + const updated: ImprovementProject = { ...project, status: 'active' }; + useImprovementProjectStore.getState().upsertProject(updated); + + const slot = useImprovementProjectStore.getState().projectsByHub['hub-1']; + expect(slot).toHaveLength(1); + expect(slot![0]!.status).toBe('active'); + }); + + it('does not pollute a different hub slot', () => { + const projectA = makeProject('ip-1', 'hub-1'); + const projectB = makeProject('ip-2', 'hub-2'); + useImprovementProjectStore.getState().upsertProject(projectA); + useImprovementProjectStore.getState().upsertProject(projectB); + + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toEqual([projectA]); + expect(useImprovementProjectStore.getState().projectsByHub['hub-2']).toEqual([projectB]); + }); +}); + +describe('improvementProjectStore removeProject', () => { + it('removes a project from whichever hub holds it', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().upsertProject(project); + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toHaveLength(1); + + useImprovementProjectStore.getState().removeProject('ip-1'); + expect(useImprovementProjectStore.getState().projectsByHub['hub-1']).toEqual([]); + }); + + it('is a silent no-op for an unknown project id', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().upsertProject(project); + const stateBefore = useImprovementProjectStore.getState().projectsByHub; + + useImprovementProjectStore.getState().removeProject('does-not-exist'); + + // State reference is unchanged (no mutation triggered) + expect(useImprovementProjectStore.getState().projectsByHub).toBe(stateBefore); + }); +}); + +describe('improvementProjectStore selector usage', () => { + it('selector s => s.projectsByHub[hubId] returns projects after upsert', () => { + const project = makeProject('ip-1', 'hub-1'); + useImprovementProjectStore.getState().upsertProject(project); + + const result = useImprovementProjectStore.getState().projectsByHub['hub-1']; + expect(result).toEqual([project]); + }); +}); diff --git a/packages/stores/src/__tests__/layerBoundary.test.ts b/packages/stores/src/__tests__/layerBoundary.test.ts index 88a3986c3..72a991924 100644 --- a/packages/stores/src/__tests__/layerBoundary.test.ts +++ b/packages/stores/src/__tests__/layerBoundary.test.ts @@ -49,6 +49,7 @@ function loadStoreFiles(): StoreFile[] { 'wallLayoutStore.ts', 'preferencesStore.ts', 'viewStore.ts', + 'improvementProjectStore.ts', ]; return filenames.map(filename => { const path = resolve(SRC, filename); diff --git a/packages/stores/src/improvementProjectStore.ts b/packages/stores/src/improvementProjectStore.ts new file mode 100644 index 000000000..55330fb06 --- /dev/null +++ b/packages/stores/src/improvementProjectStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import type { ProcessHub } from '@variscout/core'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; + +export const STORE_LAYER = 'document' as const; + +export interface ImprovementProjectStoreState { + projectsByHub: Record; +} + +export interface ImprovementProjectStoreActions { + setProjectsForHub: (hubId: ProcessHub['id'], projects: ImprovementProject[]) => void; + getProjectsForHub: (hubId: ProcessHub['id']) => ImprovementProject[]; + upsertProject: (project: ImprovementProject) => void; + removeProject: (projectId: ImprovementProject['id']) => void; +} + +export type ImprovementProjectStore = ImprovementProjectStoreState & ImprovementProjectStoreActions; + +export function getImprovementProjectInitialState(): ImprovementProjectStoreState { + return { projectsByHub: {} }; +} + +export const useImprovementProjectStore = create()((set, get) => ({ + ...getImprovementProjectInitialState(), + + setProjectsForHub: (hubId, projects) => + set(state => ({ + projectsByHub: { ...state.projectsByHub, [hubId]: projects }, + })), + + getProjectsForHub: hubId => get().projectsByHub[hubId] ?? [], + + upsertProject: project => + set(state => { + const existing = state.projectsByHub[project.hubId] ?? []; + const idx = existing.findIndex(p => p.id === project.id); + const next = + idx === -1 + ? [...existing, project] + : existing.map(p => (p.id === project.id ? project : p)); + return { projectsByHub: { ...state.projectsByHub, [project.hubId]: next } }; + }), + + removeProject: projectId => + set(state => { + const nextByHub: Record = { ...state.projectsByHub }; + let mutated = false; + for (const [hubId, projects] of Object.entries(state.projectsByHub)) { + const filtered = projects.filter(p => p.id !== projectId); + if (filtered.length !== projects.length) { + nextByHub[hubId] = filtered; + mutated = true; + } + } + return mutated ? { projectsByHub: nextByHub } : state; + }), +})); diff --git a/packages/stores/src/index.ts b/packages/stores/src/index.ts index 5fda1f89e..f94ee41ed 100644 --- a/packages/stores/src/index.ts +++ b/packages/stores/src/index.ts @@ -83,3 +83,13 @@ export type { PITab, } from './preferencesStore'; export type { DocumentSnapshot } from './documentSnapshot'; +export { + useImprovementProjectStore, + getImprovementProjectInitialState, + STORE_LAYER as IMPROVEMENT_PROJECT_STORE_LAYER, +} from './improvementProjectStore'; +export type { + ImprovementProjectStoreState, + ImprovementProjectStoreActions, + ImprovementProjectStore, +} from './improvementProjectStore'; diff --git a/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx b/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx new file mode 100644 index 000000000..203c1429a --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/CollapsibleSection.tsx @@ -0,0 +1,61 @@ +import React, { useId, useState } from 'react'; + +export interface CollapsibleSectionProps { + title: string; + children: React.ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export const CollapsibleSection: React.FC = ({ + title, + children, + open, + defaultOpen = false, + onOpenChange, +}) => { + const generatedId = useId(); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : uncontrolledOpen; + const headerId = `improvement-section-header-${generatedId}`; + const panelId = `improvement-section-panel-${generatedId}`; + + const handleToggle = () => { + const nextOpen = !isOpen; + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }; + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx b/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx new file mode 100644 index 000000000..9f2210bcf --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/ImprovementProjectForm.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { CollapsibleSection } from './CollapsibleSection'; +import { ProgressIndicator } from './ProgressIndicator'; +import { + HeaderMetadataSection, + type HeaderMetadataSectionProps, +} from './sections/HeaderMetadataSection'; +import { BackgroundSection, type BackgroundSectionProps } from './sections/BackgroundSection'; +import { GoalSection, type GoalSectionProps } from './sections/GoalSection'; +import { + InvestigationLineageSection, + type InvestigationLineageSectionProps, +} from './sections/InvestigationLineageSection'; +import { ApproachSection, type ApproachSectionProps } from './sections/ApproachSection'; +import { + OutcomeReferenceSection, + type OutcomeReferenceSectionProps, +} from './sections/OutcomeReferenceSection'; + +export type ImprovementProjectSectionKey = + | 'metadata' + | 'background' + | 'goal' + | 'lineage' + | 'approach' + | 'outcome'; + +type SectionContent = React.ReactNode | (() => React.ReactNode); + +interface ImprovementProjectSection { + key: ImprovementProjectSectionKey; + title: string; + defaultOpen: boolean; +} + +const SECTIONS: ImprovementProjectSection[] = [ + { key: 'metadata', title: 'Project metadata', defaultOpen: true }, + { key: 'background', title: 'Background / Current State', defaultOpen: true }, + { key: 'goal', title: 'Goal', defaultOpen: false }, + { key: 'lineage', title: 'Investigation lineage', defaultOpen: false }, + { key: 'approach', title: 'Approach / Countermeasures', defaultOpen: false }, + { key: 'outcome', title: 'Outcome reference', defaultOpen: false }, +]; + +const DEFAULT_CONTENT: Record = { + metadata:

Project metadata placeholder

, + background:

Background / Current State placeholder

, + goal:

Goal placeholder

, + lineage:

Investigation lineage placeholder

, + approach:

Approach / Countermeasures placeholder

, + outcome:

Outcome reference placeholder

, +}; + +export interface ImprovementProjectFormProps { + currentStep?: number; + metadataProps?: HeaderMetadataSectionProps; + backgroundProps?: BackgroundSectionProps; + goalProps?: GoalSectionProps; + lineageProps?: InvestigationLineageSectionProps; + approachProps?: ApproachSectionProps; + outcomeReferenceProps?: OutcomeReferenceSectionProps; + sectionContent?: Partial>; +} + +function renderSectionContent( + content: SectionContent | undefined, + key: ImprovementProjectSectionKey, + metadataProps?: HeaderMetadataSectionProps, + backgroundProps?: BackgroundSectionProps, + goalProps?: GoalSectionProps, + lineageProps?: InvestigationLineageSectionProps, + approachProps?: ApproachSectionProps, + outcomeReferenceProps?: OutcomeReferenceSectionProps +) { + if (typeof content === 'function') { + return content(); + } + + if (content === undefined && key === 'metadata' && metadataProps) { + return ; + } + + if (content === undefined && key === 'background' && backgroundProps) { + return ; + } + + if (content === undefined && key === 'goal' && goalProps) { + return ; + } + + if (content === undefined && key === 'lineage' && lineageProps) { + return ; + } + + if (content === undefined && key === 'approach' && approachProps) { + return ; + } + + if (content === undefined && key === 'outcome' && outcomeReferenceProps) { + return ; + } + + return content ?? DEFAULT_CONTENT[key]; +} + +export const ImprovementProjectForm: React.FC = ({ + currentStep = 1, + metadataProps, + backgroundProps, + goalProps, + lineageProps, + approachProps, + outcomeReferenceProps, + sectionContent, +}) => { + return ( +
+ + +
+ {SECTIONS.map(section => ( + + {renderSectionContent( + sectionContent?.[section.key], + section.key, + metadataProps, + backgroundProps, + goalProps, + lineageProps, + approachProps, + outcomeReferenceProps + )} + + ))} +
+
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx b/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx new file mode 100644 index 000000000..5c98f3efb --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/ProgressIndicator.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +const DEFAULT_STEPS = [ + 'Project metadata', + 'Background / Current State', + 'Goal', + 'Investigation lineage', + 'Approach / Countermeasures', + 'Outcome reference', +]; + +export interface ProgressIndicatorProps { + currentStep?: number; + steps?: string[]; +} + +export const ProgressIndicator: React.FC = ({ + currentStep = 1, + steps = DEFAULT_STEPS, +}) => { + const boundedCurrentStep = Math.min(Math.max(currentStep, 1), steps.length); + + return ( +
    + {steps.map((label, index) => { + const stepNumber = index + 1; + const state = + stepNumber < boundedCurrentStep + ? 'complete' + : stepNumber === boundedCurrentStep + ? 'current' + : 'upcoming'; + + return ( +
  1. + + {label} +
  2. + ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx new file mode 100644 index 000000000..e8c3c65a8 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ApproachSection.test.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; +import { ApproachSection } from '../sections/ApproachSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import type { ApproachSectionProps } from '../sections/ApproachSection'; + +const makeIdea = (overrides: Partial & Pick) => + ({ + createdAt: 1, + deletedAt: null, + ...overrides, + }) as ImprovementIdea; + +const makeAction = (overrides: Partial & Pick) => + ({ + createdAt: 1, + deletedAt: null, + ...overrides, + }) as ActionItem; + +describe('ApproachSection', () => { + const populatedProps: ApproachSectionProps = { + improvementIdeas: [ + makeIdea({ + id: 'idea-1', + text: 'Simplify setup with visual guides', + direction: 'simplify', + timeframe: 'weeks', + selected: true, + }), + ], + actionItems: [ + makeAction({ + id: 'action-1', + text: 'Pilot setup checklist', + assignee: { upn: 'lee@example.com', displayName: 'Lee Process' }, + dueDate: '2026-06-15', + completedAt: 1770940800000, + }), + ], + narrative: 'Start with one line and verify before rollout.', + }; + + it('renders idea and action metadata', () => { + render(); + + expect(screen.getByText('Simplify setup with visual guides')).toBeInTheDocument(); + expect(screen.getByText('simplify')).toBeInTheDocument(); + expect(screen.getByText('weeks')).toBeInTheDocument(); + expect(screen.getByText('selected')).toBeInTheDocument(); + + expect(screen.getByText('Pilot setup checklist')).toBeInTheDocument(); + expect(screen.getByText('Lee Process')).toBeInTheDocument(); + expect(screen.getByText('Due 2026-06-15')).toBeInTheDocument(); + expect(screen.getByText('completed')).toBeInTheDocument(); + }); + + it('clicking idea and action calls onNavigate with the correct kind and id', () => { + const onNavigate = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /simplify setup with visual guides/i })); + fireEvent.click(screen.getByRole('button', { name: /pilot setup checklist/i })); + + expect(onNavigate).toHaveBeenNthCalledWith(1, { + kind: 'improvementIdea', + id: 'idea-1', + }); + expect(onNavigate).toHaveBeenNthCalledWith(2, { + kind: 'actionItem', + id: 'action-1', + }); + }); + + it('does not render focusable no-op controls when onNavigate is omitted', () => { + render(); + + const ideaList = screen.getByRole('list', { name: /improvement ideas/i }); + const actionList = screen.getByRole('list', { name: /action items/i }); + + expect(within(ideaList).queryByRole('button')).not.toBeInTheDocument(); + expect(within(actionList).queryByRole('button')).not.toBeInTheDocument(); + }); + + it('narrative textarea calls onNarrativeChange', () => { + const onNarrativeChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/approach narrative/i), { + target: { value: 'Pilot the countermeasure before rollout.' }, + }); + + expect(onNarrativeChange).toHaveBeenCalledWith('Pilot the countermeasure before rollout.'); + }); + + it('renders empty states for no ideas and no actions', () => { + render(); + + expect(screen.getByText('No improvement ideas linked yet.')).toBeInTheDocument(); + expect(screen.getByText('No action items linked yet.')).toBeInTheDocument(); + }); +}); + +describe('ImprovementProjectForm approach integration', () => { + it('renders ApproachSection in section five when approach props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approach / Countermeasures' })); + + expect(screen.getByLabelText(/approach narrative/i)).toHaveValue( + 'Pilot the setup guide in one cell.' + ); + expect(screen.getByText('Simplify setup guide')).toBeInTheDocument(); + }); + + it('keeps sectionContent approach override ahead of approach props', () => { + render( + Custom approach override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Approach / Countermeasures' })); + + expect(screen.getByText('Custom approach override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/approach narrative/i)).not.toBeInTheDocument(); + expect(screen.queryByText('Simplify setup guide')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx new file mode 100644 index 000000000..45399b8ce --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/BackgroundSection.test.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { BackgroundSection } from '../sections/BackgroundSection'; +import { BackgroundSnapshot } from '../sections/BackgroundSnapshot'; + +const snapshot = { + value: 'Baseline Cpk is 0.84 across the last 12 weeks.', + sourceHash: 'baseline-hash', +}; + +const matchingCurrent = { + value: 'Baseline Cpk is 0.84 across the last 12 weeks.', + hash: 'baseline-hash', +}; + +const changedCurrent = { + value: 'Live Cpk is 1.12 across the last 12 weeks.', + hash: 'live-hash', +}; + +describe('BackgroundSnapshot', () => { + it('hides drift indicator when hashes match and shows it when hashes differ', () => { + const { rerender } = render( + + ); + + expect(screen.queryByText(/live source changed/i)).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(/live source changed/i)).toBeInTheDocument(); + }); + + it('shows refresh only for drift with a callback and sends the refreshed snapshot payload', () => { + const onRefreshFromLive = vi.fn(); + const { rerender } = render( + + ); + + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + + rerender( + + ); + + fireEvent.click(screen.getByRole('button', { name: /refresh from live/i })); + + expect(onRefreshFromLive).toHaveBeenCalledTimes(1); + expect(onRefreshFromLive).toHaveBeenCalledWith({ + value: changedCurrent.value, + sourceHash: changedCurrent.hash, + snapshottedAt: expect.any(String), + }); + expect(Date.parse(onRefreshFromLive.mock.calls[0][0].snapshottedAt)).not.toBeNaN(); + }); + + it('clears drift after rerendering with the refreshed snapshot hash', () => { + let refreshedSnapshot = snapshot; + const onRefreshFromLive = vi.fn(nextSnapshot => { + refreshedSnapshot = nextSnapshot; + }); + + const { rerender } = render( + + ); + + expect(screen.getByText(/live source changed/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /refresh from live/i })); + + rerender( + + ); + + expect(screen.queryByText(/live source changed/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /refresh from live/i })).not.toBeInTheDocument(); + }); +}); + +describe('BackgroundSection', () => { + it('keeps manual narrative changes independent from refresh', () => { + const onManualNarrativeChange = vi.fn(); + const onRefreshFromLive = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/manual narrative/i), { + target: { value: 'Updated manual context' }, + }); + + expect(onManualNarrativeChange).toHaveBeenCalledTimes(1); + expect(onManualNarrativeChange).toHaveBeenCalledWith('Updated manual context'); + expect(onRefreshFromLive).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx new file mode 100644 index 000000000..9ba809d8f --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/CollapsibleSection.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { CollapsibleSection } from '../CollapsibleSection'; + +describe('CollapsibleSection', () => { + it('renders closed by default and hides children', () => { + render( + +

Goal content

+
+ ); + + const button = screen.getByRole('button', { name: 'Goal' }); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Goal content')).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Goal' })).not.toBeInTheDocument(); + }); + + it('renders open by default when defaultOpen is true', () => { + render( + +

Metadata content

+
+ ); + + const button = screen.getByRole('button', { name: 'Project metadata' }); + const panel = screen.getByRole('region', { name: 'Project metadata' }); + + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(button).toHaveAttribute('aria-controls', panel.id); + expect(panel).toHaveAttribute('aria-labelledby', button.id); + expect(screen.getByText('Metadata content')).toBeInTheDocument(); + }); + + it('toggles uncontrolled sections and reports changes', () => { + const onOpenChange = vi.fn(); + + render( + +

Approach content

+
+ ); + + const button = screen.getByRole('button', { name: 'Approach / Countermeasures' }); + fireEvent.click(button); + + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('Approach content')).toBeInTheDocument(); + }); + + it('supports controlled open state without mutating itself', () => { + const onOpenChange = vi.fn(); + + render( + +

Outcome content

+
+ ); + + const button = screen.getByRole('button', { name: 'Outcome reference' }); + fireEvent.click(button); + + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Outcome content')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx new file mode 100644 index 000000000..1d705419c --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/GoalSection.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Hypothesis } from '@variscout/core/findings'; +import { GoalSection } from '../sections/GoalSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +const makeHypothesis = ( + overrides: Partial & Pick +): Hypothesis => + ({ + createdAt: 1, + deletedAt: null, + synthesis: '', + questionIds: [], + findingIds: [], + updatedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Hypothesis; + +describe('GoalSection', () => { + it('renders Y required guidance when no outcome FK or free text exists and clears it for either path', () => { + const { rerender } = render(); + + expect(screen.getByText(/choose an outcome target or describe the goal/i)).toBeInTheDocument(); + + rerender(); + expect( + screen.queryByText(/choose an outcome target or describe the goal/i) + ).not.toBeInTheDocument(); + + rerender(); + expect( + screen.queryByText(/choose an outcome target or describe the goal/i) + ).not.toBeInTheDocument(); + }); + + it('emits merged outcome goal changes from outcome, baseline, target, and deadline controls', () => { + const onOutcomeGoalChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/outcome spec/i), { target: { value: 'scrap' } }); + fireEvent.change(screen.getByLabelText(/baseline/i), { target: { value: '79.5' } }); + fireEvent.change(screen.getByLabelText(/^target$/i), { target: { value: '94.25' } }); + fireEvent.change(screen.getByLabelText(/deadline/i), { target: { value: '2026-07-15' } }); + + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(1, { + outcomeSpecId: 'scrap', + baseline: 82, + target: 92, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(2, { + outcomeSpecId: 'yield', + baseline: 79.5, + target: 92, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(3, { + outcomeSpecId: 'yield', + baseline: 82, + target: 94.25, + deadline: '2026-06-01', + }); + expect(onOutcomeGoalChange).toHaveBeenNthCalledWith(4, { + outcomeSpecId: 'yield', + baseline: 82, + target: 92, + deadline: '2026-07-15', + }); + }); + + it('renders only confirmed hypothesis suggestions and appends a deterministic factor control', () => { + const onFactorControlsChange = vi.fn(); + + render( + + ); + + expect( + screen.getByRole('button', { name: /use night shift setup drift/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /use candidate hypothesis/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /use already linked/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /use night shift setup drift/i })); + + expect(onFactorControlsChange).toHaveBeenCalledWith([ + { factor: 'Shift', targetCondition: 'Day shift', linkedHypothesisId: 'h-linked' }, + { + factor: 'Shift', + targetCondition: 'Target condition for Night shift setup drift', + linkedHypothesisId: 'h-confirmed', + }, + ]); + }); + + it('adds, removes, and edits factor control rows', () => { + const onFactorControlsChange = vi.fn(); + const factorControls = [ + { factor: 'Shift', targetCondition: 'Day shift', linkedHypothesisId: 'h-1' }, + { factor: 'Machine', targetCondition: 'Calibrated' }, + ]; + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /add factor control/i })); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(1, [ + ...factorControls, + { factor: '', targetCondition: '', linkedHypothesisId: undefined }, + ]); + + const rows = screen.getAllByTestId('goal-factor-control-row'); + fireEvent.change(within(rows[0]).getByLabelText(/factor/i), { + target: { value: 'Team' }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/target condition/i), { + target: { value: 'PM complete' }, + }); + fireEvent.change(within(rows[1]).getByLabelText(/linked hypothesis/i), { + target: { value: 'h-2' }, + }); + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove factor control/i })); + + expect(onFactorControlsChange).toHaveBeenNthCalledWith(2, [ + { factor: 'Team', targetCondition: 'Day shift', linkedHypothesisId: 'h-1' }, + factorControls[1], + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(3, [ + factorControls[0], + { factor: 'Machine', targetCondition: 'PM complete' }, + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(4, [ + factorControls[0], + { factor: 'Machine', targetCondition: 'Calibrated', linkedHypothesisId: 'h-2' }, + ]); + expect(onFactorControlsChange).toHaveBeenNthCalledWith(5, [factorControls[1]]); + }); + + it('keeps mechanism goals optional and emits description plus linked finding IDs', () => { + const onMechanismGoalsChange = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /add mechanism goal/i })); + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(1, [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1'] }, + { description: '', linkedFindingIds: [] }, + ]); + + const row = screen.getByTestId('goal-mechanism-goal-row'); + fireEvent.change(within(row).getByLabelText(/mechanism description/i), { + target: { value: 'Pilot setup checklist' }, + }); + const findingsSelect = within(row).getByLabelText(/linked findings/i) as HTMLSelectElement; + for (const option of Array.from(findingsSelect.options)) { + option.selected = option.value === 'finding-1' || option.value === 'finding-2'; + } + fireEvent.change(findingsSelect); + + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(2, [ + { description: 'Pilot setup checklist', linkedFindingIds: ['finding-1'] }, + ]); + expect(onMechanismGoalsChange).toHaveBeenNthCalledWith(3, [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1', 'finding-2'] }, + ]); + }); + + it('removes a mechanism goal row and emits the full next array', () => { + const onMechanismGoalsChange = vi.fn(); + const mechanismGoals = [ + { description: 'Standardize handoff checks', linkedFindingIds: ['finding-1'] }, + { description: 'Stabilize calibration routine', linkedFindingIds: ['finding-2'] }, + ]; + + render( + + ); + + const rows = screen.getAllByTestId('goal-mechanism-goal-row'); + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove mechanism goal/i })); + + expect(onMechanismGoalsChange).toHaveBeenCalledTimes(1); + expect(onMechanismGoalsChange).toHaveBeenCalledWith([mechanismGoals[1]]); + }); +}); + +describe('ImprovementProjectForm goal integration', () => { + it('renders GoalSection in section three when goal props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Goal' })); + + expect(screen.getByLabelText(/fallback goal/i)).toHaveValue('Improve first-pass yield'); + }); + + it('keeps sectionContent goal override ahead of goal props', () => { + render( + Custom goal override }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Goal' })); + + expect(screen.getByText('Custom goal override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/fallback goal/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx new file mode 100644 index 000000000..7bf868292 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/HeaderMetadataSection.test.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { HeaderMetadataSection } from '../sections/HeaderMetadataSection'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +describe('HeaderMetadataSection', () => { + it('renders title required validation for a blank title and clears it for a nonblank title', () => { + const { rerender } = render(); + + const titleInput = screen.getByLabelText(/project title/i); + expect(titleInput).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText(/project title is required/i)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByLabelText(/project title/i)).toHaveAttribute('aria-invalid', 'false'); + expect(screen.queryByText(/project title is required/i)).not.toBeInTheDocument(); + }); + + it('calls the business case callback when the textarea changes', () => { + const onBusinessCaseChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/business case/i), { + target: { value: 'Expected savings from fewer escalations' }, + }); + + expect(onBusinessCaseChange).toHaveBeenCalledWith('Expected savings from fewer escalations'); + }); + + it('calls the financial impact callback with merged amount and currency values', () => { + const onFinancialImpactChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/financial impact amount/i), { + target: { value: '25000' }, + }); + fireEvent.change(screen.getByLabelText(/financial impact currency/i), { + target: { value: 'EUR' }, + }); + fireEvent.change(screen.getByLabelText(/financial impact amount/i), { + target: { value: '1e309' }, + }); + + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(1, { + amount: 25000, + currency: 'USD', + }); + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(2, { + amount: 12000, + currency: 'EUR', + }); + expect(onFinancialImpactChange).toHaveBeenNthCalledWith(3, { + amount: undefined, + currency: 'USD', + }); + }); + + it('calls the investigation callback with selected ids and undefined when cleared', () => { + const onInvestigationIdChange = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByLabelText(/linked investigation/i), { + target: { value: 'inv-2' }, + }); + fireEvent.change(screen.getByLabelText(/linked investigation/i), { + target: { value: '' }, + }); + + expect(onInvestigationIdChange).toHaveBeenNthCalledWith(1, 'inv-2'); + expect(onInvestigationIdChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('calls onTeamChange with full next arrays for add, remove, role, and display name edits', () => { + const onTeamChange = vi.fn(); + const team = [ + { role: 'champion' as const, person: { id: 'person-1', displayName: 'Ari Champion' } }, + { role: 'teamMember' as const, person: { displayName: 'Tia Member' } }, + ]; + + render(); + + fireEvent.click(screen.getByRole('button', { name: /add team member/i })); + expect(onTeamChange).toHaveBeenNthCalledWith(1, [ + ...team, + { role: 'teamMember', person: { displayName: '' } }, + ]); + + const rows = screen.getAllByTestId('metadata-team-row'); + fireEvent.change(within(rows[0]).getByLabelText(/role/i), { + target: { value: 'sponsor' }, + }); + expect(onTeamChange).toHaveBeenNthCalledWith(2, [ + { role: 'sponsor', person: { id: 'person-1', displayName: 'Ari Champion' } }, + team[1], + ]); + + fireEvent.change(within(rows[1]).getByLabelText(/display name/i), { + target: { value: 'Taylor Lead' }, + }); + expect(onTeamChange).toHaveBeenNthCalledWith(3, [ + team[0], + { role: 'teamMember', person: { displayName: 'Taylor Lead' } }, + ]); + + fireEvent.click(within(rows[0]).getByRole('button', { name: /remove/i })); + expect(onTeamChange).toHaveBeenNthCalledWith(4, [team[1]]); + }); +}); + +describe('ImprovementProjectForm metadata integration', () => { + it('renders HeaderMetadataSection in Section 1 when metadataProps are provided', () => { + render(); + + expect(screen.getByLabelText(/project title/i)).toHaveValue('Reduce rework'); + }); + + it('keeps sectionContent.metadata override compatibility when metadataProps are provided', () => { + render( + Custom metadata override }} + /> + ); + + expect(screen.getByText('Custom metadata override')).toBeInTheDocument(); + expect(screen.queryByLabelText(/project title/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx new file mode 100644 index 000000000..84964dfba --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ImprovementProjectForm.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; + +const sectionNames = [ + 'Project metadata', + 'Background / Current State', + 'Goal', + 'Investigation lineage', + 'Approach / Countermeasures', + 'Outcome reference', +]; + +describe('ImprovementProjectForm', () => { + it('renders the six-section shell and progress indicator', () => { + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(6); + for (const name of sectionNames) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); + + it('opens sections one and two by default and collapses sections three through six', () => { + render(); + + expect(screen.getByRole('button', { name: 'Project metadata' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(screen.getByRole('button', { name: 'Background / Current State' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + for (const name of sectionNames.slice(2)) { + expect(screen.getByRole('button', { name })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('region', { name })).not.toBeInTheDocument(); + } + }); + + it('renders supplied section bodies without app state wiring', () => { + render( + Metadata fields, + background:
Background fields
, + }} + /> + ); + + expect(screen.getByText('Metadata fields')).toBeInTheDocument(); + expect(screen.getByText('Background fields')).toBeInTheDocument(); + }); + + it('renders the background section in section two when background props are provided', () => { + render( + + ); + + expect(screen.getByText('Snapshotted process state')).toBeInTheDocument(); + expect(screen.getByLabelText(/manual narrative/i)).toHaveValue('Manual project context'); + }); + + it('keeps the sectionContent background override ahead of background props', () => { + render( + Custom background override, + }} + /> + ); + + expect(screen.getByText('Custom background override')).toBeInTheDocument(); + expect(screen.queryByText('Snapshotted process state')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/manual narrative/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx new file mode 100644 index 000000000..65675b234 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/InvestigationLineageSection.test.tsx @@ -0,0 +1,192 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Finding, Hypothesis } from '@variscout/core/findings'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import { InvestigationLineageSection } from '../sections/InvestigationLineageSection'; + +const makeHypothesis = ( + overrides: Partial & Pick +): Hypothesis => + ({ + createdAt: 1, + deletedAt: null, + synthesis: '', + questionIds: [], + findingIds: [], + updatedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Hypothesis; + +const makeFinding = (overrides: Partial & Pick): Finding => + ({ + createdAt: 1, + deletedAt: null, + context: { type: 'chart', chartId: 'chart-1' }, + evidenceType: 'data', + status: 'observed', + comments: [], + statusChangedAt: 1, + investigationId: 'inv-1', + ...overrides, + }) as Finding; + +describe('InvestigationLineageSection', () => { + it('renders hypothesis chips with name, status, synthesis, and theme metadata', () => { + render( + + ); + + const chip = screen.getByText('Night shift setup drift').closest('article'); + + expect(chip).toHaveTextContent('Night shift setup drift'); + expect(chip).toHaveTextContent('confirmed'); + expect(chip).toHaveTextContent('Setup standards vary after handoff.'); + expect(chip).toHaveTextContent('handoff'); + expect(chip).toHaveTextContent('setup'); + }); + + it('renders finding chips with text, evidence type, and status metadata', () => { + render( + + ); + + const chip = screen.getByText('Setup time spikes on night shift.').closest('article'); + + expect(chip).toHaveTextContent('Setup time spikes on night shift.'); + expect(chip).toHaveTextContent('gemba'); + expect(chip).toHaveTextContent('analyzed'); + }); + + it('clicking hypothesis and finding chips fires onNavigate with the correct target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /nozzle wear/i })); + fireEvent.click(screen.getByRole('button', { name: /scrap rises after 2 pm/i })); + + expect(onNavigate).toHaveBeenNthCalledWith(1, { kind: 'hypothesis', id: 'h-1' }); + expect(onNavigate).toHaveBeenNthCalledWith(2, { kind: 'finding', id: 'f-1' }); + }); + + it('does not render a textbox or narrative editor', () => { + render( + + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('renders non-interactive chips when no navigation callback is provided', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /nozzle wear/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /scrap rises after 2 pm/i }) + ).not.toBeInTheDocument(); + }); + + it('uses unique heading ids when multiple lineage sections render', () => { + render( +
+ + +
+ ); + + const hypothesisHeadingIds = screen + .getAllByRole('heading', { name: 'Linked hypotheses' }) + .map(heading => heading.id); + const findingHeadingIds = screen + .getAllByRole('heading', { name: 'Linked findings' }) + .map(heading => heading.id); + + expect(new Set(hypothesisHeadingIds).size).toBe(hypothesisHeadingIds.length); + expect(new Set(findingHeadingIds).size).toBe(findingHeadingIds.length); + }); + + it('renders empty states for no linked hypotheses and no linked findings', () => { + render(); + + expect(screen.getByText(/no linked hypotheses yet/i)).toBeInTheDocument(); + expect(screen.getByText(/no linked findings yet/i)).toBeInTheDocument(); + }); +}); + +describe('ImprovementProjectForm investigation lineage integration', () => { + it('renders InvestigationLineageSection in section four when lineage props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Investigation lineage' })); + + const section = screen.getByRole('region', { name: 'Investigation lineage' }); + expect(within(section).getByText('Night shift setup drift')).toBeInTheDocument(); + expect(within(section).getByText('Setup time spikes on night shift.')).toBeInTheDocument(); + }); + + it('keeps sectionContent lineage override ahead of lineage props', () => { + render( + Custom lineage override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Investigation lineage' })); + + expect(screen.getByText('Custom lineage override')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /night shift setup drift/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx new file mode 100644 index 000000000..d4b92cc74 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/OutcomeReferenceSection.test.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ControlHandoff, SustainmentRecord } from '@variscout/core'; +import { ImprovementProjectForm } from '../ImprovementProjectForm'; +import { OutcomeReferenceSection } from '../sections/OutcomeReferenceSection'; + +const makeSustainmentRecord = ( + overrides: Partial & + Pick +): SustainmentRecord & { title?: string } => + ({ + createdAt: 1, + deletedAt: null, + updatedAt: 1, + investigationId: 'inv-1', + hubId: 'hub-1', + ...overrides, + }) as SustainmentRecord & { title?: string }; + +const makeHandoff = ( + overrides: Partial & Pick +): ControlHandoff => + ({ + createdAt: 1, + deletedAt: null, + investigationId: 'inv-1', + hubId: 'hub-1', + operationalOwner: { displayName: 'Process Owner' }, + handoffDate: Date.UTC(2026, 5, 15), + description: 'Control transferred to operating system.', + retainSustainmentReview: true, + recordedBy: { displayName: 'Improvement Lead' }, + ...overrides, + }) as ControlHandoff; + +describe('OutcomeReferenceSection', () => { + it('renders the required empty state when no sustainment record is linked', () => { + render(); + + expect( + screen.getByText('Sustainment: not yet started - set up after Improvement closes.') + ).toBeInTheDocument(); + }); + + it('renders sustainment summary metadata and clicking calls onNavigate with its target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + const card = screen.getByRole('button', { name: /mix temperature sustainment/i }); + + expect(card).toHaveTextContent('Mix temperature sustainment'); + expect(card).toHaveTextContent('holding'); + expect(card).toHaveTextContent('monthly'); + expect(card).toHaveTextContent('Next review 2026-07-01'); + expect(card).toHaveTextContent('Avery Owner'); + + fireEvent.click(card); + + expect(onNavigate).toHaveBeenCalledWith({ kind: 'sustainmentRecord', id: 'sr-1' }); + }); + + it('renders handoff summary metadata and clicking calls onNavigate with its target', () => { + const onNavigate = vi.fn(); + + render( + + ); + + const card = screen.getByRole('button', { name: /qms-42/i }); + + expect(card).toHaveTextContent('qms procedure'); + expect(card).toHaveTextContent('QMS-42'); + expect(card).toHaveTextContent('Jordan Ops'); + expect(card).toHaveTextContent('Effective 2026-07-02'); + + fireEvent.click(card); + + expect(onNavigate).toHaveBeenCalledWith({ kind: 'controlHandoff', id: 'handoff-1' }); + }); + + it('does not render focusable no-op buttons when onNavigate is omitted', () => { + render( + + ); + + expect( + screen.queryByRole('button', { name: /mix temperature sustainment/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /wi-900/i })).not.toBeInTheDocument(); + }); + + it('does not render editable form fields', () => { + const { container } = render( + + ); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + expect(container.querySelector('input, textarea, select')).toBeNull(); + }); +}); + +describe('ImprovementProjectForm outcome reference integration', () => { + it('renders OutcomeReferenceSection in section six when outcome reference props are provided', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Outcome reference' })); + + const section = screen.getByRole('region', { name: 'Outcome reference' }); + expect(within(section).getByText('Mix temperature sustainment')).toBeInTheDocument(); + expect(within(section).getByText('holding')).toBeInTheDocument(); + }); + + it('keeps sectionContent outcome override ahead of outcome reference props', () => { + render( + Custom outcome override, + }} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'Outcome reference' })); + + expect(screen.getByText('Custom outcome override')).toBeInTheDocument(); + expect(screen.queryByText('Mix temperature sustainment')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx b/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx new file mode 100644 index 000000000..bbea2a932 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/ProgressIndicator.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProgressIndicator } from '../ProgressIndicator'; + +describe('ProgressIndicator', () => { + it('renders exactly six PR-RPS-6 progress segments by default', () => { + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(6); + expect(screen.getByRole('list', { name: 'Improvement project progress' })).toBeInTheDocument(); + }); + + it('exposes segment state through accessible labels', () => { + render(); + + expect(screen.getByLabelText('Step 1 of 6, Project metadata, complete')).toBeInTheDocument(); + expect( + screen.getByLabelText('Step 2 of 6, Background / Current State, current') + ).toBeInTheDocument(); + expect(screen.getByLabelText('Step 3 of 6, Goal, upcoming')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts b/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts new file mode 100644 index 000000000..2a793bd87 --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/__tests__/public-export.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { + CollapsibleSection, + HeaderMetadataSection, + ImprovementProjectForm, + ProgressIndicator, +} from '../../../index'; +import type { + CollapsibleSectionProps, + HeaderMetadataSectionProps, + ImprovementProjectFormProps, + ImprovementProjectSectionKey, + ProgressIndicatorProps, +} from '../../../index'; + +describe('ImprovementProject public exports', () => { + it('exposes the ImprovementProject component group from @variscout/ui', () => { + const sectionKey: ImprovementProjectSectionKey = 'metadata'; + const metadataProps: HeaderMetadataSectionProps = { title: 'Reduce rework' }; + const formProps: ImprovementProjectFormProps = { metadataProps }; + const sectionProps: CollapsibleSectionProps = { title: 'Project metadata', children: null }; + const progressProps: ProgressIndicatorProps = { currentStep: 1 }; + + expect(sectionKey).toBe('metadata'); + expect(metadataProps.title).toBe('Reduce rework'); + expect(formProps.metadataProps).toBe(metadataProps); + expect(sectionProps.title).toBe('Project metadata'); + expect(progressProps.currentStep).toBe(1); + expect(ImprovementProjectForm).toBeTypeOf('function'); + expect(HeaderMetadataSection).toBeTypeOf('function'); + expect(CollapsibleSection).toBeTypeOf('function'); + expect(ProgressIndicator).toBeTypeOf('function'); + }); +}); diff --git a/packages/ui/src/components/ImprovementProject/index.ts b/packages/ui/src/components/ImprovementProject/index.ts new file mode 100644 index 000000000..481104e9a --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/index.ts @@ -0,0 +1,23 @@ +export { CollapsibleSection } from './CollapsibleSection'; +export type { CollapsibleSectionProps } from './CollapsibleSection'; +export { ImprovementProjectForm } from './ImprovementProjectForm'; +export type { + ImprovementProjectFormProps, + ImprovementProjectSectionKey, +} from './ImprovementProjectForm'; +export { ProgressIndicator } from './ProgressIndicator'; +export type { ProgressIndicatorProps } from './ProgressIndicator'; +export { BackgroundSection } from './sections/BackgroundSection'; +export type { BackgroundSectionProps } from './sections/BackgroundSection'; +export { BackgroundSnapshot } from './sections/BackgroundSnapshot'; +export type { BackgroundSnapshotProps } from './sections/BackgroundSnapshot'; +export { GoalSection } from './sections/GoalSection'; +export type { GoalSectionProps } from './sections/GoalSection'; +export { HeaderMetadataSection } from './sections/HeaderMetadataSection'; +export type { HeaderMetadataSectionProps } from './sections/HeaderMetadataSection'; +export { InvestigationLineageSection } from './sections/InvestigationLineageSection'; +export type { InvestigationLineageSectionProps } from './sections/InvestigationLineageSection'; +export { ApproachSection } from './sections/ApproachSection'; +export type { ApproachSectionProps } from './sections/ApproachSection'; +export { OutcomeReferenceSection } from './sections/OutcomeReferenceSection'; +export type { OutcomeReferenceSectionProps } from './sections/OutcomeReferenceSection'; diff --git a/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx b/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx new file mode 100644 index 000000000..aeba6598e --- /dev/null +++ b/packages/ui/src/components/ImprovementProject/sections/ApproachSection.tsx @@ -0,0 +1,148 @@ +import React, { useId } from 'react'; +import type { ActionItem, ImprovementIdea } from '@variscout/core/findings'; + +export interface ApproachSectionProps { + improvementIdeas?: ImprovementIdea[]; + actionItems?: ActionItem[]; + narrative?: string; + onNarrativeChange?: (value: string) => void; + onNavigate?: (target: { kind: 'improvementIdea' | 'actionItem'; id: string }) => void; +} + +const panelClassName = 'rounded-md border border-edge bg-surface-secondary p-4'; +const cardClassName = + 'w-full rounded-md border border-edge bg-surface p-3 text-left text-sm text-content'; +const interactiveCardClassName = `${cardClassName} transition-colors hover:bg-surface-secondary focus:outline-none focus:ring-2 focus:ring-ring`; +const chipClassName = + 'rounded-full border border-edge bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content/80'; +const textareaClassName = + 'min-h-28 w-full rounded-md border border-edge bg-surface px-3 py-2 text-sm text-content shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'; + +function formatCompleted(action: ActionItem): string | undefined { + return action.completedAt ? 'completed' : undefined; +} + +function ideaMetadata(idea: ImprovementIdea): string[] { + return [idea.direction, idea.timeframe, idea.selected ? 'selected' : undefined].filter( + (value): value is string => Boolean(value) + ); +} + +function actionMetadata(action: ActionItem): string[] { + return [ + action.assignee?.displayName, + action.dueDate ? `Due ${action.dueDate}` : undefined, + formatCompleted(action), + ].filter((value): value is string => Boolean(value)); +} + +function MetadataChips({ values }: { values: string[] }) { + if (values.length === 0) return null; + + return ( +
+ {values.map(value => ( + + {value} + + ))} +
+ ); +} + +export const ApproachSection: React.FC = ({ + improvementIdeas = [], + actionItems = [], + narrative = '', + onNarrativeChange, + onNavigate, +}) => { + const generatedId = useId(); + const ideasHeadingId = `approach-ideas-heading-${generatedId}`; + const actionsHeadingId = `approach-actions-heading-${generatedId}`; + const narrativeHeadingId = `approach-narrative-heading-${generatedId}`; + const narrativeTextareaId = `approach-narrative-textarea-${generatedId}`; + + return ( +
+
+

+ Improvement ideas +

+ + {improvementIdeas.length === 0 ? ( +

No improvement ideas linked yet.

+ ) : ( +
    + {improvementIdeas.map(idea => ( +
  • + {onNavigate ? ( + + ) : ( +
    +

    {idea.text}

    + +
    + )} +
  • + ))} +
+ )} +
+ +
+

+ Action items +

+ + {actionItems.length === 0 ? ( +

No action items linked yet.

+ ) : ( +
    + {actionItems.map(action => ( +
  • + {onNavigate ? ( + + ) : ( +
    +

    {action.text}

    + +
    + )} +
  • + ))} +
+ )} +
+ +
+

+ Narrative +

+