import { useApp } from '../../app/AppProvider'; import { useEnginePreferences } from '../../app/engine-preferences'; import type { FindingRecord } from '../../domain/finding'; import { gpoRecordSettingKey } from '../../comparison/gpo-record-key'; import type { DynamicFacet } from '../search-filter/dynamic-facets'; import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report'; import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison'; /** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */ export const COMPARISON_CHANGE_FACET_ID = 'comparison:change'; const SOURCE_SCAN_FACET_ID = 'provenance:source-scan'; /** Facet order: better first, then worse, then neutral and unclear states. */ export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [ { change: 'resolved', label: 'Resolved' }, { change: 'new', label: 'New' }, { change: 'regressed', label: 'Regressed' }, { change: 'value-changed', label: 'Value changed' }, { change: 'unchanged', label: 'Unchanged' }, { change: 'unverified', label: 'Unverified, still open' }, { change: 'not-applicable', label: 'Not applicable' }, { change: 'not-comparable', label: 'Not comparable' } ]; export interface ComparisonWorkFacetIndex { facets: DynamicFacet[]; /** The change of a Work record of timeslot B, if the comparison names one. */ changeOf(record: FindingRecord): TimeslotChange | undefined; /** The full comparison entry behind that change, for the Finding Reader. */ entryOf(record: FindingRecord): TimeslotChangeEntry | undefined; comparison: PublishedComparison; } /** * Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the * B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs. * A deviation of A without a B record has no Work row in B and is therefore not offered. */ export function entryIndex(report: TimeslotComparisonReport) { const byOccurrence = new Map(); const bySetting = new Map(); for (const entry of report.changes) { if (entry.sourceId === GPO_SOURCE_ID) { if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry); continue; } for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry); } return (record: FindingRecord): TimeslotChangeEntry | undefined => { const scanner = record.findingOccurrenceId ? byOccurrence.get(record.findingOccurrenceId) : undefined; if (scanner) return scanner; const key = gpoRecordSettingKey(record); return key ? bySetting.get(key) : undefined; }; } /** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */ export function changesWithWorkRecords(report: TimeslotComparisonReport): Set { return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change)); } const indexes = new WeakMap(); export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex { const existing = indexes.get(comparison); if (existing) return existing; const entryOf = entryIndex(comparison.report); const changeOf = (record: FindingRecord) => entryOf(record)?.change; const date = new Date(comparison.baseline.date); const label = Number.isNaN(date.getTime()) ? comparison.baseline.date : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); const index: ComparisonWorkFacetIndex = { comparison, changeOf, entryOf, facets: [{ id: COMPARISON_CHANGE_FACET_ID, label: 'Change', source: 'derived', kind: 'category', description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`, values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })), valuesFor: record => { const change = changeOf(record); return change ? [change] : []; } }] }; indexes.set(comparison, index); return index; } /** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */ export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] { if (!index) return facets; const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID); return sourceScan < 0 ? [...facets, ...index.facets] : [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)]; } /** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */ export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined { const app = useApp(); const enabled = useEnginePreferences().comparison; const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []); return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined; } /** * Work record IDs of one change in the timeslot that is open: scanner records by occurrence (the A records of * a baseline entry, or the B records and counterparts), GPO settings by scope and technical identity. */ export function workRecordIdsFor(entry: TimeslotChangeEntry, timeslot: 'baseline' | 'target', records: FindingRecord[]): string[] { if (entry.sourceId === GPO_SOURCE_ID) { if (!entry.identityKey) return []; return records.filter(record => { return gpoRecordSettingKey(record) === entry.identityKey; }).map(record => record.id); } const occurrences = new Set(timeslot === 'baseline' ? (entry.side === 'baseline' ? entry.recordIds : entry.counterpartIds) : (entry.side === 'target' ? entry.recordIds : entry.counterpartIds)); return records.filter(record => record.findingOccurrenceId && occurrences.has(record.findingOccurrenceId)).map(record => record.id); }