diff --git a/apps/azure/src/components/editor/FrameView.tsx b/apps/azure/src/components/editor/FrameView.tsx index f1ec04675..89acd6fe4 100644 --- a/apps/azure/src/components/editor/FrameView.tsx +++ b/apps/azure/src/components/editor/FrameView.tsx @@ -5,7 +5,12 @@ * only reads app/store state and wires the app-specific Analysis navigation. */ import React from 'react'; -import { CanvasWorkspace, type ContextLinkGroup, type ContextLinkItem } from '@variscout/ui'; +import { + CanvasWorkspace, + type ContextLinkGroup, + type ContextLinkItem, + type LogActionPayload, +} from '@variscout/ui'; import { useImprovementProjectStore, useInvestigationStore, @@ -18,11 +23,23 @@ import type { StepCapabilityStamp, WorkflowReadinessSignals, } from '@variscout/core'; +import { createActionItem, type ActionItem } from '@variscout/core/findings'; 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[] = []; + +function mergeActionItems( + current: readonly ActionItem[], + next: readonly ActionItem[] +): ActionItem[] { + const byId = new Map(); + for (const item of current) byId.set(item.id, item); + for (const item of next) byId.set(item.id, item); + return Array.from(byId.values()); +} function priorStepStatsFromSnapshots( snapshots: readonly EvidenceSnapshot[] @@ -54,6 +71,12 @@ const FrameView: React.FC = () => { const projectsByHub = useImprovementProjectStore(s => s.projectsByHub); const [priorStepStats, setPriorStepStats] = React.useState>(EMPTY_PRIOR_STEP_STATS); + const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); + const activeHubIdRef = React.useRef(activeHubId); + + React.useEffect(() => { + activeHubIdRef.current = activeHubId; + }, [activeHubId]); React.useEffect(() => { if (!activeHubId) { @@ -76,6 +99,28 @@ const FrameView: React.FC = () => { }; }, [activeHubId]); + React.useEffect(() => { + setActionItems(EMPTY_ACTION_ITEMS); + + if (!activeHubId) { + return; + } + + let cancelled = false; + void (async () => { + try { + const items = await azureHubRepository.actionItems.listByHub(activeHubId); + if (!cancelled) setActionItems(items); + } catch { + // Keep any in-memory quick actions if the local repository is unavailable. + } + })(); + + return () => { + cancelled = true; + }; + }, [activeHubId]); + const signals: WorkflowReadinessSignals = React.useMemo( () => ({ hasIntervention: false, sustainmentConfirmed: false }), [] @@ -113,9 +158,37 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showAnalysis(); }, []); - const handleQuickAction = React.useCallback(() => { - usePanelsStore.getState().showImprovement(); - }, []); + const handleLogQuickAction = React.useCallback( + (stepId: string, payload: LogActionPayload) => { + if (!activeHubId) return; + const actionItem = createActionItem(payload.text, { + stepId, + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: payload.status === 'open' ? payload.assignedTo : null, + dueAt: payload.status === 'open' ? (payload.dueAt ?? null) : null, + status: payload.status, + doneAt: payload.status === 'done' ? new Date().toISOString() : null, + doneBy: null, + createdBy: { displayName: 'Local browser' }, + }); + setActionItems(current => mergeActionItems(current, [actionItem])); + void (async () => { + try { + await azureHubRepository.dispatch({ + kind: 'ACTION_ITEM_ADD', + hubId: activeHubId, + actionItem, + }); + const items = await azureHubRepository.actionItems.listByHub(activeHubId); + if (activeHubIdRef.current === activeHubId) setActionItems(items); + } catch { + // Keep the local quick action visible even when persistence is unavailable. + } + })(); + }, + [activeHubId] + ); const handleFocusedInvestigation = React.useCallback(() => { usePanelsStore.getState().showInvestigation(); @@ -199,7 +272,7 @@ const FrameView: React.FC = () => { processContext={processContext} setProcessContext={setProcessContext} onSeeData={handleSeeData} - onQuickAction={handleQuickAction} + onLogQuickAction={handleLogQuickAction} onFocusedInvestigation={handleFocusedInvestigation} findings={findings} questions={questions} @@ -216,6 +289,7 @@ const FrameView: React.FC = () => { contextLinkGroups={contextLinkGroups} onNavigateContextLink={handleNavigateContextLink} priorStepStats={priorStepStats} + actionItems={actionItems} /> ); }; diff --git a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx index 7828369f3..5395c9bd4 100644 --- a/apps/azure/src/components/editor/__tests__/FrameView.test.tsx +++ b/apps/azure/src/components/editor/__tests__/FrameView.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowReadinessSignals } from '@variscout/core'; @@ -55,8 +55,32 @@ const improvementProjectStateRef: { current: Record } = { const hoisted = vi.hoisted(() => ({ canvasWorkspaceMock: vi.fn(), listByHubMock: vi.fn(), + actionItemsListByHubMock: vi.fn(), + dispatchMock: vi.fn(), })); +function actionItem(id: string, text: string, stepId = 'step-1') { + return { + id, + text, + stepId, + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + createdAt: 1, + deletedAt: null, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + vi.mock('@variscout/stores', () => ({ useProjectStore: vi.fn((selector: (s: unknown) => unknown) => selector(storeStateRef.current)), useInvestigationStore: Object.assign( @@ -85,6 +109,10 @@ vi.mock('@variscout/ui', async () => { signals: WorkflowReadinessSignals; onSeeData: () => void; onQuickAction?: (stepId: string) => void; + onLogQuickAction?: ( + stepId: string, + payload: { text: string; status: 'open' | 'done'; assignedTo?: unknown; dueAt?: string } + ) => void; onFocusedInvestigation?: (stepId: string) => void; onOpenWall?: () => void; onOpenInvestigationFocus?: (focus: { questionId?: string }) => void; @@ -99,6 +127,7 @@ vi.mock('@variscout/ui', async () => { onSustainment?: () => void; onHandoff?: () => void; priorStepStats?: ReadonlyMap; + actionItems?: unknown[]; }) => { hoisted.canvasWorkspaceMock(props); return React.createElement( @@ -114,7 +143,11 @@ vi.mock('@variscout/ui', async () => { { type: 'button', 'data-testid': 'quick-action', - onClick: () => props.onQuickAction?.('step-1'), + onClick: () => + props.onLogQuickAction?.('step-1', { + text: 'Refill buffer tank', + status: 'done', + }), }, 'Quick action' ), @@ -189,9 +222,13 @@ vi.mock('../../../features/investigation/investigationStore', () => ({ vi.mock('../../../persistence', () => ({ azureHubRepository: { + dispatch: hoisted.dispatchMock, evidenceSnapshots: { listByHub: hoisted.listByHubMock, }, + actionItems: { + listByHub: hoisted.actionItemsListByHubMock, + }, }, })); @@ -215,6 +252,10 @@ describe('FrameView (Azure shell)', () => { addCausalLinkMock.mockReturnValue({ id: 'link-created' }); hoisted.listByHubMock.mockReset(); hoisted.listByHubMock.mockResolvedValue([]); + hoisted.actionItemsListByHubMock.mockReset(); + hoisted.actionItemsListByHubMock.mockResolvedValue([]); + hoisted.dispatchMock.mockReset(); + hoisted.dispatchMock.mockResolvedValue(undefined); improvementProjectStateRef.current = { projectsByHub: {}, getProjectsForHub: () => [], @@ -307,6 +348,69 @@ describe('FrameView (Azure shell)', () => { }); }); + it('passes action items read for the active process hub to CanvasWorkspace', async () => { + const actionItems = [actionItem('action-1', 'Check oven gasket seating')]; + hoisted.actionItemsListByHubMock.mockResolvedValue(actionItems); + + render(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-1')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual(actionItems); + }); + }); + + it('replaces previous hub action items when switching hubs and the next read is empty', async () => { + hoisted.actionItemsListByHubMock + .mockResolvedValueOnce([actionItem('hub-1-action', 'Hub 1 action')]) + .mockResolvedValueOnce([]); + + const { rerender } = render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([expect.objectContaining({ text: 'Hub 1 action' })]); + }); + + storeStateRef.current = { + ...storeStateRef.current, + processContext: { currentUnderstanding: 'fill line', processHubId: 'hub-2' }, + }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + + it('clears previous hub action items when switching hubs and the next read fails', async () => { + hoisted.actionItemsListByHubMock + .mockResolvedValueOnce([actionItem('hub-1-action', 'Hub 1 action')]) + .mockRejectedValueOnce(new Error('hub 2 unavailable')); + + const { rerender } = render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([expect.objectContaining({ text: 'Hub 1 action' })]); + }); + + storeStateRef.current = { + ...storeStateRef.current, + processContext: { currentUnderstanding: 'fill line', processHubId: 'hub-2' }, + }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + it('does not read snapshots when there is no active process hub', () => { storeStateRef.current = { ...storeStateRef.current, @@ -316,6 +420,7 @@ describe('FrameView (Azure shell)', () => { render(); expect(hoisted.listByHubMock).not.toHaveBeenCalled(); + expect(hoisted.actionItemsListByHubMock).not.toHaveBeenCalled(); const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; expect(props?.priorStepStats?.size).toBe(0); }); @@ -328,13 +433,109 @@ describe('FrameView (Azure shell)', () => { expect(showAnalysisMock).toHaveBeenCalledTimes(1); }); - it('wires Canvas response paths to the Azure workflow panels', () => { + it('dispatches ACTION_ITEM_ADD when Canvas logs a quick action', async () => { render(); fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => expect(hoisted.dispatchMock).toHaveBeenCalledTimes(1)); + expect(hoisted.dispatchMock).toHaveBeenCalledWith({ + kind: 'ACTION_ITEM_ADD', + hubId: 'hub-1', + actionItem: expect.objectContaining({ + id: expect.any(String), + text: 'Refill buffer tank', + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'done', + assignedTo: null, + dueAt: null, + doneAt: expect.any(String), + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: expect.any(Number), + deletedAt: null, + }), + }); + expect(showImprovementMock).not.toHaveBeenCalled(); + }); + + it('shows quick actions immediately when repository refresh is unavailable', async () => { + hoisted.actionItemsListByHubMock.mockRejectedValue(new Error('refresh unavailable')); + hoisted.dispatchMock.mockRejectedValue(new Error('refresh unavailable')); + + render(); + + fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([ + expect.objectContaining({ + text: 'Refill buffer tank', + stepId: 'step-1', + status: 'done', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + }), + ]); + }); + expect(hoisted.dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('does not apply a post-dispatch refresh after switching to another hub', async () => { + const refresh = deferred(); + let hubOneReadCount = 0; + hoisted.actionItemsListByHubMock.mockImplementation((hubId: string) => { + if (hubId === 'hub-1') { + hubOneReadCount += 1; + return hubOneReadCount === 1 ? Promise.resolve([]) : refresh.promise; + } + return Promise.resolve([]); + }); + + const { rerender } = render(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-1')); + + fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => expect(hubOneReadCount).toBe(2)); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([ + expect.objectContaining({ + text: 'Refill buffer tank', + stepId: 'step-1', + }), + ]); + }); + + storeStateRef.current = { + ...storeStateRef.current, + processContext: { currentUnderstanding: 'fill line', processHubId: 'hub-2' }, + }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await act(async () => { + refresh.resolve([actionItem('hub-1-refresh', 'Hub 1 refresh action')]); + await refresh.promise; + await Promise.resolve(); + }); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + + it('wires Canvas focused investigation response path to the Azure Investigation panel', () => { + render(); + fireEvent.click(screen.getByTestId('focused-investigation')); - expect(showImprovementMock).toHaveBeenCalledTimes(1); expect(showInvestigationMock).toHaveBeenCalledTimes(1); }); diff --git a/apps/azure/src/db/schema.ts b/apps/azure/src/db/schema.ts index fdb5d8314..777fba05e 100644 --- a/apps/azure/src/db/schema.ts +++ b/apps/azure/src/db/schema.ts @@ -37,6 +37,9 @@ export type EvidenceSourceRecord = import('@variscout/core').EvidenceSource; export type EvidenceSnapshotRecord = import('@variscout/core').EvidenceSnapshot; export type ImprovementProjectRecord = import('@variscout/core/improvementProject').ImprovementProject; +export type ActionItemRecord = import('@variscout/core/findings').ActionItem & { + hubId: import('@variscout/core').ProcessHub['id']; +}; export class VariScoutDatabase extends Dexie { projects!: Dexie.Table; @@ -50,6 +53,7 @@ export class VariScoutDatabase extends Dexie { controlHandoffs!: Dexie.Table; evidenceSourceCursors!: Dexie.Table; improvementProjects!: Dexie.Table; + actionItems!: Dexie.Table; constructor() { super('VaRiScoutAzure'); @@ -144,6 +148,12 @@ export class VariScoutDatabase extends Dexie { this.version(10).stores({ improvementProjects: 'id, hubId, deletedAt, status, updatedAt', }); + + // Version 11: PR-RPS-8 — ActionItem dedicated table for Quick Action audit trail. + this.version(11).stores({ + actionItems: + 'id, hubId, stepId, parentImprovementProjectId, parentImprovementIdeaId, status, deletedAt, createdAt', + }); } } diff --git a/apps/azure/src/persistence/AzureHubRepository.ts b/apps/azure/src/persistence/AzureHubRepository.ts index 339a55e49..9c68ddb30 100644 --- a/apps/azure/src/persistence/AzureHubRepository.ts +++ b/apps/azure/src/persistence/AzureHubRepository.ts @@ -19,8 +19,10 @@ import type { CausalLinkReadAPI, HypothesisReadAPI, CanvasStateReadAPI, + ActionItemReadAPI, } from '@variscout/core/persistence'; import type { HubAction } from '@variscout/core/actions'; +import type { ActionItem } from '@variscout/core/findings'; import { db } from '../db/schema'; import { saveProcessHubToIndexedDB } from '../services/localDb'; import { applyAction } from './applyAction'; @@ -212,6 +214,30 @@ export class AzureHubRepository implements HubRepository { return []; }, }; + + actionItems: ActionItemReadAPI = { + async get(id) { + const row = await db.actionItems.get(id); + if (!row || row.deletedAt !== null) return undefined; + return stripActionItemHubId(row); + }, + async listByHub(hubId) { + const rows = await db.actionItems.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null).map(stripActionItemHubId); + }, + async listByStep(hubId, stepId) { + const rows = await db.actionItems.where('hubId').equals(hubId).toArray(); + return rows + .filter(row => row.deletedAt === null && row.stepId === stepId) + .map(stripActionItemHubId); + }, + }; +} + +function stripActionItemHubId(row: { hubId: string } & ActionItem): ActionItem { + const { hubId: _hubId, ...actionItem } = row; + void _hubId; + return actionItem; } // Module-scoped singleton. Composition root + dispatch boundary documented in apps/azure/CLAUDE.md. diff --git a/apps/azure/src/persistence/__tests__/applyAction.test.ts b/apps/azure/src/persistence/__tests__/applyAction.test.ts index 258fd0d25..0de7d67c5 100644 --- a/apps/azure/src/persistence/__tests__/applyAction.test.ts +++ b/apps/azure/src/persistence/__tests__/applyAction.test.ts @@ -119,6 +119,7 @@ beforeEach(async () => { await db.evidenceSources.clear(); await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); + await db.actionItems.clear(); }); afterEach(async () => { @@ -126,6 +127,7 @@ afterEach(async () => { await db.evidenceSources.clear(); await db.evidenceSnapshots.clear(); await db.evidenceSourceCursors.clear(); + await db.actionItems.clear(); vi.restoreAllMocks(); }); @@ -214,6 +216,44 @@ describe('applyAction — OUTCOME_ADD', () => { }); }); +describe('applyAction — ACTION_ITEM_ADD', () => { + it('writes an orphan action item row tagged with hubId and stepId', async () => { + await db.processHubs.put(makeHub('hub-action')); + + await applyAction({ + kind: 'ACTION_ITEM_ADD', + hubId: 'hub-action', + actionItem: { + id: 'action-1', + text: 'Refill buffer tank', + stepId: 'step-fill', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'done', + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: NOW, + deletedAt: null, + }, + }); + + const rows = await db.actionItems.where('hubId').equals('hub-action').toArray(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: 'action-1', + hubId: 'hub-action', + stepId: 'step-fill', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'done', + deletedAt: null, + }); + }); +}); + describe('applyAction — OUTCOME_UPDATE', () => { it('patches the outcome by outcomeId', async () => { const outcome = makeOutcome('outcome-patch', 'hub-4'); @@ -691,6 +731,25 @@ describe('exhaustiveness — every HubAction kind has a handler', () => { { kind: 'OUTCOME_ADD', hubId, outcome: makeOutcome('o-new', hubId) }, { kind: 'OUTCOME_UPDATE', outcomeId: 'o-exhaust', patch: { columnName: 'x' } }, { kind: 'OUTCOME_ARCHIVE', outcomeId: 'o-exhaust' }, + { + kind: 'ACTION_ITEM_ADD', + hubId, + actionItem: { + id: 'action-exhaust', + text: 'Refill buffer tank', + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'done', + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: NOW, + deletedAt: null, + }, + }, { kind: 'EVIDENCE_ADD_SNAPSHOT', hubId, diff --git a/apps/azure/src/persistence/applyAction.ts b/apps/azure/src/persistence/applyAction.ts index 3a2d9d729..04b7a6f64 100644 --- a/apps/azure/src/persistence/applyAction.ts +++ b/apps/azure/src/persistence/applyAction.ts @@ -309,6 +309,15 @@ export async function applyAction(action: HubAction): Promise { return; } + case 'ACTION_ITEM_ADD': { + const hub = await db.processHubs.get(action.hubId); + if (!hub) { + throw new Error(`ACTION_ITEM_ADD: parent hub ${action.hubId} does not exist`); + } + await db.actionItems.add({ ...action.actionItem, hubId: action.hubId }); + return; + } + // ------------------------------------------------------------------------- // Session-only — Azure has no dedicated Dexie table today; F3 normalizes. // ------------------------------------------------------------------------- diff --git a/apps/pwa/e2e/quick-action-recent-activity.spec.ts b/apps/pwa/e2e/quick-action-recent-activity.spec.ts new file mode 100644 index 000000000..5035c0fae --- /dev/null +++ b/apps/pwa/e2e/quick-action-recent-activity.spec.ts @@ -0,0 +1,84 @@ +import { expect, test } from '@playwright/test'; + +const CANVAS_CSV = [ + 'weight_g,bake_time,machine,timestamp', + '4.5,30,A,2026-05-01T08:00:00.000Z', + '4.4,31,A,2026-05-01T08:01:00.000Z', + '4.6,29,B,2026-05-01T08:02:00.000Z', + '4.5,32,B,2026-05-01T08:03:00.000Z', + '4.4,28,A,2026-05-01T08:04:00.000Z', +].join('\n'); + +async function openPasteScreen(page: import('@playwright/test').Page) { + await page.goto('/'); + await expect(page.getByTestId('home-paste-button')).toBeVisible({ timeout: 10000 }); + await page.getByTestId('home-paste-button').click(); + await expect(page.getByTestId('paste-textarea')).toBeVisible({ timeout: 5000 }); +} + +async function completeModeBToCanvas(page: import('@playwright/test').Page) { + await openPasteScreen(page); + await page.getByTestId('paste-textarea').fill(CANVAS_CSV); + await page.getByTestId('paste-start-analysis').click(); + + await expect(page.getByTestId('hub-goal-form')).toBeVisible({ timeout: 8000 }); + await page.getByRole('textbox', { name: /process goal/i }).fill('Reduce weight variation.'); + await page.getByRole('button', { name: /Continue/i }).click(); + + await expect(page.getByTestId('map-your-data-heading')).toBeVisible({ timeout: 8000 }); + const weightCheckbox = page + .getByTestId('outcome-candidate-list') + .locator('input[type="checkbox"][aria-label="weight_g"]'); + if (!(await weightCheckbox.isChecked().catch(() => false))) { + await weightCheckbox.click(); + } + await page.locator('button:has-text("Start Analysis")').first().click(); + + await expect(page.getByTestId('stage-five-modal')).toBeVisible({ timeout: 10000 }); + await page.getByTestId('stage-five-skip').click(); + await expect(page.getByTestId('stage-five-modal')).toBeHidden({ timeout: 5000 }); + + await expect(page.getByTestId('phase-tab-frame')).toBeVisible({ timeout: 10000 }); + await page.getByTestId('phase-tab-frame').click(); + await expect(page.getByTestId('process-steps-expander-header')).toBeVisible({ timeout: 10000 }); + await page.getByTestId('process-steps-expander-header').click(); + await expect(page.getByTestId('chip-rail')).toBeVisible({ timeout: 5000 }); +} + +test.describe('Quick action recent activity (PWA)', () => { + test('logs a card Quick Action and shows it in Recent activity', async ({ page }) => { + const actionText = 'Check oven gasket seating'; + + await completeModeBToCanvas(page); + + await page + .getByRole('button', { name: /add step/i }) + .first() + .click(); + const processStep = page.locator('[data-testid^="process-map-step-"]').first(); + await expect(processStep).toBeVisible({ timeout: 5000 }); + + const chip = page.getByTestId('chip-rail-item-bake_time'); + await expect(chip).toBeVisible({ timeout: 5000 }); + await chip.focus(); + await page.keyboard.press('Enter'); + await processStep.focus(); + await page.keyboard.press('Enter'); + + const card = page.locator('[data-testid^="canvas-step-card-"]').first(); + await expect(card).toBeVisible({ timeout: 5000 }); + await card.click(); + + await expect(page.getByTestId('canvas-step-overlay')).toBeVisible({ timeout: 5000 }); + await page.getByTestId('canvas-cta-quick-action').click(); + + const logActionDialog = page.getByRole('dialog', { name: /Log action/i }); + await expect(logActionDialog).toBeVisible({ timeout: 5000 }); + await logActionDialog.getByLabel('What').fill(actionText); + await logActionDialog.getByRole('button', { name: 'Log action' }).click(); + + await expect(page.getByRole('dialog', { name: /Log action/i })).toBeHidden({ timeout: 5000 }); + await page.getByText('Recent activity').click(); + await expect(page.getByText(actionText)).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/apps/pwa/src/components/views/FrameView.tsx b/apps/pwa/src/components/views/FrameView.tsx index 9b32ad8f6..1b1687765 100644 --- a/apps/pwa/src/components/views/FrameView.tsx +++ b/apps/pwa/src/components/views/FrameView.tsx @@ -5,7 +5,12 @@ * reads app/store state and wires the app-specific Analysis navigation. */ import React from 'react'; -import { CanvasWorkspace, type ContextLinkGroup, type ContextLinkItem } from '@variscout/ui'; +import { + CanvasWorkspace, + type ContextLinkGroup, + type ContextLinkItem, + type LogActionPayload, +} from '@variscout/ui'; import { useImprovementProjectStore, useInvestigationStore, @@ -18,12 +23,24 @@ import type { StepCapabilityStamp, WorkflowReadinessSignals, } from '@variscout/core'; +import { createActionItem, type ActionItem } from '@variscout/core/findings'; import { pwaHubRepository } from '../../persistence'; import { useSession } from '../../store/sessionStore'; 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[] = []; + +function mergeActionItems( + current: readonly ActionItem[], + next: readonly ActionItem[] +): ActionItem[] { + const byId = new Map(); + for (const item of current) byId.set(item.id, item); + for (const item of next) byId.set(item.id, item); + return Array.from(byId.values()); +} function priorStepStatsFromSnapshots( snapshots: readonly EvidenceSnapshot[] @@ -56,6 +73,12 @@ const FrameView: React.FC = () => { const projectsByHub = useImprovementProjectStore(s => s.projectsByHub); const [priorStepStats, setPriorStepStats] = React.useState>(EMPTY_PRIOR_STEP_STATS); + const [actionItems, setActionItems] = React.useState(EMPTY_ACTION_ITEMS); + const activeHubIdRef = React.useRef(activeHubId); + + React.useEffect(() => { + activeHubIdRef.current = activeHubId; + }, [activeHubId]); React.useEffect(() => { if (!activeHubId) { @@ -78,6 +101,28 @@ const FrameView: React.FC = () => { }; }, [activeHubId]); + React.useEffect(() => { + setActionItems(EMPTY_ACTION_ITEMS); + + if (!activeHubId) { + return; + } + + let cancelled = false; + void (async () => { + try { + const items = await pwaHubRepository.actionItems.listByHub(activeHubId); + if (!cancelled) setActionItems(items); + } catch { + // Session-only hubs may not exist in IndexedDB; keep any in-memory quick actions. + } + })(); + + return () => { + cancelled = true; + }; + }, [activeHubId]); + const signals: WorkflowReadinessSignals = React.useMemo( () => ({ hasIntervention: false, sustainmentConfirmed: false }), [] @@ -115,9 +160,37 @@ const FrameView: React.FC = () => { usePanelsStore.getState().showAnalysis(); }, []); - const handleQuickAction = React.useCallback(() => { - usePanelsStore.getState().showImprovement(); - }, []); + const handleLogQuickAction = React.useCallback( + (stepId: string, payload: LogActionPayload) => { + if (!activeHubId) return; + const actionItem = createActionItem(payload.text, { + stepId, + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: payload.status === 'open' ? payload.assignedTo : null, + dueAt: payload.status === 'open' ? (payload.dueAt ?? null) : null, + status: payload.status, + doneAt: payload.status === 'done' ? new Date().toISOString() : null, + doneBy: null, + createdBy: { displayName: 'Local browser' }, + }); + setActionItems(current => mergeActionItems(current, [actionItem])); + void (async () => { + try { + await pwaHubRepository.dispatch({ + kind: 'ACTION_ITEM_ADD', + hubId: activeHubId, + actionItem, + }); + const items = await pwaHubRepository.actionItems.listByHub(activeHubId); + if (activeHubIdRef.current === activeHubId) setActionItems(items); + } catch { + // Session-only quick actions remain visible even when persistence is unavailable. + } + })(); + }, + [activeHubId] + ); const handleFocusedInvestigation = React.useCallback(() => { usePanelsStore.getState().showInvestigation(); @@ -198,7 +271,7 @@ const FrameView: React.FC = () => { processContext={processContext} setProcessContext={setProcessContext} onSeeData={handleSeeData} - onQuickAction={handleQuickAction} + onLogQuickAction={handleLogQuickAction} onFocusedInvestigation={handleFocusedInvestigation} findings={findings} questions={questions} @@ -215,6 +288,7 @@ const FrameView: React.FC = () => { contextLinkGroups={contextLinkGroups} onNavigateContextLink={handleNavigateContextLink} priorStepStats={priorStepStats} + actionItems={actionItems} /> ); }; diff --git a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx index 6f1674741..6f9e376b2 100644 --- a/apps/pwa/src/components/views/__tests__/FrameView.test.tsx +++ b/apps/pwa/src/components/views/__tests__/FrameView.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkflowReadinessSignals } from '@variscout/core'; @@ -54,9 +54,33 @@ const improvementProjectStateRef: { current: Record } = { const hoisted = vi.hoisted(() => ({ canvasWorkspaceMock: vi.fn(), listByHubMock: vi.fn(), + actionItemsListByHubMock: vi.fn(), + dispatchMock: vi.fn(), sessionStateRef: { current: { hub: { id: 'hub-1' } as { id: string } | null } }, })); +function actionItem(id: string, text: string, stepId = 'step-1') { + return { + id, + text, + stepId, + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + createdAt: 1, + deletedAt: null, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + vi.mock('@variscout/stores', () => ({ useProjectStore: vi.fn((selector: (s: unknown) => unknown) => selector(storeStateRef.current)), useInvestigationStore: Object.assign( @@ -85,6 +109,10 @@ vi.mock('@variscout/ui', async () => { signals: WorkflowReadinessSignals; onSeeData: () => void; onQuickAction?: (stepId: string) => void; + onLogQuickAction?: ( + stepId: string, + payload: { text: string; status: 'open' | 'done'; assignedTo?: unknown; dueAt?: string } + ) => void; onFocusedInvestigation?: (stepId: string) => void; onOpenWall?: () => void; onOpenInvestigationFocus?: (focus: { questionId?: string }) => void; @@ -99,6 +127,7 @@ vi.mock('@variscout/ui', async () => { onSustainment?: () => void; onHandoff?: () => void; priorStepStats?: ReadonlyMap; + actionItems?: unknown[]; }) => { hoisted.canvasWorkspaceMock(props); return React.createElement( @@ -114,7 +143,11 @@ vi.mock('@variscout/ui', async () => { { type: 'button', 'data-testid': 'quick-action', - onClick: () => props.onQuickAction?.('step-1'), + onClick: () => + props.onLogQuickAction?.('step-1', { + text: 'Refill buffer tank', + status: 'done', + }), }, 'Quick action' ), @@ -188,9 +221,13 @@ vi.mock('../../../features/investigation/investigationStore', () => ({ vi.mock('../../../persistence', () => ({ pwaHubRepository: { + dispatch: hoisted.dispatchMock, evidenceSnapshots: { listByHub: hoisted.listByHubMock, }, + actionItems: { + listByHub: hoisted.actionItemsListByHubMock, + }, }, })); @@ -217,6 +254,10 @@ describe('FrameView (PWA shell)', () => { addCausalLinkMock.mockReturnValue({ id: 'link-created' }); hoisted.listByHubMock.mockReset(); hoisted.listByHubMock.mockResolvedValue([]); + hoisted.actionItemsListByHubMock.mockReset(); + hoisted.actionItemsListByHubMock.mockResolvedValue([]); + hoisted.dispatchMock.mockReset(); + hoisted.dispatchMock.mockResolvedValue(undefined); hoisted.sessionStateRef.current = { hub: { id: 'hub-1' } }; improvementProjectStateRef.current = { projectsByHub: {}, @@ -310,12 +351,70 @@ describe('FrameView (PWA shell)', () => { }); }); + it('passes action items read for the active session hub to CanvasWorkspace', async () => { + const actionItems = [actionItem('action-1', 'Check oven gasket seating')]; + hoisted.actionItemsListByHubMock.mockResolvedValue(actionItems); + + render(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-1')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual(actionItems); + }); + }); + + it('replaces previous hub action items when switching hubs and the next read is empty', async () => { + hoisted.actionItemsListByHubMock + .mockResolvedValueOnce([actionItem('hub-1-action', 'Hub 1 action')]) + .mockResolvedValueOnce([]); + + const { rerender } = render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([expect.objectContaining({ text: 'Hub 1 action' })]); + }); + + hoisted.sessionStateRef.current = { hub: { id: 'hub-2' } }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + + it('clears previous hub action items when switching hubs and the next read fails', async () => { + hoisted.actionItemsListByHubMock + .mockResolvedValueOnce([actionItem('hub-1-action', 'Hub 1 action')]) + .mockRejectedValueOnce(new Error('hub 2 unavailable')); + + const { rerender } = render(); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([expect.objectContaining({ text: 'Hub 1 action' })]); + }); + + hoisted.sessionStateRef.current = { hub: { id: 'hub-2' } }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + it('does not read snapshots when there is no active session hub', () => { hoisted.sessionStateRef.current = { hub: null }; render(); expect(hoisted.listByHubMock).not.toHaveBeenCalled(); + expect(hoisted.actionItemsListByHubMock).not.toHaveBeenCalled(); const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; expect(props?.priorStepStats?.size).toBe(0); }); @@ -328,13 +427,106 @@ describe('FrameView (PWA shell)', () => { expect(showAnalysisMock).toHaveBeenCalledTimes(1); }); - it('wires Canvas response paths to the PWA workflow panels', () => { + it('dispatches ACTION_ITEM_ADD when Canvas logs a quick action', async () => { render(); fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => expect(hoisted.dispatchMock).toHaveBeenCalledTimes(1)); + expect(hoisted.dispatchMock).toHaveBeenCalledWith({ + kind: 'ACTION_ITEM_ADD', + hubId: 'hub-1', + actionItem: expect.objectContaining({ + id: expect.any(String), + text: 'Refill buffer tank', + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'done', + assignedTo: null, + dueAt: null, + doneAt: expect.any(String), + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: expect.any(Number), + deletedAt: null, + }), + }); + expect(showImprovementMock).not.toHaveBeenCalled(); + }); + + it('shows session-only quick actions immediately when repository persistence is unavailable', async () => { + hoisted.actionItemsListByHubMock.mockRejectedValue(new Error('hub not persisted')); + hoisted.dispatchMock.mockRejectedValue(new Error('hub not persisted')); + + render(); + + fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([ + expect.objectContaining({ + text: 'Refill buffer tank', + stepId: 'step-1', + status: 'done', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + }), + ]); + }); + expect(hoisted.dispatchMock).toHaveBeenCalledTimes(1); + }); + + it('does not apply a post-dispatch refresh after switching to another hub', async () => { + const refresh = deferred(); + let hubOneReadCount = 0; + hoisted.actionItemsListByHubMock.mockImplementation((hubId: string) => { + if (hubId === 'hub-1') { + hubOneReadCount += 1; + return hubOneReadCount === 1 ? Promise.resolve([]) : refresh.promise; + } + return Promise.resolve([]); + }); + + const { rerender } = render(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-1')); + + fireEvent.click(screen.getByTestId('quick-action')); + + await waitFor(() => expect(hubOneReadCount).toBe(2)); + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([ + expect.objectContaining({ + text: 'Refill buffer tank', + stepId: 'step-1', + }), + ]); + }); + + hoisted.sessionStateRef.current = { hub: { id: 'hub-2' } }; + rerender(); + + await waitFor(() => expect(hoisted.actionItemsListByHubMock).toHaveBeenCalledWith('hub-2')); + await act(async () => { + refresh.resolve([actionItem('hub-1-refresh', 'Hub 1 refresh action')]); + await refresh.promise; + await Promise.resolve(); + }); + + await waitFor(() => { + const props = hoisted.canvasWorkspaceMock.mock.lastCall?.[0]; + expect(props?.actionItems).toEqual([]); + }); + }); + + it('wires Canvas focused investigation response path to the PWA Investigation panel', () => { + render(); + fireEvent.click(screen.getByTestId('focused-investigation')); - expect(showImprovementMock).toHaveBeenCalledTimes(1); expect(showInvestigationMock).toHaveBeenCalledTimes(1); }); diff --git a/apps/pwa/src/db/schema.ts b/apps/pwa/src/db/schema.ts index 36103eb2c..20f4751a2 100644 --- a/apps/pwa/src/db/schema.ts +++ b/apps/pwa/src/db/schema.ts @@ -35,7 +35,13 @@ import type { EvidenceSourceCursor, RowProvenanceTag, } from '@variscout/core'; -import type { Finding, Question, CausalLink, Hypothesis } from '@variscout/core/findings'; +import type { + Finding, + Question, + CausalLink, + Hypothesis, + ActionItem, +} from '@variscout/core/findings'; import type { ImprovementProject } from '@variscout/core/improvementProject'; import type { ProcessMap } from '@variscout/core/frame'; @@ -80,6 +86,7 @@ export type QuestionRow = Question; export type CausalLinkRow = CausalLink; export type HypothesisRow = Hypothesis; export type ImprovementProjectRow = ImprovementProject; +export type ActionItemRow = ActionItem & { hubId: ProcessHub['id'] }; // --------------------------------------------------------------------------- // Database @@ -98,6 +105,7 @@ export class PwaDatabase extends Dexie { causalLinks!: Table; hypotheses!: Table; improvementProjects!: Table; + actionItems!: Table; canvasState!: Table; meta!: Table; @@ -119,6 +127,10 @@ export class PwaDatabase extends Dexie { canvasState: '&hubId', meta: '&key', }); + this.version(2).stores({ + actionItems: + '&id, hubId, stepId, parentImprovementProjectId, parentImprovementIdeaId, status, deletedAt, createdAt', + }); } } diff --git a/apps/pwa/src/persistence/PwaHubRepository.ts b/apps/pwa/src/persistence/PwaHubRepository.ts index 0a17e4bb1..a154470e5 100644 --- a/apps/pwa/src/persistence/PwaHubRepository.ts +++ b/apps/pwa/src/persistence/PwaHubRepository.ts @@ -43,10 +43,12 @@ import type { CausalLinkReadAPI, HypothesisReadAPI, CanvasStateReadAPI, + ActionItemReadAPI, } from '@variscout/core/persistence'; import type { HubAction } from '@variscout/core/actions'; import type { ProcessHub } from '@variscout/core/processHub'; import type { ProcessMap } from '@variscout/core/frame'; +import type { ActionItem } from '@variscout/core/findings'; import { db, type HubRow } from '../db/schema'; import { applyAction } from './applyAction'; @@ -261,6 +263,24 @@ export class PwaHubRepository implements HubRepository { return rows.filter(r => r.deletedAt === null); }, }; + + actionItems: ActionItemReadAPI = { + get: async id => { + const row = await db.actionItems.get(id); + if (!row || row.deletedAt !== null) return undefined; + return stripActionItemHubId(row); + }, + listByHub: async hubId => { + const rows = await db.actionItems.where('hubId').equals(hubId).toArray(); + return rows.filter(row => row.deletedAt === null).map(stripActionItemHubId); + }, + listByStep: async (hubId, stepId) => { + const rows = await db.actionItems.where('hubId').equals(hubId).toArray(); + return rows + .filter(row => row.deletedAt === null && row.stepId === stepId) + .map(stripActionItemHubId); + }, + }; } // --------------------------------------------------------------------------- @@ -276,6 +296,12 @@ function stripHubId(row: { hubId: string } & ProcessMap): ProcessMap { return processMap; } +function stripActionItemHubId(row: { hubId: string } & ActionItem): ActionItem { + const { hubId: _hubId, ...actionItem } = row; + void _hubId; + return actionItem; +} + // 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__/applyAction.test.ts b/apps/pwa/src/persistence/__tests__/applyAction.test.ts index bd1991bbc..7ecd9d60d 100644 --- a/apps/pwa/src/persistence/__tests__/applyAction.test.ts +++ b/apps/pwa/src/persistence/__tests__/applyAction.test.ts @@ -64,12 +64,14 @@ beforeEach(async () => { await db.hubs.clear(); await db.outcomes.clear(); await db.canvasState.clear(); + await db.actionItems.clear(); }); afterEach(async () => { await db.hubs.clear(); await db.outcomes.clear(); await db.canvasState.clear(); + await db.actionItems.clear(); vi.restoreAllMocks(); }); @@ -272,6 +274,44 @@ describe('applyAction — HUB_UPDATE_GOAL', () => { }); }); +describe('applyAction — ACTION_ITEM_ADD', () => { + it('writes an orphan action item row tagged with hubId and stepId', async () => { + await applyAction(db, { kind: 'HUB_PERSIST_SNAPSHOT', hub: makeHub('hub-action') }); + + await applyAction(db, { + kind: 'ACTION_ITEM_ADD', + hubId: 'hub-action', + actionItem: { + id: 'action-1', + text: 'Refill buffer tank', + stepId: 'step-fill', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'done', + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: NOW, + deletedAt: null, + }, + }); + + const rows = await db.actionItems.where('hubId').equals('hub-action').toArray(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: 'action-1', + hubId: 'hub-action', + stepId: 'step-fill', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'done', + deletedAt: null, + }); + }); +}); + // --------------------------------------------------------------------------- // HUB_UPDATE_PRIMARY_SCOPE_DIMENSIONS // --------------------------------------------------------------------------- diff --git a/apps/pwa/src/persistence/applyAction.ts b/apps/pwa/src/persistence/applyAction.ts index b437c31fb..9b3726d7a 100644 --- a/apps/pwa/src/persistence/applyAction.ts +++ b/apps/pwa/src/persistence/applyAction.ts @@ -189,6 +189,15 @@ export async function applyAction(db: PwaDatabase, action: HubAction): Promise { const a2 = createActionItem('b'); expect(a1.id).not.toBe(a2.id); }); + + it('creates a quick-action item with step and orphan parent FKs', () => { + const action = createActionItem('Refill buffer tank', { + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'done', + assignedTo: null, + dueAt: null, + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + }); + + expect(action).toEqual( + expect.objectContaining({ + id: expect.any(String), + text: 'Refill buffer tank', + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'done', + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: expect.any(Number), + deletedAt: null, + }) + ); + }); }); // ============================================================================ diff --git a/packages/core/src/actions/HubAction.ts b/packages/core/src/actions/HubAction.ts index 459c372c6..17e7128e8 100644 --- a/packages/core/src/actions/HubAction.ts +++ b/packages/core/src/actions/HubAction.ts @@ -9,6 +9,7 @@ import type { HypothesisAction } from './hypothesisActions'; import type { HubMetaAction } from './hubMetaActions'; import type { CanvasAction } from './canvasActions'; import type { ImprovementProjectAction } from './improvementProjectActions'; +import type { ActionItemAction } from './actionItemActions'; /** * Top-level discriminated union for all hub write operations. @@ -26,4 +27,5 @@ export type HubAction = | HypothesisAction | HubMetaAction | CanvasAction - | ImprovementProjectAction; + | ImprovementProjectAction + | ActionItemAction; diff --git a/packages/core/src/actions/__tests__/exhaustiveness.test.ts b/packages/core/src/actions/__tests__/exhaustiveness.test.ts index 6724d5be2..5302b9c8b 100644 --- a/packages/core/src/actions/__tests__/exhaustiveness.test.ts +++ b/packages/core/src/actions/__tests__/exhaustiveness.test.ts @@ -98,6 +98,9 @@ function _exhaustive(action: HubAction): void { return; case 'IMPROVEMENT_PROJECT_ARCHIVE': return; + // Action Item + case 'ACTION_ITEM_ADD': + return; default: return assertNever(action); } @@ -111,6 +114,34 @@ describe('HubAction exhaustiveness', () => { }); }); +describe('ACTION_ITEM_ADD action', () => { + it('compiles under the HubAction discriminated union', () => { + const action: HubAction = { + kind: 'ACTION_ITEM_ADD', + hubId: 'hub-1', + actionItem: { + id: 'action-1', + text: 'Refill buffer tank', + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'done', + doneAt: '2026-05-10T10:00:00.000Z', + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: 1_746_352_800_000, + deletedAt: null, + }, + }; + + expect(action.kind).toBe('ACTION_ITEM_ADD'); + expect(action.hubId).toBe('hub-1'); + expect(action.actionItem.stepId).toBe('step-1'); + }); +}); + describe('IMPROVEMENT_PROJECT actions', () => { it('compile under the HubAction discriminated union', () => { const create: HubAction = { diff --git a/packages/core/src/actions/actionItemActions.ts b/packages/core/src/actions/actionItemActions.ts new file mode 100644 index 000000000..778bfb4a7 --- /dev/null +++ b/packages/core/src/actions/actionItemActions.ts @@ -0,0 +1,8 @@ +import type { ActionItem } from '../findings/types'; +import type { ProcessHub } from '../processHub'; + +export type ActionItemAction = { + kind: 'ACTION_ITEM_ADD'; + hubId: ProcessHub['id']; + actionItem: ActionItem; +}; diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 5b502a944..d74003924 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -9,4 +9,5 @@ export type { HypothesisAction } from './hypothesisActions'; export type { HubMetaAction } from './hubMetaActions'; export type { CanvasAction } from './canvasActions'; export type { ImprovementProjectAction } from './improvementProjectActions'; +export type { ActionItemAction } from './actionItemActions'; export type { HubAction } from './HubAction'; diff --git a/packages/core/src/findings/factories.ts b/packages/core/src/findings/factories.ts index dd3b39df5..2a9d6e006 100644 --- a/packages/core/src/findings/factories.ts +++ b/packages/core/src/findings/factories.ts @@ -11,6 +11,7 @@ import { type FindingOutcome, type FindingAssignee, type ActionItem, + type ActionItemQuickActionFields, type Question, type QuestionValidationType, type ImprovementIdea, @@ -163,14 +164,32 @@ export function createFindingComment( */ export function createActionItem( text: string, - assignee?: FindingAssignee, + assigneeOrFields?: FindingAssignee | ActionItemQuickActionFields, dueDate?: string, ideaId?: string ): ActionItem { + if (assigneeOrFields && 'stepId' in assigneeOrFields) { + return { + id: generateDeterministicId(), + text, + stepId: assigneeOrFields.stepId, + parentImprovementProjectId: assigneeOrFields.parentImprovementProjectId, + parentImprovementIdeaId: assigneeOrFields.parentImprovementIdeaId, + assignedTo: assigneeOrFields.assignedTo, + dueAt: assigneeOrFields.dueAt, + status: assigneeOrFields.status, + doneAt: assigneeOrFields.doneAt, + doneBy: assigneeOrFields.doneBy, + createdBy: assigneeOrFields.createdBy, + createdAt: Date.now(), + deletedAt: null, + }; + } + const action: ActionItem = { id: generateDeterministicId(), text, - assignee, + assignee: assigneeOrFields, dueDate, createdAt: Date.now(), deletedAt: null, diff --git a/packages/core/src/findings/types.ts b/packages/core/src/findings/types.ts index 27b523e1a..7ff1ff080 100644 --- a/packages/core/src/findings/types.ts +++ b/packages/core/src/findings/types.ts @@ -144,6 +144,20 @@ export interface FindingAssignee { // ============================================================================ /** A corrective/preventive action task within a finding */ +export type ActionItemStatus = 'open' | 'in-progress' | 'done'; + +export interface ActionItemQuickActionFields { + stepId: string; + parentImprovementIdeaId: null | ImprovementIdea['id']; + parentImprovementProjectId: null | string; + assignedTo: null | ProcessParticipantRef; + dueAt: null | string; + status: ActionItemStatus; + doneAt: null | string; + doneBy: null | ProcessParticipantRef; + createdBy: ProcessParticipantRef; +} + export interface ActionItem extends EntityBase { text: string; assignee?: FindingAssignee; @@ -151,6 +165,16 @@ export interface ActionItem extends EntityBase { completedAt?: number; // Date.now() timestamp — soft-completion; distinct from deletedAt /** Link to the ImprovementIdea that spawned this action (for traceability) */ ideaId?: ImprovementIdea['id']; + /** Canvas step FK for Quick Action / response-path actions. */ + stepId?: string; + parentImprovementIdeaId?: null | ImprovementIdea['id']; + parentImprovementProjectId?: null | string; + assignedTo?: null | ProcessParticipantRef; + dueAt?: null | string; + status?: ActionItemStatus; + doneAt?: null | string; + doneBy?: null | ProcessParticipantRef; + createdBy?: ProcessParticipantRef; } // ============================================================================ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 70cfe5893..b526d40e2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -741,6 +741,8 @@ export type { FindingComment, FindingTag, FindingSource, + ActionItemStatus, + ActionItemQuickActionFields, FindingProjection, FindingProjectionModelContext, PhotoAttachment, diff --git a/packages/core/src/persistence/HubRepository.ts b/packages/core/src/persistence/HubRepository.ts index 569472d58..2f263506f 100644 --- a/packages/core/src/persistence/HubRepository.ts +++ b/packages/core/src/persistence/HubRepository.ts @@ -1,7 +1,7 @@ import type { HubAction } from '../actions/HubAction'; import type { ProcessHub, OutcomeSpec, ProcessHubInvestigation } from '../processHub'; import type { EvidenceSource, EvidenceSnapshot, EvidenceSourceCursor } from '../evidenceSources'; -import type { Finding, Question, CausalLink, Hypothesis } from '../findings/types'; +import type { Finding, Question, CausalLink, Hypothesis, ActionItem } from '../findings/types'; import type { ProcessMap } from '../frame/types'; export interface HubReadAPI { @@ -57,6 +57,12 @@ export interface CanvasStateReadAPI { getByHub(hubId: ProcessHub['id']): Promise; } +export interface ActionItemReadAPI { + get(id: ActionItem['id']): Promise; + listByHub(hubId: ProcessHub['id']): Promise; + listByStep(hubId: ProcessHub['id'], stepId: string): Promise; +} + /** * Single-interface repository for all hub domain writes + grouped reads. * Write path: one `dispatch(action)` entry point — all mutations flow through it. @@ -78,4 +84,5 @@ export interface HubRepository { causalLinks: CausalLinkReadAPI; hypotheses: HypothesisReadAPI; canvasState: CanvasStateReadAPI; + actionItems: ActionItemReadAPI; } diff --git a/packages/core/src/persistence/index.ts b/packages/core/src/persistence/index.ts index 660f9a44c..de3169bd3 100644 --- a/packages/core/src/persistence/index.ts +++ b/packages/core/src/persistence/index.ts @@ -10,6 +10,7 @@ export type { CausalLinkReadAPI, HypothesisReadAPI, CanvasStateReadAPI, + ActionItemReadAPI, } from './HubRepository'; export type { EntityKind, CascadeRule, CascadeRuleset } from './cascadeRules'; export { cascadeRules, transitiveCascade } from './cascadeRules'; diff --git a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx index 20a5b2b4a..5958caebc 100644 --- a/packages/ui/src/components/Canvas/CanvasWorkspace.tsx +++ b/packages/ui/src/components/Canvas/CanvasWorkspace.tsx @@ -25,6 +25,7 @@ import { type TimelineWindow, type WorkflowReadinessSignals, } from '@variscout/core'; +import type { ActionItem } from '@variscout/core/findings'; import { createEmptyMap, detectGaps, type ProcessMap } from '@variscout/core/frame'; import { useCanvasStore } from '@variscout/stores'; import { Canvas, type CanvasAuthoringMode } from './index'; @@ -33,6 +34,7 @@ import { FrameViewB0, type FrameViewB0YCandidate } from '../FrameViewB0'; import type { XCandidate } from '../XPickerSection'; import type { ChipRailEntry } from '../ChipRail'; import type { ContextLinkGroup, ContextLinkItem } from '../CrossSurface'; +import type { LogActionPayload } from '../QuickAction'; const DEFAULT_CPK_TARGET = 1.33; @@ -49,6 +51,7 @@ export interface CanvasWorkspaceProps { onSeeData: () => void; signals: WorkflowReadinessSignals; onQuickAction?: (stepId: string) => void; + onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; onSustainment?: (stepId: string) => void; @@ -72,6 +75,7 @@ export interface CanvasWorkspaceProps { contextLinkGroups?: readonly ContextLinkGroup[]; onNavigateContextLink?: (item: ContextLinkItem) => void; priorStepStats?: ReadonlyMap; + actionItems?: ActionItem[]; } function formatTimelineWindow(w: TimelineWindow): string { @@ -167,6 +171,7 @@ export const CanvasWorkspace: React.FC = ({ signals, onSeeData, onQuickAction, + onLogQuickAction, onFocusedInvestigation, onCharter, onSustainment, @@ -185,6 +190,7 @@ export const CanvasWorkspace: React.FC = ({ contextLinkGroups, onNavigateContextLink, priorStepStats, + actionItems = [], }) => { const { t } = useTranslation(); const fallbackMap = React.useMemo(() => createEmptyMap(), []); @@ -494,6 +500,7 @@ export const CanvasWorkspace: React.FC = ({ onRemoveCausalLink={onRemoveCausalLink} signals={signals} onQuickAction={onQuickAction} + onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} onSustainment={onSustainment} @@ -501,6 +508,7 @@ export const CanvasWorkspace: React.FC = ({ onOpenInvestigationFocus={onOpenInvestigationFocus} contextLinkGroups={contextLinkGroups} onNavigateContextLink={onNavigateContextLink} + actionItems={actionItems} mode={authoringMode} onModeChange={setAuthoringMode} chips={chips} diff --git a/packages/ui/src/components/Canvas/index.tsx b/packages/ui/src/components/Canvas/index.tsx index 1cc148038..7faf5a3e0 100644 --- a/packages/ui/src/components/Canvas/index.tsx +++ b/packages/ui/src/components/Canvas/index.tsx @@ -23,6 +23,7 @@ import { } from '@variscout/hooks'; import type { ProcessMap, Gap } from '@variscout/core/frame'; import type { Finding, SpecLimits, WorkflowReadinessSignals } from '@variscout/core'; +import type { ActionItem } from '@variscout/core/findings'; import { type ProductionLineGlanceFilterStripProps, ProductionLineGlanceFilterStrip, @@ -46,6 +47,7 @@ import { CanvasWallOverlay } from './internal/CanvasWallOverlay'; import { WallShortcutButton } from './internal/WallShortcutButton'; import { useWallIsMobile } from '../InvestigationWall'; import type { ContextLinkGroup, ContextLinkItem } from '../CrossSurface'; +import type { LogActionPayload } from '../QuickAction'; /** * Canonical FRAME canvas surface. @@ -154,6 +156,7 @@ export interface CanvasProps { signals: WorkflowReadinessSignals; onStepSpecsRequest?: (column: string, stepId: string) => void; onQuickAction?: (stepId: string) => void; + onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; onSustainment?: (stepId: string) => void; @@ -162,6 +165,7 @@ export interface CanvasProps { onRemoveCausalLink?: (linkId: string) => void; contextLinkGroups?: readonly ContextLinkGroup[]; onNavigateContextLink?: (item: ContextLinkItem) => void; + actionItems?: ActionItem[]; findings?: ReadonlyArray; problemCpk?: number; eventsPerWeek?: number; @@ -212,6 +216,7 @@ export const Canvas: React.FC = ({ signals, onStepSpecsRequest, onQuickAction, + onLogQuickAction, onFocusedInvestigation, onCharter, onSustainment, @@ -220,6 +225,7 @@ export const Canvas: React.FC = ({ onRemoveCausalLink, contextLinkGroups, onNavigateContextLink, + actionItems = [], findings = [], problemCpk, eventsPerWeek, @@ -781,6 +787,7 @@ export const Canvas: React.FC = ({ onClose={handleCloseStepOverlay} signals={signals} onQuickAction={onQuickAction} + onLogQuickAction={onLogQuickAction} onFocusedInvestigation={onFocusedInvestigation} onCharter={onCharter} onSustainment={onSustainment} @@ -790,6 +797,7 @@ export const Canvas: React.FC = ({ onRemoveCausalLink={onRemoveCausalLink} contextLinkGroups={contextLinkGroups} onNavigateContextLink={onNavigateContextLink} + actionItems={actionItems} /> ) : null} diff --git a/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx b/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx index c92fcf9ee..521daf910 100644 --- a/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx +++ b/packages/ui/src/components/Canvas/internal/CanvasStepOverlay.tsx @@ -2,6 +2,7 @@ import React from 'react'; import FocusTrap from 'focus-trap-react'; import { formatStatistic } from '@variscout/core/i18n'; import type { WorkflowReadinessSignals } from '@variscout/core'; +import type { ActionItem } from '@variscout/core/findings'; import type { CanvasInvestigationFocus, CanvasStepCardModel, @@ -14,6 +15,7 @@ import { type PrerequisiteLockedReason, } from './responsePathCta'; import { ContextBadgesRow, type ContextLinkGroup, type ContextLinkItem } from '../../CrossSurface'; +import { LogActionModal, RecentActivityPanel, type LogActionPayload } from '../../QuickAction'; export interface CanvasOverlayAnchorRect { top: number; @@ -30,6 +32,7 @@ interface CanvasStepOverlayProps { onClose: () => void; signals: WorkflowReadinessSignals; onQuickAction?: (stepId: string) => void; + onLogQuickAction?: (stepId: string, payload: LogActionPayload) => void; onFocusedInvestigation?: (stepId: string) => void; onCharter?: (stepId: string) => void; onSustainment?: (stepId: string) => void; @@ -39,6 +42,7 @@ interface CanvasStepOverlayProps { onRemoveCausalLink?: (linkId: string) => void; contextLinkGroups?: readonly ContextLinkGroup[]; onNavigateContextLink?: (item: ContextLinkItem) => void; + actionItems?: ActionItem[]; } const DESKTOP_WIDTH = 440; @@ -118,6 +122,7 @@ export const CanvasStepOverlay: React.FC = ({ onClose, signals, onQuickAction, + onLogQuickAction, onFocusedInvestigation, onCharter, onSustainment, @@ -127,13 +132,15 @@ export const CanvasStepOverlay: React.FC = ({ onRemoveCausalLink, contextLinkGroups = [], onNavigateContextLink, + actionItems = [], }) => { const { t } = useTranslation(); + const [showLogAction, setShowLogAction] = React.useState(false); const touchStartY = React.useRef(null); const mobile = isMobileViewport(); const handlerMap: Record void) | undefined> = { - 'quick-action': onQuickAction, + 'quick-action': onLogQuickAction ? () => setShowLogAction(true) : onQuickAction, 'focused-investigation': onFocusedInvestigation, charter: onCharter, sustainment: onSustainment, @@ -219,7 +226,7 @@ export const CanvasStepOverlay: React.FC = ({ focusTrapOptions={{ allowOutsideClick: true, escapeDeactivates: true, - fallbackFocus: '[data-testid="canvas-step-overlay"]', + fallbackFocus: () => document.body, }} >
= ({ /> ) : null} +
+ +
+
{renderCta('quick-action')} {renderCta('focused-investigation')} @@ -375,6 +386,16 @@ export const CanvasStepOverlay: React.FC = ({
+ {showLogAction ? ( + setShowLogAction(false)} + onLog={payload => { + onLogQuickAction?.(card.stepId, payload); + setShowLogAction(false); + }} + /> + ) : null} ); }; diff --git a/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx b/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx index a41ce5144..ac99b67eb 100644 --- a/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx +++ b/packages/ui/src/components/Canvas/internal/__tests__/CanvasStepOverlay.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import type { CanvasStepCardModel } from '@variscout/hooks'; -import type { WorkflowReadinessSignals } from '@variscout/core'; +import type { ActionItem, WorkflowReadinessSignals } from '@variscout/core'; import { CanvasStepOverlay } from '../CanvasStepOverlay'; const baseCard: CanvasStepCardModel = { @@ -32,12 +32,32 @@ const emptySignals: WorkflowReadinessSignals = { sustainmentConfirmed: false, }; +function makeAction(overrides: Partial = {}): ActionItem { + return { + id: overrides.id ?? 'action-1', + text: overrides.text ?? 'Check oven gasket seating', + stepId: overrides.stepId ?? 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + assignedTo: null, + dueAt: null, + status: 'open', + doneAt: null, + doneBy: null, + createdBy: { displayName: 'Local browser' }, + createdAt: 1, + deletedAt: null, + ...overrides, + }; +} + function renderOverlay(overrides: Partial> = {}) { return render( undefined} signals={emptySignals} + actionItems={[]} onQuickAction={() => undefined} onFocusedInvestigation={() => undefined} onCharter={() => undefined} @@ -131,6 +151,48 @@ describe('CanvasStepOverlay — response-path CTA rendering', () => { expect(onCharter).toHaveBeenCalledWith('step-1'); }); + it('opens LogActionModal from Quick action and submits payload with the step id', () => { + const onLogQuickAction = vi.fn(); + renderOverlay({ onQuickAction: undefined, onLogQuickAction }); + + fireEvent.click(screen.getByTestId('canvas-cta-quick-action')); + expect(screen.getByRole('dialog', { name: 'Log action — Bake step' })).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('What'), { + target: { value: 'Refill buffer tank' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLogQuickAction).toHaveBeenCalledWith('step-1', { + text: 'Refill buffer tank', + status: 'done', + }); + }); + + it('renders selected-step orphan quick actions in Recent activity after expanding', () => { + renderOverlay({ + actionItems: [ + makeAction({ id: 'action-1', text: 'Check oven gasket seating' }), + makeAction({ id: 'action-other-step', stepId: 'step-2', text: 'Wrong step action' }), + makeAction({ + id: 'action-linked', + text: 'Linked project action', + parentImprovementProjectId: 'project-1', + }), + ], + }); + + const summary = screen.getByText('Recent activity'); + const details = summary.closest('details'); + expect(details).not.toHaveAttribute('open'); + + fireEvent.click(summary); + + expect(screen.getByRole('button', { name: /check oven gasket seating\s*open/i })).toBeVisible(); + expect(screen.queryByText('Wrong step action')).toBeNull(); + expect(screen.queryByText('Linked project action')).toBeNull(); + }); + it('renders linked context badge counts above the response-path CTAs', () => { const { container } = renderOverlay({ contextLinkGroups: [ diff --git a/packages/ui/src/components/QuickAction/LogActionModal.tsx b/packages/ui/src/components/QuickAction/LogActionModal.tsx new file mode 100644 index 000000000..8163b835b --- /dev/null +++ b/packages/ui/src/components/QuickAction/LogActionModal.tsx @@ -0,0 +1,166 @@ +import { useState } from 'react'; +import type { FormEvent } from 'react'; +import FocusTrap from 'focus-trap-react'; + +type LogActionMode = 'done' | 'assign'; + +export type LogActionPayload = + | { + text: string; + status: 'done'; + } + | { + text: string; + status: 'open'; + assignedTo: { + displayName: string; + upn: string; + }; + dueAt?: string; + }; + +export interface LogActionModalProps { + cardTitle: string; + onCancel: () => void; + onLog: (payload: LogActionPayload) => void; +} + +export function LogActionModal({ cardTitle, onCancel, onLog }: LogActionModalProps) { + const [mode, setMode] = useState('done'); + const [text, setText] = useState(''); + const [owner, setOwner] = useState(''); + const [dueAt, setDueAt] = useState(''); + + const title = `Log action — ${cardTitle}`; + const whatInputId = 'log-action-what'; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedText = text.trim(); + const trimmedOwner = owner.trim(); + if (!trimmedText) return; + + if (mode === 'done') { + onLog({ text: trimmedText, status: 'done' }); + return; + } + + if (!trimmedOwner) return; + + onLog({ + text: trimmedText, + status: 'open', + assignedTo: { + displayName: trimmedOwner, + upn: trimmedOwner, + }, + ...(dueAt ? { dueAt } : {}), + }); + }; + + return ( +
+ document.body, + initialFocus: `#${whatInputId}`, + tabbableOptions: { + displayCheck: 'none', + }, + }} + > +
+
+
+

+ {title} +

+
+ + + +
+ Action status + + +
+ + {mode === 'assign' ? ( +
+ + +
+ ) : null} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/ui/src/components/QuickAction/RecentActivityPanel.tsx b/packages/ui/src/components/QuickAction/RecentActivityPanel.tsx new file mode 100644 index 000000000..d0b7717c9 --- /dev/null +++ b/packages/ui/src/components/QuickAction/RecentActivityPanel.tsx @@ -0,0 +1,140 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import type { ActionItem } from '@variscout/core/findings'; + +export interface RecentActivityPanelProps { + stepId: string; + actionItems: ActionItem[]; +} + +function isOrphanStepAction(action: ActionItem, stepId: string): boolean { + return ( + action.stepId === stepId && + action.parentImprovementProjectId === null && + action.parentImprovementIdeaId === null && + action.deletedAt === null + ); +} + +function ownerLabel(action: ActionItem): string | undefined { + return action.assignedTo?.displayName ?? action.assignedTo?.upn; +} + +function DetailRow({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function RecentActivityPanel({ stepId, actionItems }: RecentActivityPanelProps) { + const titleId = useId(); + const closeButtonRef = useRef(null); + const dialogRef = useRef(null); + const openerRef = useRef(null); + const shouldRestoreFocusRef = useRef(false); + const [selectedAction, setSelectedAction] = useState(null); + const orphanActions = useMemo( + () => actionItems.filter(action => isOrphanStepAction(action, stepId)), + [actionItems, stepId] + ); + + useEffect(() => { + if (!selectedAction && shouldRestoreFocusRef.current) { + shouldRestoreFocusRef.current = false; + openerRef.current?.focus(); + } + }, [selectedAction]); + + const closeDialog = () => { + shouldRestoreFocusRef.current = true; + setSelectedAction(null); + }; + + return ( + <> +
+ + Recent activity + + + {orphanActions.length === 0 ? ( +

No recent activity.

+ ) : ( +
    + {orphanActions.map(action => ( +
  • + +
  • + ))} +
+ )} +
+ + {selectedAction ? ( +
+ dialogRef.current ?? document.body, + initialFocus: () => closeButtonRef.current ?? dialogRef.current ?? document.body, + onDeactivate: closeDialog, + returnFocusOnDeactivate: false, + tabbableOptions: { + displayCheck: 'none', + }, + }} + > +
+
+

+ Action details +

+ +
+ +
+ + + + + +
+
+
+
+ ) : null} + + ); +} diff --git a/packages/ui/src/components/QuickAction/__tests__/LogActionModal.test.tsx b/packages/ui/src/components/QuickAction/__tests__/LogActionModal.test.tsx new file mode 100644 index 000000000..b6e1976c5 --- /dev/null +++ b/packages/ui/src/components/QuickAction/__tests__/LogActionModal.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LogActionModal } from '../LogActionModal'; + +describe('LogActionModal', () => { + it('renders accessible dialog content for a card', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: 'Log action — Reduce rework' }); + + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByRole('heading', { name: 'Log action — Reduce rework' })).toBeInTheDocument(); + expect(screen.getByLabelText('What')).toBeRequired(); + expect(screen.getByRole('radio', { name: 'Done now' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Assign to' })).toBeInTheDocument(); + }); + + it('requires action text before submitting a done-now action', () => { + const onLog = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLog).not.toHaveBeenCalled(); + }); + + it('moves focus into the dialog and wraps tab focus inside it', async () => { + render( + <> + + + + ); + + const whatInput = screen.getByLabelText('What'); + + await waitFor(() => expect(whatInput).toHaveFocus()); + + screen.getByRole('button', { name: 'Log action' }).focus(); + fireEvent.keyDown(document, { key: 'Tab' }); + + expect(whatInput).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Outside action' })).not.toHaveFocus(); + }); + + it('logs a done action without owner or deadline', () => { + const onLog = vi.fn(); + render(); + + fireEvent.change(screen.getByLabelText('What'), { + target: { value: 'Update the control plan' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLog).toHaveBeenCalledWith({ + text: 'Update the control plan', + status: 'done', + }); + }); + + it('requires an owner in assign mode', () => { + const onLog = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Assign to' })); + fireEvent.change(screen.getByLabelText('What'), { + target: { value: 'Check the gage setup' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLog).not.toHaveBeenCalled(); + }); + + it('logs an assigned action with owner and optional due date', () => { + const onLog = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Assign to' })); + fireEvent.change(screen.getByLabelText('What'), { + target: { value: 'Check the gage setup' }, + }); + fireEvent.change(screen.getByLabelText('Owner'), { + target: { value: 'alex@example.com' }, + }); + fireEvent.change(screen.getByLabelText('Due date'), { + target: { value: '2026-05-14' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLog).toHaveBeenCalledWith({ + text: 'Check the gage setup', + status: 'open', + assignedTo: { + displayName: 'alex@example.com', + upn: 'alex@example.com', + }, + dueAt: '2026-05-14', + }); + }); + + it('omits dueAt when the assigned action has no due date', () => { + const onLog = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('radio', { name: 'Assign to' })); + fireEvent.change(screen.getByLabelText('What'), { + target: { value: 'Check the gage setup' }, + }); + fireEvent.change(screen.getByLabelText('Owner'), { + target: { value: 'alex@example.com' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Log action' })); + + expect(onLog).toHaveBeenCalledWith({ + text: 'Check the gage setup', + status: 'open', + assignedTo: { + displayName: 'alex@example.com', + upn: 'alex@example.com', + }, + }); + }); + + it('calls onCancel when canceled', () => { + const onCancel = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/components/QuickAction/__tests__/RecentActivityPanel.test.tsx b/packages/ui/src/components/QuickAction/__tests__/RecentActivityPanel.test.tsx new file mode 100644 index 000000000..ddd006116 --- /dev/null +++ b/packages/ui/src/components/QuickAction/__tests__/RecentActivityPanel.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { ActionItem } from '@variscout/core/findings'; +import { RecentActivityPanel } from '../RecentActivityPanel'; + +const makeAction = (overrides: Partial & Pick) => + ({ + createdAt: 1, + deletedAt: null, + stepId: 'step-1', + parentImprovementProjectId: null, + parentImprovementIdeaId: null, + status: 'open', + assignedTo: null, + dueAt: null, + doneAt: null, + ...overrides, + }) as ActionItem; + +describe('RecentActivityPanel', () => { + it('renders a collapsed Recent activity section with only step-scoped orphan actions', () => { + render( + + ); + + const section = screen.getByText('Recent activity').closest('details'); + + expect(section).toBeInTheDocument(); + expect(section).not.toHaveAttribute('open'); + + fireEvent.click(screen.getByText('Recent activity')); + + expect(screen.getByRole('button', { name: /check gasket seating/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /different step/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /linked to project/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /linked to idea/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /deleted action/i })).not.toBeInTheDocument(); + }); + + it('expands and collapses using native details behavior', () => { + render( + + ); + + const section = screen.getByText('Recent activity').closest('details'); + + expect(section).not.toHaveAttribute('open'); + + fireEvent.click(screen.getByText('Recent activity')); + expect(section).toHaveAttribute('open'); + + fireEvent.click(screen.getByText('Recent activity')); + expect(section).not.toHaveAttribute('open'); + }); + + it('opens a labelled, closable details dialog when an action row is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByText('Recent activity')); + fireEvent.click(screen.getByRole('button', { name: /update setup checklist/i })); + + const dialog = screen.getByRole('dialog', { name: 'Action details' }); + + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(within(dialog).getByText('Update setup checklist')).toBeInTheDocument(); + expect(within(dialog).getByText('done')).toBeInTheDocument(); + expect(within(dialog).getByText('Alex Chen')).toBeInTheDocument(); + expect(within(dialog).getByText('2026-05-18')).toBeInTheDocument(); + expect(within(dialog).getByText('2026-05-20T08:30:00.000Z')).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })); + + expect(screen.queryByRole('dialog', { name: 'Action details' })).not.toBeInTheDocument(); + }); + + it('moves focus into the details dialog, traps tab focus, closes on Escape, and restores focus', async () => { + render( + <> + + + + ); + + fireEvent.click(screen.getByText('Recent activity')); + const actionRow = screen.getByRole('button', { name: /update setup checklist/i }); + fireEvent.click(actionRow); + + const dialog = screen.getByRole('dialog', { name: 'Action details' }); + const closeButton = within(dialog).getByRole('button', { name: 'Close' }); + + await waitFor(() => expect(closeButton).toHaveFocus()); + + fireEvent.keyDown(document, { key: 'Tab' }); + + expect(closeButton).toHaveFocus(); + expect(screen.getByRole('button', { name: 'Outside action' })).not.toHaveFocus(); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(screen.queryByRole('dialog', { name: 'Action details' })).not.toBeInTheDocument(); + await waitFor(() => expect(actionRow).toHaveFocus()); + }); +}); diff --git a/packages/ui/src/components/QuickAction/index.ts b/packages/ui/src/components/QuickAction/index.ts new file mode 100644 index 000000000..5a4563d72 --- /dev/null +++ b/packages/ui/src/components/QuickAction/index.ts @@ -0,0 +1,2 @@ +export { LogActionModal, type LogActionModalProps, type LogActionPayload } from './LogActionModal'; +export { RecentActivityPanel, type RecentActivityPanelProps } from './RecentActivityPanel'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 805999d5e..1b3a846cb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -815,3 +815,10 @@ export type { CanvasFilterChipsProps } from './components/CanvasFilterChips'; // Pareto make-scope button (framing layer — scope-to-investigation affordance, spec §9.2) export { ParetoMakeScopeButton, buildIssueStatement } from './components/ParetoMakeScopeButton'; export type { ParetoMakeScopeButtonProps } from './components/ParetoMakeScopeButton'; + +export { LogActionModal, RecentActivityPanel } from './components/QuickAction'; +export type { + LogActionModalProps, + LogActionPayload, + RecentActivityPanelProps, +} from './components/QuickAction';