diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index b4224c267..1ed710725 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { Plus } from 'lucide-react'; import { buildCurrentProcessState, buildProcessHubCadence } from '@variscout/core'; import type { ProcessHubInvestigation, ProcessHubRollup } from '@variscout/core'; +import { ProcessHubCurrentStatePanel } from '@variscout/ui'; import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions'; import ProcessHubCadenceQueues from './ProcessHubCadenceQueues'; -import ProcessHubCurrentStatePanel from './ProcessHubCurrentStatePanel'; import { formatLatestActivity } from './ProcessHubFormat'; interface ProcessHubReviewPanelProps { diff --git a/apps/azure/src/components/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx similarity index 92% rename from apps/azure/src/components/ProcessHubCurrentStatePanel.tsx rename to packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx index f46ccc958..1c66e600d 100644 --- a/apps/azure/src/components/ProcessHubCurrentStatePanel.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx @@ -7,9 +7,9 @@ import type { ProcessStateResponsePath, ProcessStateSeverity, } from '@variscout/core'; -import { formatChangeSignals, formatMetric } from './ProcessHubFormat'; +import { formatPlural, formatStatistic } from '@variscout/core/i18n'; -interface ProcessHubCurrentStatePanelProps { +export interface ProcessHubCurrentStatePanelProps { state: CurrentProcessState; } @@ -55,6 +55,11 @@ const LENS_ICONS: Record = { const LENSES: ProcessStateLens[] = ['outcome', 'flow', 'conversion', 'measurement', 'sustainment']; +const formatMetric = (value: number): string => formatStatistic(value, 'en', 2); + +const formatChangeSignals = (count: number): string => + `${count} ${formatPlural(count, { one: 'change signal', other: 'change signals' })}`; + const StateCountCard: React.FC<{ lens: ProcessStateLens; count: number }> = ({ lens, count }) => (
@@ -114,7 +119,9 @@ const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => { ); }; -const ProcessHubCurrentStatePanel: React.FC = ({ state }) => { +export const ProcessHubCurrentStatePanel: React.FC = ({ + state, +}) => { const visibleItems = state.items.slice(0, 6); const hiddenCount = Math.max(0, state.items.length - visibleItems.length); @@ -157,5 +164,3 @@ const ProcessHubCurrentStatePanel: React.FC =
); }; - -export default ProcessHubCurrentStatePanel; diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx new file mode 100644 index 000000000..0c9fca612 --- /dev/null +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { CurrentProcessState, ProcessStateItem, ProcessStateLens } from '@variscout/core'; +import { ProcessHubCurrentStatePanel } from '../ProcessHubCurrentStatePanel'; + +const HUB = { + id: 'h-1', + name: 'Filling Line A', + description: undefined, + processOwner: undefined, +}; + +const ZERO_LENS_COUNTS: Record = { + outcome: 0, + flow: 0, + conversion: 0, + measurement: 0, + sustainment: 0, +}; + +const buildState = (overrides: Partial = {}): CurrentProcessState => ({ + hub: HUB, + assessedAt: '2026-04-27T00:00:00.000Z', + overallSeverity: 'neutral', + items: [], + lensCounts: { ...ZERO_LENS_COUNTS }, + responsePathCounts: {}, + ...overrides, +}); + +const buildItem = (overrides: Partial = {}): ProcessStateItem => ({ + id: 'item-1', + lens: 'outcome', + severity: 'amber', + responsePath: 'monitor', + source: 'review-signal', + label: 'Capability gap', + ...overrides, +}); + +describe('ProcessHubCurrentStatePanel', () => { + it('renders the heading and overall severity badge', () => { + render(); + expect(screen.getByTestId('current-process-state')).toBeInTheDocument(); + expect(screen.getByText('Current Process State')).toBeInTheDocument(); + expect(screen.getByText('Red')).toBeInTheDocument(); + }); + + it('renders all five lens count cards using lensCounts', () => { + const state = buildState({ + lensCounts: { outcome: 3, flow: 1, conversion: 0, measurement: 2, sustainment: 5 }, + }); + render(); + expect(screen.getByTestId('current-state-lens-outcome')).toHaveTextContent('3'); + expect(screen.getByTestId('current-state-lens-flow')).toHaveTextContent('1'); + expect(screen.getByTestId('current-state-lens-conversion')).toHaveTextContent('0'); + expect(screen.getByTestId('current-state-lens-measurement')).toHaveTextContent('2'); + expect(screen.getByTestId('current-state-lens-sustainment')).toHaveTextContent('5'); + }); + + it('shows the empty placeholder when there are no items', () => { + render(); + expect(screen.getByText('No current process state signals yet')).toBeInTheDocument(); + }); + + it('renders item cards capped at 6 with a +N indicator for the rest', () => { + const items = Array.from({ length: 9 }, (_, i) => + buildItem({ id: `item-${i}`, label: `Item ${i + 1}` }) + ); + render(); + expect(screen.getAllByTestId('current-state-item')).toHaveLength(6); + expect(screen.getByText('+3 more current-state items')).toBeInTheDocument(); + }); + + it('formats Cpk vs target detail when both are present on metric', () => { + const item = buildItem({ + lens: 'outcome', + metric: { cpk: 1.05, cpkTarget: 1.33 }, + }); + render(); + const card = screen.getByTestId('current-state-item'); + expect(within(card).getByText(/Cpk 1\.05 vs target 1\.33/)).toBeInTheDocument(); + }); + + it('formats change-signal count using singular vs plural', () => { + const items = [ + buildItem({ id: 'a', lens: 'flow', metric: { changeSignalCount: 1 } }), + buildItem({ id: 'b', lens: 'flow', metric: { changeSignalCount: 4 } }), + ]; + render(); + expect(screen.getByText('1 change signal')).toBeInTheDocument(); + expect(screen.getByText('4 change signals')).toBeInTheDocument(); + }); + + it('falls back to item.detail when no metric formatter applies', () => { + const item = buildItem({ detail: 'Free-text fallback' }); + render(); + expect(screen.getByText('Free-text fallback')).toBeInTheDocument(); + }); + + it('renders the response path label per item', () => { + const item = buildItem({ responsePath: 'chartered-project' }); + render(); + expect(screen.getByText('Chartered project')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/index.ts b/packages/ui/src/components/ProcessHubCurrentStatePanel/index.ts new file mode 100644 index 000000000..f294b0015 --- /dev/null +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/index.ts @@ -0,0 +1,2 @@ +export { ProcessHubCurrentStatePanel } from './ProcessHubCurrentStatePanel'; +export type { ProcessHubCurrentStatePanelProps } from './ProcessHubCurrentStatePanel'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 341750120..2ff2868e2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -594,6 +594,10 @@ export { SubgroupConfigPopover, type SubgroupConfigProps } from './components/Su // FRAME workspace — visual Process Map (ADR-070) export { ProcessMapBase, type ProcessMapBaseProps } from './components/ProcessMap/ProcessMapBase'; export { LayeredProcessView, type LayeredProcessViewProps } from './components/LayeredProcessView'; +export { + ProcessHubCurrentStatePanel, + type ProcessHubCurrentStatePanelProps, +} from './components/ProcessHubCurrentStatePanel'; // Hooks export {