Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -55,6 +55,11 @@ const LENS_ICONS: Record<ProcessStateLens, React.ReactNode> = {

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 }) => (
<div className="rounded-md border border-edge bg-surface px-3 py-2">
<div className="flex items-center gap-2 text-xs font-medium text-content-secondary">
Expand Down Expand Up @@ -114,7 +119,9 @@ const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => {
);
};

const ProcessHubCurrentStatePanel: React.FC<ProcessHubCurrentStatePanelProps> = ({ state }) => {
export const ProcessHubCurrentStatePanel: React.FC<ProcessHubCurrentStatePanelProps> = ({
state,
}) => {
const visibleItems = state.items.slice(0, 6);
const hiddenCount = Math.max(0, state.items.length - visibleItems.length);

Expand Down Expand Up @@ -157,5 +164,3 @@ const ProcessHubCurrentStatePanel: React.FC<ProcessHubCurrentStatePanelProps> =
</div>
);
};

export default ProcessHubCurrentStatePanel;
Original file line number Diff line number Diff line change
@@ -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<ProcessStateLens, number> = {
outcome: 0,
flow: 0,
conversion: 0,
measurement: 0,
sustainment: 0,
};

const buildState = (overrides: Partial<CurrentProcessState> = {}): CurrentProcessState => ({
hub: HUB,
assessedAt: '2026-04-27T00:00:00.000Z',
overallSeverity: 'neutral',
items: [],
lensCounts: { ...ZERO_LENS_COUNTS },
responsePathCounts: {},
...overrides,
});

const buildItem = (overrides: Partial<ProcessStateItem> = {}): 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(<ProcessHubCurrentStatePanel state={buildState({ overallSeverity: 'red' })} />);
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(<ProcessHubCurrentStatePanel state={state} />);
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(<ProcessHubCurrentStatePanel state={buildState()} />);
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(<ProcessHubCurrentStatePanel state={buildState({ items })} />);
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(<ProcessHubCurrentStatePanel state={buildState({ items: [item] })} />);
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(<ProcessHubCurrentStatePanel state={buildState({ items, overallSeverity: 'amber' })} />);
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(<ProcessHubCurrentStatePanel state={buildState({ items: [item] })} />);
expect(screen.getByText('Free-text fallback')).toBeInTheDocument();
});

it('renders the response path label per item', () => {
const item = buildItem({ responsePath: 'chartered-project' });
render(<ProcessHubCurrentStatePanel state={buildState({ items: [item] })} />);
expect(screen.getByText('Chartered project')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ProcessHubCurrentStatePanel } from './ProcessHubCurrentStatePanel';
export type { ProcessHubCurrentStatePanelProps } from './ProcessHubCurrentStatePanel';
4 changes: 4 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down