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
43 changes: 40 additions & 3 deletions apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { buildCurrentProcessState, buildProcessHubCadence } from '@variscout/core';
import type { ProcessHubInvestigation, ProcessHubRollup } from '@variscout/core';
import {
buildCurrentProcessState,
buildProcessHubCadence,
deriveResponsePathAction,
} from '@variscout/core';
import type {
ProcessHubInvestigation,
ProcessHubRollup,
ProcessStateItem,
ResponsePathAction,
} from '@variscout/core';
import { ProcessHubCurrentStatePanel } from '@variscout/ui';
import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions';
import ProcessHubCadenceQueues from './ProcessHubCadenceQueues';
Expand All @@ -14,6 +23,7 @@ interface ProcessHubReviewPanelProps {
onSetupSustainment: (investigationId: string) => void;
onLogReview: (recordId: string) => void;
onRecordHandoff: (investigationId: string) => void;
onResponsePathAction: (item: ProcessStateItem, action: ResponsePathAction, hubId: string) => void;
}

const SnapshotCard: React.FC<{
Expand Down Expand Up @@ -42,9 +52,29 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onSetupSustainment,
onLogReview,
onRecordHandoff,
onResponsePathAction,
}) => {
const cadence = buildProcessHubCadence(rollup);
const currentState = buildCurrentProcessState(rollup, cadence);

// Pick the most-recently-modified investigation in this hub as the
// default navigation target for hub-aggregate state items (capability-gap,
// change-signals, top-focus). For per-investigation items, the action
// uses item.investigationIds[0] instead.
const defaultInvestigationId = React.useMemo(() => {
const sorted = [...rollup.investigations].sort((a, b) =>
(b.modified ?? '').localeCompare(a.modified ?? '')
);
// Empty fallback when the rollup has no investigations: deriveResponsePathAction
// will then return unsupported actions for hub-aggregate items, which actionToHref
// maps to null, producing a silent no-op (correct UX — nothing to navigate to).
return sorted[0]?.id ?? '';
}, [rollup.investigations]);

const actionFor = React.useCallback(
(item: ProcessStateItem) => deriveResponsePathAction(item, defaultInvestigationId),
[defaultInvestigationId]
);
const headingId = `process-hub-current-state-${rollup.hub.id}`;

return (
Expand Down Expand Up @@ -75,7 +105,14 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
</button>
</div>

<ProcessHubCurrentStatePanel state={currentState} />
<ProcessHubCurrentStatePanel
state={currentState}
actions={{
actionFor,
onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id),
}}
evidence={{ findingsFor: () => [], onChipClick: () => {} }}
/>

<div className="mt-4 grid gap-2 sm:grid-cols-5">
<SnapshotCard
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`actionToHref > snapshot — URL shapes are stable 1`] = `
{
"chartered": "/editor/X?intent=chartered",
"focused": "/editor/X?intent=focused",
"quick": "/editor/X?intent=quick",
"sustainmentHandoff": "/editor/X/sustainment?surface=handoff",
"sustainmentReview": "/editor/X/sustainment",
"unsupportedInfo": null,
"unsupportedPlanned": null,
}
`;
94 changes: 94 additions & 0 deletions apps/azure/src/lib/__tests__/processHubRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import type { ResponsePathAction } from '@variscout/core';
import { actionToHref } from '../processHubRoutes';

describe('actionToHref', () => {
it('returns null for unsupported actions', () => {
const action: ResponsePathAction = { kind: 'unsupported', reason: 'planned' };
expect(actionToHref(action)).toBeNull();
});

it('returns null for unsupported/informational', () => {
const action: ResponsePathAction = { kind: 'unsupported', reason: 'informational' };
expect(actionToHref(action)).toBeNull();
});

it('builds /editor/:id?intent=focused for open-investigation/focused', () => {
const action: ResponsePathAction = {
kind: 'open-investigation',
investigationId: 'inv-123',
intent: 'focused',
};
expect(actionToHref(action)).toBe('/editor/inv-123?intent=focused');
});

it('builds /editor/:id?intent=chartered for open-investigation/chartered', () => {
const action: ResponsePathAction = {
kind: 'open-investigation',
investigationId: 'inv-abc',
intent: 'chartered',
};
expect(actionToHref(action)).toBe('/editor/inv-abc?intent=chartered');
});

it('builds /editor/:id?intent=quick for open-investigation/quick', () => {
const action: ResponsePathAction = {
kind: 'open-investigation',
investigationId: 'inv-q',
intent: 'quick',
};
expect(actionToHref(action)).toBe('/editor/inv-q?intent=quick');
});

it('builds /editor/:id/sustainment for open-sustainment/review', () => {
const action: ResponsePathAction = {
kind: 'open-sustainment',
investigationId: 'inv-s',
surface: 'review',
};
expect(actionToHref(action)).toBe('/editor/inv-s/sustainment');
});

it('builds /editor/:id/sustainment?surface=handoff for open-sustainment/handoff', () => {
const action: ResponsePathAction = {
kind: 'open-sustainment',
investigationId: 'inv-h',
surface: 'handoff',
};
expect(actionToHref(action)).toBe('/editor/inv-h/sustainment?surface=handoff');
});

it('snapshot — URL shapes are stable', () => {
expect({
focused: actionToHref({
kind: 'open-investigation',
investigationId: 'X',
intent: 'focused',
}),
chartered: actionToHref({
kind: 'open-investigation',
investigationId: 'X',
intent: 'chartered',
}),
quick: actionToHref({ kind: 'open-investigation', investigationId: 'X', intent: 'quick' }),
sustainmentReview: actionToHref({
kind: 'open-sustainment',
investigationId: 'X',
surface: 'review',
}),
sustainmentHandoff: actionToHref({
kind: 'open-sustainment',
investigationId: 'X',
surface: 'handoff',
}),
unsupportedPlanned: actionToHref({ kind: 'unsupported', reason: 'planned' }),
unsupportedInfo: actionToHref({ kind: 'unsupported', reason: 'informational' }),
}).toMatchSnapshot();
});

it('exhaustive switch — adding a new ResponsePathAction kind without a case is a compile error', () => {
// @ts-expect-error — if this stops erroring, a new ResponsePathAction kind
// was added in @variscout/core without a matching case in actionToHref.
expect(() => actionToHref({ kind: 'not-a-real-kind' } as ResponsePathAction)).toThrow();
});
});
22 changes: 22 additions & 0 deletions apps/azure/src/lib/appInsights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ export function trackException(error: Error, severityLevel?: number): void {
appInsights?.trackException({ exception: error, severityLevel });
}

/**
* Safe wrapper around App Insights' trackEvent.
*
* Telemetry must NEVER block UX. If App Insights is unavailable (local dev,
* SDK not loaded, transient failure), this silently swallows the error.
*
* Per ADR-059, payload MUST NOT contain PII (no labels, names, descriptions,
* customer text, raw column names). Stick to enum values, hashed/opaque IDs,
* and integers.
*/
export function safeTrackEvent(
name: string,
properties: Record<string, string | number | boolean | undefined>
): void {
if (!appInsights) return;
try {
appInsights.trackEvent({ name }, properties);
} catch {
// Telemetry failure is never load-bearing.
}
}

/**
* Flush pending AI traces to Application Insights as custom events.
* Reads from the in-memory trace buffer and sends only new traces
Expand Down
20 changes: 20 additions & 0 deletions apps/azure/src/lib/processHubRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { assertNever, type ResponsePathAction } from '@variscout/core';

/**
* Single URL source for ProcessHub state-item actions.
* Exhaustive switch on action.kind. Returns null for 'unsupported' actions.
*/
export function actionToHref(action: ResponsePathAction): string | null {
switch (action.kind) {
case 'unsupported':
return null;
case 'open-investigation':
return `/editor/${action.investigationId}?intent=${action.intent}`;
case 'open-sustainment': {
const base = `/editor/${action.investigationId}/sustainment`;
return action.surface === 'handoff' ? `${base}?surface=handoff` : base;
}
default:
return assertNever(action);
}
}
27 changes: 27 additions & 0 deletions apps/azure/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import {
} from '@variscout/core';
import type { ProcessHub, SustainmentRecord, ControlHandoff } from '@variscout/core';
import type { EvidenceSnapshot } from '@variscout/core';
import type { ProcessStateItem, ResponsePathAction } from '@variscout/core';
import { actionToHref } from '../lib/processHubRoutes';
import { safeTrackEvent } from '../lib/appInsights';
import type { SampleDataset } from '@variscout/data';
import { useStorage, type CloudProject, downloadFileFromGraph } from '../services/storage';
import { getEasyAuthUser } from '../auth/easyAuth';
Expand Down Expand Up @@ -219,6 +222,29 @@ export const Dashboard: React.FC<DashboardProps> = ({
[onOpenProject]
);

const handleResponsePathAction = useCallback(
(item: ProcessStateItem, action: ResponsePathAction, hubId: string) => {
const href = actionToHref(action);
if (!href) return; // unsupported

safeTrackEvent('process_hub.response_path_click', {
hubId,
responsePath: item.responsePath,
lens: item.lens,
severity: item.severity,
});

// Dashboard already exposes onOpenProject for investigation navigation.
// For now, route through that callback by extracting the investigation
// id from the action. Full URL routing (intent + sustainment surface
// query params) is a follow-up — see plan PR #4 Task 12 note.
if (action.kind === 'open-investigation' || action.kind === 'open-sustainment') {
onOpenProject(action.investigationId);
}
},
[onOpenProject, safeTrackEvent]
);

const handleSampleSelect = (sample: SampleDataset): void => {
if (onLoadSample) {
onLoadSample(sample);
Expand Down Expand Up @@ -437,6 +463,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
onSetupSustainment={handleSetupSustainment}
onLogReview={handleLogReview}
onRecordHandoff={handleRecordHandoff}
onResponsePathAction={handleResponsePathAction}
/>
<ProcessHubEvidencePanel
hubId={selectedHubRollup.hub.id}
Expand Down
118 changes: 118 additions & 0 deletions packages/core/src/__tests__/responsePathAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import type { ProcessStateItem } from '../processState';
import { deriveResponsePathAction } from '../responsePathAction';

const baseItem = (overrides: Partial<ProcessStateItem> = {}): ProcessStateItem => ({
id: 'item-1',
lens: 'outcome',
severity: 'amber',
responsePath: 'monitor',
source: 'review-signal',
label: 'Item label',
...overrides,
});

const DEFAULT_ID = 'inv-default';

describe('deriveResponsePathAction', () => {
it('returns unsupported/informational for monitor', () => {
const action = deriveResponsePathAction(baseItem({ responsePath: 'monitor' }), DEFAULT_ID);
expect(action).toEqual({ kind: 'unsupported', reason: 'informational' });
});

it('returns unsupported/planned for measurement-system-work', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'measurement-system-work' }),
DEFAULT_ID
);
expect(action).toEqual({ kind: 'unsupported', reason: 'planned' });
});

it('maps quick-action to open-investigation/quick using defaultInvestigationId', () => {
const action = deriveResponsePathAction(baseItem({ responsePath: 'quick-action' }), DEFAULT_ID);
expect(action).toEqual({
kind: 'open-investigation',
investigationId: DEFAULT_ID,
intent: 'quick',
});
});

it('maps focused-investigation to open-investigation/focused', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'focused-investigation' }),
DEFAULT_ID
);
expect(action).toEqual({
kind: 'open-investigation',
investigationId: DEFAULT_ID,
intent: 'focused',
});
});

it('maps chartered-project to open-investigation/chartered', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'chartered-project' }),
DEFAULT_ID
);
expect(action).toEqual({
kind: 'open-investigation',
investigationId: DEFAULT_ID,
intent: 'chartered',
});
});

it('maps sustainment-review to open-sustainment/review', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'sustainment-review' }),
DEFAULT_ID
);
expect(action).toEqual({
kind: 'open-sustainment',
investigationId: DEFAULT_ID,
surface: 'review',
});
});

it('maps control-handoff to open-sustainment/handoff', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'control-handoff' }),
DEFAULT_ID
);
expect(action).toEqual({
kind: 'open-sustainment',
investigationId: DEFAULT_ID,
surface: 'handoff',
});
});

it('uses item.investigationIds[0] when present (queue items)', () => {
const action = deriveResponsePathAction(
baseItem({
responsePath: 'focused-investigation',
investigationIds: ['inv-from-item', 'inv-other'],
}),
DEFAULT_ID
);
expect(action).toMatchObject({ kind: 'open-investigation', investigationId: 'inv-from-item' });
});

it('falls back to defaultInvestigationId when item.investigationIds is empty', () => {
const action = deriveResponsePathAction(
baseItem({ responsePath: 'focused-investigation', investigationIds: [] }),
DEFAULT_ID
);
expect(action).toMatchObject({ kind: 'open-investigation', investigationId: DEFAULT_ID });
});

it('exhaustive switch — adding a new ProcessStateResponsePath without a case is a compile error', () => {
// The @ts-expect-error below asserts that passing a plain string (not a valid
// ProcessStateResponsePath) to deriveResponsePathAction is a type error.
// If this directive becomes "unused" (i.e. tsc stops erroring here), it means
// the function's signature was widened unexpectedly — investigate before suppressing.
// At runtime the invalid value hits assertNever and throws — that is expected behaviour.
expect(() =>
// @ts-expect-error — 'not-a-real-response-path' is not assignable to ProcessStateResponsePath
deriveResponsePathAction(baseItem({ responsePath: 'not-a-real-response-path' }), DEFAULT_ID)
).toThrow();
});
});
Loading