From 6dab8f6f767b4b9c84dfb65bcad6089aa35d8ac5 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Tue, 12 May 2026 16:13:53 +0300 Subject: [PATCH] feat: implement sustainment v1 --- .../src/components/ProcessHubReviewPanel.tsx | 57 +++- .../components/SustainmentRecordEditor.tsx | 5 + .../ProcessHubSustainmentRegion.test.tsx | 5 + .../__tests__/SustainmentEditors.test.tsx | 15 + .../azure/src/components/editor/FrameView.tsx | 167 ++++++++--- .../editor/__tests__/FrameView.test.tsx | 125 ++++++++ .../sustainment/SustainmentPanel.test.tsx | 191 ++++++++++++ .../sustainment/SustainmentPanel.tsx | 273 ++++++++++++++++-- apps/azure/src/features/panels/panelsStore.ts | 11 +- apps/azure/src/pages/Editor.tsx | 6 +- .../src/persistence/AzureHubRepository.ts | 144 +++++++-- .../__tests__/AzureHubRepository.read.test.ts | 128 +++++++- .../AzureHubRepository.snapshot.test.ts | 95 ++++++ .../__tests__/AzureHubRepository.test.ts | 16 + .../__tests__/applyAction.sustainment.test.ts | 164 +++++++++++ apps/azure/src/persistence/applyAction.ts | 102 ++++++- .../src/services/__tests__/blobClient.test.ts | 5 + .../__tests__/sustainmentStorage.test.ts | 5 + apps/azure/src/services/storage.ts | 21 +- apps/pwa/src/App.tsx | 6 +- apps/pwa/src/components/SustainmentPanel.tsx | 273 ++++++++++++++++-- .../__tests__/SustainmentPanel.test.tsx | 191 ++++++++++++ apps/pwa/src/components/views/FrameView.tsx | 178 +++++++++--- .../views/__tests__/FrameView.test.tsx | 121 ++++++++ apps/pwa/src/db/schema.ts | 15 +- apps/pwa/src/features/panels/panelsStore.ts | 11 +- apps/pwa/src/hooks/useAppPanels.ts | 2 + apps/pwa/src/persistence/PwaHubRepository.ts | 60 +++- .../__tests__/PwaHubRepository.test.ts | 112 ++++++- .../__tests__/applyAction.sustainment.test.ts | 179 ++++++++++++ .../persistence/__tests__/applyAction.test.ts | 90 ++++++ apps/pwa/src/persistence/applyAction.ts | 172 +++++++++-- .../core/src/__tests__/sustainment.test.ts | 176 +++++++++++ packages/core/src/actions/HubAction.ts | 4 +- .../actions/__tests__/exhaustiveness.test.ts | 67 +++++ .../__tests__/sustainmentActions.test.ts | 70 +++++ packages/core/src/actions/index.ts | 1 + .../core/src/actions/sustainmentActions.ts | 38 +++ packages/core/src/index.ts | 1 + .../core/src/persistence/HubRepository.ts | 17 ++ packages/core/src/persistence/index.ts | 2 + packages/core/src/processHub.ts | 8 + .../core/src/survey/__tests__/inbox.test.ts | 78 +++++ .../src/survey/__tests__/sustainment.test.ts | 191 ++++++++++++ packages/core/src/survey/inbox.ts | 41 +++ packages/core/src/survey/index.ts | 3 + packages/core/src/survey/sustainment.ts | 118 ++++++++ packages/core/src/survey/types.ts | 10 +- packages/core/src/sustainment.ts | 117 ++++++++ .../ui/src/components/Inbox/InboxDigest.tsx | 62 ++++ .../Inbox/__tests__/InboxDigest.test.tsx | 63 ++++ packages/ui/src/components/Inbox/index.ts | 2 + .../Sustainment/SustainmentForm.tsx | 240 +++++++++++++++ .../__tests__/SustainmentForm.test.tsx | 113 ++++++++ .../ui/src/components/Sustainment/index.ts | 2 + packages/ui/src/index.ts | 5 + 56 files changed, 4169 insertions(+), 205 deletions(-) create mode 100644 apps/azure/src/components/sustainment/SustainmentPanel.test.tsx create mode 100644 apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts create mode 100644 apps/pwa/src/components/__tests__/SustainmentPanel.test.tsx create mode 100644 apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts create mode 100644 packages/core/src/actions/__tests__/sustainmentActions.test.ts create mode 100644 packages/core/src/actions/sustainmentActions.ts create mode 100644 packages/core/src/survey/__tests__/inbox.test.ts create mode 100644 packages/core/src/survey/__tests__/sustainment.test.ts create mode 100644 packages/core/src/survey/inbox.ts create mode 100644 packages/core/src/survey/sustainment.ts create mode 100644 packages/ui/src/components/Inbox/InboxDigest.tsx create mode 100644 packages/ui/src/components/Inbox/__tests__/InboxDigest.test.tsx create mode 100644 packages/ui/src/components/Inbox/index.ts create mode 100644 packages/ui/src/components/Sustainment/SustainmentForm.tsx create mode 100644 packages/ui/src/components/Sustainment/__tests__/SustainmentForm.test.tsx create mode 100644 packages/ui/src/components/Sustainment/index.ts diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index f1b85b7b0..cfab0fa46 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -13,7 +13,8 @@ import type { ProcessStateNote, ResponsePathAction, } from '@variscout/core'; -import { ProcessHubCurrentStatePanel } from '@variscout/ui'; +import { InboxDigest, ProcessHubCurrentStatePanel, type InboxDigestPrompt } from '@variscout/ui'; +import { surveyInboxRules } from '@variscout/core/survey'; import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions'; import ProcessHubCadenceQueues from './ProcessHubCadenceQueues'; import { formatLatestActivity } from './ProcessHubFormat'; @@ -75,6 +76,22 @@ const ProcessHubReviewPanel: React.FC = ({ }) => { const cadence = buildProcessHubCadence(rollup); const currentState = buildCurrentProcessState(rollup, cadence); + const inboxPrompts = React.useMemo( + () => + surveyInboxRules({ + hub: rollup.hub, + improvementProjects: rollup.hub.improvementProjects ?? [], + sustainmentRecords: rollup.sustainmentRecords, + sustainmentReviews: rollup.hub.sustainmentReviews ?? [], + now: Date.now(), + }), + [ + rollup.hub.id, + rollup.hub.improvementProjects, + rollup.hub.sustainmentReviews, + rollup.sustainmentRecords, + ] + ); // Pick the most-recently-modified investigation in this hub as the // default navigation target for hub-aggregate state items (capability-gap, @@ -95,6 +112,40 @@ const ProcessHubReviewPanel: React.FC = ({ [defaultInvestigationId] ); + const handleInboxNavigate = React.useCallback( + (prompt: InboxDigestPrompt) => { + const surface = prompt.action?.opensSurface; + const targetId = prompt.action?.opensId; + const targetProject = rollup.hub.improvementProjects?.find( + project => project.id === targetId + ); + if (surface === 'sustainment' && targetId) { + if (rollup.sustainmentRecords.some(record => record.id === targetId)) { + onLogReview(targetId); + return; + } + if (targetProject?.metadata.investigationId) { + onSetupSustainment(targetProject.metadata.investigationId); + return; + } + onOpenInvestigation(targetId); + return; + } + if (surface === 'improvement-projects' && targetId) { + onOpenInvestigation(targetProject?.metadata.investigationId ?? targetId); + return; + } + if (targetId) onOpenInvestigation(targetId); + }, + [ + onLogReview, + onOpenInvestigation, + onSetupSustainment, + rollup.hub.improvementProjects, + rollup.sustainmentRecords, + ] + ); + // Resolver: given a state item, return the investigation IDs whose findings // should "back" it. Mirrors the spec's Investigation-ID resolver table. // @@ -180,6 +231,10 @@ const ProcessHubReviewPanel: React.FC = ({ +
+ +
+ = ({ const nowMs = Date.now(); const record: SustainmentRecord = { id: existingRecord?.id ?? crypto.randomUUID(), + title: existingRecord?.title ?? 'Sustainment cadence', investigationId, hubId, + status: existingRecord?.status ?? 'pending', + consecutiveOnTargetTicks: existingRecord?.consecutiveOnTargetTicks ?? 0, + hasOverride: existingRecord?.hasOverride ?? false, + lastEvaluatedSnapshotId: existingRecord?.lastEvaluatedSnapshotId, cadence, nextReviewDue: nextReviewDue ? new Date(nextReviewDue + 'T00:00:00.000Z').toISOString() diff --git a/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx b/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx index 607a54ee8..67551cb31 100644 --- a/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx +++ b/apps/azure/src/components/__tests__/ProcessHubSustainmentRegion.test.tsx @@ -82,8 +82,13 @@ function makeRecord( ): SustainmentRecord { return { id: `rec-${investigationId}`, + title: 'Sustainment cadence', investigationId, hubId: 'hub-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', createdAt: 1735689600000, // 2026-01-01T00:00:00.000Z updatedAt: 1735689600000, // 2026-01-01T00:00:00.000Z diff --git a/apps/azure/src/components/__tests__/SustainmentEditors.test.tsx b/apps/azure/src/components/__tests__/SustainmentEditors.test.tsx index 0e2697e92..d35c47d04 100644 --- a/apps/azure/src/components/__tests__/SustainmentEditors.test.tsx +++ b/apps/azure/src/components/__tests__/SustainmentEditors.test.tsx @@ -166,8 +166,13 @@ describe('SustainmentRecordEditor', () => { it("preserves an existing record's next-review-due when cadence changes (treated as user-set)", () => { const existingRecord: SustainmentRecord = { id: 'rec-existing', + title: 'Sustainment cadence', investigationId: 'inv-abc', hubId: 'hub-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', nextReviewDue: '2026-12-01T00:00:00.000Z', createdAt: 1743465600000, // 2026-04-01T00:00:00.000Z @@ -242,8 +247,13 @@ describe('SustainmentRecordEditor', () => { describe('SustainmentReviewLogger', () => { const baseRecord: SustainmentRecord = { id: 'rec-1', + title: 'Sustainment cadence', investigationId: 'inv-abc', hubId: 'hub-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', nextReviewDue: '2026-04-27T00:00:00.000Z', createdAt: 1740787200000, // 2026-03-01T00:00:00.000Z @@ -382,8 +392,13 @@ describe('ControlHandoffEditor', () => { it('updates relatedRecord.controlHandoffId after saving the handoff', async () => { const relatedRecord: SustainmentRecord = { id: 'rec-1', + title: 'Sustainment cadence', investigationId: 'inv-abc', hubId: 'hub-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', createdAt: 1740787200000, // 2026-03-01T00:00:00.000Z updatedAt: 1740787200000, diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index 89acd6fe4..098e30aa0 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { CanvasWorkspace, + InboxDigest, + type InboxDigestPrompt, type ContextLinkGroup, type ContextLinkItem, type LogActionPayload, @@ -21,15 +23,19 @@ import type { CanvasInvestigationFocus } from '@variscout/hooks'; import type { EvidenceSnapshot, StepCapabilityStamp, + SustainmentRecord, WorkflowReadinessSignals, } from '@variscout/core'; import { createActionItem, type ActionItem } from '@variscout/core/findings'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { surveyInboxRules } from '@variscout/core/survey'; import { azureHubRepository } from '../../persistence'; import { usePanelsStore } from '../../features/panels/panelsStore'; import { useInvestigationFeatureStore } from '../../features/investigation/investigationStore'; const EMPTY_PRIOR_STEP_STATS: ReadonlyMap = new Map(); const EMPTY_ACTION_ITEMS: ActionItem[] = []; +const EMPTY_SUSTAINMENT_RECORDS: SustainmentRecord[] = []; function mergeActionItems( current: readonly ActionItem[], @@ -53,6 +59,26 @@ function priorStepStatsFromSnapshots( return new Map(stamps.map(stamp => [stamp.stepId, stamp])); } +function hasCompletedInterventionEvidence( + projects: readonly ImprovementProject[], + items: readonly ActionItem[] +): boolean { + const completedActionIds = new Set( + items + .filter( + item => + item.deletedAt === null && + (item.completedAt !== undefined || item.status === 'done' || item.doneAt != null) + ) + .map(item => item.id) + ); + return projects.some(project => { + if (project.deletedAt !== null || project.status !== 'closed') return false; + const actionItemIds = project.sections.approach.actionItemIds ?? []; + return actionItemIds.some(id => completedActionIds.has(id)); + }); +} + const FrameView: React.FC = () => { const rawData = useProjectStore(s => s.rawData); const outcome = useProjectStore(s => s.outcome); @@ -72,6 +98,8 @@ const FrameView: React.FC = () => { const [priorStepStats, setPriorStepStats] = React.useState>(EMPTY_PRIOR_STEP_STATS); const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); + const [sustainmentRecords, setSustainmentRecords] = + React.useState(EMPTY_SUSTAINMENT_RECORDS); const activeHubIdRef = React.useRef(activeHubId); React.useEffect(() => { @@ -101,6 +129,7 @@ const FrameView: React.FC = () => { React.useEffect(() => { setActionItems(EMPTY_ACTION_ITEMS); + setSustainmentRecords(EMPTY_SUSTAINMENT_RECORDS); if (!activeHubId) { return; @@ -109,8 +138,16 @@ const FrameView: React.FC = () => { let cancelled = false; void (async () => { try { - const items = await azureHubRepository.actionItems.listByHub(activeHubId); - if (!cancelled) setActionItems(items); + const [items, records] = await Promise.all([ + azureHubRepository.actionItems.listByHub(activeHubId), + azureHubRepository.sustainmentRecords.listByHub(activeHubId), + ]); + if (!cancelled) { + setActionItems(items); + setSustainmentRecords( + records.filter((record: SustainmentRecord) => record.deletedAt === null) + ); + } } catch { // Keep any in-memory quick actions if the local repository is unavailable. } @@ -121,15 +158,11 @@ const FrameView: React.FC = () => { }; }, [activeHubId]); - const signals: WorkflowReadinessSignals = React.useMemo( - () => ({ hasIntervention: false, sustainmentConfirmed: false }), - [] - ); - const contextLinkGroups: readonly ContextLinkGroup[] = React.useMemo(() => { const improvementProjects = (activeHubId ? (projectsByHub[activeHubId] ?? []) : []).filter( project => project.deletedAt === null ); + const liveSustainmentRecords = sustainmentRecords.filter(record => record.deletedAt === null); return [ { @@ -149,10 +182,42 @@ const FrameView: React.FC = () => { })), }, { surfaceType: 'quick-actions', items: [] }, - { surfaceType: 'sustainment', items: [] }, + { + surfaceType: 'sustainment', + items: liveSustainmentRecords.map(record => ({ + id: record.id, + label: record.title, + description: record.status, + })), + }, { surfaceType: 'handoff', items: [] }, ]; - }, [activeHubId, hypotheses, projectsByHub]); + }, [activeHubId, hypotheses, projectsByHub, sustainmentRecords]); + + const signals: WorkflowReadinessSignals = React.useMemo(() => { + const improvementProjects = (activeHubId ? (projectsByHub[activeHubId] ?? []) : []).filter( + project => project.deletedAt === null + ); + + return { + hasIntervention: hasCompletedInterventionEvidence(improvementProjects, actionItems), + sustainmentConfirmed: sustainmentRecords.some( + record => record.deletedAt === null && record.status === 'confirmed-sustained' + ), + }; + }, [activeHubId, actionItems, projectsByHub, sustainmentRecords]); + + const inboxPrompts = React.useMemo(() => { + const improvementProjects = (activeHubId ? (projectsByHub[activeHubId] ?? []) : []).filter( + project => project.deletedAt === null + ); + + return surveyInboxRules({ + improvementProjects, + sustainmentRecords, + now: Date.now(), + }); + }, [activeHubId, projectsByHub, sustainmentRecords]); const handleSeeData = React.useCallback(() => { usePanelsStore.getState().showAnalysis(); @@ -243,6 +308,19 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showHandoff(); }, []); + const handleInboxNavigate = React.useCallback((prompt: InboxDigestPrompt) => { + const surface = prompt.action?.opensSurface; + if (surface === 'sustainment') { + usePanelsStore.getState().showSustainment(prompt.action?.opensId); + return; + } + if (surface === 'improvement-projects') { + usePanelsStore.getState().showCharter(); + return; + } + usePanelsStore.getState().showInvestigation(); + }, []); + const handleNavigateContextLink = React.useCallback( (item: ContextLinkItem) => { const isImprovementProject = @@ -255,42 +333,51 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showCharter(); return; } + if (sustainmentRecords.some(record => record.id === item.id)) { + usePanelsStore.getState().showSustainment(item.id); + return; + } usePanelsStore.getState().showInvestigation(); }, - [activeHubId] + [activeHubId, sustainmentRecords] ); return ( - +
+
+ +
+ +
); }; diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index 5395c9bd4..96edcce51 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -56,6 +56,7 @@ const hoisted = vi.hoisted(() => ({ canvasWorkspaceMock: vi.fn(), listByHubMock: vi.fn(), actionItemsListByHubMock: vi.fn(), + sustainmentRecordsListByHubMock: vi.fn(), dispatchMock: vi.fn(), })); @@ -105,6 +106,21 @@ vi.mock('@variscout/stores', () => ({ vi.mock('@variscout/ui', async () => { const React = await import('react'); return { + InboxDigest: (props: { prompts: unknown[]; onNavigate: (prompt: unknown) => void }) => + React.createElement( + 'div', + { 'data-testid': 'inbox-digest', 'data-count': props.prompts.length }, + props.prompts.length > 0 + ? React.createElement( + 'button', + { + type: 'button', + onClick: () => props.onNavigate(props.prompts[0]), + }, + 'Open inbox prompt' + ) + : null + ), CanvasWorkspace: (props: { signals: WorkflowReadinessSignals; onSeeData: () => void; @@ -128,6 +144,7 @@ vi.mock('@variscout/ui', async () => { onHandoff?: () => void; priorStepStats?: ReadonlyMap; actionItems?: unknown[]; + contextLinkGroups?: { surfaceType: string; items: { id: string }[] }[]; }) => { hoisted.canvasWorkspaceMock(props); return React.createElement( @@ -229,6 +246,9 @@ vi.mock('../../../persistence', () => ({ actionItems: { listByHub: hoisted.actionItemsListByHubMock, }, + sustainmentRecords: { + listByHub: hoisted.sustainmentRecordsListByHubMock, + }, }, })); @@ -254,6 +274,8 @@ describe('FrameView (Azure shell)', () => { hoisted.listByHubMock.mockResolvedValue([]); hoisted.actionItemsListByHubMock.mockReset(); hoisted.actionItemsListByHubMock.mockResolvedValue([]); + hoisted.sustainmentRecordsListByHubMock.mockReset(); + hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([]); hoisted.dispatchMock.mockReset(); hoisted.dispatchMock.mockResolvedValue(undefined); improvementProjectStateRef.current = { @@ -591,4 +613,107 @@ describe('FrameView (Azure shell)', () => { expect(showSustainmentMock).toHaveBeenCalledTimes(1); expect(showHandoffMock).toHaveBeenCalledTimes(1); }); + + it('marks Sustainment ready only when a closed project has completed intervention evidence and keeps Handoff gated until sustainment is confirmed', async () => { + improvementProjectStateRef.current = { + projectsByHub: { + 'hub-1': [ + { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce rework' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: { actionItemIds: ['action-1'] }, + outcomeReference: {}, + }, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ], + }, + getProjectsForHub: () => [], + }; + hoisted.actionItemsListByHubMock.mockResolvedValue([ + { ...actionItem('action-1', 'Change nozzle'), completedAt: 1714000000000 }, + ]); + + render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.signals).toEqual({ hasIntervention: true, sustainmentConfirmed: false }); + }); + }); + + it('marks Handoff ready and includes sustainment context links when a live record is confirmed', async () => { + hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([ + { + id: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + status: 'confirmed-sustained', + title: 'Sustain Reduce rework', + consecutiveOnTargetTicks: 4, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ]); + + render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.signals.sustainmentConfirmed).toBe(true); + expect( + props?.contextLinkGroups?.find( + (group: { surfaceType: string }) => group.surfaceType === 'sustainment' + )?.items + ).toEqual([expect.objectContaining({ id: 'sr-1' })]); + }); + }); + + it('passes the Inbox sustainment target id when opening a lifecycle prompt', async () => { + improvementProjectStateRef.current = { + projectsByHub: { + 'hub-1': [ + { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce rework' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ], + }, + getProjectsForHub: () => [], + }; + storeStateRef.current = { + ...storeStateRef.current, + processContext: { processHubId: 'hub-1' }, + }; + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Open inbox prompt' })); + + expect(showSustainmentMock).toHaveBeenCalledWith('ip-1'); + }); }); diff --git a/apps/azure/src/components/sustainment/SustainmentPanel.test.tsx b/apps/azure/src/components/sustainment/SustainmentPanel.test.tsx new file mode 100644 index 000000000..6a89fa3fa --- /dev/null +++ b/apps/azure/src/components/sustainment/SustainmentPanel.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProcessHub, SustainmentRecord, SustainmentReview } from '@variscout/core'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import SustainmentPanel from './SustainmentPanel'; +import { azureHubRepository } from '../../persistence'; + +vi.mock('@variscout/ui', async () => { + const React = await import('react'); + return { + SustainmentForm: (props: { + record: SustainmentRecord; + reviews?: SustainmentReview[]; + onRecordChange?: (patch: Partial) => void; + }) => + React.createElement( + 'section', + { 'data-testid': 'sustainment-form' }, + React.createElement('h3', null, props.record.title), + React.createElement('p', null, props.record.goal?.freeText ?? 'No goal'), + React.createElement('p', null, `Reviews ${props.reviews?.length ?? 0}`), + React.createElement( + 'button', + { + type: 'button', + onClick: () => props.onRecordChange?.({ targetSummary: 'Updated target' }), + }, + 'Update target' + ) + ), + }; +}); + +vi.mock('../../persistence', () => ({ + azureHubRepository: { + dispatch: vi.fn().mockResolvedValue(undefined), + sustainmentRecords: { + listByHub: vi.fn().mockResolvedValue([]), + }, + sustainmentReviews: { + listByRecord: vi.fn().mockResolvedValue([]), + }, + }, +})); + +function makeProject(overrides: Partial = {}): ImprovementProject { + return { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce defects', investigationId: 'inv-1' }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 }, + freeText: 'Hold first pass yield at 98%.', + }, + sections: { + background: {}, + investigationLineage: {}, + approach: { actionItemIds: ['action-1'] }, + outcomeReference: {}, + }, + createdAt: 1714000000000, + updatedAt: 1714000000000, + deletedAt: null, + ...overrides, + }; +} + +function makeHub(projects: ImprovementProject[] = [makeProject()]): ProcessHub { + return { + id: 'hub-1', + name: 'Paint line', + createdAt: 1714000000000, + deletedAt: null, + improvementProjects: projects, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(azureHubRepository.dispatch).mockResolvedValue(undefined); + vi.mocked(azureHubRepository.sustainmentRecords.listByHub).mockResolvedValue([]); + vi.mocked(azureHubRepository.sustainmentReviews.listByRecord).mockResolvedValue([]); +}); + +describe('SustainmentPanel (Azure)', () => { + it('creates a sustainment record for the active hub and carries forward the first closed project goal', async () => { + render(); + + await waitFor(() => expect(azureHubRepository.dispatch).toHaveBeenCalledTimes(1)); + expect(azureHubRepository.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-1', + record: expect.objectContaining({ + hubId: 'hub-1', + investigationId: 'inv-1', + improvementProjectId: 'ip-1', + title: 'Sustain Reduce defects', + goal: expect.objectContaining({ freeText: 'Hold first pass yield at 98%.' }), + }), + }) + ); + expect(await screen.findByTestId('sustainment-form')).toHaveTextContent( + 'Hold first pass yield at 98%.' + ); + }); + + it('selects an existing live record, reads reviews through the repository, and persists edits by dispatch', async () => { + const record: SustainmentRecord = { + id: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + status: 'pending', + title: 'Existing sustainment', + consecutiveOnTargetTicks: 1, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: 1714000000000, + updatedAt: 1714000000000, + deletedAt: null, + }; + vi.mocked(azureHubRepository.sustainmentRecords.listByHub).mockResolvedValue([record]); + vi.mocked(azureHubRepository.sustainmentReviews.listByRecord).mockResolvedValue([ + { + id: 'review-1', + recordId: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + reviewedAt: 1714000000000, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: 1714000000000, + deletedAt: null, + }, + ]); + + render(); + + await waitFor(() => + expect(screen.getByTestId('sustainment-form')).toHaveTextContent('Reviews 1') + ); + expect(azureHubRepository.dispatch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Update target' })); + + await waitFor(() => + expect(azureHubRepository.dispatch).toHaveBeenCalledWith({ + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'sr-1', + patch: { targetSummary: 'Updated target' }, + }) + ); + }); + + it('creates for the prompted closed project when a target id is supplied', async () => { + const first = makeProject({ + id: 'ip-first', + metadata: { title: 'First', investigationId: 'inv-1' }, + }); + const second = makeProject({ + id: 'ip-second', + metadata: { title: 'Second', investigationId: 'inv-2' }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-2', target: 99 }, + freeText: 'Hold the second target.', + }, + }); + + render( + + ); + + await waitFor(() => + expect(azureHubRepository.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + improvementProjectId: 'ip-second', + investigationId: 'inv-2', + title: 'Sustain Second', + targetSummary: 'Hold the second target.', + }), + }) + ) + ); + }); +}); diff --git a/apps/azure/src/components/sustainment/SustainmentPanel.tsx b/apps/azure/src/components/sustainment/SustainmentPanel.tsx index 9d7904f8f..464b06895 100644 --- a/apps/azure/src/components/sustainment/SustainmentPanel.tsx +++ b/apps/azure/src/components/sustainment/SustainmentPanel.tsx @@ -1,28 +1,263 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SustainmentForm, type SustainmentRecordChangePatch } from '@variscout/ui'; +import type { ProcessHub, SustainmentRecord, SustainmentReview } from '@variscout/core'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { azureHubRepository } from '../../persistence'; interface SustainmentPanelProps { + activeHub?: ProcessHub; + targetId?: string; onBack: () => void; } -const SustainmentPanel: React.FC = ({ onBack }) => { +const buttonClassName = + 'rounded-md border border-edge bg-surface px-3 py-2 text-left text-sm font-medium text-content transition-colors hover:bg-surface-secondary focus:outline-none focus:ring-2 focus:ring-ring'; + +function makeId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); + return `sr-${Date.now()}`; +} + +function liveRecords(records: SustainmentRecord[] | undefined): SustainmentRecord[] { + return (records ?? []).filter(record => record.deletedAt === null); +} + +function firstClosedProject( + hub: ProcessHub, + preferredProjectId?: string +): ImprovementProject | undefined { + const liveClosedProjects = (hub.improvementProjects ?? []).filter( + project => project.deletedAt === null && project.status === 'closed' + ); + const preferred = liveClosedProjects.find(project => project.id === preferredProjectId); + if (preferred) return preferred; + return liveClosedProjects[0]; +} + +function recordMatchesTarget(record: SustainmentRecord, targetId: string | undefined): boolean { return ( -
-

Sustainment

-

- Sustainment monitors a process change after implementation to verify the gain holds. The - full monitoring surface ships in a future release. -

-

Available in a future release.

- + targetId !== undefined && (record.id === targetId || record.improvementProjectId === targetId) + ); +} + +function selectedRecordForTarget( + records: SustainmentRecord[], + selectedRecordId: string | null, + targetId: string | undefined +): SustainmentRecord | null { + const targetRecord = records.find(record => recordMatchesTarget(record, targetId)); + if (targetRecord) return targetRecord; + if (records.length === 1) return records[0]; + return records.find(record => record.id === selectedRecordId) ?? null; +} + +function firstClosedProjectLegacy(hub: ProcessHub): ImprovementProject | undefined { + return (hub.improvementProjects ?? []).find( + project => project.deletedAt === null && project.status === 'closed' + ); +} + +function buildDraftRecord(hub: ProcessHub, preferredProjectId?: string): SustainmentRecord { + const project = firstClosedProject(hub, preferredProjectId) ?? firstClosedProjectLegacy(hub); + const now = Date.now(); + const investigationId = project?.metadata.investigationId ?? `${hub.id}:sustainment`; + const title = project ? `Sustain ${project.metadata.title}` : `Sustain ${hub.name}`; + + return { + id: makeId(), + hubId: hub.id, + investigationId, + status: 'pending', + title, + improvementProjectId: project?.id, + goal: project?.goal, + targetSummary: project?.goal.freeText, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +} + +function mergeRecordPatch( + record: SustainmentRecord, + patch: SustainmentRecordChangePatch +): SustainmentRecord { + return { ...record, ...patch, updatedAt: Date.now() }; +} + +const SustainmentPanel: React.FC = ({ activeHub, targetId, onBack }) => { + const [records, setRecords] = useState([]); + const [selectedRecordId, setSelectedRecordId] = useState(null); + const [reviews, setReviews] = useState([]); + const [error, setError] = useState(null); + const [isLoadingRecords, setIsLoadingRecords] = useState(Boolean(activeHub)); + const creatingForHubRef = useRef(null); + + useEffect(() => { + setRecords(liveRecords(activeHub?.sustainmentRecords)); + setSelectedRecordId(null); + setReviews([]); + setIsLoadingRecords(Boolean(activeHub)); + + if (!activeHub) { + setIsLoadingRecords(false); + return; + } + + let cancelled = false; + void azureHubRepository.sustainmentRecords + .listByHub(activeHub.id) + .then((rows: SustainmentRecord[]) => { + if (!cancelled) setRecords(liveRecords(rows)); + }) + .catch(() => { + if (!cancelled) setRecords(liveRecords(activeHub.sustainmentRecords)); + }) + .finally(() => { + if (!cancelled) setIsLoadingRecords(false); + }); + + return () => { + cancelled = true; + }; + }, [activeHub]); + + useEffect(() => { + if ( + !activeHub || + isLoadingRecords || + records.length > 0 || + creatingForHubRef.current === activeHub.id + ) { + return; + } + + const createHubId = activeHub.id; + const record = buildDraftRecord(activeHub, targetId); + creatingForHubRef.current = activeHub.id; + setError(null); + let cancelled = false; + + void azureHubRepository + .dispatch({ kind: 'SUSTAINMENT_RECORD_CREATE', hubId: activeHub.id, record }) + .then(() => { + if (cancelled || creatingForHubRef.current !== createHubId) return; + setRecords([record]); + setSelectedRecordId(record.id); + }) + .catch(() => { + if (!cancelled) setError('Could not create a sustainment record.'); + }) + .finally(() => { + if (creatingForHubRef.current === createHubId) creatingForHubRef.current = null; + }); + + return () => { + cancelled = true; + }; + }, [activeHub, isLoadingRecords, records.length, targetId]); + + const selectedRecord = selectedRecordForTarget(records, selectedRecordId, targetId); + + useEffect(() => { + if (!activeHub || !selectedRecord) { + setReviews([]); + return; + } + + let cancelled = false; + void azureHubRepository.sustainmentReviews + .listByRecord(activeHub.id, selectedRecord.id) + .then((rows: SustainmentReview[]) => { + if (!cancelled) + setReviews(rows.filter((review: SustainmentReview) => review.deletedAt === null)); + }) + .catch(() => { + if (!cancelled) { + setReviews( + (activeHub.sustainmentReviews ?? []).filter( + review => review.deletedAt === null && review.recordId === selectedRecord.id + ) + ); + } + }); + + return () => { + cancelled = true; + }; + }, [activeHub, selectedRecord]); + + const updateSelectedRecord = useCallback( + (patch: SustainmentRecordChangePatch) => { + if (!selectedRecord) return; + const next = mergeRecordPatch(selectedRecord, patch); + setRecords(current => current.map(record => (record.id === next.id ? next : record))); + void azureHubRepository + .dispatch({ kind: 'SUSTAINMENT_RECORD_UPDATE', recordId: selectedRecord.id, patch }) + .catch(() => { + setError('Could not save the sustainment record changes.'); + }); + }, + [selectedRecord] + ); + + const heading = useMemo(() => activeHub?.name ?? 'No active hub', [activeHub]); + + return ( +
+
+
+

Sustainment

+

{heading}

+
+ +
+ + {!activeHub ? ( +

+ Create or select a Process Hub before opening sustainment. +

+ ) : error ? ( +

+ {error} +

+ ) : records.length > 1 && !selectedRecord ? ( +
+

Choose a sustainment record

+
+ {records.map(record => ( + + ))} +
+
+ ) : selectedRecord ? ( + + ) : ( +

+ Creating sustainment record... +

+ )}
); }; diff --git a/apps/azure/src/features/panels/panelsStore.ts b/apps/azure/src/features/panels/panelsStore.ts index bbe825dc2..14ab4fc68 100644 --- a/apps/azure/src/features/panels/panelsStore.ts +++ b/apps/azure/src/features/panels/panelsStore.ts @@ -37,6 +37,7 @@ interface PanelsState { activeImprovementView: 'plan' | 'track'; /** ID of idea highlighted via matrix<->card bidirectional navigation */ highlightedIdeaId: string | null; + sustainmentTargetId: string | null; } // ── Actions ────────────────────────────────────────────────────────────────── @@ -49,7 +50,7 @@ interface PanelsActions { showImprovement: () => void; showReport: () => void; showCharter: () => void; - showSustainment: () => void; + showSustainment: (targetId?: string) => void; showHandoff: () => void; openDataTable: () => void; closeDataTable: () => void; @@ -114,6 +115,7 @@ export const usePanelsStore = create(set => ({ factorPreviewDismissed: false, activeImprovementView: 'plan', highlightedIdeaId: null, + sustainmentTargetId: null, // Workspace navigation (ADR-055 + header-redesign spec, extended with 'frame' per ADR-070) showDashboard: () => set(() => ({ activeView: 'dashboard' })), @@ -135,7 +137,12 @@ export const usePanelsStore = create(set => ({ })), showReport: () => set(() => ({ activeView: 'report' })), showCharter: () => set(() => ({ activeView: 'charter', isFindingsOpen: false })), - showSustainment: () => set(() => ({ activeView: 'sustainment', isFindingsOpen: false })), + showSustainment: targetId => + set(() => ({ + activeView: 'sustainment', + isFindingsOpen: false, + sustainmentTargetId: targetId ?? null, + })), showHandoff: () => set(() => ({ activeView: 'handoff', isFindingsOpen: false })), // Data table diff --git a/apps/azure/src/pages/Editor.tsx b/apps/azure/src/pages/Editor.tsx index b74ec2f34..4eac4ef89 100644 --- a/apps/azure/src/pages/Editor.tsx +++ b/apps/azure/src/pages/Editor.tsx @@ -1609,7 +1609,11 @@ export const Editor: React.FC = ({ }} /> ) : activeView === 'sustainment' ? ( - usePanelsStore.getState().showFrame()} /> + usePanelsStore.getState().showFrame()} + /> ) : activeView === 'handoff' ? ( usePanelsStore.getState().showFrame()} /> ) : activeView === 'investigation' ? ( diff --git a/apps/azure/src/persistence/AzureHubRepository.ts b/apps/azure/src/persistence/AzureHubRepository.ts index 9c68ddb30..49c2926bf 100644 --- a/apps/azure/src/persistence/AzureHubRepository.ts +++ b/apps/azure/src/persistence/AzureHubRepository.ts @@ -20,9 +20,12 @@ import type { HypothesisReadAPI, CanvasStateReadAPI, ActionItemReadAPI, + SustainmentRecordReadAPI, + SustainmentReviewReadAPI, } from '@variscout/core/persistence'; import type { HubAction } from '@variscout/core/actions'; import type { ActionItem } from '@variscout/core/findings'; +import type { ProcessHub } from '@variscout/core/processHub'; import { db } from '../db/schema'; import { saveProcessHubToIndexedDB } from '../services/localDb'; import { applyAction } from './applyAction'; @@ -39,22 +42,47 @@ export class AzureHubRepository implements HubRepository { if (action.kind === 'HUB_PERSIST_SNAPSHOT') { // 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 })) - ); + const { improvementProjects, sustainmentRecords, sustainmentReviews, ...hubRow } = action.hub; + await db.transaction( + 'rw', + [db.processHubs, db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews], + async () => { + await saveProcessHubToIndexedDB(hubRow); + // 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 })) + ); + } + const incomingSustainmentRecords = sustainmentRecords ?? []; + const incomingRecordIds = new Set(incomingSustainmentRecords.map(record => record.id)); + await db.sustainmentRecords + .where('hubId') + .equals(action.hub.id) + .filter(record => !incomingRecordIds.has(record.id)) + .delete(); + if (incomingSustainmentRecords.length > 0) { + await db.sustainmentRecords.bulkPut(incomingSustainmentRecords); + } + + const incomingSustainmentReviews = sustainmentReviews ?? []; + const incomingReviewIds = new Set(incomingSustainmentReviews.map(review => review.id)); + await db.sustainmentReviews + .where('hubId') + .equals(action.hub.id) + .filter(review => !incomingReviewIds.has(review.id)) + .delete(); + if (incomingSustainmentReviews.length > 0) { + await db.sustainmentReviews.bulkPut(incomingSustainmentReviews); + } } - }); + ); return; } @@ -70,23 +98,26 @@ export class AzureHubRepository implements HubRepository { hubs: HubReadAPI = { // hubs.get is unscoped — direct id lookup; hydrates improvementProjects from dedicated table. async 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 }; + return db.transaction( + 'r', + [db.processHubs, db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews], + async () => { + const hub = await db.processHubs.get(id); + if (!hub) return undefined; + if (hub.deletedAt !== null) return undefined; + return hydrateHub(hub); + } + ); }, async list() { - const all = await db.processHubs.toArray(); - 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 }; - }) + return db.transaction( + 'r', + [db.processHubs, db.improvementProjects, db.sustainmentRecords, db.sustainmentReviews], + async () => { + const all = await db.processHubs.toArray(); + const live = all.filter(h => h.deletedAt === null); + return Promise.all(live.map(hydrateHub)); + } ); }, }; @@ -232,6 +263,55 @@ export class AzureHubRepository implements HubRepository { .map(stripActionItemHubId); }, }; + + sustainmentRecords: SustainmentRecordReadAPI = { + async get(id) { + const row = await db.sustainmentRecords.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + async listByHub(hubId) { + const rows = await db.sustainmentRecords.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null); + }, + }; + + sustainmentReviews: SustainmentReviewReadAPI = { + async get(id) { + const row = await db.sustainmentReviews.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + async listByHub(hubId) { + const rows = await db.sustainmentReviews.where('hubId').equals(hubId).toArray(); + return sortReviewsDescending(rows.filter(row => row.deletedAt === null)); + }, + async listByRecord(hubId, recordId) { + const rows = await db.sustainmentReviews.where('recordId').equals(recordId).toArray(); + return sortReviewsDescending( + rows.filter(row => row.hubId === hubId && row.deletedAt === null) + ); + }, + }; +} + +async function hydrateHub(hub: ProcessHub): Promise { + const [ips, sustainmentRecords, sustainmentReviews] = await Promise.all([ + db.improvementProjects.where('hubId').equals(hub.id).toArray(), + db.sustainmentRecords.where('hubId').equals(hub.id).toArray(), + db.sustainmentReviews.where('hubId').equals(hub.id).toArray(), + ]); + const liveIps = ips.filter(p => p.deletedAt === null); + const liveSustainmentRecords = sustainmentRecords.filter(record => record.deletedAt === null); + const liveSustainmentReviews = sortReviewsDescending( + sustainmentReviews.filter(review => review.deletedAt === null) + ); + return { + ...hub, + ...(liveIps.length > 0 ? { improvementProjects: liveIps } : {}), + ...(liveSustainmentRecords.length > 0 ? { sustainmentRecords: liveSustainmentRecords } : {}), + ...(liveSustainmentReviews.length > 0 ? { sustainmentReviews: liveSustainmentReviews } : {}), + }; } function stripActionItemHubId(row: { hubId: string } & ActionItem): ActionItem { @@ -240,6 +320,10 @@ function stripActionItemHubId(row: { hubId: string } & ActionItem): ActionItem { return actionItem; } +function sortReviewsDescending(rows: T[]): T[] { + return rows.sort((a, b) => b.reviewedAt - a.reviewedAt); +} + // Module-scoped singleton. Composition root + dispatch boundary documented in apps/azure/CLAUDE.md. // Vitest module-mocking handles test override. export const azureHubRepository = new AzureHubRepository(); diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts index 0868f3a62..4a303a85a 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.read.test.ts @@ -20,7 +20,13 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { AzureHubRepository } from '../AzureHubRepository'; import { db } from '../../db/schema'; import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; -import type { EvidenceSource, EvidenceSnapshot, EvidenceSourceCursor } from '@variscout/core'; +import type { + EvidenceSource, + EvidenceSnapshot, + EvidenceSourceCursor, + SustainmentRecord, + SustainmentReview, +} from '@variscout/core'; // --------------------------------------------------------------------------- // Fixture helpers @@ -91,6 +97,48 @@ function makeCursor( }; } +function makeSustainmentRecord( + id: string, + hubId = 'hub-azure-1', + overrides: Partial = {} +): SustainmentRecord { + return { + id, + hubId, + investigationId: `inv-${id}`, + status: 'pending', + title: `Record ${id}`, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + updatedAt: NOW, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeSustainmentReview( + id: string, + recordId: string, + hubId = 'hub-azure-1', + overrides: Partial = {} +): SustainmentReview { + return { + id, + recordId, + hubId, + investigationId: `inv-${recordId}`, + reviewedAt: NOW, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Read API tests // --------------------------------------------------------------------------- @@ -105,6 +153,8 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); await db.improvementProjects.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); }); afterEach(async () => { @@ -113,6 +163,8 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); await db.improvementProjects.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); }); // ---- hubs.get ---- @@ -134,6 +186,23 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { const result = await repo.hubs.get('hub-azure-1'); expect(result).toBeUndefined(); }); + + it('hydrates live sustainment records and reviews from dedicated tables', async () => { + await db.processHubs.put(makeHub({ id: 'hub-azure-1' })); + await db.sustainmentRecords.bulkPut([ + makeSustainmentRecord('sr-live'), + makeSustainmentRecord('sr-dead', 'hub-azure-1', { deletedAt: NOW }), + ]); + await db.sustainmentReviews.bulkPut([ + makeSustainmentReview('rev-live', 'sr-live'), + makeSustainmentReview('rev-dead', 'sr-live', 'hub-azure-1', { deletedAt: NOW }), + ]); + + const result = await repo.hubs.get('hub-azure-1'); + + expect(result?.sustainmentRecords?.map(record => record.id)).toEqual(['sr-live']); + expect(result?.sustainmentReviews?.map(review => review.id)).toEqual(['rev-live']); + }); }); // ---- hubs.list ---- @@ -163,6 +232,63 @@ describe('AzureHubRepository read APIs (Dexie tables)', () => { const result = await repo.hubs.list(); expect(result.map(h => h.id)).toEqual(['hub-live']); }); + + it('hydrates live sustainment records and reviews for each listed hub', async () => { + await db.processHubs.bulkPut([ + makeHub({ id: 'hub-1', name: 'Hub One' }), + makeHub({ id: 'hub-2', name: 'Hub Two' }), + ]); + await db.sustainmentRecords.bulkPut([ + makeSustainmentRecord('sr-1', 'hub-1'), + makeSustainmentRecord('sr-2', 'hub-2'), + ]); + await db.sustainmentReviews.bulkPut([ + makeSustainmentReview('rev-1', 'sr-1', 'hub-1'), + makeSustainmentReview('rev-2', 'sr-2', 'hub-2'), + ]); + + const result = await repo.hubs.list(); + const byId = new Map(result.map(hub => [hub.id, hub])); + + expect(byId.get('hub-1')?.sustainmentRecords?.map(record => record.id)).toEqual(['sr-1']); + expect(byId.get('hub-1')?.sustainmentReviews?.map(review => review.id)).toEqual(['rev-1']); + expect(byId.get('hub-2')?.sustainmentRecords?.map(record => record.id)).toEqual(['sr-2']); + expect(byId.get('hub-2')?.sustainmentReviews?.map(review => review.id)).toEqual(['rev-2']); + }); + }); + + // ---- sustainmentRecords / sustainmentReviews ---- + + describe('sustainment read APIs', () => { + it('sustainmentRecords list/get return only live records for the requested hub', async () => { + await db.sustainmentRecords.bulkPut([ + makeSustainmentRecord('sr-live', 'hub-azure-1'), + makeSustainmentRecord('sr-dead', 'hub-azure-1', { deletedAt: NOW }), + makeSustainmentRecord('sr-other', 'hub-other'), + ]); + + const rows = await repo.sustainmentRecords.listByHub('hub-azure-1'); + + expect(rows.map(row => row.id)).toEqual(['sr-live']); + expect((await repo.sustainmentRecords.get('sr-live'))?.id).toBe('sr-live'); + expect(await repo.sustainmentRecords.get('sr-dead')).toBeUndefined(); + }); + + it('sustainmentReviews list/get return only live reviews for the requested record and hub', async () => { + await db.sustainmentReviews.bulkPut([ + makeSustainmentReview('rev-live', 'sr-live', 'hub-azure-1', { reviewedAt: NOW + 1 }), + makeSustainmentReview('rev-old', 'sr-live', 'hub-azure-1', { reviewedAt: NOW - 1 }), + makeSustainmentReview('rev-dead', 'sr-live', 'hub-azure-1', { deletedAt: NOW }), + makeSustainmentReview('rev-other-record', 'sr-other', 'hub-azure-1'), + makeSustainmentReview('rev-other-hub', 'sr-live', 'hub-other'), + ]); + + const rows = await repo.sustainmentReviews.listByRecord('hub-azure-1', 'sr-live'); + + expect(rows.map(row => row.id)).toEqual(['rev-live', 'rev-old']); + expect((await repo.sustainmentReviews.get('rev-live'))?.id).toBe('rev-live'); + expect(await repo.sustainmentReviews.get('rev-dead')).toBeUndefined(); + }); }); // ---- outcomes.get ---- diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts index 80932adfc..859838de9 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.snapshot.test.ts @@ -17,6 +17,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { ProcessHub } from '@variscout/core/processHub'; +import type { SustainmentRecord, SustainmentReview } from '@variscout/core'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import { AzureHubRepository } from '../AzureHubRepository'; import { db } from '../../db/schema'; @@ -61,6 +62,37 @@ function makeHub(id: string, ips: ImprovementProject[] = []): ProcessHub { }; } +function makeSustainmentRecord(id: string, hubId: string): SustainmentRecord { + return { + id, + hubId, + investigationId: `inv-${id}`, + status: 'pending', + title: `Record ${id}`, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + updatedAt: NOW, + createdAt: NOW, + deletedAt: null, + }; +} + +function makeSustainmentReview(id: string, recordId: string, hubId: string): SustainmentReview { + return { + id, + recordId, + hubId, + investigationId: `inv-${recordId}`, + reviewedAt: NOW, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: NOW, + deletedAt: null, + }; +} + // --------------------------------------------------------------------------- // Setup / teardown — clear tables touched by dispatch before each test. // --------------------------------------------------------------------------- @@ -68,11 +100,15 @@ function makeHub(id: string, ips: ImprovementProject[] = []): ProcessHub { beforeEach(async () => { await db.processHubs.clear(); await db.improvementProjects.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); }); afterEach(async () => { await db.processHubs.clear(); await db.improvementProjects.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); }); // --------------------------------------------------------------------------- @@ -133,4 +169,63 @@ describe('AzureHubRepository.dispatch HUB_PERSIST_SNAPSHOT — Dexie integration const ipRows = await db.improvementProjects.where('hubId').equals('hub-4').toArray(); expect(ipRows.map(r => r.id)).toEqual(['ip-keep']); }); + + it('strips sustainment arrays from the hub blob and writes them to normalized tables', async () => { + const repo = new AzureHubRepository(); + const record = makeSustainmentRecord('sr-1', 'hub-5'); + const review = makeSustainmentReview('rev-1', 'sr-1', 'hub-5'); + const hub = { + ...makeHub('hub-5'), + sustainmentRecords: [record], + sustainmentReviews: [review], + } as ProcessHub; + + await repo.dispatch({ kind: 'HUB_PERSIST_SNAPSHOT', hub }); + + const hubRow = await db.processHubs.get('hub-5'); + expect((hubRow as Partial | undefined)?.sustainmentRecords).toBeUndefined(); + expect((hubRow as Partial | undefined)?.sustainmentReviews).toBeUndefined(); + expect( + (await db.sustainmentRecords.where('hubId').equals('hub-5').toArray()).map(r => r.id) + ).toEqual(['sr-1']); + expect( + (await db.sustainmentReviews.where('hubId').equals('hub-5').toArray()).map(r => r.id) + ).toEqual(['rev-1']); + }); + + it('cleans up stale sustainment rows absent from the new snapshot', async () => { + const repo = new AzureHubRepository(); + await db.sustainmentRecords.put(makeSustainmentRecord('sr-stale', 'hub-6')); + await db.sustainmentReviews.put(makeSustainmentReview('rev-stale', 'sr-stale', 'hub-6')); + + await repo.dispatch({ + kind: 'HUB_PERSIST_SNAPSHOT', + hub: { + ...makeHub('hub-6'), + sustainmentRecords: [makeSustainmentRecord('sr-keep', 'hub-6')], + sustainmentReviews: [makeSustainmentReview('rev-keep', 'sr-keep', 'hub-6')], + } as ProcessHub, + }); + + expect( + (await db.sustainmentRecords.where('hubId').equals('hub-6').toArray()).map(r => r.id) + ).toEqual(['sr-keep']); + expect( + (await db.sustainmentReviews.where('hubId').equals('hub-6').toArray()).map(r => r.id) + ).toEqual(['rev-keep']); + }); + + it('cleans up stale sustainment rows when a snapshot omits sustainment arrays', async () => { + const repo = new AzureHubRepository(); + await db.sustainmentRecords.put(makeSustainmentRecord('sr-stale', 'hub-7')); + await db.sustainmentReviews.put(makeSustainmentReview('rev-stale', 'sr-stale', 'hub-7')); + + await repo.dispatch({ + kind: 'HUB_PERSIST_SNAPSHOT', + hub: makeHub('hub-7'), + }); + + expect(await db.sustainmentRecords.where('hubId').equals('hub-7').count()).toBe(0); + expect(await db.sustainmentReviews.where('hubId').equals('hub-7').count()).toBe(0); + }); }); diff --git a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts index 41b7e7786..59a7ba8e7 100644 --- a/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts +++ b/apps/azure/src/persistence/__tests__/AzureHubRepository.test.ts @@ -55,6 +55,22 @@ vi.mock('../../db/schema', () => ({ bulkPut: vi.fn().mockResolvedValue([]), clear: vi.fn(), }, + sustainmentRecords: { + get: vi.fn(), + where: vi.fn(() => ({ + equals: vi.fn(() => ({ filter: vi.fn(() => ({ delete: vi.fn().mockResolvedValue(0) })) })), + })), + bulkPut: vi.fn().mockResolvedValue([]), + clear: vi.fn(), + }, + sustainmentReviews: { + 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() diff --git a/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts b/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts new file mode 100644 index 000000000..a748ac44d --- /dev/null +++ b/apps/azure/src/persistence/__tests__/applyAction.sustainment.test.ts @@ -0,0 +1,164 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { EvidenceSnapshot, SustainmentRecord } from '@variscout/core'; +import type { ProcessHub } from '@variscout/core/processHub'; +import { applyAction } from '../applyAction'; +import { db } from '../../db/schema'; + +const NOW = 1_746_352_800_000; + +function makeHub(id: string): ProcessHub { + return { + id, + name: `Hub ${id}`, + createdAt: NOW, + deletedAt: null, + }; +} + +function makeRecord( + id: string, + hubId: string, + overrides: Partial = {} +): SustainmentRecord { + return { + id, + title: 'Hold improved fill weight', + investigationId: 'inv-1', + hubId, + cadence: 'weekly', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeSnapshot( + id: string, + hubId: string, + overrides: Partial = {} +): EvidenceSnapshot { + return { + id, + hubId, + sourceId: 'source-1', + capturedAt: '2026-05-12T00:00:00.000Z', + rowCount: 10, + origin: 'paste', + importedAt: NOW, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +beforeEach(async () => { + await Promise.all([ + db.processHubs.clear(), + db.sustainmentRecords.clear(), + db.sustainmentReviews.clear(), + db.evidenceSnapshots.clear(), + ]); +}); + +afterEach(async () => { + await Promise.all([ + db.processHubs.clear(), + db.sustainmentRecords.clear(), + db.sustainmentReviews.clear(), + db.evidenceSnapshots.clear(), + ]); +}); + +describe('applyAction (Azure) — sustainment records', () => { + it('creates, updates, archives, and persists tick evaluations', async () => { + await db.processHubs.put(makeHub('hub-1')); + const record = makeRecord('record-1', 'hub-1'); + + await applyAction({ kind: 'SUSTAINMENT_RECORD_CREATE', hubId: 'hub-1', record }); + await applyAction({ + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'record-1', + patch: { targetSummary: 'Cpk >= 1.33' }, + }); + await applyAction({ + kind: 'SUSTAINMENT_TICK_EVALUATED', + record: { ...record, consecutiveOnTargetTicks: 1, lastEvaluatedSnapshotId: 'snapshot-1' }, + review: { + id: 'review-1', + recordId: 'record-1', + investigationId: 'inv-1', + hubId: 'hub-1', + reviewedAt: NOW, + reviewer: { displayName: 'System' }, + verdict: 'holding', + snapshotId: 'snapshot-1', + createdAt: NOW, + deletedAt: null, + }, + }); + await applyAction({ kind: 'SUSTAINMENT_RECORD_ARCHIVE', recordId: 'record-1' }); + + const stored = await db.sustainmentRecords.get('record-1'); + expect(stored?.targetSummary).toBe('Cpk >= 1.33'); + expect(stored?.lastEvaluatedSnapshotId).toBe('snapshot-1'); + expect(stored?.deletedAt).toEqual(expect.any(Number)); + expect(await db.sustainmentReviews.get('review-1')).toMatchObject({ + recordId: 'record-1', + verdict: 'holding', + }); + }); + + it('rejects create payloads whose record hubId does not match the action hubId', async () => { + await db.processHubs.put(makeHub('hub-guard')); + + await expect( + applyAction({ + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-guard', + record: makeRecord('record-mismatch', 'other-hub'), + }) + ).rejects.toThrow(/hubId mismatch/); + + expect(await db.sustainmentRecords.get('record-mismatch')).toBeUndefined(); + }); + + it('evaluates live records when evidence snapshots are added', async () => { + await db.processHubs.put(makeHub('hub-2')); + await applyAction({ + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-2', + record: makeRecord('record-2', 'hub-2', { consecutiveOnTargetTicks: 3 }), + }); + + await applyAction({ + kind: 'EVIDENCE_ADD_SNAPSHOT', + hubId: 'hub-2', + snapshot: makeSnapshot('snapshot-green', 'hub-2', { + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }), + provenance: [], + }); + + const record = await db.sustainmentRecords.get('record-2'); + expect(record?.status).toBe('confirmed-sustained'); + expect(record?.consecutiveOnTargetTicks).toBe(4); + expect(record?.lastEvaluatedSnapshotId).toBe('snapshot-green'); + const reviews = await db.sustainmentReviews.where('recordId').equals('record-2').toArray(); + expect(reviews).toHaveLength(1); + expect(reviews[0]).toMatchObject({ snapshotId: 'snapshot-green', verdict: 'holding' }); + }); +}); diff --git a/apps/azure/src/persistence/applyAction.ts b/apps/azure/src/persistence/applyAction.ts index 04b7a6f64..ac771be5a 100644 --- a/apps/azure/src/persistence/applyAction.ts +++ b/apps/azure/src/persistence/applyAction.ts @@ -37,6 +37,7 @@ // reaching this function — do not handle it here. import type { EvidenceSnapshot } from '@variscout/core'; +import { applySustainmentTick } from '@variscout/core'; import type { HubAction } from '@variscout/core/actions'; import { db } from '../db/schema'; import { saveProcessHubToIndexedDB } from '../services/localDb'; @@ -158,14 +159,21 @@ export async function applyAction(action: HubAction): Promise { // close could leave the replaced snapshot soft-deleted without the new // one inserted), but this is acceptable for Azure's single-table model // pre-F3.6. Future Azure normalization will revisit. - if (action.replacedSnapshotId) { - await db.evidenceSnapshots.update(action.replacedSnapshotId, { deletedAt: Date.now() }); - } - const envelope: EvidenceSnapshot = { - ...action.snapshot, - provenance: action.provenance, - }; - await db.evidenceSnapshots.put(envelope); + await db.transaction( + 'rw', + [db.evidenceSnapshots, db.sustainmentRecords, db.sustainmentReviews], + async () => { + if (action.replacedSnapshotId) { + await db.evidenceSnapshots.update(action.replacedSnapshotId, { deletedAt: Date.now() }); + } + const envelope: EvidenceSnapshot = { + ...action.snapshot, + provenance: action.provenance, + }; + await db.evidenceSnapshots.put(envelope); + await evaluateSustainmentRecordsForSnapshot(action.hubId, envelope); + } + ); return; } @@ -318,6 +326,64 @@ export async function applyAction(action: HubAction): Promise { return; } + case 'SUSTAINMENT_RECORD_CREATE': { + const hub = await db.processHubs.get(action.hubId); + if (!hub) { + throw new Error(`SUSTAINMENT_RECORD_CREATE: parent hub ${action.hubId} does not exist`); + } + if (action.record.hubId !== action.hubId) { + throw new Error( + `SUSTAINMENT_RECORD_CREATE hubId mismatch: action hub '${action.hubId}' does not match record hub '${action.record.hubId}'` + ); + } + await db.sustainmentRecords.add(action.record); + return; + } + + case 'SUSTAINMENT_RECORD_UPDATE': { + const existing = await db.sustainmentRecords.get(action.recordId); + if (!existing) return; + await db.sustainmentRecords.update(action.recordId, { + ...action.patch, + updatedAt: Date.now(), + }); + return; + } + + case 'SUSTAINMENT_RECORD_ARCHIVE': { + await db.sustainmentRecords.update(action.recordId, { + deletedAt: Date.now(), + updatedAt: Date.now(), + }); + return; + } + + case 'SUSTAINMENT_CONFIRM': { + await db.sustainmentRecords.update(action.recordId, { + status: 'confirmed-sustained', + updatedAt: Date.now(), + }); + return; + } + + case 'SUSTAINMENT_MARK_DRIFTED': { + await db.sustainmentRecords.update(action.recordId, { + status: 'drifted', + consecutiveOnTargetTicks: 0, + updatedAt: Date.now(), + }); + return; + } + + case 'SUSTAINMENT_TICK_EVALUATED': { + await db.transaction('rw', [db.sustainmentRecords, db.sustainmentReviews], async () => { + const existing = await db.sustainmentRecords.get(action.record.id); + await db.sustainmentRecords.put({ ...existing, ...action.record }); + await db.sustainmentReviews.put(action.review); + }); + return; + } + // ------------------------------------------------------------------------- // Session-only — Azure has no dedicated Dexie table today; F3 normalizes. // ------------------------------------------------------------------------- @@ -431,3 +497,23 @@ export async function applyAction(action: HubAction): Promise { assertNever(action); } } + +async function evaluateSustainmentRecordsForSnapshot( + hubId: string, + snapshot: EvidenceSnapshot +): Promise { + await db.transaction('rw', [db.sustainmentRecords, db.sustainmentReviews], async () => { + const liveRecords = await db.sustainmentRecords + .where('hubId') + .equals(hubId) + .filter(record => record.deletedAt === null && record.lastEvaluatedSnapshotId !== snapshot.id) + .toArray(); + + if (liveRecords.length === 0) return; + + const now = Date.now(); + const evaluations = liveRecords.map(record => applySustainmentTick(record, snapshot, now)); + await db.sustainmentRecords.bulkPut(evaluations.map(evaluation => evaluation.record)); + await db.sustainmentReviews.bulkPut(evaluations.map(evaluation => evaluation.review)); + }); +} diff --git a/apps/azure/src/services/__tests__/blobClient.test.ts b/apps/azure/src/services/__tests__/blobClient.test.ts index 4e40dcaea..323a9a325 100644 --- a/apps/azure/src/services/__tests__/blobClient.test.ts +++ b/apps/azure/src/services/__tests__/blobClient.test.ts @@ -385,8 +385,13 @@ describe('blobClient', () => { await saveBlobSustainmentRecord({ id: 'rec-1', + title: 'Sustainment cadence', hubId: 'hub-1', investigationId: 'inv-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', createdAt: 1745712000000, // 2026-04-27T00:00:00.000Z updatedAt: 1745712000000, diff --git a/apps/azure/src/services/__tests__/sustainmentStorage.test.ts b/apps/azure/src/services/__tests__/sustainmentStorage.test.ts index b457ac65d..1e50a1451 100644 --- a/apps/azure/src/services/__tests__/sustainmentStorage.test.ts +++ b/apps/azure/src/services/__tests__/sustainmentStorage.test.ts @@ -21,8 +21,13 @@ import type { const makeRecord = (overrides: Partial = {}): SustainmentRecord => ({ id: 'rec-1', + title: 'Sustainment cadence', investigationId: 'inv-1', hubId: 'hub-1', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, cadence: 'monthly', nextReviewDue: '2026-05-26T00:00:00.000Z', createdAt: 1745625600000, // 2026-04-26T00:00:00.000Z diff --git a/apps/azure/src/services/storage.ts b/apps/azure/src/services/storage.ts index 4eaf3eaa5..c30ab3652 100644 --- a/apps/azure/src/services/storage.ts +++ b/apps/azure/src/services/storage.ts @@ -134,6 +134,7 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child }); const [notifications, setNotifications] = useState([]); const retryTimerRef = useRef | null>(null); + const processRetryQueueRef = useRef<() => Promise>(async () => {}); // ── Notification helpers ──────────────────────────────────────────── @@ -177,6 +178,12 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child const isSyncingRef = useRef(false); + const scheduleRetry = useCallback((delay: number) => { + retryTimerRef.current = setTimeout(() => { + void processRetryQueueRef.current(); + }, delay); + }, []); + const processRetryQueue = useCallback(async () => { if (retryQueue.current.length === 0) return; if (!navigator.onLine) return; @@ -201,7 +208,7 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child } else { // Process next item const delay = Math.min(RETRY_DELAYS[0], MAX_RETRY_DELAY); - retryTimerRef.current = setTimeout(processRetryQueue, delay); + scheduleRetry(delay); } } catch (error) { if (error instanceof CloudSyncUnavailableErrorClass) { @@ -235,12 +242,16 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child error instanceof GraphErrorClass && error.retryAfterMs ? error.retryAfterMs : undefined; const delayIdx = Math.min(item.attempt - 1, RETRY_DELAYS.length - 1); const delay = Math.min(serverDelay ?? RETRY_DELAYS[delayIdx], MAX_RETRY_DELAY); - retryTimerRef.current = setTimeout(processRetryQueue, delay); + scheduleRetry(delay); } } finally { isSyncingRef.current = false; } - }, [addNotification]); + }, [addNotification, scheduleRetry]); + + useEffect(() => { + processRetryQueueRef.current = processRetryQueue; + }, [processRetryQueue]); // Cleanup timers on unmount useEffect(() => { @@ -406,7 +417,7 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child // Queue for retry with backoff retryQueue.current.push({ project, name, location, attempt: 1 }); const delay = RETRY_DELAYS[0]; - retryTimerRef.current = setTimeout(processRetryQueue, delay); + scheduleRetry(delay); addNotification({ type: 'warning', message: classified.message, dismissAfter: 5000 }); } else { setSyncStatus({ @@ -416,7 +427,7 @@ export const StorageProvider: React.FC<{ children: React.ReactNode }> = ({ child } } }, - [addNotification, processRetryQueue] + [addNotification, scheduleRetry] ); // ── Load project (cloud first if online, with conflict detection) ─── diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index b9ce76ccd..afbdc462b 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -1057,7 +1057,11 @@ function AppMain() { }} /> ) : panels.activeView === 'sustainment' ? ( - + ) : panels.activeView === 'handoff' ? ( ) : panels.activeView === 'investigation' ? ( diff --git a/apps/pwa/src/components/SustainmentPanel.tsx b/apps/pwa/src/components/SustainmentPanel.tsx index 9d7904f8f..50d24dd9e 100644 --- a/apps/pwa/src/components/SustainmentPanel.tsx +++ b/apps/pwa/src/components/SustainmentPanel.tsx @@ -1,28 +1,263 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { SustainmentForm, type SustainmentRecordChangePatch } from '@variscout/ui'; +import type { ProcessHub, SustainmentRecord, SustainmentReview } from '@variscout/core'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { pwaHubRepository } from '../persistence'; interface SustainmentPanelProps { + activeHub?: ProcessHub; + targetId?: string; onBack: () => void; } -const SustainmentPanel: React.FC = ({ onBack }) => { +const buttonClassName = + 'rounded-md border border-edge bg-surface px-3 py-2 text-left text-sm font-medium text-content transition-colors hover:bg-surface-secondary focus:outline-none focus:ring-2 focus:ring-ring'; + +function makeId(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID(); + return `sr-${Date.now()}`; +} + +function liveRecords(records: SustainmentRecord[] | undefined): SustainmentRecord[] { + return (records ?? []).filter(record => record.deletedAt === null); +} + +function firstClosedProject( + hub: ProcessHub, + preferredProjectId?: string +): ImprovementProject | undefined { + const liveClosedProjects = (hub.improvementProjects ?? []).filter( + project => project.deletedAt === null && project.status === 'closed' + ); + const preferred = liveClosedProjects.find(project => project.id === preferredProjectId); + if (preferred) return preferred; + return liveClosedProjects[0]; +} + +function recordMatchesTarget(record: SustainmentRecord, targetId: string | undefined): boolean { return ( -
-

Sustainment

-

- Sustainment monitors a process change after implementation to verify the gain holds. The - full monitoring surface ships in a future release. -

-

Available in a future release.

- + targetId !== undefined && (record.id === targetId || record.improvementProjectId === targetId) + ); +} + +function selectedRecordForTarget( + records: SustainmentRecord[], + selectedRecordId: string | null, + targetId: string | undefined +): SustainmentRecord | null { + const targetRecord = records.find(record => recordMatchesTarget(record, targetId)); + if (targetRecord) return targetRecord; + if (records.length === 1) return records[0]; + return records.find(record => record.id === selectedRecordId) ?? null; +} + +function firstClosedProjectLegacy(hub: ProcessHub): ImprovementProject | undefined { + return (hub.improvementProjects ?? []).find( + project => project.deletedAt === null && project.status === 'closed' + ); +} + +function buildDraftRecord(hub: ProcessHub, preferredProjectId?: string): SustainmentRecord { + const project = firstClosedProject(hub, preferredProjectId) ?? firstClosedProjectLegacy(hub); + const now = Date.now(); + const investigationId = project?.metadata.investigationId ?? `${hub.id}:sustainment`; + const title = project ? `Sustain ${project.metadata.title}` : `Sustain ${hub.name}`; + + return { + id: makeId(), + hubId: hub.id, + investigationId, + status: 'pending', + title, + improvementProjectId: project?.id, + goal: project?.goal, + targetSummary: project?.goal.freeText, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +} + +function mergeRecordPatch( + record: SustainmentRecord, + patch: SustainmentRecordChangePatch +): SustainmentRecord { + return { ...record, ...patch, updatedAt: Date.now() }; +} + +const SustainmentPanel: React.FC = ({ activeHub, targetId, onBack }) => { + const [records, setRecords] = useState([]); + const [selectedRecordId, setSelectedRecordId] = useState(null); + const [reviews, setReviews] = useState([]); + const [error, setError] = useState(null); + const [isLoadingRecords, setIsLoadingRecords] = useState(Boolean(activeHub)); + const creatingForHubRef = useRef(null); + + useEffect(() => { + setRecords(liveRecords(activeHub?.sustainmentRecords)); + setSelectedRecordId(null); + setReviews([]); + setIsLoadingRecords(Boolean(activeHub)); + + if (!activeHub) { + setIsLoadingRecords(false); + return; + } + + let cancelled = false; + void pwaHubRepository.sustainmentRecords + .listByHub(activeHub.id) + .then((rows: SustainmentRecord[]) => { + if (!cancelled) setRecords(liveRecords(rows)); + }) + .catch(() => { + if (!cancelled) setRecords(liveRecords(activeHub.sustainmentRecords)); + }) + .finally(() => { + if (!cancelled) setIsLoadingRecords(false); + }); + + return () => { + cancelled = true; + }; + }, [activeHub]); + + useEffect(() => { + if ( + !activeHub || + isLoadingRecords || + records.length > 0 || + creatingForHubRef.current === activeHub.id + ) { + return; + } + + const createHubId = activeHub.id; + const record = buildDraftRecord(activeHub, targetId); + creatingForHubRef.current = activeHub.id; + setError(null); + let cancelled = false; + + void pwaHubRepository + .dispatch({ kind: 'SUSTAINMENT_RECORD_CREATE', hubId: activeHub.id, record }) + .then(() => { + if (cancelled || creatingForHubRef.current !== createHubId) return; + setRecords([record]); + setSelectedRecordId(record.id); + }) + .catch(() => { + if (!cancelled) setError('Could not create a sustainment record.'); + }) + .finally(() => { + if (creatingForHubRef.current === createHubId) creatingForHubRef.current = null; + }); + + return () => { + cancelled = true; + }; + }, [activeHub, isLoadingRecords, records.length, targetId]); + + const selectedRecord = selectedRecordForTarget(records, selectedRecordId, targetId); + + useEffect(() => { + if (!activeHub || !selectedRecord) { + setReviews([]); + return; + } + + let cancelled = false; + void pwaHubRepository.sustainmentReviews + .listByRecord(activeHub.id, selectedRecord.id) + .then((rows: SustainmentReview[]) => { + if (!cancelled) + setReviews(rows.filter((review: SustainmentReview) => review.deletedAt === null)); + }) + .catch(() => { + if (!cancelled) { + setReviews( + (activeHub.sustainmentReviews ?? []).filter( + review => review.deletedAt === null && review.recordId === selectedRecord.id + ) + ); + } + }); + + return () => { + cancelled = true; + }; + }, [activeHub, selectedRecord]); + + const updateSelectedRecord = useCallback( + (patch: SustainmentRecordChangePatch) => { + if (!selectedRecord) return; + const next = mergeRecordPatch(selectedRecord, patch); + setRecords(current => current.map(record => (record.id === next.id ? next : record))); + void pwaHubRepository + .dispatch({ kind: 'SUSTAINMENT_RECORD_UPDATE', recordId: selectedRecord.id, patch }) + .catch(() => { + setError('Could not save the sustainment record changes.'); + }); + }, + [selectedRecord] + ); + + const heading = useMemo(() => activeHub?.name ?? 'No active hub', [activeHub]); + + return ( +
+
+
+

Sustainment

+

{heading}

+
+ +
+ + {!activeHub ? ( +

+ Create or select a Process Hub before opening sustainment. +

+ ) : error ? ( +

+ {error} +

+ ) : records.length > 1 && !selectedRecord ? ( +
+

Choose a sustainment record

+
+ {records.map(record => ( + + ))} +
+
+ ) : selectedRecord ? ( + + ) : ( +

+ Creating sustainment record... +

+ )}
); }; diff --git a/apps/pwa/src/components/__tests__/SustainmentPanel.test.tsx b/apps/pwa/src/components/__tests__/SustainmentPanel.test.tsx new file mode 100644 index 000000000..c0bd2d5fb --- /dev/null +++ b/apps/pwa/src/components/__tests__/SustainmentPanel.test.tsx @@ -0,0 +1,191 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ProcessHub, SustainmentRecord, SustainmentReview } from '@variscout/core'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import SustainmentPanel from '../SustainmentPanel'; +import { pwaHubRepository } from '../../persistence'; + +vi.mock('@variscout/ui', async () => { + const React = await import('react'); + return { + SustainmentForm: (props: { + record: SustainmentRecord; + reviews?: SustainmentReview[]; + onRecordChange?: (patch: Partial) => void; + }) => + React.createElement( + 'section', + { 'data-testid': 'sustainment-form' }, + React.createElement('h3', null, props.record.title), + React.createElement('p', null, props.record.goal?.freeText ?? 'No goal'), + React.createElement('p', null, `Reviews ${props.reviews?.length ?? 0}`), + React.createElement( + 'button', + { + type: 'button', + onClick: () => props.onRecordChange?.({ title: 'Updated sustainment' }), + }, + 'Rename' + ) + ), + }; +}); + +vi.mock('../../persistence', () => ({ + pwaHubRepository: { + dispatch: vi.fn().mockResolvedValue(undefined), + sustainmentRecords: { + listByHub: vi.fn().mockResolvedValue([]), + }, + sustainmentReviews: { + listByRecord: vi.fn().mockResolvedValue([]), + }, + }, +})); + +function makeProject(overrides: Partial = {}): ImprovementProject { + return { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce defects', investigationId: 'inv-1' }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 }, + freeText: 'Hold first pass yield at 98%.', + }, + sections: { + background: {}, + investigationLineage: {}, + approach: { actionItemIds: ['action-1'] }, + outcomeReference: {}, + }, + createdAt: 1714000000000, + updatedAt: 1714000000000, + deletedAt: null, + ...overrides, + }; +} + +function makeHub(projects: ImprovementProject[] = [makeProject()]): ProcessHub { + return { + id: 'hub-1', + name: 'Paint line', + createdAt: 1714000000000, + deletedAt: null, + improvementProjects: projects, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(pwaHubRepository.dispatch).mockResolvedValue(undefined); + vi.mocked(pwaHubRepository.sustainmentRecords.listByHub).mockResolvedValue([]); + vi.mocked(pwaHubRepository.sustainmentReviews.listByRecord).mockResolvedValue([]); +}); + +describe('SustainmentPanel (PWA)', () => { + it('creates a sustainment record for the active hub and carries forward the first closed project goal', async () => { + render(); + + await waitFor(() => expect(pwaHubRepository.dispatch).toHaveBeenCalledTimes(1)); + expect(pwaHubRepository.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-1', + record: expect.objectContaining({ + hubId: 'hub-1', + investigationId: 'inv-1', + improvementProjectId: 'ip-1', + title: 'Sustain Reduce defects', + goal: expect.objectContaining({ freeText: 'Hold first pass yield at 98%.' }), + }), + }) + ); + expect(await screen.findByTestId('sustainment-form')).toHaveTextContent( + 'Hold first pass yield at 98%.' + ); + }); + + it('selects an existing live record, reads reviews through the repository, and persists edits by dispatch', async () => { + const record: SustainmentRecord = { + id: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + status: 'pending', + title: 'Existing sustainment', + consecutiveOnTargetTicks: 1, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: 1714000000000, + updatedAt: 1714000000000, + deletedAt: null, + }; + vi.mocked(pwaHubRepository.sustainmentRecords.listByHub).mockResolvedValue([record]); + vi.mocked(pwaHubRepository.sustainmentReviews.listByRecord).mockResolvedValue([ + { + id: 'review-1', + recordId: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + reviewedAt: 1714000000000, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: 1714000000000, + deletedAt: null, + }, + ]); + + render(); + + await waitFor(() => + expect(screen.getByTestId('sustainment-form')).toHaveTextContent('Reviews 1') + ); + expect(pwaHubRepository.dispatch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + + await waitFor(() => + expect(pwaHubRepository.dispatch).toHaveBeenCalledWith({ + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'sr-1', + patch: { title: 'Updated sustainment' }, + }) + ); + }); + + it('creates for the prompted closed project when a target id is supplied', async () => { + const first = makeProject({ + id: 'ip-first', + metadata: { title: 'First', investigationId: 'inv-1' }, + }); + const second = makeProject({ + id: 'ip-second', + metadata: { title: 'Second', investigationId: 'inv-2' }, + goal: { + outcomeGoal: { outcomeSpecId: 'outcome-2', target: 99 }, + freeText: 'Hold the second target.', + }, + }); + + render( + + ); + + await waitFor(() => + expect(pwaHubRepository.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + record: expect.objectContaining({ + improvementProjectId: 'ip-second', + investigationId: 'inv-2', + title: 'Sustain Second', + targetSummary: 'Hold the second target.', + }), + }) + ) + ); + }); +}); diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index 1b1687765..1d6704e8d 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { CanvasWorkspace, + InboxDigest, + type InboxDigestPrompt, type ContextLinkGroup, type ContextLinkItem, type LogActionPayload, @@ -21,9 +23,12 @@ import type { CanvasInvestigationFocus } from '@variscout/hooks'; import type { EvidenceSnapshot, StepCapabilityStamp, + SustainmentRecord, WorkflowReadinessSignals, } from '@variscout/core'; import { createActionItem, type ActionItem } from '@variscout/core/findings'; +import type { ImprovementProject } from '@variscout/core/improvementProject'; +import { surveyInboxRules } from '@variscout/core/survey'; import { pwaHubRepository } from '../../persistence'; import { useSession } from '../../store/sessionStore'; import { usePanelsStore } from '../../features/panels/panelsStore'; @@ -31,6 +36,7 @@ import { useInvestigationFeatureStore } from '../../features/investigation/inves const EMPTY_PRIOR_STEP_STATS: ReadonlyMap = new Map(); const EMPTY_ACTION_ITEMS: ActionItem[] = []; +const EMPTY_SUSTAINMENT_RECORDS: SustainmentRecord[] = []; function mergeActionItems( current: readonly ActionItem[], @@ -54,6 +60,26 @@ function priorStepStatsFromSnapshots( return new Map(stamps.map(stamp => [stamp.stepId, stamp])); } +function hasCompletedInterventionEvidence( + projects: readonly ImprovementProject[], + items: readonly ActionItem[] +): boolean { + const completedActionIds = new Set( + items + .filter( + item => + item.deletedAt === null && + (item.completedAt !== undefined || item.status === 'done' || item.doneAt != null) + ) + .map(item => item.id) + ); + return projects.some(project => { + if (project.deletedAt !== null || project.status !== 'closed') return false; + const actionItemIds = project.sections.approach.actionItemIds ?? []; + return actionItemIds.some(id => completedActionIds.has(id)); + }); +} + const FrameView: React.FC = () => { const rawData = useProjectStore(s => s.rawData); const outcome = useProjectStore(s => s.outcome); @@ -74,6 +100,8 @@ const FrameView: React.FC = () => { const [priorStepStats, setPriorStepStats] = React.useState>(EMPTY_PRIOR_STEP_STATS); const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); + const [sustainmentRecords, setSustainmentRecords] = + React.useState(EMPTY_SUSTAINMENT_RECORDS); const activeHubIdRef = React.useRef(activeHubId); React.useEffect(() => { @@ -103,6 +131,7 @@ const FrameView: React.FC = () => { React.useEffect(() => { setActionItems(EMPTY_ACTION_ITEMS); + setSustainmentRecords(EMPTY_SUSTAINMENT_RECORDS); if (!activeHubId) { return; @@ -111,27 +140,32 @@ const FrameView: React.FC = () => { let cancelled = false; void (async () => { try { - const items = await pwaHubRepository.actionItems.listByHub(activeHubId); - if (!cancelled) setActionItems(items); + const [items, records] = await Promise.all([ + pwaHubRepository.actionItems.listByHub(activeHubId), + pwaHubRepository.sustainmentRecords.listByHub(activeHubId), + ]); + if (!cancelled) { + setActionItems(items); + setSustainmentRecords( + records.filter((record: SustainmentRecord) => record.deletedAt === null) + ); + } } catch { // Session-only hubs may not exist in IndexedDB; keep any in-memory quick actions. + if (!cancelled) setSustainmentRecords(activeHub?.sustainmentRecords ?? []); } })(); return () => { cancelled = true; }; - }, [activeHubId]); - - const signals: WorkflowReadinessSignals = React.useMemo( - () => ({ hasIntervention: false, sustainmentConfirmed: false }), - [] - ); + }, [activeHub?.sustainmentRecords, activeHubId]); const contextLinkGroups: readonly ContextLinkGroup[] = React.useMemo(() => { const improvementProjects = ( activeHubId ? (projectsByHub[activeHubId] ?? activeHub?.improvementProjects ?? []) : [] ).filter(project => project.deletedAt === null); + const liveSustainmentRecords = sustainmentRecords.filter(record => record.deletedAt === null); return [ { @@ -151,10 +185,50 @@ const FrameView: React.FC = () => { })), }, { surfaceType: 'quick-actions', items: [] }, - { surfaceType: 'sustainment', items: [] }, + { + surfaceType: 'sustainment', + items: liveSustainmentRecords.map(record => ({ + id: record.id, + label: record.title, + description: record.status, + })), + }, { surfaceType: 'handoff', items: [] }, ]; - }, [activeHub?.improvementProjects, activeHubId, hypotheses, projectsByHub]); + }, [activeHub?.improvementProjects, activeHubId, hypotheses, projectsByHub, sustainmentRecords]); + + const signals: WorkflowReadinessSignals = React.useMemo(() => { + const improvementProjects = ( + activeHubId ? (projectsByHub[activeHubId] ?? activeHub?.improvementProjects ?? []) : [] + ).filter(project => project.deletedAt === null); + + return { + hasIntervention: hasCompletedInterventionEvidence(improvementProjects, actionItems), + sustainmentConfirmed: sustainmentRecords.some( + record => record.deletedAt === null && record.status === 'confirmed-sustained' + ), + }; + }, [activeHub?.improvementProjects, activeHubId, actionItems, projectsByHub, sustainmentRecords]); + + const inboxPrompts = React.useMemo(() => { + const improvementProjects = ( + activeHubId ? (projectsByHub[activeHubId] ?? activeHub?.improvementProjects ?? []) : [] + ).filter(project => project.deletedAt === null); + + return surveyInboxRules({ + hub: activeHub ?? undefined, + improvementProjects, + sustainmentRecords, + sustainmentReviews: activeHub?.sustainmentReviews ?? [], + now: Date.now(), + }); + }, [ + activeHub?.improvementProjects, + activeHub?.sustainmentReviews, + activeHubId, + projectsByHub, + sustainmentRecords, + ]); const handleSeeData = React.useCallback(() => { usePanelsStore.getState().showAnalysis(); @@ -243,6 +317,19 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showHandoff(); }, []); + const handleInboxNavigate = React.useCallback((prompt: InboxDigestPrompt) => { + const surface = prompt.action?.opensSurface; + if (surface === 'sustainment') { + usePanelsStore.getState().showSustainment(prompt.action?.opensId); + return; + } + if (surface === 'improvement-projects') { + usePanelsStore.getState().showCharter(); + return; + } + usePanelsStore.getState().showInvestigation(); + }, []); + const handleNavigateContextLink = React.useCallback( (item: ContextLinkItem) => { if ( @@ -254,42 +341,51 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showCharter(); return; } + if (sustainmentRecords.some(record => record.id === item.id)) { + usePanelsStore.getState().showSustainment(item.id); + return; + } usePanelsStore.getState().showInvestigation(); }, - [activeHubId] + [activeHubId, sustainmentRecords] ); return ( - +
+
+ +
+ +
); }; diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index 6f9e376b2..bb276a6dc 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -55,6 +55,7 @@ const hoisted = vi.hoisted(() => ({ canvasWorkspaceMock: vi.fn(), listByHubMock: vi.fn(), actionItemsListByHubMock: vi.fn(), + sustainmentRecordsListByHubMock: vi.fn(), dispatchMock: vi.fn(), sessionStateRef: { current: { hub: { id: 'hub-1' } as { id: string } | null } }, })); @@ -105,6 +106,21 @@ vi.mock('@variscout/stores', () => ({ vi.mock('@variscout/ui', async () => { const React = await import('react'); return { + InboxDigest: (props: { prompts: unknown[]; onNavigate: (prompt: unknown) => void }) => + React.createElement( + 'div', + { 'data-testid': 'inbox-digest', 'data-count': props.prompts.length }, + props.prompts.length > 0 + ? React.createElement( + 'button', + { + type: 'button', + onClick: () => props.onNavigate(props.prompts[0]), + }, + 'Open inbox prompt' + ) + : null + ), CanvasWorkspace: (props: { signals: WorkflowReadinessSignals; onSeeData: () => void; @@ -128,6 +144,7 @@ vi.mock('@variscout/ui', async () => { onHandoff?: () => void; priorStepStats?: ReadonlyMap; actionItems?: unknown[]; + contextLinkGroups?: { surfaceType: string; items: { id: string }[] }[]; }) => { hoisted.canvasWorkspaceMock(props); return React.createElement( @@ -228,6 +245,9 @@ vi.mock('../../../persistence', () => ({ actionItems: { listByHub: hoisted.actionItemsListByHubMock, }, + sustainmentRecords: { + listByHub: hoisted.sustainmentRecordsListByHubMock, + }, }, })); @@ -256,6 +276,8 @@ describe('FrameView (PWA shell)', () => { hoisted.listByHubMock.mockResolvedValue([]); hoisted.actionItemsListByHubMock.mockReset(); hoisted.actionItemsListByHubMock.mockResolvedValue([]); + hoisted.sustainmentRecordsListByHubMock.mockReset(); + hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([]); hoisted.dispatchMock.mockReset(); hoisted.dispatchMock.mockResolvedValue(undefined); hoisted.sessionStateRef.current = { hub: { id: 'hub-1' } }; @@ -581,4 +603,103 @@ describe('FrameView (PWA shell)', () => { expect(showSustainmentMock).toHaveBeenCalledTimes(1); expect(showHandoffMock).toHaveBeenCalledTimes(1); }); + + it('marks Sustainment ready only when a closed project has completed intervention evidence and keeps Handoff gated until sustainment is confirmed', async () => { + improvementProjectStateRef.current = { + projectsByHub: { + 'hub-1': [ + { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce rework' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: { actionItemIds: ['action-1'] }, + outcomeReference: {}, + }, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ], + }, + getProjectsForHub: () => [], + }; + hoisted.actionItemsListByHubMock.mockResolvedValue([ + { ...actionItem('action-1', 'Change nozzle'), completedAt: 1714000000000 }, + ]); + + render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.signals).toEqual({ hasIntervention: true, sustainmentConfirmed: false }); + }); + }); + + it('marks Handoff ready and includes sustainment context links when a live record is confirmed', async () => { + hoisted.sustainmentRecordsListByHubMock.mockResolvedValue([ + { + id: 'sr-1', + hubId: 'hub-1', + investigationId: 'inv-1', + status: 'confirmed-sustained', + title: 'Sustain Reduce rework', + consecutiveOnTargetTicks: 4, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ]); + + render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.signals.sustainmentConfirmed).toBe(true); + expect( + props?.contextLinkGroups?.find( + (group: { surfaceType: string }) => group.surfaceType === 'sustainment' + )?.items + ).toEqual([expect.objectContaining({ id: 'sr-1' })]); + }); + }); + + it('passes the Inbox sustainment target id when opening a lifecycle prompt', async () => { + improvementProjectStateRef.current = { + projectsByHub: { + 'hub-1': [ + { + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce rework' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 98 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + createdAt: 1, + updatedAt: 1, + deletedAt: null, + }, + ], + }, + getProjectsForHub: () => [], + }; + + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Open inbox prompt' })); + + expect(showSustainmentMock).toHaveBeenCalledWith('ip-1'); + }); }); diff --git a/apps/pwa/src/db/schema.ts b/apps/pwa/src/db/schema.ts index 20f4751a2..1e832e0ed 100644 --- a/apps/pwa/src/db/schema.ts +++ b/apps/pwa/src/db/schema.ts @@ -34,6 +34,8 @@ import type { EvidenceSource, EvidenceSourceCursor, RowProvenanceTag, + SustainmentRecord, + SustainmentReview, } from '@variscout/core'; import type { Finding, @@ -59,7 +61,10 @@ export interface MetaRow { * `outcomes` array and `canonicalProcessMap` blob, which are decomposed into * the `outcomes` and `canvasState` tables respectively on `HUB_PERSIST_SNAPSHOT`. */ -export type HubRow = Omit; +export type HubRow = Omit< + ProcessHub, + 'outcomes' | 'canonicalProcessMap' | 'sustainmentRecords' | 'sustainmentReviews' +>; /** * Outcome row — the public `OutcomeSpec` entity. `OutcomeSpec` already carries @@ -87,6 +92,8 @@ export type CausalLinkRow = CausalLink; export type HypothesisRow = Hypothesis; export type ImprovementProjectRow = ImprovementProject; export type ActionItemRow = ActionItem & { hubId: ProcessHub['id'] }; +export type SustainmentRecordRow = SustainmentRecord; +export type SustainmentReviewRow = SustainmentReview; // --------------------------------------------------------------------------- // Database @@ -106,6 +113,8 @@ export class PwaDatabase extends Dexie { hypotheses!: Table; improvementProjects!: Table; actionItems!: Table; + sustainmentRecords!: Table; + sustainmentReviews!: Table; canvasState!: Table; meta!: Table; @@ -131,6 +140,10 @@ export class PwaDatabase extends Dexie { actionItems: '&id, hubId, stepId, parentImprovementProjectId, parentImprovementIdeaId, status, deletedAt, createdAt', }); + this.version(3).stores({ + sustainmentRecords: '&id, investigationId, hubId, nextReviewDue, updatedAt, deletedAt', + sustainmentReviews: '&id, recordId, investigationId, hubId, reviewedAt', + }); } } diff --git a/apps/pwa/src/features/panels/panelsStore.ts b/apps/pwa/src/features/panels/panelsStore.ts index 7c5ecfd4c..a6b6fee02 100644 --- a/apps/pwa/src/features/panels/panelsStore.ts +++ b/apps/pwa/src/features/panels/panelsStore.ts @@ -30,6 +30,7 @@ interface PanelsState { showExcludedOnly: boolean; showResetConfirm: boolean; openSpecEditorRequested: boolean; + sustainmentTargetId: string | null; } // ── Actions ────────────────────────────────────────────────────────────────── @@ -42,7 +43,7 @@ interface PanelsActions { showImprovement: () => void; showReport: () => void; showCharter: () => void; - showSustainment: () => void; + showSustainment: (targetId?: string) => void; showHandoff: () => void; // Simple toggles @@ -87,6 +88,7 @@ export const initialPanelsState: PanelsState = { showExcludedOnly: false, showResetConfirm: false, openSpecEditorRequested: false, + sustainmentTargetId: null, }; // ── Store ──────────────────────────────────────────────────────────────────── @@ -101,7 +103,12 @@ export const usePanelsStore = create(set => ({ showImprovement: () => set({ activeView: 'improvement' }), showReport: () => set({ activeView: 'report' }), showCharter: () => set({ activeView: 'charter', isFindingsOpen: false }), - showSustainment: () => set({ activeView: 'sustainment', isFindingsOpen: false }), + showSustainment: targetId => + set({ + activeView: 'sustainment', + isFindingsOpen: false, + sustainmentTargetId: targetId ?? null, + }), showHandoff: () => set({ activeView: 'handoff', isFindingsOpen: false }), // Simple toggles diff --git a/apps/pwa/src/hooks/useAppPanels.ts b/apps/pwa/src/hooks/useAppPanels.ts index f2903af24..a74a49e1d 100644 --- a/apps/pwa/src/hooks/useAppPanels.ts +++ b/apps/pwa/src/hooks/useAppPanels.ts @@ -46,6 +46,7 @@ export interface UseAppPanelsReturn { setHighlightedChartPoint: (v: number | null) => void; isDesktop: boolean; openSpecEditorRequested: boolean; + sustainmentTargetId: string | null; setOpenSpecEditorRequested: (v: boolean) => void; openDataTableAtRow: (index: number) => void; handleToggleFindingsPanel: () => void; @@ -151,6 +152,7 @@ export function useAppPanels(options: UseAppPanelsOptions): UseAppPanelsReturn { highlightedChartPoint: store.highlightedChartPoint, isDesktop, openSpecEditorRequested: store.openSpecEditorRequested, + sustainmentTargetId: store.sustainmentTargetId, isPISidebarOpen: store.isPISidebarOpen, // Setters (delegate to store) diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts index a154470e5..d867cab40 100644 --- a/apps/pwa/src/persistence/PwaHubRepository.ts +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -44,6 +44,8 @@ import type { HypothesisReadAPI, CanvasStateReadAPI, ActionItemReadAPI, + SustainmentRecordReadAPI, + SustainmentReviewReadAPI, } from '@variscout/core/persistence'; import type { HubAction } from '@variscout/core/actions'; import type { ProcessHub } from '@variscout/core/processHub'; @@ -87,6 +89,10 @@ export class PwaHubRepository implements HubRepository { db.canvasState.get(hubMeta.id), db.improvementProjects.where('hubId').equals(hubMeta.id).toArray(), ]); + const [sustainmentRecords, sustainmentReviews] = await Promise.all([ + this.sustainmentRecords.listByHub(hubMeta.id), + this.sustainmentReviews.listByHub(hubMeta.id), + ]); const liveOutcomes = outcomes.filter(o => o.deletedAt === null); const liveProjects = improvementProjects.filter(p => p.deletedAt === null); const canonicalProcessMap = canvasRow ? stripHubId(canvasRow) : undefined; @@ -95,6 +101,8 @@ export class PwaHubRepository implements HubRepository { ...(liveOutcomes.length > 0 ? { outcomes: liveOutcomes } : {}), ...(canonicalProcessMap ? { canonicalProcessMap } : {}), ...(liveProjects.length > 0 ? { improvementProjects: liveProjects } : {}), + ...(sustainmentRecords.length > 0 ? { sustainmentRecords } : {}), + ...(sustainmentReviews.length > 0 ? { sustainmentReviews } : {}), } as ProcessHub; } @@ -111,7 +119,14 @@ export class PwaHubRepository implements HubRepository { get: async id => { return db.transaction( 'r', - [db.hubs, db.outcomes, db.canvasState, db.improvementProjects], + [ + db.hubs, + db.outcomes, + db.canvasState, + db.improvementProjects, + db.sustainmentRecords, + db.sustainmentReviews, + ], async () => { const hubMeta = await db.hubs.get(id); if (!hubMeta) return undefined; @@ -123,7 +138,14 @@ export class PwaHubRepository implements HubRepository { list: async () => { return db.transaction( 'r', - [db.hubs, db.outcomes, db.canvasState, db.improvementProjects], + [ + db.hubs, + db.outcomes, + db.canvasState, + db.improvementProjects, + db.sustainmentRecords, + db.sustainmentReviews, + ], async () => { const allHubs = await db.hubs.toArray(); const liveHubs = allHubs.filter(h => h.deletedAt === null); @@ -281,6 +303,36 @@ export class PwaHubRepository implements HubRepository { .map(stripActionItemHubId); }, }; + + sustainmentRecords: SustainmentRecordReadAPI = { + get: async id => { + const row = await db.sustainmentRecords.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + listByHub: async hubId => { + const rows = await db.sustainmentRecords.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null); + }, + }; + + sustainmentReviews: SustainmentReviewReadAPI = { + get: async id => { + const row = await db.sustainmentReviews.get(id); + if (!row || row.deletedAt !== null) return undefined; + return row; + }, + listByHub: async hubId => { + const rows = await db.sustainmentReviews.where('hubId').equals(hubId).toArray(); + return sortReviewsDescending(rows.filter(row => row.deletedAt === null)); + }, + listByRecord: async (hubId, recordId) => { + const rows = await db.sustainmentReviews.where('recordId').equals(recordId).toArray(); + return sortReviewsDescending( + rows.filter(row => row.hubId === hubId && row.deletedAt === null) + ); + }, + }; } // --------------------------------------------------------------------------- @@ -302,6 +354,10 @@ function stripActionItemHubId(row: { hubId: string } & ActionItem): ActionItem { return actionItem; } +function sortReviewsDescending(rows: T[]): T[] { + return rows.sort((a, b) => b.reviewedAt - a.reviewedAt); +} + // Module-scoped singleton. Composition root + dispatch boundary documented in apps/pwa/CLAUDE.md. // Vitest module-mocking handles test override. export const pwaHubRepository = new PwaHubRepository(); diff --git a/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts index 4c1a71e6a..004c93d6a 100644 --- a/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts +++ b/apps/pwa/src/persistence/__tests__/PwaHubRepository.test.ts @@ -19,7 +19,13 @@ import 'fake-indexeddb/auto'; import Dexie from 'dexie'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EvidenceSource, EvidenceSnapshot, EvidenceSourceCursor } from '@variscout/core'; +import type { + EvidenceSource, + EvidenceSnapshot, + EvidenceSourceCursor, + SustainmentRecord, + SustainmentReview, +} from '@variscout/core'; import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; import type { ProcessMap } from '@variscout/core/frame'; import { db } from '../../db/schema'; @@ -102,6 +108,48 @@ function makeCursor(id: string, hubId: string, sourceId: string): EvidenceSource }; } +function makeSustainmentRecord( + id: string, + hubId: string, + overrides: Partial = {} +): SustainmentRecord { + return { + id, + hubId, + investigationId: `inv-${id}`, + status: 'pending', + title: `Record ${id}`, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + updatedAt: NOW, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeSustainmentReview( + id: string, + recordId: string, + hubId: string, + overrides: Partial = {} +): SustainmentReview { + return { + id, + recordId, + hubId, + investigationId: `inv-${recordId}`, + reviewedAt: NOW, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + // --------------------------------------------------------------------------- // Setup / teardown — clear every table the repo reads from. // --------------------------------------------------------------------------- @@ -119,6 +167,8 @@ beforeEach(async () => { db.questions.clear(), db.causalLinks.clear(), db.hypotheses.clear(), + db.sustainmentRecords.clear(), + db.sustainmentReviews.clear(), ]); }); @@ -281,6 +331,8 @@ describe('PwaHubRepository.hubs', () => { expect(tableArray).toContain(db.hubs); expect(tableArray).toContain(db.outcomes); expect(tableArray).toContain(db.canvasState); + expect(tableArray).toContain(db.sustainmentRecords); + expect(tableArray).toContain(db.sustainmentReviews); }); it('hubs.list wraps reads in a db.transaction across the three joined tables', async () => { @@ -297,6 +349,64 @@ describe('PwaHubRepository.hubs', () => { expect(tableArray).toContain(db.hubs); expect(tableArray).toContain(db.outcomes); expect(tableArray).toContain(db.canvasState); + expect(tableArray).toContain(db.sustainmentRecords); + expect(tableArray).toContain(db.sustainmentReviews); + }); +}); + +// --------------------------------------------------------------------------- +// sustainmentRecords / sustainmentReviews +// --------------------------------------------------------------------------- + +describe('PwaHubRepository.sustainment read APIs', () => { + it('sustainmentRecords list/get return only live records for the requested hub', async () => { + const repo = new PwaHubRepository(); + await db.sustainmentRecords.bulkPut([ + makeSustainmentRecord('sr-live', 'hub-s'), + makeSustainmentRecord('sr-dead', 'hub-s', { deletedAt: NOW }), + makeSustainmentRecord('sr-other', 'hub-other'), + ]); + + const rows = await repo.sustainmentRecords.listByHub('hub-s'); + + expect(rows.map(row => row.id)).toEqual(['sr-live']); + expect((await repo.sustainmentRecords.get('sr-live'))?.id).toBe('sr-live'); + expect(await repo.sustainmentRecords.get('sr-dead')).toBeUndefined(); + }); + + it('sustainmentReviews list/get return only live reviews for the requested record and hub', async () => { + const repo = new PwaHubRepository(); + await db.sustainmentReviews.bulkPut([ + makeSustainmentReview('rev-live', 'sr-live', 'hub-s', { reviewedAt: NOW + 1 }), + makeSustainmentReview('rev-old', 'sr-live', 'hub-s', { reviewedAt: NOW - 1 }), + makeSustainmentReview('rev-dead', 'sr-live', 'hub-s', { deletedAt: NOW }), + makeSustainmentReview('rev-other-record', 'sr-other', 'hub-s'), + makeSustainmentReview('rev-other-hub', 'sr-live', 'hub-other'), + ]); + + const rows = await repo.sustainmentReviews.listByRecord('hub-s', 'sr-live'); + + expect(rows.map(row => row.id)).toEqual(['rev-live', 'rev-old']); + expect((await repo.sustainmentReviews.get('rev-live'))?.id).toBe('rev-live'); + expect(await repo.sustainmentReviews.get('rev-dead')).toBeUndefined(); + }); + + it('hubs.get hydrates live sustainment records and reviews without deleted rows', async () => { + const repo = new PwaHubRepository(); + await db.hubs.put(makeHub('hub-s')); + await db.sustainmentRecords.bulkPut([ + makeSustainmentRecord('sr-live', 'hub-s'), + makeSustainmentRecord('sr-dead', 'hub-s', { deletedAt: NOW }), + ]); + await db.sustainmentReviews.bulkPut([ + makeSustainmentReview('rev-live', 'sr-live', 'hub-s'), + makeSustainmentReview('rev-dead', 'sr-live', 'hub-s', { deletedAt: NOW }), + ]); + + const hub = await repo.hubs.get('hub-s'); + + expect(hub?.sustainmentRecords?.map(record => record.id)).toEqual(['sr-live']); + expect(hub?.sustainmentReviews?.map(review => review.id)).toEqual(['rev-live']); }); }); diff --git a/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts new file mode 100644 index 000000000..5c42185dd --- /dev/null +++ b/apps/pwa/src/persistence/__tests__/applyAction.sustainment.test.ts @@ -0,0 +1,179 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { EvidenceSnapshot, RowProvenanceTag, SustainmentRecord } from '@variscout/core'; +import type { ProcessHub } from '@variscout/core/processHub'; +import { applyAction } from '../applyAction'; +import { db } from '../../db/schema'; + +const NOW = 1_746_352_800_000; + +function makeHub(id: string): ProcessHub { + return { + id, + name: `Hub ${id}`, + createdAt: NOW, + deletedAt: null, + }; +} + +function makeRecord( + id: string, + hubId: string, + overrides: Partial = {} +): SustainmentRecord { + return { + id, + title: 'Hold improved fill weight', + investigationId: 'inv-1', + hubId, + cadence: 'weekly', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + createdAt: NOW, + updatedAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeSnapshot( + id: string, + hubId: string, + overrides: Partial = {} +): EvidenceSnapshot { + return { + id, + hubId, + sourceId: 'source-1', + capturedAt: '2026-05-12T00:00:00.000Z', + rowCount: 10, + origin: 'paste', + importedAt: NOW, + createdAt: NOW, + deletedAt: null, + ...overrides, + }; +} + +function makeTag(id: string): RowProvenanceTag { + return { + id, + snapshotId: '', + rowKey: '0', + source: 'source-1', + joinKey: 'batch', + createdAt: NOW, + deletedAt: null, + }; +} + +beforeEach(async () => { + await Promise.all([ + db.hubs.clear(), + db.sustainmentRecords.clear(), + db.sustainmentReviews.clear(), + db.evidenceSnapshots.clear(), + db.rowProvenance.clear(), + ]); +}); + +afterEach(async () => { + await Promise.all([ + db.hubs.clear(), + db.sustainmentRecords.clear(), + db.sustainmentReviews.clear(), + db.evidenceSnapshots.clear(), + db.rowProvenance.clear(), + ]); +}); + +describe('applyAction — sustainment records', () => { + it('creates, updates, archives, and persists tick evaluations', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-1') }); + const record = makeRecord('record-1', 'hub-1'); + + await applyAction(db, { kind: 'SUSTAINMENT_RECORD_CREATE', hubId: 'hub-1', record }); + await applyAction(db, { + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'record-1', + patch: { targetSummary: 'Cpk >= 1.33' }, + }); + await applyAction(db, { + kind: 'SUSTAINMENT_TICK_EVALUATED', + record: { ...record, consecutiveOnTargetTicks: 1, lastEvaluatedSnapshotId: 'snapshot-1' }, + review: { + id: 'review-1', + recordId: 'record-1', + investigationId: 'inv-1', + hubId: 'hub-1', + reviewedAt: NOW, + reviewer: { displayName: 'System' }, + verdict: 'holding', + snapshotId: 'snapshot-1', + createdAt: NOW, + deletedAt: null, + }, + }); + await applyAction(db, { kind: 'SUSTAINMENT_RECORD_ARCHIVE', recordId: 'record-1' }); + + const stored = await db.sustainmentRecords.get('record-1'); + expect(stored?.targetSummary).toBe('Cpk >= 1.33'); + expect(stored?.lastEvaluatedSnapshotId).toBe('snapshot-1'); + expect(stored?.deletedAt).toEqual(expect.any(Number)); + expect(await db.sustainmentReviews.get('review-1')).toMatchObject({ + recordId: 'record-1', + verdict: 'holding', + }); + }); + + it('rejects create payloads whose record hubId does not match the action hubId', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-guard') }); + + await expect( + applyAction(db, { + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-guard', + record: makeRecord('record-mismatch', 'other-hub'), + }) + ).rejects.toThrow(/hubId mismatch/); + + expect(await db.sustainmentRecords.get('record-mismatch')).toBeUndefined(); + }); + + it('evaluates live records when evidence snapshots are added', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-2') }); + await applyAction(db, { + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-2', + record: makeRecord('record-2', 'hub-2', { consecutiveOnTargetTicks: 3 }), + }); + + await applyAction(db, { + kind: 'EVIDENCE_ADD_SNAPSHOT', + hubId: 'hub-2', + snapshot: makeSnapshot('snapshot-green', 'hub-2', { + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }), + provenance: [makeTag('tag-1')], + }); + + const record = await db.sustainmentRecords.get('record-2'); + expect(record?.status).toBe('confirmed-sustained'); + expect(record?.consecutiveOnTargetTicks).toBe(4); + expect(record?.lastEvaluatedSnapshotId).toBe('snapshot-green'); + const reviews = await db.sustainmentReviews.where('recordId').equals('record-2').toArray(); + expect(reviews).toHaveLength(1); + expect(reviews[0]).toMatchObject({ snapshotId: 'snapshot-green', verdict: 'holding' }); + expect(await db.rowProvenance.where('snapshotId').equals('snapshot-green').count()).toBe(1); + }); +}); diff --git a/apps/pwa/src/persistence/__tests__/applyAction.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.test.ts index 7ecd9d60d..33df947d6 100644 --- a/apps/pwa/src/persistence/__tests__/applyAction.test.ts +++ b/apps/pwa/src/persistence/__tests__/applyAction.test.ts @@ -11,6 +11,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SustainmentRecord, SustainmentReview } from '@variscout/core'; import type { ProcessHub, OutcomeSpec } from '@variscout/core/processHub'; import type { ProcessMap } from '@variscout/core/frame'; import type { HubAction } from '@variscout/core/actions'; @@ -56,6 +57,37 @@ function makeProcessMap(overrides: Partial = {}): ProcessMap { }; } +function makeSustainmentRecord(id: string, hubId: string): SustainmentRecord { + return { + id, + hubId, + investigationId: `inv-${id}`, + status: 'pending', + title: `Record ${id}`, + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'monthly', + updatedAt: NOW, + createdAt: NOW, + deletedAt: null, + }; +} + +function makeSustainmentReview(id: string, recordId: string, hubId: string): SustainmentReview { + return { + id, + recordId, + hubId, + investigationId: `inv-${recordId}`, + reviewedAt: NOW, + reviewer: { displayName: 'Reviewer' }, + verdict: 'holding', + createdAt: NOW, + deletedAt: null, + }; +} + // --------------------------------------------------------------------------- // Setup / teardown — clear all tables touched by F3 dispatch handlers. // --------------------------------------------------------------------------- @@ -65,6 +97,8 @@ beforeEach(async () => { await db.outcomes.clear(); await db.canvasState.clear(); await db.actionItems.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); }); afterEach(async () => { @@ -72,6 +106,8 @@ afterEach(async () => { await db.outcomes.clear(); await db.canvasState.clear(); await db.actionItems.clear(); + await db.sustainmentRecords.clear(); + await db.sustainmentReviews.clear(); vi.restoreAllMocks(); }); @@ -108,6 +144,60 @@ describe('applyAction — HUB_PERSIST_SNAPSHOT', () => { expect(canvasRow?.version).toBe(1); }); + it('strips sustainment arrays from the hub row and persists them in normalized tables', async () => { + const record = makeSustainmentRecord('sr-1', 'hub-s'); + const review = makeSustainmentReview('rev-1', 'sr-1', 'hub-s'); + const hub = makeHub('hub-s', { + sustainmentRecords: [record], + sustainmentReviews: [review], + } as Partial); + + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub }); + + const hubRow = await db.hubs.get('hub-s'); + expect((hubRow as Partial | undefined)?.sustainmentRecords).toBeUndefined(); + expect((hubRow as Partial | undefined)?.sustainmentReviews).toBeUndefined(); + expect( + (await db.sustainmentRecords.where('hubId').equals('hub-s').toArray()).map(r => r.id) + ).toEqual(['sr-1']); + expect( + (await db.sustainmentReviews.where('hubId').equals('hub-s').toArray()).map(r => r.id) + ).toEqual(['rev-1']); + }); + + it('removes stale sustainment rows when re-persisting with a smaller snapshot', async () => { + await db.sustainmentRecords.put(makeSustainmentRecord('sr-stale', 'hub-stale')); + await db.sustainmentReviews.put(makeSustainmentReview('rev-stale', 'sr-stale', 'hub-stale')); + + await applyAction(db, { + kind: 'HUB_PERSIST_SNAPSHOT', + hub: makeHub('hub-stale', { + sustainmentRecords: [makeSustainmentRecord('sr-keep', 'hub-stale')], + sustainmentReviews: [makeSustainmentReview('rev-keep', 'sr-keep', 'hub-stale')], + } as Partial), + }); + + expect( + (await db.sustainmentRecords.where('hubId').equals('hub-stale').toArray()).map(r => r.id) + ).toEqual(['sr-keep']); + expect( + (await db.sustainmentReviews.where('hubId').equals('hub-stale').toArray()).map(r => r.id) + ).toEqual(['rev-keep']); + }); + + it('removes stale sustainment rows when a snapshot omits sustainment arrays', async () => { + await db.sustainmentRecords.put(makeSustainmentRecord('sr-stale', 'hub-empty')); + await db.sustainmentReviews.put(makeSustainmentReview('rev-stale', 'sr-stale', 'hub-empty')); + + await applyAction(db, { + kind: 'HUB_PERSIST_SNAPSHOT', + hub: makeHub('hub-empty'), + }); + + expect(await db.sustainmentRecords.where('hubId').equals('hub-empty').count()).toBe(0); + expect(await db.sustainmentReviews.where('hubId').equals('hub-empty').count()).toBe(0); + }); + it('persists a hub with no outcomes — outcomes table stays empty', async () => { const hub = makeHub('hub-2'); diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index 9b3726d7a..3a99a10fb 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -38,6 +38,7 @@ import type { HubAction } from '@variscout/core/actions'; import { generateDeterministicId } from '@variscout/core/identity'; +import { applySustainmentTick, type EvidenceSnapshot } from '@variscout/core'; import type { PwaDatabase } from '../db/schema'; // --------------------------------------------------------------------------- @@ -80,10 +81,24 @@ 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 @@ -122,6 +137,27 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise record.id)); + await db.sustainmentRecords + .where('hubId') + .equals(hubMeta.id) + .filter(record => !incomingRecordIds.has(record.id)) + .delete(); + if (incomingSustainmentRecords.length > 0) { + await db.sustainmentRecords.bulkPut(incomingSustainmentRecords); + } + + const incomingSustainmentReviews = sustainmentReviews ?? []; + const incomingReviewIds = new Set(incomingSustainmentReviews.map(review => review.id)); + await db.sustainmentReviews + .where('hubId') + .equals(hubMeta.id) + .filter(review => !incomingReviewIds.has(review.id)) + .delete(); + if (incomingSustainmentReviews.length > 0) { + await db.sustainmentReviews.bulkPut(incomingSustainmentReviews); + } } ); return; @@ -198,6 +234,64 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { + const existing = await db.sustainmentRecords.get(action.record.id); + await db.sustainmentRecords.put({ ...existing, ...action.record }); + await db.sustainmentReviews.put(action.review); + }); + return; + } + // ----------------------------------------------------------------------- // Investigation entity actions (investigation / finding / question / // causalLink / hypothesis) — F5 wires these when the investigation @@ -235,35 +329,40 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { - const now = Date.now(); + await db.transaction( + 'rw', + [db.evidenceSnapshots, db.rowProvenance, db.sustainmentRecords, db.sustainmentReviews], + async () => { + const now = Date.now(); - // Cascade: if replacing, mark replaced snapshot + its provenance rows. - if (action.replacedSnapshotId) { - await db.evidenceSnapshots.update(action.replacedSnapshotId, { deletedAt: now }); - const replacedTags = await db.rowProvenance - .where('snapshotId') - .equals(action.replacedSnapshotId) - .toArray(); - if (replacedTags.length > 0) { - await db.rowProvenance.bulkUpdate( - replacedTags.map(t => ({ key: t.id, changes: { deletedAt: now } })) - ); + // Cascade: if replacing, mark replaced snapshot + its provenance rows. + if (action.replacedSnapshotId) { + await db.evidenceSnapshots.update(action.replacedSnapshotId, { deletedAt: now }); + const replacedTags = await db.rowProvenance + .where('snapshotId') + .equals(action.replacedSnapshotId) + .toArray(); + if (replacedTags.length > 0) { + await db.rowProvenance.bulkUpdate( + replacedTags.map(t => ({ key: t.id, changes: { deletedAt: now } })) + ); + } } - } - // Insert the new snapshot. - await db.evidenceSnapshots.put(action.snapshot); + // Insert the new snapshot. + await db.evidenceSnapshots.put(action.snapshot); - // Insert provenance tags with snapshotId now populated (closes F3.5 wiring gap). - if (action.provenance.length > 0) { - const tagsWithSnapshotId = action.provenance.map(t => ({ - ...t, - snapshotId: action.snapshot.id, - })); - await db.rowProvenance.bulkPut(tagsWithSnapshotId); + // Insert provenance tags with snapshotId now populated (closes F3.5 wiring gap). + if (action.provenance.length > 0) { + const tagsWithSnapshotId = action.provenance.map(t => ({ + ...t, + snapshotId: action.snapshot.id, + })); + await db.rowProvenance.bulkPut(tagsWithSnapshotId); + } + await evaluateSustainmentRecordsForSnapshot(db, action.hubId, action.snapshot); } - }); + ); return; } @@ -418,3 +517,24 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { + await db.transaction('rw', [db.sustainmentRecords, db.sustainmentReviews], async () => { + const liveRecords = await db.sustainmentRecords + .where('hubId') + .equals(hubId) + .filter(record => record.deletedAt === null && record.lastEvaluatedSnapshotId !== snapshot.id) + .toArray(); + + if (liveRecords.length === 0) return; + + const now = Date.now(); + const evaluations = liveRecords.map(record => applySustainmentTick(record, snapshot, now)); + await db.sustainmentRecords.bulkPut(evaluations.map(evaluation => evaluation.record)); + await db.sustainmentReviews.bulkPut(evaluations.map(evaluation => evaluation.review)); + }); +} diff --git a/packages/core/src/__tests__/sustainment.test.ts b/packages/core/src/__tests__/sustainment.test.ts index 1f8af71fa..61b8c4fa7 100644 --- a/packages/core/src/__tests__/sustainment.test.ts +++ b/packages/core/src/__tests__/sustainment.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + applySustainmentTick, + evaluateSustainmentSnapshot, isSustainmentDue, isSustainmentOverdue, nextDueFromCadence, @@ -12,6 +14,7 @@ import { type SustainmentRecord, type SustainmentVerdict, } from '../sustainment'; +import type { EvidenceSnapshot } from '../evidenceSources'; import type { ProcessHubInvestigation } from '../processHub'; describe('nextDueFromCadence', () => { @@ -59,9 +62,14 @@ describe('nextDueFromCadence', () => { function makeRecord(nextReviewDue?: string): SustainmentRecord { return { id: 'rec-1', + title: 'Sustain fill-weight gains', investigationId: 'inv-1', hubId: 'hub-1', cadence: 'monthly', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, nextReviewDue, createdAt: 1743465600000, updatedAt: 1743465600000, @@ -69,6 +77,174 @@ function makeRecord(nextReviewDue?: string): SustainmentRecord { }; } +function makeSnapshot(overrides: Partial = {}): EvidenceSnapshot { + return { + id: 'snapshot-1', + hubId: 'hub-1', + sourceId: 'source-1', + capturedAt: '2026-05-12T00:00:00.000Z', + rowCount: 5, + origin: 'paste', + importedAt: 1_746_352_800_000, + createdAt: 1_746_352_800_000, + deletedAt: null, + ...overrides, + }; +} + +describe('evaluateSustainmentSnapshot', () => { + it('returns inconclusive when the snapshot has no actionable latestSignals', () => { + expect(evaluateSustainmentSnapshot(makeRecord(), makeSnapshot()).verdict).toBe('inconclusive'); + expect( + evaluateSustainmentSnapshot( + makeRecord(), + makeSnapshot({ + latestSignals: [ + { + id: 'signal-neutral', + label: 'No target configured', + value: 0, + severity: 'neutral', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }) + ).verdict + ).toBe('inconclusive'); + }); + + it('returns drifting when any actionable signal is amber or red', () => { + const result = evaluateSustainmentSnapshot( + makeRecord(), + makeSnapshot({ + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + { + id: 'signal-amber', + label: 'Scrap', + value: 0.08, + severity: 'amber', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }) + ); + + expect(result.verdict).toBe('drifting'); + expect(result.actionableSignalCount).toBe(2); + }); + + it('returns holding when all actionable signals are green', () => { + const result = evaluateSustainmentSnapshot( + makeRecord(), + makeSnapshot({ + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }) + ); + + expect(result.verdict).toBe('holding'); + expect(result.actionableSignalCount).toBe(1); + }); +}); + +describe('applySustainmentTick', () => { + it('increments consecutiveOnTargetTicks and auto-confirms after four holding ticks', () => { + const record = { ...makeRecord(), consecutiveOnTargetTicks: 3 }; + const snapshot = makeSnapshot({ + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }); + + const result = applySustainmentTick(record, snapshot, 1_746_352_800_000); + + expect(result.record.consecutiveOnTargetTicks).toBe(4); + expect(result.record.status).toBe('confirmed-sustained'); + expect(result.record.lastEvaluatedSnapshotId).toBe('snapshot-1'); + expect(result.review.verdict).toBe('holding'); + expect(result.review.snapshotId).toBe('snapshot-1'); + }); + + it('does not auto-confirm when hasOverride is true', () => { + const result = applySustainmentTick( + { ...makeRecord(), consecutiveOnTargetTicks: 3, hasOverride: true }, + makeSnapshot({ + latestSignals: [ + { + id: 'signal-green', + label: 'Cpk', + value: 1.41, + severity: 'green', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }), + 1_746_352_800_000 + ); + + expect(result.record.consecutiveOnTargetTicks).toBe(4); + expect(result.record.status).toBe('pending'); + }); + + it('resets the counter and marks confirmed records drifted on amber or red signals', () => { + const result = applySustainmentTick( + { + ...makeRecord(), + status: 'confirmed-sustained', + consecutiveOnTargetTicks: 4, + }, + makeSnapshot({ + latestSignals: [ + { + id: 'signal-red', + label: 'Defects', + value: 3, + severity: 'red', + capturedAt: '2026-05-12T00:00:00.000Z', + }, + ], + }), + 1_746_352_800_000 + ); + + expect(result.record.consecutiveOnTargetTicks).toBe(0); + expect(result.record.status).toBe('drifted'); + expect(result.review.verdict).toBe('drifting'); + }); + + it('leaves counter and status unchanged for inconclusive snapshots', () => { + const result = applySustainmentTick( + { ...makeRecord(), consecutiveOnTargetTicks: 2 }, + makeSnapshot({ latestSignals: [] }), + 1_746_352_800_000 + ); + + expect(result.record.consecutiveOnTargetTicks).toBe(2); + expect(result.record.status).toBe('pending'); + expect(result.review.verdict).toBe('inconclusive'); + }); +}); + describe('isSustainmentDue', () => { it('returns false when nextReviewDue is undefined', () => { expect(isSustainmentDue(makeRecord(), new Date('2026-04-26T00:00:00.000Z'))).toBe(false); diff --git a/packages/core/src/actions/HubAction.ts b/packages/core/src/actions/HubAction.ts index 17e7128e8..5014e1591 100644 --- a/packages/core/src/actions/HubAction.ts +++ b/packages/core/src/actions/HubAction.ts @@ -10,6 +10,7 @@ import type { HubMetaAction } from './hubMetaActions'; import type { CanvasAction } from './canvasActions'; import type { ImprovementProjectAction } from './improvementProjectActions'; import type { ActionItemAction } from './actionItemActions'; +import type { SustainmentAction } from './sustainmentActions'; /** * Top-level discriminated union for all hub write operations. @@ -28,4 +29,5 @@ export type HubAction = | HubMetaAction | CanvasAction | ImprovementProjectAction - | ActionItemAction; + | ActionItemAction + | SustainmentAction; diff --git a/packages/core/src/actions/__tests__/exhaustiveness.test.ts b/packages/core/src/actions/__tests__/exhaustiveness.test.ts index 5302b9c8b..2523016e6 100644 --- a/packages/core/src/actions/__tests__/exhaustiveness.test.ts +++ b/packages/core/src/actions/__tests__/exhaustiveness.test.ts @@ -101,6 +101,19 @@ function _exhaustive(action: HubAction): void { // Action Item case 'ACTION_ITEM_ADD': return; + // Sustainment + case 'SUSTAINMENT_RECORD_CREATE': + return; + case 'SUSTAINMENT_RECORD_UPDATE': + return; + case 'SUSTAINMENT_RECORD_ARCHIVE': + return; + case 'SUSTAINMENT_CONFIRM': + return; + case 'SUSTAINMENT_MARK_DRIFTED': + return; + case 'SUSTAINMENT_TICK_EVALUATED': + return; default: return assertNever(action); } @@ -179,3 +192,57 @@ describe('IMPROVEMENT_PROJECT actions', () => { expect(partialSections.kind).toBe('IMPROVEMENT_PROJECT_UPDATE'); }); }); + +describe('SUSTAINMENT actions', () => { + it('compile under the HubAction discriminated union', () => { + const create: HubAction = { + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-1', + record: { + id: 'sus-1', + title: 'Hold improved fill weight', + investigationId: 'inv-1', + hubId: 'hub-1', + cadence: 'weekly', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + createdAt: 1_746_352_800_000, + updatedAt: 1_746_352_800_000, + deletedAt: null, + }, + }; + const update: HubAction = { + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'sus-1', + patch: { targetSummary: 'Cpk >= 1.33' }, + }; + const archive: HubAction = { kind: 'SUSTAINMENT_RECORD_ARCHIVE', recordId: 'sus-1' }; + const confirm: HubAction = { kind: 'SUSTAINMENT_CONFIRM', recordId: 'sus-1' }; + const drifted: HubAction = { kind: 'SUSTAINMENT_MARK_DRIFTED', recordId: 'sus-1' }; + const tick: HubAction = { + kind: 'SUSTAINMENT_TICK_EVALUATED', + record: create.record, + review: { + id: 'review-1', + recordId: 'sus-1', + investigationId: 'inv-1', + hubId: 'hub-1', + reviewedAt: 1_746_352_800_000, + reviewer: { displayName: 'System' }, + verdict: 'holding', + snapshotId: 'snapshot-1', + createdAt: 1_746_352_800_000, + deletedAt: null, + }, + }; + + expect(create.kind).toBe('SUSTAINMENT_RECORD_CREATE'); + expect(update.kind).toBe('SUSTAINMENT_RECORD_UPDATE'); + expect(archive.kind).toBe('SUSTAINMENT_RECORD_ARCHIVE'); + expect(confirm.kind).toBe('SUSTAINMENT_CONFIRM'); + expect(drifted.kind).toBe('SUSTAINMENT_MARK_DRIFTED'); + expect(tick.kind).toBe('SUSTAINMENT_TICK_EVALUATED'); + }); +}); diff --git a/packages/core/src/actions/__tests__/sustainmentActions.test.ts b/packages/core/src/actions/__tests__/sustainmentActions.test.ts new file mode 100644 index 000000000..bf950b7a7 --- /dev/null +++ b/packages/core/src/actions/__tests__/sustainmentActions.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import type { HubAction } from '../HubAction'; +import type { SustainmentAction } from '../sustainmentActions'; + +describe('SustainmentAction', () => { + it('covers all sustainment action kinds and is included in HubAction', () => { + const create: SustainmentAction = { + kind: 'SUSTAINMENT_RECORD_CREATE', + hubId: 'hub-1', + record: { + id: 'sustainment-1', + title: 'Hold improved fill weight', + investigationId: 'inv-1', + hubId: 'hub-1', + cadence: 'weekly', + status: 'pending', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + createdAt: 1_746_352_800_000, + updatedAt: 1_746_352_800_000, + deletedAt: null, + }, + }; + const update: SustainmentAction = { + kind: 'SUSTAINMENT_RECORD_UPDATE', + recordId: 'sustainment-1', + patch: { targetSummary: 'Cpk >= 1.33' }, + }; + const archive: SustainmentAction = { + kind: 'SUSTAINMENT_RECORD_ARCHIVE', + recordId: 'sustainment-1', + }; + const confirm: SustainmentAction = { + kind: 'SUSTAINMENT_CONFIRM', + recordId: 'sustainment-1', + }; + const drifted: SustainmentAction = { + kind: 'SUSTAINMENT_MARK_DRIFTED', + recordId: 'sustainment-1', + }; + const tick: SustainmentAction = { + kind: 'SUSTAINMENT_TICK_EVALUATED', + record: create.record, + review: { + id: 'review-1', + recordId: 'sustainment-1', + investigationId: 'inv-1', + hubId: 'hub-1', + reviewedAt: 1_746_352_800_000, + reviewer: { displayName: 'System' }, + verdict: 'holding', + snapshotId: 'snapshot-1', + createdAt: 1_746_352_800_000, + deletedAt: null, + }, + }; + + const actions: HubAction[] = [create, update, archive, confirm, drifted, tick]; + + expect(actions.map(action => action.kind)).toEqual([ + 'SUSTAINMENT_RECORD_CREATE', + 'SUSTAINMENT_RECORD_UPDATE', + 'SUSTAINMENT_RECORD_ARCHIVE', + 'SUSTAINMENT_CONFIRM', + 'SUSTAINMENT_MARK_DRIFTED', + 'SUSTAINMENT_TICK_EVALUATED', + ]); + }); +}); diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index d74003924..406893f7e 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -10,4 +10,5 @@ export type { HubMetaAction } from './hubMetaActions'; export type { CanvasAction } from './canvasActions'; export type { ImprovementProjectAction } from './improvementProjectActions'; export type { ActionItemAction } from './actionItemActions'; +export type { SustainmentAction } from './sustainmentActions'; export type { HubAction } from './HubAction'; diff --git a/packages/core/src/actions/sustainmentActions.ts b/packages/core/src/actions/sustainmentActions.ts new file mode 100644 index 000000000..e6e67d2a1 --- /dev/null +++ b/packages/core/src/actions/sustainmentActions.ts @@ -0,0 +1,38 @@ +import type { EvidenceSnapshot } from '../evidenceSources'; +import type { ProcessHub } from '../processHub'; +import type { SustainmentRecord, SustainmentReview } from '../sustainment'; + +export type SustainmentAction = + | { + kind: 'SUSTAINMENT_RECORD_CREATE'; + hubId: ProcessHub['id']; + record: SustainmentRecord; + } + | { + kind: 'SUSTAINMENT_RECORD_UPDATE'; + recordId: SustainmentRecord['id']; + patch: Partial< + Omit< + SustainmentRecord, + 'id' | 'createdAt' | 'hubId' | 'investigationId' | 'updatedAt' | 'deletedAt' + > + >; + } + | { + kind: 'SUSTAINMENT_RECORD_ARCHIVE'; + recordId: SustainmentRecord['id']; + } + | { + kind: 'SUSTAINMENT_CONFIRM'; + recordId: SustainmentRecord['id']; + } + | { + kind: 'SUSTAINMENT_MARK_DRIFTED'; + recordId: SustainmentRecord['id']; + } + | { + kind: 'SUSTAINMENT_TICK_EVALUATED'; + record: SustainmentRecord; + review: SustainmentReview; + snapshotId?: EvidenceSnapshot['id']; + }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b526d40e2..646aa2e5f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -553,6 +553,7 @@ export type { SustainmentBucketOptions, } from './sustainment'; export { + applySustainmentTick, nextDueFromCadence, isSustainmentDue, isSustainmentOverdue, diff --git a/packages/core/src/persistence/HubRepository.ts b/packages/core/src/persistence/HubRepository.ts index 2f263506f..6a7839643 100644 --- a/packages/core/src/persistence/HubRepository.ts +++ b/packages/core/src/persistence/HubRepository.ts @@ -3,6 +3,7 @@ import type { ProcessHub, OutcomeSpec, ProcessHubInvestigation } from '../proces import type { EvidenceSource, EvidenceSnapshot, EvidenceSourceCursor } from '../evidenceSources'; import type { Finding, Question, CausalLink, Hypothesis, ActionItem } from '../findings/types'; import type { ProcessMap } from '../frame/types'; +import type { SustainmentRecord, SustainmentReview } from '../sustainment'; export interface HubReadAPI { get(id: ProcessHub['id']): Promise; @@ -63,6 +64,20 @@ export interface ActionItemReadAPI { listByStep(hubId: ProcessHub['id'], stepId: string): Promise; } +export interface SustainmentRecordReadAPI { + get(id: SustainmentRecord['id']): Promise; + listByHub(hubId: ProcessHub['id']): Promise; +} + +export interface SustainmentReviewReadAPI { + get(id: SustainmentReview['id']): Promise; + listByHub(hubId: ProcessHub['id']): Promise; + listByRecord( + hubId: ProcessHub['id'], + recordId: SustainmentRecord['id'] + ): Promise; +} + /** * Single-interface repository for all hub domain writes + grouped reads. * Write path: one `dispatch(action)` entry point — all mutations flow through it. @@ -85,4 +100,6 @@ export interface HubRepository { hypotheses: HypothesisReadAPI; canvasState: CanvasStateReadAPI; actionItems: ActionItemReadAPI; + sustainmentRecords: SustainmentRecordReadAPI; + sustainmentReviews: SustainmentReviewReadAPI; } diff --git a/packages/core/src/persistence/index.ts b/packages/core/src/persistence/index.ts index de3169bd3..6766c18a2 100644 --- a/packages/core/src/persistence/index.ts +++ b/packages/core/src/persistence/index.ts @@ -11,6 +11,8 @@ export type { HypothesisReadAPI, CanvasStateReadAPI, ActionItemReadAPI, + SustainmentRecordReadAPI, + SustainmentReviewReadAPI, } from './HubRepository'; export type { EntityKind, CascadeRule, CascadeRuleset } from './cascadeRules'; export { cascadeRules, transitiveCascade } from './cascadeRules'; diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index 6360b3ebf..386fa38a3 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -125,6 +125,14 @@ export interface ProcessHub extends EntityBase { * imports ProcessHub). */ improvementProjects?: import('./improvementProject').ImprovementProject[]; + /** + * Sustainment entities owned by this hub. In-memory hydrated lists, loaded + * by HubRepository reads from normalized tables. Mutations flow through + * `SUSTAINMENT_*` HubAction kinds; persistence must decompose these out of + * hub rows before writing. + */ + sustainmentRecords?: SustainmentRecord[]; + sustainmentReviews?: import('./sustainment').SustainmentReview[]; } export const DEFAULT_PROCESS_HUB: ProcessHub = { diff --git a/packages/core/src/survey/__tests__/inbox.test.ts b/packages/core/src/survey/__tests__/inbox.test.ts new file mode 100644 index 000000000..0105ccd7e --- /dev/null +++ b/packages/core/src/survey/__tests__/inbox.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { surveyInboxRules } from '../inbox'; +import type { ImprovementProject } from '../../improvementProject'; +import type { SustainmentRecord } from '../../sustainment'; + +const NOW = Date.UTC(2026, 4, 12); +const DAY_MS = 24 * 60 * 60 * 1000; + +const sustainmentRecord = (overrides: Partial): SustainmentRecord => + ({ + id: 'sr-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'pending', + title: 'Mix temperature control', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'weekly', + createdAt: NOW - 10 * DAY_MS, + deletedAt: null, + updatedAt: NOW - DAY_MS, + ...overrides, + }) as SustainmentRecord; + +const improvementProject = (overrides: Partial): ImprovementProject => + ({ + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce mix temperature drift' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + createdAt: NOW - 45 * DAY_MS, + deletedAt: null, + updatedAt: NOW - 45 * DAY_MS, + ...overrides, + }) as ImprovementProject; + +describe('surveyInboxRules', () => { + it('aggregates sustainment hints into inbox prompts sorted by severity then message and id', () => { + const prompts = surveyInboxRules({ + improvementProjects: [ + improvementProject({ id: 'ip-old', metadata: { title: 'A closed project' } }), + ], + sustainmentRecords: [ + sustainmentRecord({ id: 'sr-info', title: 'B progress', consecutiveOnTargetTicks: 3 }), + sustainmentRecord({ id: 'sr-critical', title: 'C drift', status: 'drifted' }), + ], + now: NOW, + }); + + expect(prompts.map(prompt => prompt.severity)).toEqual(['critical', 'warning', 'info']); + expect(prompts.map(prompt => prompt.sourceHint.surface)).toEqual(['inbox', 'inbox', 'inbox']); + expect(prompts.map(prompt => prompt.id)).toEqual([ + 'inbox:drift-detection:sr-critical', + 'inbox:lifecycle-gap:ip-old', + 'inbox:drift-detection:sr-info', + ]); + expect(prompts[0]).toMatchObject({ + action: { + label: 'Open sustainment record', + opensSurface: 'sustainment', + opensId: 'sr-critical', + }, + sourceHint: { + kind: 'drift-detection', + surface: 'inbox', + targetEntityId: 'sr-critical', + }, + }); + }); +}); diff --git a/packages/core/src/survey/__tests__/sustainment.test.ts b/packages/core/src/survey/__tests__/sustainment.test.ts new file mode 100644 index 000000000..5b58bc436 --- /dev/null +++ b/packages/core/src/survey/__tests__/sustainment.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { surveySustainmentRules } from '../sustainment'; +import type { ImprovementProject } from '../../improvementProject'; +import type { SustainmentRecord } from '../../sustainment'; + +const NOW = Date.UTC(2026, 4, 12); +const DAY_MS = 24 * 60 * 60 * 1000; + +const sustainmentRecord = (overrides: Partial): SustainmentRecord => + ({ + id: 'sr-1', + investigationId: 'inv-1', + hubId: 'hub-1', + status: 'pending', + title: 'Mix temperature control', + consecutiveOnTargetTicks: 0, + hasOverride: false, + lastEvaluatedSnapshotId: undefined, + cadence: 'weekly', + createdAt: NOW - 10 * DAY_MS, + deletedAt: null, + updatedAt: NOW - DAY_MS, + ...overrides, + }) as SustainmentRecord; + +const improvementProject = (overrides: Partial): ImprovementProject => + ({ + id: 'ip-1', + hubId: 'hub-1', + status: 'closed', + metadata: { title: 'Reduce mix temperature drift' }, + goal: { outcomeGoal: { outcomeSpecId: 'outcome-1', target: 1.33 } }, + sections: { + background: {}, + investigationLineage: {}, + approach: {}, + outcomeReference: {}, + }, + createdAt: NOW - 45 * DAY_MS, + deletedAt: null, + updatedAt: NOW - 45 * DAY_MS, + ...overrides, + }) as ImprovementProject; + +describe('surveySustainmentRules', () => { + it('emits a critical drift hint for drifted sustainment records', () => { + const hints = surveySustainmentRules({ + sustainmentRecords: [sustainmentRecord({ id: 'sr-drifted', status: 'drifted' })], + now: NOW, + }); + + expect(hints).toEqual([ + expect.objectContaining({ + kind: 'drift-detection', + surface: 'sustainment', + targetEntityId: 'sr-drifted', + severity: 'critical', + action: { + label: 'Open sustainment record', + opensSurface: 'sustainment', + opensId: 'sr-drifted', + }, + }), + ]); + expect(hints[0].message).toContain('drift'); + }); + + it('does not emit drift or progress hints for archived sustainment records', () => { + const hints = surveySustainmentRules({ + sustainmentRecords: [ + sustainmentRecord({ + id: 'sr-archived-drift', + status: 'drifted', + deletedAt: NOW - DAY_MS, + }), + sustainmentRecord({ + id: 'sr-archived-progress', + status: 'pending', + consecutiveOnTargetTicks: 3, + deletedAt: NOW - DAY_MS, + }), + ], + now: NOW, + }); + + expect(hints).toHaveLength(0); + }); + + it('emits a warning drift hint for records whose latest verdict is drifting', () => { + const hints = surveySustainmentRules({ + sustainmentRecords: [ + sustainmentRecord({ id: 'sr-verdict', status: 'pending', latestVerdict: 'drifting' }), + ], + now: NOW, + }); + + expect(hints).toHaveLength(1); + expect(hints[0]).toMatchObject({ + kind: 'drift-detection', + surface: 'sustainment', + targetEntityId: 'sr-verdict', + severity: 'warning', + }); + }); + + it('emits a 3-of-4 progress prompt for pending records with three on-target ticks', () => { + const hints = surveySustainmentRules({ + sustainmentRecords: [ + sustainmentRecord({ + id: 'sr-progress', + status: 'pending', + consecutiveOnTargetTicks: 3, + latestVerdict: 'holding', + }), + ], + now: NOW, + }); + + expect(hints).toEqual([ + expect.objectContaining({ + kind: 'drift-detection', + surface: 'sustainment', + targetEntityId: 'sr-progress', + severity: 'info', + message: '3 of 4 ticks confirmed', + }), + ]); + }); + + it('emits a lifecycle gap for closed improvement projects older than 30 days without live sustainment', () => { + const hints = surveySustainmentRules({ + improvementProjects: [improvementProject({ id: 'ip-old' })], + sustainmentRecords: [], + now: NOW, + }); + + expect(hints).toEqual([ + expect.objectContaining({ + kind: 'lifecycle-gap', + surface: 'inbox', + targetEntityId: 'ip-old', + severity: 'warning', + action: { label: 'Set up sustainment', opensSurface: 'sustainment', opensId: 'ip-old' }, + }), + ]); + expect(hints[0].message).toContain('Reduce mix temperature drift'); + }); + + it('emits a lifecycle gap when a closed improvement project reaches 30 days without live sustainment', () => { + const hints = surveySustainmentRules({ + improvementProjects: [ + improvementProject({ + id: 'ip-threshold', + createdAt: NOW - 30 * DAY_MS, + updatedAt: NOW - 30 * DAY_MS, + }), + ], + sustainmentRecords: [], + now: NOW, + }); + + expect(hints.filter(hint => hint.kind === 'lifecycle-gap')).toHaveLength(1); + }); + + it('does not emit lifecycle gaps for archived improvement projects', () => { + const hints = surveySustainmentRules({ + improvementProjects: [improvementProject({ id: 'ip-archived', deletedAt: NOW - DAY_MS })], + sustainmentRecords: [], + now: NOW, + }); + + expect(hints.filter(hint => hint.kind === 'lifecycle-gap')).toHaveLength(0); + }); + + it('does not emit a lifecycle gap when the closed project has linked live sustainment', () => { + const hints = surveySustainmentRules({ + improvementProjects: [improvementProject({ id: 'ip-linked' })], + sustainmentRecords: [ + sustainmentRecord({ + id: 'sr-linked', + status: 'pending', + improvementProjectId: 'ip-linked', + deletedAt: null, + }), + ], + now: NOW, + }); + + expect(hints.filter(hint => hint.kind === 'lifecycle-gap')).toHaveLength(0); + }); +}); diff --git a/packages/core/src/survey/inbox.ts b/packages/core/src/survey/inbox.ts new file mode 100644 index 000000000..c9b01c615 --- /dev/null +++ b/packages/core/src/survey/inbox.ts @@ -0,0 +1,41 @@ +import { surveySustainmentRules } from './sustainment'; +import type { SurveyContext, SurveyHint } from './types'; + +export interface SurveyInboxPrompt { + id: string; + message: string; + severity: SurveyHint['severity']; + action?: SurveyHint['action']; + sourceHint: SurveyHint; +} + +const SEVERITY_RANK: Record = { + critical: 0, + warning: 1, + info: 2, +}; + +function toInboxPrompt(hint: SurveyHint): SurveyInboxPrompt { + const sourceHint: SurveyHint = { ...hint, surface: 'inbox' }; + return { + id: `inbox:${sourceHint.kind}:${sourceHint.targetEntityId}`, + message: sourceHint.message, + severity: sourceHint.severity, + action: sourceHint.action, + sourceHint, + }; +} + +export function surveyInboxRules(ctx: SurveyContext): SurveyInboxPrompt[] { + return surveySustainmentRules(ctx) + .map(toInboxPrompt) + .sort((a, b) => { + const severity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]; + if (severity !== 0) return severity; + + const message = a.message.localeCompare(b.message); + if (message !== 0) return message; + + return a.id.localeCompare(b.id); + }); +} diff --git a/packages/core/src/survey/index.ts b/packages/core/src/survey/index.ts index 088e1492b..60abb1333 100644 --- a/packages/core/src/survey/index.ts +++ b/packages/core/src/survey/index.ts @@ -35,4 +35,7 @@ export { SURVEY_RECOMMENDATION_KIND_LABELS, SURVEY_STATUS_LABELS } from './types // --- Surface 2: cross-phase rule registry --- export { surveyWallRules, deriveHypothesisStatus } from './wall'; +export { surveySustainmentRules } from './sustainment'; +export { surveyInboxRules } from './inbox'; +export type { SurveyInboxPrompt } from './inbox'; export type { SurveyHint, SurveyRule, SurveyContext, SurveyHintKind } from './types'; diff --git a/packages/core/src/survey/sustainment.ts b/packages/core/src/survey/sustainment.ts new file mode 100644 index 000000000..383a40617 --- /dev/null +++ b/packages/core/src/survey/sustainment.ts @@ -0,0 +1,118 @@ +import type { ImprovementProject } from '../improvementProject'; +import type { SustainmentRecord } from '../sustainment'; +import type { SurveyHint, SurveyRule } from './types'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const LIFECYCLE_GAP_DAYS = 30; + +function timestamp(value: Date | number | undefined): number | undefined { + if (value instanceof Date) return value.getTime(); + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function projectClosedAt(project: ImprovementProject): number { + return project.updatedAt ?? project.createdAt; +} + +function isLiveRecord(record: SustainmentRecord): boolean { + return record.deletedAt === null; +} + +function isLiveProject(project: ImprovementProject): boolean { + return project.deletedAt === null; +} + +function hasLinkedLiveSustainment( + project: ImprovementProject, + records: SustainmentRecord[] +): boolean { + const referencedRecordId = project.sections.outcomeReference.sustainmentRecordId; + return records.some( + record => + isLiveRecord(record) && + (record.improvementProjectId === project.id || + (referencedRecordId !== undefined && record.id === referencedRecordId)) + ); +} + +function driftSeverity(record: SustainmentRecord): SurveyHint['severity'] | undefined { + if (record.status === 'drifted' || record.latestVerdict === 'broken') return 'critical'; + if (record.latestVerdict === 'drifting') return 'warning'; + return undefined; +} + +function driftMessage(record: SustainmentRecord, severity: SurveyHint['severity']): string { + if (severity === 'critical') return `${record.title} drift detected`; + return `${record.title} is drifting`; +} + +export const surveySustainmentRules: SurveyRule = ctx => { + const hints: SurveyHint[] = []; + const records = ctx.sustainmentRecords ?? []; + const projects = ctx.improvementProjects ?? []; + + for (const record of records.filter(isLiveRecord)) { + const severity = driftSeverity(record); + if (severity) { + hints.push({ + kind: 'drift-detection', + surface: 'sustainment', + targetEntityId: record.id, + message: driftMessage(record, severity), + severity, + action: { + label: 'Open sustainment record', + opensSurface: 'sustainment', + opensId: record.id, + }, + }); + continue; + } + + if ( + record.status === 'pending' && + record.consecutiveOnTargetTicks >= 1 && + record.consecutiveOnTargetTicks <= 3 + ) { + hints.push({ + kind: 'drift-detection', + surface: 'sustainment', + targetEntityId: record.id, + message: `${record.consecutiveOnTargetTicks} of 4 ticks confirmed`, + severity: 'info', + action: { + label: 'Open sustainment record', + opensSurface: 'sustainment', + opensId: record.id, + }, + }); + } + } + + const now = timestamp(ctx.now); + if (now === undefined) return hints; + + for (const project of projects) { + if (!isLiveProject(project)) continue; + if (project.status !== 'closed') continue; + if (hasLinkedLiveSustainment(project, records)) continue; + + const ageMs = now - projectClosedAt(project); + if (ageMs < LIFECYCLE_GAP_DAYS * DAY_MS) continue; + + hints.push({ + kind: 'lifecycle-gap', + surface: 'inbox', + targetEntityId: project.id, + message: `${project.metadata.title} closed more than 30 days ago without live sustainment`, + severity: 'warning', + action: { + label: 'Set up sustainment', + opensSurface: 'sustainment', + opensId: project.id, + }, + }); + } + + return hints; +}; diff --git a/packages/core/src/survey/types.ts b/packages/core/src/survey/types.ts index df6f0908b..139c85a31 100644 --- a/packages/core/src/survey/types.ts +++ b/packages/core/src/survey/types.ts @@ -11,6 +11,8 @@ import type { SignalSourceArchetype, SignalTrustGrade, } from '../signalCards'; +import type { ImprovementProject } from '../improvementProject'; +import type { SustainmentRecord, SustainmentReview } from '../sustainment'; export type SurveyStatus = 'can-do-now' | 'can-do-with-caution' | 'cannot-do-yet' | 'ask-for-next'; @@ -187,15 +189,15 @@ export interface SurveyHint { * rules that only need a subset of context to be called without providing * irrelevant data. * - * Note: `improvementProjects` is intentionally absent from V1 — the - * `ImprovementProject` type is added in PR-RPS-5. The Wall rule (Task 8) - * does not consume it. PR-RPS-5 will extend this interface when it adds - * the IP types. */ export interface SurveyContext { hub?: import('../processHub').ProcessHub; hypotheses?: Hypothesis[]; findings?: Finding[]; + improvementProjects?: ImprovementProject[]; + sustainmentRecords?: SustainmentRecord[]; + sustainmentReviews?: SustainmentReview[]; + now?: Date | number; } /** diff --git a/packages/core/src/sustainment.ts b/packages/core/src/sustainment.ts index 570797e21..7a095fb06 100644 --- a/packages/core/src/sustainment.ts +++ b/packages/core/src/sustainment.ts @@ -8,6 +8,7 @@ import type { ProcessParticipantRef, } from './processHub'; import type { EvidenceSnapshot } from './evidenceSources'; +import type { ImprovementProjectGoal } from './improvementProject'; export type SustainmentCadence = | 'weekly' @@ -19,6 +20,7 @@ export type SustainmentCadence = | 'on-demand'; export type SustainmentVerdict = 'holding' | 'drifting' | 'broken' | 'inconclusive'; +export type SustainmentStatus = 'pending' | 'confirmed-sustained' | 'drifted'; export type ControlHandoffSurface = | 'mes-recipe' @@ -38,6 +40,14 @@ export interface SustainmentRecord extends EntityBase { // record is archived but readable. investigationId: ProcessHubInvestigation['id']; hubId: ProcessHub['id']; + status: SustainmentStatus; + title: string; + improvementProjectId?: string; + goal?: ImprovementProjectGoal; + targetSummary?: string; + consecutiveOnTargetTicks: number; + hasOverride: boolean; + lastEvaluatedSnapshotId: EvidenceSnapshot['id'] | undefined; cadence: SustainmentCadence; nextReviewDue?: string; latestVerdict?: SustainmentVerdict; @@ -49,6 +59,15 @@ export interface SustainmentRecord extends EntityBase { updatedAt: number; } +export interface SustainmentSnapshotEvaluation { + verdict: SustainmentVerdict; + onTarget: boolean | null; + actionableSignalCount: number; + nextConsecutiveOnTargetTicks: number; + nextStatus: SustainmentStatus; + observation: string; +} + export interface SustainmentReview extends EntityBase { // EntityBase contributes: id, createdAt (number, Unix ms), deletedAt (number | null). // createdAt == reviewedAt at construction (both set to Date.now() when the review is logged). @@ -90,6 +109,104 @@ export interface SustainmentMetadataProjection { handoffSurface?: ControlHandoffSurface; } +function normalizedSustainmentStatus(record: SustainmentRecord): SustainmentStatus { + return record.status ?? 'pending'; +} + +function normalizedConsecutiveOnTargetTicks(record: SustainmentRecord): number { + const ticks = record.consecutiveOnTargetTicks ?? 0; + return Number.isFinite(ticks) && ticks > 0 ? Math.floor(ticks) : 0; +} + +function normalizedHasOverride(record: SustainmentRecord): boolean { + return record.hasOverride === true; +} + +export function evaluateSustainmentSnapshot( + record: SustainmentRecord, + snapshot: EvidenceSnapshot +): SustainmentSnapshotEvaluation { + const currentTicks = normalizedConsecutiveOnTargetTicks(record); + const currentStatus = normalizedSustainmentStatus(record); + const actionableSignals = (snapshot.latestSignals ?? []).filter( + signal => signal.severity !== 'neutral' + ); + + if (actionableSignals.length === 0) { + return { + verdict: 'inconclusive', + onTarget: null, + actionableSignalCount: 0, + nextConsecutiveOnTargetTicks: currentTicks, + nextStatus: currentStatus, + observation: 'No actionable sustainment signals were available for this snapshot.', + }; + } + + const hasAmber = actionableSignals.some(signal => signal.severity === 'amber'); + const hasRed = actionableSignals.some(signal => signal.severity === 'red'); + if (hasAmber || hasRed) { + return { + verdict: 'drifting', + onTarget: false, + actionableSignalCount: actionableSignals.length, + nextConsecutiveOnTargetTicks: 0, + nextStatus: currentStatus === 'confirmed-sustained' ? 'drifted' : currentStatus, + observation: 'An amber or red sustainment signal indicates the gain is drifting.', + }; + } + + const nextTicks = currentTicks + 1; + const nextStatus = + nextTicks >= 4 && !normalizedHasOverride(record) ? 'confirmed-sustained' : currentStatus; + + return { + verdict: 'holding', + onTarget: true, + actionableSignalCount: actionableSignals.length, + nextConsecutiveOnTargetTicks: nextTicks, + nextStatus, + observation: + nextStatus === 'confirmed-sustained' + ? 'Sustainment target held for four consecutive ticks.' + : 'All actionable sustainment signals are on target.', + }; +} + +export function applySustainmentTick( + record: SustainmentRecord, + snapshot: EvidenceSnapshot, + now: number = Date.now() +): { record: SustainmentRecord; review: SustainmentReview } { + const evaluation = evaluateSustainmentSnapshot(record, snapshot); + const nextRecord: SustainmentRecord = { + ...record, + status: evaluation.nextStatus, + consecutiveOnTargetTicks: evaluation.nextConsecutiveOnTargetTicks, + hasOverride: normalizedHasOverride(record), + lastEvaluatedSnapshotId: snapshot.id, + latestVerdict: evaluation.verdict, + latestReviewAt: new Date(now).toISOString(), + updatedAt: now, + }; + + const review: SustainmentReview = { + id: `${record.id}:${snapshot.id}:review`, + recordId: record.id, + investigationId: record.investigationId, + hubId: record.hubId, + reviewedAt: now, + reviewer: { displayName: 'System' }, + verdict: evaluation.verdict, + snapshotId: snapshot.id, + observation: evaluation.observation, + createdAt: now, + deletedAt: null, + }; + + return { record: nextRecord, review }; +} + /** * Return the ISO-8601 timestamp of the next review due date, computed from * a cadence and an anchor (typically the most recent review's `reviewedAt`, diff --git a/packages/ui/src/components/Inbox/InboxDigest.tsx b/packages/ui/src/components/Inbox/InboxDigest.tsx new file mode 100644 index 000000000..f359745c8 --- /dev/null +++ b/packages/ui/src/components/Inbox/InboxDigest.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { SurveyInboxPrompt } from '@variscout/core/survey'; + +export interface InboxDigestPrompt { + id: string; + message: string; + severity: SurveyInboxPrompt['severity']; + action?: SurveyInboxPrompt['action']; +} + +export interface InboxDigestProps { + prompts: InboxDigestPrompt[]; + onNavigate: (prompt: InboxDigestPrompt) => void; +} + +const severityClassName: Record = { + critical: 'border-danger/40 bg-danger/10 text-danger', + warning: 'border-warning/40 bg-warning/10 text-warning', + info: 'border-edge bg-surface-secondary text-content/70', +}; + +function promptCountLabel(count: number): string { + return `${count} ${count === 1 ? 'prompt' : 'prompts'}`; +} + +export const InboxDigest: React.FC = ({ prompts, onNavigate }) => { + if (prompts.length === 0) return null; + + return ( +
+
+

Inbox

+ + {promptCountLabel(prompts.length)} + +
+ +
    + {prompts.map(prompt => ( +
  • + + {prompt.severity} + +

    {prompt.message}

    + +
  • + ))} +
+
+ ); +}; diff --git a/packages/ui/src/components/Inbox/__tests__/InboxDigest.test.tsx b/packages/ui/src/components/Inbox/__tests__/InboxDigest.test.tsx new file mode 100644 index 000000000..7e2388139 --- /dev/null +++ b/packages/ui/src/components/Inbox/__tests__/InboxDigest.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { SurveyInboxPrompt } from '@variscout/core/survey'; +import { InboxDigest } from '../InboxDigest'; + +const prompts: SurveyInboxPrompt[] = [ + { + id: 'inbox:sustainment-drift:sustain-1', + severity: 'critical', + message: 'Sustainment drift detected for reject-rate control.', + action: { label: 'Review sustainment', opensSurface: 'sustainment', opensId: 'sustain-1' }, + sourceHint: { + kind: 'drift-detection', + severity: 'critical', + surface: 'inbox', + targetEntityId: 'sustain-1', + message: 'Sustainment drift detected for reject-rate control.', + action: { + label: 'Review sustainment', + opensSurface: 'sustainment', + opensId: 'sustain-1', + }, + }, + }, + { + id: 'inbox:sustainment-due:sustain-2', + severity: 'warning', + message: 'Weekly sustainment review is due.', + sourceHint: { + kind: 'lifecycle-gap', + severity: 'warning', + surface: 'inbox', + targetEntityId: 'sustain-2', + message: 'Weekly sustainment review is due.', + }, + }, +]; + +describe('InboxDigest', () => { + it('renders prompt count, severity, message, and navigates from CTA', () => { + const onNavigate = vi.fn(); + render(); + + expect(screen.getByText('2 prompts')).toBeInTheDocument(); + expect(screen.getByText('critical')).toBeInTheDocument(); + expect(screen.getByText('warning')).toBeInTheDocument(); + expect( + screen.getByText('Sustainment drift detected for reject-rate control.') + ).toBeInTheDocument(); + expect(screen.getByText('Weekly sustainment review is due.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Review sustainment' })); + + expect(onNavigate).toHaveBeenCalledWith(prompts[0]); + }); + + it('renders compactly when there are no prompts', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/ui/src/components/Inbox/index.ts b/packages/ui/src/components/Inbox/index.ts new file mode 100644 index 000000000..43b7f5f96 --- /dev/null +++ b/packages/ui/src/components/Inbox/index.ts @@ -0,0 +1,2 @@ +export { InboxDigest } from './InboxDigest'; +export type { InboxDigestPrompt, InboxDigestProps } from './InboxDigest'; diff --git a/packages/ui/src/components/Sustainment/SustainmentForm.tsx b/packages/ui/src/components/Sustainment/SustainmentForm.tsx new file mode 100644 index 000000000..9ab0a9e23 --- /dev/null +++ b/packages/ui/src/components/Sustainment/SustainmentForm.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import type { SustainmentCadence, SustainmentRecord, SustainmentReview } from '@variscout/core'; +import type { + ImprovementProjectFactorControl, + ImprovementProjectGoal, + ImprovementProjectMechanismGoal, +} from '@variscout/core/improvementProject'; +import { CollapsibleSection } from '../ImprovementProject/CollapsibleSection'; + +export interface SustainmentFormProps { + record: SustainmentRecord; + reviews?: SustainmentReview[]; + onRecordChange?: (patch: SustainmentRecordChangePatch) => void; +} + +export type SustainmentRecordChangePatch = Partial< + Pick +>; + +export type { SustainmentCadence }; + +const cadenceOptions: SustainmentCadence[] = [ + 'weekly', + 'biweekly', + 'monthly', + 'quarterly', + 'semiannual', + 'annual', + 'on-demand', +]; + +const labelClassName = 'block space-y-2'; +const labelTextClassName = 'text-sm font-medium text-content'; +const inputClassName = + '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'; +const disabledInputClassName = `${inputClassName} disabled:cursor-not-allowed disabled:bg-surface-secondary disabled:text-content/60`; +const metadataClassName = + 'rounded border border-edge bg-surface-secondary px-2 py-0.5 text-xs font-medium text-content/70'; + +function formatLabel(value: string | undefined): string { + return value?.replaceAll('-', ' ') ?? 'not set'; +} + +function formatDate(value: string | number | undefined): string { + if (value === undefined) return 'not set'; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'not set'; + + return date.toISOString().slice(0, 10); +} + +function renderFactorControl(control: ImprovementProjectFactorControl, index: number) { + return ( +
  • +
    {control.factor || 'Unnamed factor'}
    +
    {control.targetCondition || 'Target condition not set'}
    +
  • + ); +} + +function renderMechanismGoal(goal: ImprovementProjectMechanismGoal, index: number) { + return ( +
  • + {goal.description || 'Mechanism goal not set'} +
  • + ); +} + +function GoalCarryForward({ goal }: { goal?: ImprovementProjectGoal }) { + if (!goal) { + return

    No carried-forward goal.

    ; + } + + const outcome = goal.outcomeGoal; + const factorControls = goal.factorControls ?? []; + const mechanismGoals = goal.mechanismGoals ?? []; + + return ( +
    +
    +

    + Y-level outcome target +

    +
    +
    +
    Outcome spec
    +
    {outcome.outcomeSpecId}
    +
    +
    + {outcome.baseline !== undefined && ( + Baseline {outcome.baseline} + )} + Target {outcome.target} + {outcome.deadline && Due {outcome.deadline}} +
    +
    + {goal.freeText &&

    {goal.freeText}

    } +
    + +
    +

    + X-level factor controls +

    + {factorControls.length > 0 ? ( +
      {factorControls.map(renderFactorControl)}
    + ) : ( +

    No factor controls.

    + )} +
    + +
    +

    + x-level mechanism goals +

    + {mechanismGoals.length > 0 ? ( +
      {mechanismGoals.map(renderMechanismGoal)}
    + ) : ( +

    No mechanism goals.

    + )} +
    +
    + ); +} + +export const SustainmentForm: React.FC = ({ + record, + reviews = [], + onRecordChange, +}) => { + const ticks = Math.max(0, Math.floor(record.consecutiveOnTargetTicks ?? 0)); + const isReadOnly = !onRecordChange; + + return ( +
    + +
    + + + + +