import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { AssessorApiClient } from '../../api/assessor-api-client'; import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups'; import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report'; import type { TimeslotDomainComparison } from '../../comparison/timeslot-domain-comparison'; const api = new AssessorApiClient(); export interface ComparableTimeslot { id: string; date: string; assessmentIds: string[]; scanRunIds: string[]; } export type TimeslotComparisonState = | { status: 'loading' } | { status: 'error'; message: string } | { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' } | { status: 'ready'; report: TimeslotComparisonReport }; /** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */ export interface PublishedComparison { projectId: string; /** Sorted assessment IDs of timeslot B, the open timeslot. */ targetAssessmentIds: string[]; baseline: ComparableTimeslot; target: ComparableTimeslot; report: TimeslotComparisonReport; } const key = (values: readonly string[]) => [...values].sort().join('\u0000'); const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right); const listeners = new Set<() => void>(); let storeVersion = 0; const notify = () => { storeVersion++; listeners.forEach(listener => listener()); }; const version = () => storeVersion; const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; // Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work. const baselineChoices = new Map(); let published: PublishedComparison | undefined; export function publishedComparison(): PublishedComparison | undefined { return published; } /** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */ export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined { const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison); return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined; } /** Test support. */ export function resetTimeslotComparisonRequestsForTests(): void { reports.clear(); domainRequests.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify(); } function comparable(group: ScanImportGroup): ComparableTimeslot { return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() }; } const timeslotRequests = new Map>(); /** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */ function loadTimeslots(projectId: string, scope: string): Promise { const requestKey = `${projectId}\u0000${scope}`; let request = timeslotRequests.get(requestKey); if (!request) { request = Promise.all([ api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId) ]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records) .filter(group => group.assessmentIds.length) .map(comparable) .sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id))); timeslotRequests.set(requestKey, request); while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!); request.catch(() => timeslotRequests.delete(requestKey)); } return request; } const reports = new Map>(); function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) { const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]); let request = reports.get(reportKey); if (!request) { request = api.getTimeslotComparison(projectId, baseline, target); reports.set(reportKey, request); while (reports.size > 8) reports.delete(reports.keys().next().value!); request.catch(() => reports.delete(reportKey)); } return request; } /** * Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client * Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is * the open timeslot is published for the Work `Change` facet. */ export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) { const openKey = key(openAssessmentIds); const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>(); const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>(); const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>(); const scope = `${projectId}\u0000${openKey}`; useSyncExternalStore(subscribe, version, version); useEffect(() => { if (!projectId) return; let current = true; loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); }, error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); }); return () => { current = false; }; }, [scope]); // eslint-disable-line react-hooks/exhaustive-deps const items = timeslots?.scope === scope ? timeslots.items : undefined; const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1]; const earlier = items && target ? items.slice(0, items.indexOf(target)) : []; const choiceKey = target ? JSON.stringify([projectId, target.id]) : ''; const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1]; const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : ''; useEffect(() => { if (!projectId || !baseline || !target) return; let current = true; requestReport(projectId, baseline, target).then(report => { if (!current) return; setResult({ key: requestKey, state: { status: 'ready', report } }); if (target === open) { published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report }; notify(); } }, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); }); return () => { current = false; }; }, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps let state: TimeslotComparisonState; if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error }; else if (!items) state = { status: 'loading' }; else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' }; else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' }; else state = result?.key === requestKey ? result.state : { status: 'loading' }; return { state, timeslots: items ?? [], baseline, target, earlier, /** True while B is the timeslot open in Client Info, so Work shows the same records. */ targetIsOpen: Boolean(target && target === open), selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } }, selectTarget: (id: string) => setTargetChoice({ scope, id }) }; } const domainRequests = new Map>(); export type DomainComparisonState = { status: 'loading' } | { status: 'error' } | { status: 'ready'; domains: TimeslotDomainComparison }; /** Security Domain values of the selected A/B pair; requested apart from the report because they read both Work datasets. */ export function useDomainComparison(projectId: string | undefined, baseline?: ComparableTimeslot, target?: ComparableTimeslot): DomainComparisonState { const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]) : ''; const [result, setResult] = useState<{ key: string; state: DomainComparisonState }>(); useEffect(() => { if (!requestKey || !projectId || !baseline || !target) return; let current = true; let request = domainRequests.get(requestKey); if (!request) { request = api.getTimeslotDomainComparison(projectId, baseline, target); domainRequests.set(requestKey, request); while (domainRequests.size > 8) domainRequests.delete(domainRequests.keys().next().value!); request.catch(() => domainRequests.delete(requestKey)); } request.then(domains => { if (current) setResult({ key: requestKey, state: { status: 'ready', domains } }); }, () => { if (current) setResult({ key: requestKey, state: { status: 'error' } }); }); return () => { current = false; }; }, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps return result?.key === requestKey ? result.state : { status: 'loading' }; }