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/ProcessHubCadenceQuestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const ProcessHubCadenceQuestions: React.FC<ProcessHubCadenceQuestionsProps> = ({
<div className="mt-4">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-content">
<ClipboardCheck size={16} />
<h4>Cadence Questions</h4>
<h4>Process State Questions</h4>
</div>
<div className={`grid gap-2 ${sustainmentAnswer ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
<QuestionBand question="Are we meeting the requirement?" answer={answers.requirement} />
Expand Down
161 changes: 161 additions & 0 deletions apps/azure/src/components/ProcessHubCurrentStatePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React from 'react';
import { Activity, Gauge, GitBranch, Layers3, ShieldCheck } from 'lucide-react';
import type {
CurrentProcessState,
ProcessStateItem,
ProcessStateLens,
ProcessStateResponsePath,
ProcessStateSeverity,
} from '@variscout/core';
import { formatChangeSignals, formatMetric } from './ProcessHubFormat';

interface ProcessHubCurrentStatePanelProps {
state: CurrentProcessState;
}

const LENS_LABELS: Record<ProcessStateLens, string> = {
outcome: 'Outcome',
flow: 'Flow',
conversion: 'Conversion',
measurement: 'Measurement',
sustainment: 'Sustainment',
};

const RESPONSE_LABELS: Record<ProcessStateResponsePath, string> = {
monitor: 'Monitor',
'quick-action': 'Quick action',
'focused-investigation': 'Focused investigation',
'chartered-project': 'Chartered project',
'measurement-system-work': 'Measurement system work',
'sustainment-review': 'Sustainment review',
'control-handoff': 'Control handoff',
};

const SEVERITY_LABELS: Record<ProcessStateSeverity, string> = {
red: 'Red',
amber: 'Amber',
neutral: 'Neutral',
green: 'Green',
};

const SEVERITY_CLASS: Record<ProcessStateSeverity, string> = {
red: 'border-rose-500/40 text-rose-400',
amber: 'border-amber-500/40 text-amber-400',
neutral: 'border-edge text-content-secondary',
green: 'border-emerald-500/40 text-emerald-400',
};

const LENS_ICONS: Record<ProcessStateLens, React.ReactNode> = {
outcome: <Gauge size={15} />,
flow: <GitBranch size={15} />,
conversion: <Layers3 size={15} />,
measurement: <Activity size={15} />,
sustainment: <ShieldCheck size={15} />,
};

const LENSES: ProcessStateLens[] = ['outcome', 'flow', 'conversion', 'measurement', 'sustainment'];

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">
{LENS_ICONS[lens]}
<span>{LENS_LABELS[lens]}</span>
</div>
<p
className="mt-1 text-xl font-semibold text-content"
data-testid={`current-state-lens-${lens}`}
>
{count}
</p>
</div>
);

const formatStateDetail = (item: ProcessStateItem): string | null => {
const metric = item.metric;
if (metric?.cpk !== undefined && metric.cpkTarget !== undefined) {
return `Cpk ${formatMetric(metric.cpk)} vs target ${formatMetric(metric.cpkTarget)}`;
}
if (metric?.changeSignalCount !== undefined) {
return formatChangeSignals(metric.changeSignalCount);
}
if (metric?.variationPct !== undefined) {
return `${formatMetric(metric.variationPct)}% variation`;
}
if (item.count !== undefined) {
return formatMetric(item.count);
}
return item.detail ?? null;
};

const StateItemCard: React.FC<{ item: ProcessStateItem }> = ({ item }) => {
const detail = formatStateDetail(item);

return (
<div
className={`rounded-md border bg-surface px-3 py-2 ${SEVERITY_CLASS[item.severity]}`}
data-testid="current-state-item"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
{LENS_LABELS[item.lens]}
</p>
<p className="mt-1 text-sm font-medium text-content">{item.label}</p>
{detail && <p className="mt-1 text-xs text-content-secondary">{detail}</p>}
</div>
<span className="rounded-sm border border-current px-2 py-0.5 text-xs font-medium">
{SEVERITY_LABELS[item.severity]}
</span>
</div>
<p className="mt-2 inline-flex rounded-sm border border-edge px-2 py-0.5 text-xs font-medium text-content-secondary">
{RESPONSE_LABELS[item.responsePath]}
</p>
</div>
);
};

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

return (
<div className="mt-4" data-testid="current-process-state">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-content">
<Activity size={16} />
<h4>Current Process State</h4>
</div>
<span
className={`rounded-sm border px-2 py-0.5 text-xs font-medium ${SEVERITY_CLASS[state.overallSeverity]}`}
>
{SEVERITY_LABELS[state.overallSeverity]}
</span>
</div>

<div className="grid gap-2 sm:grid-cols-5">
{LENSES.map(lens => (
<StateCountCard key={lens} lens={lens} count={state.lensCounts[lens]} />
))}
</div>

{visibleItems.length > 0 ? (
<div className="mt-3 grid gap-2 lg:grid-cols-2">
{visibleItems.map(item => (
<StateItemCard key={item.id} item={item} />
))}
{hiddenCount > 0 && (
<p className="rounded-md border border-dashed border-edge px-3 py-3 text-sm text-content-secondary">
+{hiddenCount} more current-state items
</p>
)}
</div>
) : (
<p className="mt-3 rounded-md border border-dashed border-edge px-3 py-3 text-sm text-content-secondary">
No current process state signals yet
</p>
)}
</div>
);
};

export default ProcessHubCurrentStatePanel;
12 changes: 8 additions & 4 deletions apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { buildProcessHubCadence } from '@variscout/core';
import { buildCurrentProcessState, buildProcessHubCadence } from '@variscout/core';
import type { ProcessHubInvestigation, ProcessHubRollup } from '@variscout/core';
import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions';
import ProcessHubCadenceQueues from './ProcessHubCadenceQueues';
import ProcessHubCurrentStatePanel from './ProcessHubCurrentStatePanel';
import { formatLatestActivity } from './ProcessHubFormat';

interface ProcessHubReviewPanelProps {
Expand Down Expand Up @@ -43,7 +44,8 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onRecordHandoff,
}) => {
const cadence = buildProcessHubCadence(rollup);
const headingId = `process-hub-review-${rollup.hub.id}`;
const currentState = buildCurrentProcessState(rollup, cadence);
const headingId = `process-hub-current-state-${rollup.hub.id}`;

return (
<section
Expand All @@ -53,9 +55,9 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 id={headingId} className="text-lg font-semibold text-content">
{rollup.hub.name} Cadence Review
{rollup.hub.name} Current Process State
</h3>
<p className="mt-1 text-sm font-medium text-content-secondary">Cadence Review Board</p>
<p className="mt-1 text-sm font-medium text-content-secondary">Process state review</p>
<p className="mt-1 text-xs text-content-secondary">
{rollup.hub.processOwner?.displayName
? `Owner: ${rollup.hub.processOwner.displayName} · `
Expand All @@ -73,6 +75,8 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
</button>
</div>

<ProcessHubCurrentStatePanel state={currentState} />

<div className="mt-4 grid gap-2 sm:grid-cols-5">
<SnapshotCard
label="Active"
Expand Down
8 changes: 4 additions & 4 deletions apps/azure/src/components/editor/FrameView.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
/**
* FrameView (Azure) — FRAME workspace (ADR-070).
*
* Azure-app equivalent of the PWA FrameView. Renders `ProcessMapBase` wired
* to `projectStore.processContext.processMap` with live gap detection from
* Azure-app equivalent of the PWA FrameView. Renders `LayeredProcessView`
* wired to `projectStore.processContext.processMap` with live gap detection from
* `@variscout/core/frame`.
*
* V1 is deterministic-only: no CoScout, no templates. Pre-data hunches
* persist as draft SuspectedCause hubs through the projectStore +
* investigationStore; the full integration lands in follow-up.
*/
import React from 'react';
import { ProcessMapBase } from '@variscout/ui';
import { LayeredProcessView } from '@variscout/ui';
import { useProjectStore } from '@variscout/stores';
import type { ProcessContext } from '@variscout/core';
import { detectGaps, type ProcessMap } from '@variscout/core/frame';
Expand Down Expand Up @@ -72,7 +72,7 @@ const FrameView: React.FC = () => {
least one rational-subgroup axis.
</p>
</header>
<ProcessMapBase
<LayeredProcessView
map={map}
availableColumns={availableColumns}
onChange={handleChange}
Expand Down
33 changes: 33 additions & 0 deletions apps/azure/src/components/editor/__tests__/FrameView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';

// vi.mock MUST come before component imports (per writing-tests skill)
vi.mock('@variscout/stores', () => {
const setProcessContext = vi.fn();
const setSpecs = vi.fn();
return {
useProjectStore: vi.fn((selector: (s: unknown) => unknown) =>
selector({
rawData: [],
outcome: null,
specs: null,
setSpecs,
processContext: null,
setProcessContext,
})
),
};
});

import FrameView from '../FrameView';

describe('FrameView (Azure)', () => {
it('renders LayeredProcessView with three bands', () => {
render(<FrameView />);

expect(screen.getByTestId('layered-process-view')).toBeInTheDocument();
expect(screen.getByTestId('band-outcome')).toBeInTheDocument();
expect(screen.getByTestId('band-process-flow')).toBeInTheDocument();
expect(screen.getByTestId('band-operations')).toBeInTheDocument();
});
});
27 changes: 16 additions & 11 deletions apps/azure/src/pages/__tests__/Dashboard.processHub.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ describe('Dashboard Process Hub home', () => {
expect(screen.getByText('2 change signals')).toBeInTheDocument();
});

it('renders an inline Hub Cadence Review panel for the selected hub and opens review items', async () => {
it('renders an inline Current Process State panel for the selected hub and opens review items', async () => {
const onOpenProject = vi.fn();
mockListProjects.mockResolvedValue([
makeProject(),
Expand All @@ -214,8 +214,13 @@ describe('Dashboard Process Hub home', () => {
await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));

const panel = await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
expect(within(panel).getByText('Cadence Questions')).toBeInTheDocument();
const panel = await screen.findByRole('region', { name: 'Line 4 Current Process State' });
expect(within(panel).getByText('Current Process State')).toBeInTheDocument();
expect(within(panel).getByText('Capability below target')).toBeInTheDocument();
expect(within(panel).getAllByText('Focused investigation').length).toBeGreaterThan(0);
expect(within(panel).getAllByText('Outcome').length).toBeGreaterThan(0);
expect(within(panel).getAllByText('Measurement').length).toBeGreaterThan(0);
expect(within(panel).getByText('Process State Questions')).toBeInTheDocument();
expect(within(panel).getByText('Are we meeting the requirement?')).toBeInTheDocument();
expect(
within(panel).getByText('Fill weight must stay inside customer specs.')
Expand Down Expand Up @@ -263,7 +268,7 @@ describe('Dashboard Process Hub home', () => {
await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));

const panel = await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
const panel = await screen.findByRole('region', { name: 'Line 4 Current Process State' });
expect(within(panel).getAllByText('Readiness').length).toBeGreaterThan(0);
expect(within(panel).getAllByText('Frame missing process context 1').length).toBeGreaterThan(0);
expect(within(panel).getByText('Complete process context')).toBeInTheDocument();
Expand All @@ -272,7 +277,7 @@ describe('Dashboard Process Hub home', () => {
expect(within(panel).getByText('Map one customer-felt outcome.')).toBeInTheDocument();
});

it('renders a cadence review board with snapshot metrics and truncated queues', async () => {
it('renders a current process state board with snapshot metrics and truncated queues', async () => {
mockListProjects.mockResolvedValue([
makeProject(),
makeVerificationProject(),
Expand Down Expand Up @@ -303,8 +308,8 @@ describe('Dashboard Process Hub home', () => {
await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));

const panel = await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
expect(within(panel).getByText('Cadence Review Board')).toBeInTheDocument();
const panel = await screen.findByRole('region', { name: 'Line 4 Current Process State' });
expect(within(panel).getByText('Current Process State')).toBeInTheDocument();
expect(within(panel).getByText('Decision Queues')).toBeInTheDocument();
expect(within(panel).getByTestId('cadence-snapshot-active')).toHaveTextContent('7');
expect(within(panel).getByTestId('cadence-snapshot-readiness')).toHaveTextContent('7');
Expand All @@ -324,14 +329,14 @@ describe('Dashboard Process Hub home', () => {

await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));
await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
await screen.findByRole('region', { name: 'Line 4 Current Process State' });

fireEvent.change(screen.getByPlaceholderText('Search investigations...'), {
target: { value: 'zzzz no matching project' },
});

expect(screen.getByLabelText('Open Line 4')).toBeInTheDocument();
const panel = screen.getByRole('region', { name: 'Line 4 Cadence Review' });
const panel = screen.getByRole('region', { name: 'Line 4 Current Process State' });
expect(within(panel).getByTestId('cadence-snapshot-active')).toHaveTextContent('1');
expect(screen.queryByTestId('project-card')).not.toBeInTheDocument();
});
Expand All @@ -347,7 +352,7 @@ describe('Dashboard Process Hub home', () => {
await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));

const panel = await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
const panel = await screen.findByRole('region', { name: 'Line 4 Current Process State' });
expect(within(panel).getByText('Daily huddle')).toBeInTheDocument();
expect(within(panel).getByText('Weekly process review')).toBeInTheDocument();
expect(within(panel).getByText('No latest signals yet')).toBeInTheDocument();
Expand Down Expand Up @@ -390,7 +395,7 @@ describe('Dashboard Process Hub home', () => {
await screen.findByText('Line 4');
fireEvent.click(screen.getByLabelText('Open Line 4'));

const panel = await screen.findByRole('region', { name: 'Line 4 Cadence Review' });
const panel = await screen.findByRole('region', { name: 'Line 4 Current Process State' });
const headings = within(panel)
.getAllByRole('heading')
.map(h => h.textContent?.trim());
Expand Down
6 changes: 3 additions & 3 deletions apps/pwa/src/components/views/FrameView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* FrameView — PWA FRAME workspace (ADR-070).
*
* Renders the `ProcessMapBase` component wired to `projectStore.processContext.processMap`,
* Renders the `LayeredProcessView` component wired to `projectStore.processContext.processMap`,
* with live gap detection from `@variscout/core/frame`. The user builds the process map
* (SIPOC spine + tributaries + ocean/CTS + hunches). V2+ will add CoScout drafting,
* template libraries, and data-seeded skeletons — V1 is deterministic-only.
*/
import React from 'react';
import { ProcessMapBase } from '@variscout/ui';
import { LayeredProcessView } from '@variscout/ui';
import { useProjectStore } from '@variscout/stores';
import type { ProcessContext } from '@variscout/core';
import { detectGaps, type ProcessMap } from '@variscout/core/frame';
Expand Down Expand Up @@ -69,7 +69,7 @@ const FrameView: React.FC = () => {
least one rational-subgroup axis.
</p>
</header>
<ProcessMapBase
<LayeredProcessView
map={map}
availableColumns={availableColumns}
onChange={handleChange}
Expand Down
Loading