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
73 changes: 72 additions & 1 deletion apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deriveResponsePathAction,
} from '@variscout/core';
import type {
Finding,
ProcessHubInvestigation,
ProcessHubRollup,
ProcessStateItem,
Expand All @@ -15,6 +16,7 @@ import { ProcessHubCurrentStatePanel } from '@variscout/ui';
import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions';
import ProcessHubCadenceQueues from './ProcessHubCadenceQueues';
import { formatLatestActivity } from './ProcessHubFormat';
import { safeTrackEvent } from '../lib/appInsights';

interface ProcessHubReviewPanelProps {
rollup: ProcessHubRollup<ProcessHubInvestigation>;
Expand Down Expand Up @@ -75,6 +77,75 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
(item: ProcessStateItem) => deriveResponsePathAction(item, defaultInvestigationId),
[defaultInvestigationId]
);

// Resolver: given a state item, return the investigation IDs whose findings
// should "back" it. Mirrors the spec's Investigation-ID resolver table.
//
// For per-investigation items (queue items), use item.investigationIds[0..N].
// For hub-aggregate items (capability-gap, change-signals, top-focus, etc.),
// fall back to all investigations in the hub.
const investigationIdResolver = React.useCallback(
(item: ProcessStateItem): readonly string[] => {
if (item.investigationIds && item.investigationIds.length > 0) {
return item.investigationIds;
}
return rollup.investigations.map(inv => inv.id);
},
[rollup.investigations]
);

// findingsFor: chip count is derived from
// ProcessHubInvestigationMetadata.findingCounts (already on the rollup —
// no extra data load needed). Returns an array of synthetic Finding-shaped
// placeholder objects with just enough surface (id) for the chip to count.
//
// Real Finding[] objects aren't loaded hub-wide on Dashboard today. The
// EvidenceSheet rendering (which would need the full objects) is deferred
// to a follow-up PR — see plan PR #5 future work.
const findingsFor = React.useCallback(
(item: ProcessStateItem): readonly Finding[] => {
const investigationIds = investigationIdResolver(item);
let totalRelevantCount = 0;
for (const invId of investigationIds) {
const inv = rollup.investigations.find(i => i.id === invId);
const counts = inv?.metadata?.findingCounts ?? {};
totalRelevantCount +=
(counts.analyzed ?? 0) + (counts.improving ?? 0) + (counts.resolved ?? 0);
}
if (totalRelevantCount === 0) return [];
// Placeholder objects carry only `id` — chip rendering only reads `.length`.
// The double cast is the documented pragmatic shortcut for this PR (see
// spec § Implementation Reality Notes). The follow-up EvidenceSheet PR
// will introduce a narrow `FindingCountPlaceholder = Pick<Finding, 'id'>`
// type at the panel's evidence-contract boundary so the cast goes away.
// TODO(evidence-sheet-pr): remove this cast once contracts narrow.
return Array.from({ length: totalRelevantCount }, (_, idx) => ({
id: `${item.id}-finding-placeholder-${idx}`,
})) as unknown as readonly Finding[];
},
[rollup.investigations, investigationIdResolver]
);

const handleChipClick = React.useCallback(
(item: ProcessStateItem, findings: readonly Finding[]) => {
safeTrackEvent('process_hub.evidence_chip_click', {
hubId: rollup.hub.id,
responsePath: item.responsePath,
lens: item.lens,
evidenceCount: findings.length,
});
// Navigate to a linked investigation. Per-investigation items use
// item.investigationIds[0] (the order the projection builder produced —
// typically a single linked investigation, so most-recency is moot).
// Hub-aggregate items fall back to defaultInvestigationId, which is
// sorted by `modified` descending. Full sheet rendering deferred to a
// follow-up PR — when Dashboard loads findings hub-wide.
const targetId = item.investigationIds?.[0] ?? defaultInvestigationId;
if (targetId) onOpenInvestigation(targetId);
},
[rollup.hub.id, defaultInvestigationId, onOpenInvestigation]
);

const headingId = `process-hub-current-state-${rollup.hub.id}`;

return (
Expand Down Expand Up @@ -111,7 +182,7 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
actionFor,
onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id),
}}
evidence={{ findingsFor: () => [], onChipClick: () => {} }}
evidence={{ findingsFor, onChipClick: handleChipClick }}
/>

<div className="mt-4 grid gap-2 sm:grid-cols-5">
Expand Down
138 changes: 138 additions & 0 deletions packages/core/src/__tests__/processEvidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import type { Finding } from '../findings/types';
import type { ProcessStateItem } from '../processState';
import { linkFindingsToStateItems, RELEVANT_FINDING_STATUSES } from '../processEvidence';

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

// Sequential ID counter for deterministic test fixtures (per packages/core/CLAUDE.md
// hard rule: never Math.random in tests).
let findingSeq = 0;
const baseFinding = (overrides: Partial<Finding> = {}): Finding => ({
id: `finding-${++findingSeq}`,
text: 'A finding',
createdAt: 1714000000000,
context: {} as Finding['context'],
status: 'analyzed',
comments: [],
statusChangedAt: 1714000000000,
...overrides,
});

describe('RELEVANT_FINDING_STATUSES', () => {
it('includes analyzed, improving, resolved', () => {
expect(RELEVANT_FINDING_STATUSES.has('analyzed')).toBe(true);
expect(RELEVANT_FINDING_STATUSES.has('improving')).toBe(true);
expect(RELEVANT_FINDING_STATUSES.has('resolved')).toBe(true);
});

it('excludes observed and investigating', () => {
expect(RELEVANT_FINDING_STATUSES.has('observed')).toBe(false);
expect(RELEVANT_FINDING_STATUSES.has('investigating')).toBe(false);
});
});

describe('linkFindingsToStateItems', () => {
it('returns an empty result when there are no items', () => {
const result = linkFindingsToStateItems([], new Map(), () => []);
expect(result.byItemId.size).toBe(0);
expect(result.totalLinked).toBe(0);
expect(result.unlinkedItemIds).toEqual([]);
});

it('returns an empty mapping per item when findingsByInvestigationId is empty', () => {
const items = [baseItem({ id: 'item-a' }), baseItem({ id: 'item-b' })];
const result = linkFindingsToStateItems(items, new Map(), () => ['inv-1']);
expect(result.byItemId.get('item-a')).toEqual([]);
expect(result.byItemId.get('item-b')).toEqual([]);
expect(result.totalLinked).toBe(0);
expect(result.unlinkedItemIds).toEqual(['item-a', 'item-b']);
});

it('matches findings by resolver-returned investigation IDs', () => {
const findings = new Map([
['inv-1', [baseFinding({ id: 'f-1' }), baseFinding({ id: 'f-2' })]],
['inv-2', [baseFinding({ id: 'f-3' })]],
]);
const items = [baseItem({ id: 'item-x' }), baseItem({ id: 'item-y' })];
const result = linkFindingsToStateItems(items, findings, item =>
item.id === 'item-x' ? ['inv-1'] : ['inv-2']
);
expect(result.byItemId.get('item-x')).toHaveLength(2);
expect(result.byItemId.get('item-y')).toHaveLength(1);
expect(result.totalLinked).toBe(3);
expect(result.unlinkedItemIds).toEqual([]);
});

it('filters findings outside RELEVANT_FINDING_STATUSES', () => {
const findings = new Map([
[
'inv-1',
[
baseFinding({ id: 'analyzed', status: 'analyzed' }),
baseFinding({ id: 'observed', status: 'observed' }),
baseFinding({ id: 'investigating', status: 'investigating' }),
baseFinding({ id: 'resolved', status: 'resolved' }),
],
],
]);
const result = linkFindingsToStateItems([baseItem()], findings, () => ['inv-1']);
const linked = result.byItemId.get('item-1') ?? [];
expect(linked.map(f => f.id)).toEqual(['analyzed', 'resolved']);
});

it('aggregates findings across multiple investigation IDs returned by resolver', () => {
const findings = new Map([
['inv-1', [baseFinding({ id: 'f-1' })]],
['inv-2', [baseFinding({ id: 'f-2' }), baseFinding({ id: 'f-3' })]],
['inv-3', [baseFinding({ id: 'f-4' })]],
]);
const result = linkFindingsToStateItems([baseItem({ id: 'agg' })], findings, () => [
'inv-1',
'inv-2',
'inv-3',
]);
expect(result.byItemId.get('agg')).toHaveLength(4);
});

it('deduplicates investigation IDs returned by resolver', () => {
const findings = new Map([['inv-1', [baseFinding()]]]);
const result = linkFindingsToStateItems([baseItem()], findings, () => [
'inv-1',
'inv-1',
'inv-1',
]);
expect(result.byItemId.get('item-1')).toHaveLength(1);
});

it('treats undefined resolver return as empty array', () => {
const findings = new Map([['inv-1', [baseFinding()]]]);
const result = linkFindingsToStateItems(
[baseItem()],
findings,

() => undefined as unknown as string[]
);
expect(result.byItemId.get('item-1')).toEqual([]);
expect(result.unlinkedItemIds).toEqual(['item-1']);
});

it('collects unlinkedItemIds for items with zero matched findings', () => {
const findings = new Map([['inv-1', [baseFinding()]]]);
const result = linkFindingsToStateItems(
[baseItem({ id: 'matched' }), baseItem({ id: 'no-match' })],
findings,
item => (item.id === 'matched' ? ['inv-1'] : ['inv-other'])
);
expect(result.unlinkedItemIds).toEqual(['no-match']);
expect(result.totalLinked).toBe(1);
});
});
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ export type {
ProcessStateSeverity,
ProcessStateSource,
} from './processState';

// Process Evidence — link findings to current-state items
export { linkFindingsToStateItems, RELEVANT_FINDING_STATUSES } from './processEvidence';
export type { LinkFindingsResult } from './processEvidence';
export { deriveResponsePathAction } from './responsePathAction';
export type { ResponsePathAction } from './responsePathAction';

Expand Down
73 changes: 73 additions & 0 deletions packages/core/src/processEvidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Finding, FindingStatus } from './findings/types';
import type { ProcessStateItem } from './processState';

/**
* Finding statuses that count as "evidence" for a state item.
*
* - 'observed' and 'investigating' are too early — observations, not findings.
* - 'analyzed', 'improving', 'resolved' represent findings that have passed
* through the investigation lifecycle.
*/
export const RELEVANT_FINDING_STATUSES: ReadonlySet<FindingStatus> = new Set([
'analyzed',
'improving',
'resolved',
]);

export interface LinkFindingsResult {
byItemId: Map<string, readonly Finding[]>;
totalLinked: number;
unlinkedItemIds: string[];
}

/**
* Pure 2-input join: state items × findings (grouped by investigation).
*
* The caller pre-groups findings by investigation ID (cheaper than a flat
* findings[] when items match many investigations). The caller also provides
* a resolver that says which investigation IDs each item is linked to —
* the resolver embodies the per-item-type linkage rules (see spec).
*
* Findings are filtered to RELEVANT_FINDING_STATUSES.
*
* Returns a map suitable for direct lookup, plus reporting helpers
* (totalLinked, unlinkedItemIds).
*/
export function linkFindingsToStateItems(
items: readonly ProcessStateItem[],
findingsByInvestigationId: ReadonlyMap<string, readonly Finding[]>,
resolveInvestigationIds: (item: ProcessStateItem) => readonly string[] | undefined
): LinkFindingsResult {
const byItemId = new Map<string, readonly Finding[]>();
const unlinkedItemIds: string[] = [];
let totalLinked = 0;

for (const item of items) {
const investigationIds = resolveInvestigationIds(item);
if (!investigationIds || investigationIds.length === 0) {
byItemId.set(item.id, []);
unlinkedItemIds.push(item.id);
continue;
}

const seen = new Set<string>();
const linked: Finding[] = [];
for (const invId of investigationIds) {
if (seen.has(invId)) continue;
seen.add(invId);
const findings = findingsByInvestigationId.get(invId);
if (!findings) continue;
for (const f of findings) {
if (RELEVANT_FINDING_STATUSES.has(f.status)) {
linked.push(f);
}
}
}

byItemId.set(item.id, linked);
totalLinked += linked.length;
if (linked.length === 0) unlinkedItemIds.push(item.id);
}

return { byItemId, totalLinked, unlinkedItemIds };
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,35 @@ const formatStateDetail = (item: ProcessStateItem): string | null => {
return item.detail ?? null;
};

const EvidenceChip: React.FC<{
count: number;
onClick: () => void;
}> = ({ count, onClick }) => {
if (count === 0) return null;
const label = formatPlural(count, { one: 'finding', other: 'findings' });
return (
<button
type="button"
onClick={event => {
event.stopPropagation();
onClick();
}}
data-testid="current-state-evidence-chip"
className="inline-flex items-center gap-1 rounded-sm border border-edge px-2 py-0.5 text-xs font-medium text-content-secondary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<span aria-hidden>ⓘ</span>
{count} {label}
</button>
);
};

const StateItemCard: React.FC<{
item: ProcessStateItem;
action: ResponsePathAction;
onInvoke: (item: ProcessStateItem, action: ResponsePathAction) => void;
}> = ({ item, action, onInvoke }) => {
findings: readonly Finding[];
onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void;
}> = ({ item, action, onInvoke, findings, onChipClick }) => {
const detail = formatStateDetail(item);
const isSupported = action.kind !== 'unsupported';

Expand Down Expand Up @@ -190,18 +214,23 @@ const StateItemCard: React.FC<{
{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">
<span>{RESPONSE_LABELS[item.responsePath]}</span>
{unsupportedReason !== null && <span> · {UNSUPPORTED_PILL_LABEL[unsupportedReason]}</span>}
</p>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="inline-flex rounded-sm border border-edge px-2 py-0.5 text-xs font-medium text-content-secondary">
<span>{RESPONSE_LABELS[item.responsePath]}</span>
{unsupportedReason !== null && (
<span> · {UNSUPPORTED_PILL_LABEL[unsupportedReason]}</span>
)}
</p>
<EvidenceChip count={findings.length} onClick={() => onChipClick(item, findings)} />
</div>
</div>
);
};

export const ProcessHubCurrentStatePanel: React.FC<ProcessHubCurrentStatePanelProps> = ({
state,
actions,
evidence: _evidence, // unused in PR #4 — wired in PR #5
evidence,
}) => {
const visibleItems = state.items.slice(0, 6);
const hiddenCount = Math.max(0, state.items.length - visibleItems.length);
Expand Down Expand Up @@ -234,6 +263,8 @@ export const ProcessHubCurrentStatePanel: React.FC<ProcessHubCurrentStatePanelPr
item={item}
action={actions.actionFor(item)}
onInvoke={actions.onInvoke}
findings={evidence.findingsFor(item)}
onChipClick={evidence.onChipClick}
/>
))}
{hiddenCount > 0 && (
Expand Down
Loading