Assessor_2.0/src/features/comparison/comparison-work-facets.ts

122 lines
6.2 KiB
TypeScript

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<string, TimeslotChangeEntry>();
const bySetting = new Map<string, TimeslotChangeEntry>();
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<TimeslotChange> {
return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change));
}
const indexes = new WeakMap<PublishedComparison, ComparisonWorkFacetIndex>();
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);
}