diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index 524052f60..e876b79c5 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -6,6 +6,7 @@ import { deriveResponsePathAction, } from '@variscout/core'; import type { + Finding, ProcessHubInvestigation, ProcessHubRollup, ProcessStateItem, @@ -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; @@ -75,6 +77,75 @@ const ProcessHubReviewPanel: React.FC = ({ (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` + // 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 ( @@ -111,7 +182,7 @@ const ProcessHubReviewPanel: React.FC = ({ actionFor, onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id), }} - evidence={{ findingsFor: () => [], onChipClick: () => {} }} + evidence={{ findingsFor, onChipClick: handleChipClick }} />
diff --git a/packages/core/src/__tests__/processEvidence.test.ts b/packages/core/src/__tests__/processEvidence.test.ts new file mode 100644 index 000000000..c942956ff --- /dev/null +++ b/packages/core/src/__tests__/processEvidence.test.ts @@ -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 => ({ + 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 => ({ + 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); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aa929f6e6..46555bc53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/processEvidence.ts b/packages/core/src/processEvidence.ts new file mode 100644 index 000000000..056690a02 --- /dev/null +++ b/packages/core/src/processEvidence.ts @@ -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 = new Set([ + 'analyzed', + 'improving', + 'resolved', +]); + +export interface LinkFindingsResult { + byItemId: Map; + 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, + resolveInvestigationIds: (item: ProcessStateItem) => readonly string[] | undefined +): LinkFindingsResult { + const byItemId = new Map(); + 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(); + 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 }; +} diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx index 37552aa0f..25a47ad52 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx @@ -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 ( + + ); +}; + 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'; @@ -190,10 +214,15 @@ const StateItemCard: React.FC<{ {SEVERITY_LABELS[item.severity]}
-

- {RESPONSE_LABELS[item.responsePath]} - {unsupportedReason !== null && · {UNSUPPORTED_PILL_LABEL[unsupportedReason]}} -

+
+

+ {RESPONSE_LABELS[item.responsePath]} + {unsupportedReason !== null && ( + · {UNSUPPORTED_PILL_LABEL[unsupportedReason]} + )} +

+ onChipClick(item, findings)} /> +
); }; @@ -201,7 +230,7 @@ const StateItemCard: React.FC<{ export const ProcessHubCurrentStatePanel: React.FC = ({ 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); @@ -234,6 +263,8 @@ export const ProcessHubCurrentStatePanel: React.FC ))} {hiddenCount > 0 && ( diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx index 0da004543..097932c8f 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx @@ -1,7 +1,12 @@ import { render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { vi } from 'vitest'; -import type { CurrentProcessState, ProcessStateItem, ProcessStateLens } from '@variscout/core'; +import type { + CurrentProcessState, + Finding, + ProcessStateItem, + ProcessStateLens, +} from '@variscout/core'; import type { ResponsePathAction } from '@variscout/core'; import { ProcessHubCurrentStatePanel } from '../ProcessHubCurrentStatePanel'; @@ -319,3 +324,115 @@ describe('ProcessHubCurrentStatePanel — actions', () => { expect(actions.onInvoke).toHaveBeenCalledWith(item, action); }); }); + +describe('ProcessHubCurrentStatePanel — evidence chip', () => { + it('shows the chip with finding count when findingsFor returns non-empty', () => { + const item = buildItem({ id: 'item-e1', responsePath: 'focused-investigation' }); + const findings = [ + { id: 'f-1' } as unknown as Finding, + { id: 'f-2' } as unknown as Finding, + { id: 'f-3' } as unknown as Finding, + ]; + const evidence = { + findingsFor: () => findings, + onChipClick: vi.fn(), + }; + + render( + + ); + + const chip = screen.getByTestId('current-state-evidence-chip'); + expect(chip).toHaveTextContent('3 findings'); + }); + + it('shows singular text for one finding', () => { + const item = buildItem({ id: 'item-e2' }); + const evidence = { + findingsFor: () => [{ id: 'f-only' } as unknown as Finding], + onChipClick: vi.fn(), + }; + + render( + + ); + + expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding'); + }); + + it('omits the chip when findingsFor returns empty', () => { + const item = buildItem({ id: 'item-e3' }); + const evidence = { + findingsFor: () => [], + onChipClick: vi.fn(), + }; + + render( + + ); + + expect(screen.queryByTestId('current-state-evidence-chip')).not.toBeInTheDocument(); + }); + + it('fires onChipClick with item + findings on chip click and stops card propagation', () => { + const item = buildItem({ id: 'item-e4', responsePath: 'focused-investigation' }); + const findings = [{ id: 'f-1' } as unknown as Finding]; + const onChipClick = vi.fn(); + const onInvoke = vi.fn(); + + render( + ({ + kind: 'open-investigation' as const, + investigationId: 'inv-x', + intent: 'focused' as const, + }), + onInvoke, + }} + evidence={{ findingsFor: () => findings, onChipClick }} + /> + ); + + const chip = screen.getByTestId('current-state-evidence-chip'); + chip.click(); + + expect(onChipClick).toHaveBeenCalledWith(item, findings); + // Card click should NOT have fired because chip stops propagation + expect(onInvoke).not.toHaveBeenCalled(); + }); + + it('renders chip on Planned/unsupported cards too (chip independent of action support)', () => { + const item = buildItem({ id: 'item-e5', responsePath: 'measurement-system-work' }); + const findings = [{ id: 'f-1' } as unknown as Finding]; + const evidence = { + findingsFor: () => findings, + onChipClick: vi.fn(), + }; + + render( + ({ kind: 'unsupported' as const, reason: 'planned' as const }), + })} + evidence={evidence} + /> + ); + + expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding'); + }); +});