import { useState, useEffect, useCallback, useRef } from 'react'; import { Item, ItemGroup } from "../../../../components/ui/item-group"; import type { ChangeEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { Icon } from "../../../../components/ui/icon"; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Flex, FlexItem, FlexBlock } from '../../../../components/ui/flex'; import { Text } from '../../../../components/ui/text'; //: Spacer comes from the ui folder, not WordPress. A branch predating that conversion once // reinstated the WordPress import - see the guard in // components/ui/__tests__/sweptComponentsStayConverted.test.ts, which now fails if that recurs. // // The wording above deliberately says "the ui folder" rather than writing that path with a trailing // star. The original comment wrote the star, and a slash-star sequence inside a line comment opens a // PHANTOM BLOCK COMMENT for any tool that strips comments with a naive regex: it ran to the next real // block-comment terminator, swallowing 15,058 characters of this file - including two of the three // live Button sites below - so own remaining-work counter under-reported for weeks. The // scanners now track comment state properly, but there is no reason to leave the tripwire lying here // for the next one. (Which is also why this paragraph spells the sequence out in words.) import { Spacer } from '../../../../components/ui/spacer'; import { patientCacheGuard } from '../../../../utils/cacheOwner'; import { Upload, ImageIcon as image } from 'lucide-react'; import { Annotation, Coding, ImagingStudy } from 'fhir/r4'; import { useImagingStudyServiceContext, useImagingUploadService, usePatientServiceContext } from '../../../../services'; import { AppSpinner } from '../../../../components'; import { formatDate } from '../../../../utils/dateUtils.ts'; import './ImagingStudies.css'; import { relayServerFailure } from '../../../../utils/fetchFailureMessage'; import { SourcePractice } from '../../../../components/ui/source-practice'; import { RecordPracticeNote, recordPracticeId, useRecordPracticeView, useRetireOnPracticeChange } from '../../../../contexts/RecordPracticeContext'; import { IMPORT_RECOVERY_UNAVAILABLE, ImportRecoveryUnavailable, ImportStartUnknown, forgetOwedUpload, mayRecover, owedUploadsFor, type OwedUpload, type OwnedRequest, type UploadOptions, } from '../../../../services/ImagingUploadService'; import { useOptionalAuthServiceContext } from '../../../../services/AuthService'; import { authSessionId } from '../../../../services/authStorage'; import { SaveRetiredError, isSaveRetired, startOwnedSave, useSaveScope, type SaveOperation } from '../../../../utils/saveOperation'; import { onViewerChange, writeThroughViewer } from '../../../../utils/writeThroughHold'; // ImagingStudy.modality is Coding[] - one acquisition-modality code per series present on the // study - unlike Media.modality, which is a single CodeableConcept. formatFhirCodeableConcept // doesn't apply here, so this stays local rather than forcing the shared helper to handle both shapes. const formatModality = (modality?: Coding[]): string => { if (!modality || modality.length === 0) { return ''; } return modality .map(coding => coding.display || coding.code || '') .filter(Boolean) .join(', '); }; const formatNotes = (note?: Annotation[]): string => { if (!note || note.length === 0) { return ''; } return note.map(entry => entry.text).filter(Boolean).join(' • '); }; interface UploadProgress { done: number; total: number; } // `status` distinguishes a genuine failure (re-upload needed) from the "still processing" outcome // below - both render in a Notice, but a benign "give it a moment" message has no business looking // like a warning. interface UploadNotice { status: 'warning' | 'info'; message: string; } /** One upload this page still owes an outcome for: its upload id, and its job once a start named it. */ interface OwedJob { key: string; patientId: string; owner: string; record?: OwedUpload; importJobId?: string; state: 'start-unknown' | 'processing' | 'held' | 'conflict' | 'unresolved'; message: string; unchecked?: boolean; } const STILL_PROCESSING = 'Your study was uploaded successfully and is still being processed. It will appear in the list shortly.'; const START_UNKNOWN = 'An upload of these images may have started, but its start was not confirmed. Check it before uploading the images again.'; const START_OWED = 'An earlier upload for this patient may have started. Check it before uploading again.'; const RECORD_UNAVAILABLE = 'Uploads cannot be recorded or recovered in this browser right now, so new uploads are not started here.'; const FILED_NOT_SHOWN = 'The study was filed and will appear in the list shortly.'; const FILED_LIST_UNAVAILABLE = 'The study was filed, but the list could not be refreshed. Reload the list to see it.'; const jobOf = (record: OwedUpload): OwedJob => ({ key: record.uploadId, patientId: record.patientId, owner: record.owner, record, importJobId: record.importJobId, state: record.phase === 'started' ? 'processing' : 'start-unknown', message: record.phase === 'started' ? STILL_PROCESSING : START_UNKNOWN, }); const ImagingStudies = () => { const navigate = useNavigate(); const { patientState } = usePatientServiceContext(); const { fetchImagingStudyListWithPolling, imagingStudyBundle: cachedImagingStudyBundle, imagingStudyList: cachedImagingStudyList, cachedForPatientId, shouldReload, setShouldReload } = useImagingStudyServiceContext(); const { uploadStudy, uploadStudyFromZip, replayStart, reconcilePatientImport } = useImagingUploadService(); const [reading, setLoading] = useState(false); // A failure belongs to the patient whose read failed, and only the latest read may settle the page. const [failedRead, setFailedRead] = useState<{ patientId?: string; message: string } | null>(null); const latestRead = useRef(0); const activeRead = useRef(undefined); const failure = failedRead && failedRead.patientId === patientState?.id ? failedRead.message : null; const failed = failure !== null; // The cache outlives a change of patient: show only the patient asked about. const guard = patientCacheGuard(cachedForPatientId, patientState?.id); const imagingStudyBundle = guard.bundle(cachedImagingStudyBundle, null); const imagingStudyList = guard.list(cachedImagingStudyList); const loading = guard.loading(reading, failed); // The record's practice context: its own studies under a named practice; the empty state speaks for the whole record. const practiceView = useRecordPracticeView(imagingStudyList, recordPracticeId); // A study opens on its own page, so nothing on this list is left open to close. useRetireOnPracticeChange(() => undefined); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [uploadNotice, setUploadNotice] = useState(null); // An upload belongs to the patient, practice and sign-in it was made in; a change retires it and its check. const wpOrganization = useOptionalAuthServiceContext()?.wpOrganization; const practiceId = wpOrganization?.current?.id; const sessionId = authSessionId(); // The account, or the person acted as, that uploads are owed to; each change is a new lifetime for them. const [viewer, setViewer] = useState(() => writeThroughViewer() ?? ''); const viewerLife = useRef(0); const [viewerTurn, setViewerTurn] = useState(0); // The latest practice object, so a request can check it without re-making its owner every render. const organizationRef = useRef(wpOrganization); organizationRef.current = wpOrganization; // Uploads this browser still owes an outcome for, for this patient. Each settles only on its own job's answer. const [owed, setOwed] = useState([]); const [recordUnavailable, setRecordUnavailable] = useState(false); const [checking, setChecking] = useState(null); const uploads = useSaveScope(`${patientState?.id ?? ''}|${practiceId ?? ''}|${sessionId ?? ''}|${viewerTurn}`, () => { setUploading(false); setChecking(null); }); // A list load is its own read: a new patient, practice or viewer retires it for good. const reads = useSaveScope(`${patientState?.id ?? ''}|${practiceId ?? ''}|${sessionId ?? ''}`); // The request for one operation: admitted in its practice and, for a read, only while it is still current. const requestFor = useCallback((operation: SaveOperation, current?: () => boolean): OwnedRequest => { const pinned = practiceId; return { admit: () => { operation.transport.admit(); if (current && !current()) throw new SaveRetiredError(); if (organizationRef.current?.current?.id !== pinned) throw new SaveRetiredError(); }, signal: operation.transport.signal, organizationId: pinned, }; }, [practiceId]); // One attempt made for an upload: its requests are admitted as that upload, in the practice it was made in. const attemptFor = useCallback((upload: SaveOperation, deadlineMs?: number) => { const attempt = startOwnedSave(upload, deadlineMs); return { attempt, owned: requestFor(attempt) }; }, [requestFor]); // What this browser recorded for this owner and patient returns with each opening; nothing is re-sent by itself. useEffect(() => { const patientId = patientState?.id; if (!patientId) { setOwed([]); setRecordUnavailable(false); return; } const records = owedUploadsFor(viewer, patientId); setRecordUnavailable(records === undefined); setOwed((records ?? []).map(jobOf)); }, [patientState?.id, practiceId, sessionId, viewer, viewerTurn]); // An upload's requests go out only while its owner is still the viewer, whatever this page has rendered since. const asOwner = useCallback((owned: OwnedRequest, owner: string): OwnedRequest => { const life = viewerLife.current; return { ...owned, admit: () => { owned.admit?.(); if (viewerLife.current !== life || (writeThroughViewer() ?? '') !== owner) throw new SaveRetiredError(); }, }; }, []); // Changes one owed upload of one patient; `null` drops it once it is settled. const settleJob = useCallback((key: string, patientId: string, next: Partial | null) => { setOwed((jobs) => { const same = (job: OwedJob) => job.key === key && job.patientId === patientId; if (next === null) return jobs.filter((job) => !same(job)); if (!jobs.some(same)) return next.owner !== undefined && next.state ? [...jobs, { key, patientId, owner: next.owner, state: next.state, message: STILL_PROCESSING, ...next }] : jobs; return jobs.map((job) => (same(job) ? { ...job, ...next } : job)); }); }, []); /** * One check of one owed upload, as the operation that asked. A start not yet known is asked again first, with * its original body; only an ImagingStudy of this patient settles the job, and a check that could not be made * settles nothing. A filed study the list does not show yet is said so, never confused with processing. */ const checkJob = useCallback(async (job: OwedJob, op: SaveOperation): Promise => { if (!patientState || patientState.id !== job.patientId) return; const patient = patientState; const { attempt, owned: request } = attemptFor(op); // A record is checked only by its owner, in its practice, for its patient: re-asked at every dispatch. const owned = asOwner({ ...request, admit: () => { request.admit?.(); if (job.record && !mayRecover(job.record, job.owner, request.organizationId ?? '', job.patientId)) throw new SaveRetiredError(); }, }, job.owner); let filedId: string; try { owned.admit?.(); let importJobId = job.importJobId; if (!importJobId) { const record = job.record; if (!record) return; const started = await attempt.step(() => replayStart(record, owned), true); importJobId = started; if (!op.belongs()) return; settleJob(job.key, job.patientId, { importJobId: started, record: { ...record, phase: 'started', importJobId: started }, state: 'processing', message: STILL_PROCESSING, unchecked: false, }); } const jobId = importJobId; const answer = await attempt.step(() => reconcilePatientImport(jobId, job.patientId, owned), true); if (!op.belongs()) return; if (answer.state !== 'filed') { settleJob(job.key, job.patientId, answer.state === 'processing' ? { state: 'processing', message: STILL_PROCESSING, unchecked: false } : { state: answer.state === 'identity-conflict' ? 'conflict' : answer.state, message: answer.message, unchecked: false }); return; } filedId = answer.studyId; forgetOwedUpload(job.record?.uploadId ?? job.key); settleJob(job.key, job.patientId, null); } catch (error) { if (isSaveRetired(error) || !op.belongs()) return; if (error instanceof ImportStartUnknown) { settleJob(job.key, job.patientId, { state: 'start-unknown', message: START_UNKNOWN, unchecked: true }); return; } settleJob(job.key, job.patientId, { unchecked: true }); return; } // Filed is known from here on: the list read only shows it, and its failure is never a filing outcome. try { const shown = await attempt.step(() => fetchImagingStudyListWithPolling(patient, true, 0, { belongs: attempt.belongs, request: owned })); if (op.belongs() && !(shown.entry ?? []).some((entry) => entry.resource?.id === filedId)) { setUploadNotice({ status: 'info', message: FILED_NOT_SHOWN }); } } catch (error) { if (isSaveRetired(error) || !op.belongs()) return; setUploadNotice({ status: 'info', message: FILED_LIST_UNAVAILABLE }); } }, [patientState, attemptFor, asOwner, replayStart, reconcilePatientImport, settleJob, fetchImagingStudyListWithPolling]); const folderInputRef = useRef(null); const zipInputRef = useRef(null); // minCount: 0 here, NEVER the default (1) - this is the mount/shouldReload load, not the // post-upload one. `fetchImagingStudyListWithPolling`'s minCount defaults to 1, which was designed // for the upload flow's "did a NEW study land" check, not "does this patient have ANY studies at // all." For a patient with zero studies - every patient, at launch - a minCount-1 poll never finds // one and burns all 20 retries at 3s: a ~57s spinner and 20 PHI-access AuditEvents before showing // "No imaging studies found." minCount: 0 treats the very first read (empty or not) as sufficient, // so this call always resolves in exactly one request. Do NOT touch the post-upload call in // runUpload below - that one's `baseline + 1` is a separate, deliberate fix for a different bug. const loadData = useCallback(async () => { if (!patientState) { return; } const read = ++latestRead.current; const operation = reads.start(); activeRead.current?.retire(); activeRead.current = operation; const current = () => read === latestRead.current; const owner = { belongs: () => current() && operation.belongs(), request: requestFor(operation, current) }; setLoading(true); setFailedRead(null); try { await fetchImagingStudyListWithPolling(patientState, false, 0, owner); } catch (error) { if (isSaveRetired(error) || !current() || !operation.belongs()) return; console.error('Failed to load imaging studies:', error); setFailedRead({ patientId: patientState.id, message: relayServerFailure(error, 'Could not load imaging studies. Try again.'), }); } finally { if (current()) { setLoading(false); if (shouldReload) { setShouldReload(false); } } } }, [patientState, shouldReload, fetchImagingStudyListWithPolling, setShouldReload, reads, requestFor]); // A new patient, practice or viewer, or leaving the page, retires whatever read is still out. useEffect(() => { setLoading(false); return () => { latestRead.current++; }; }, [patientState?.id, practiceId, sessionId]); // A viewer change retires the read in flight without waiting for this page to rerender. useEffect(() => onViewerChange(() => { viewerLife.current++; setViewerTurn(viewerLife.current); setViewer(writeThroughViewer() ?? ''); latestRead.current++; activeRead.current?.retire(); setLoading(false); }), []); useEffect(() => { if (!imagingStudyBundle || shouldReload) { loadData(); } }, [loadData, imagingStudyBundle, shouldReload]); // `baseline` MUST be read here, before the upload runs - never recomputed afterward - so the display poll waits // for baseline + 1 rather than reading any prior study as new. The poll only refreshes what is shown: whatever // its count, the exact job this upload started is always checked, and only its answer settles the upload. const runUpload = useCallback(async (start: (options: UploadOptions) => Promise<{ importJobId: string; uploadId?: string }>) => { if (!patientState?.id) { return; } const patientId = patientState.id; // A start not known to have failed is still owed: the images are checked, never sent again as a new upload. if (owed.some((job) => job.patientId === patientId && job.owner === viewer && job.state === 'start-unknown')) { setUploadNotice({ status: 'warning', message: START_OWED }); return; } if (recordUnavailable) { setUploadNotice({ status: 'warning', message: RECORD_UNAVAILABLE }); return; } const baseline = imagingStudyList.length; uploads.renew(); const upload = uploads.start(); const owner = viewer; const owned = asOwner(requestFor(upload), owner); setUploading(true); setUploadNotice(null); setUploadProgress(null); try { const started = await start({ owned, recover: { owner, organizationId: practiceId ?? '' } }); if (!upload.belongs()) return; const { importJobId } = started; const uploadId = started.uploadId ?? importJobId; const record = owedUploadsFor(owner, patientId)?.find((held) => held.uploadId === uploadId); const job: OwedJob = { key: uploadId, patientId, owner, importJobId, record, state: 'processing', message: STILL_PROCESSING }; settleJob(uploadId, patientId, job); // The list read only shows the study: its failure says nothing about the start, so the job is still checked. try { await fetchImagingStudyListWithPolling(patientState, true, baseline + 1, { belongs: upload.belongs, request: owned }); } catch (error) { if (isSaveRetired(error) || !upload.belongs()) return; } if (!upload.belongs()) return; await checkJob(job, upload); } catch (error) { if (isSaveRetired(error) || !upload.belongs()) return; if (error instanceof ImportStartUnknown) { settleJob(error.owed.uploadId, patientId, { owner: error.owed.owner, record: error.owed, state: 'start-unknown', message: START_UNKNOWN }); return; } // Before any start, or the server's own proof that this upload never started: nothing was imported. setUploadNotice({ status: 'warning', message: error instanceof ImportRecoveryUnavailable ? IMPORT_RECOVERY_UNAVAILABLE : relayServerFailure(error, 'Upload failed. Please try again.') }); } finally { if (upload.belongs()) setUploading(false); } }, [patientState, owed, recordUnavailable, imagingStudyList, uploads, requestFor, asOwner, viewer, practiceId, settleJob, fetchImagingStudyListWithPolling, checkJob]); // A Check is its own attempt, as the person and practice now showing this patient. const checkAgain = async (job: OwedJob) => { if (checking) return; const op = uploads.start(); setChecking(job.key); try { await checkJob(job, op); } finally { if (op.belongs()) setChecking(null); } }; const handleFolderChange = (event: ChangeEvent) => { const input = event.target; const files = input.files ? Array.from(input.files) : []; input.value = ''; if (files.length === 0 || !patientState?.id) { return; } const patientId = patientState.id; runUpload((options) => uploadStudy(files, patientId, (done, total) => setUploadProgress({ done, total }), options)); }; const handleZipChange = (event: ChangeEvent) => { const input = event.target; const file = input.files?.[0] ?? null; input.value = ''; if (!file || !patientState?.id) { return; } const patientId = patientState.id; runUpload((options) => uploadStudyFromZip(file, patientId, (done, total) => setUploadProgress({ done, total }), options)); }; return (
{patientState && ( {uploading && ( {uploadProgress ? `${uploadProgress.done} / ${uploadProgress.total}` : 'Uploading…'} )} )} {uploadNotice && ( <> setUploadNotice(null)}> {uploadNotice.message} )} {recordUnavailable && ( <> {RECORD_UNAVAILABLE} )} {/* Each owed upload is checked as itself - never uploaded again under a new upload. */} {owed.filter((job) => job.patientId === patientState?.id && job.owner === viewer).map((job) => (
{job.message}{' '} {job.unchecked && <>The import could not be checked. Try again.{' '}}
))} {!loading && failure && ( <> {failure} )} {!loading && !failed && imagingStudyList.length === 0 && ( No imaging studies found. )} {loading ? ( ) : ( <> {practiceView.shown.length !== 0 && ( {practiceView.shown.map((study: ImagingStudy) => { const modalityText = formatModality(study.modality); const studyDate = study.started ? formatDate(study.started) : ''; const noteText = formatNotes(study.note); return ( {modalityText || 'Imaging Study'} {studyDate && ( {` [${studyDate}]`} )} {/* Series and instance counts are shown prominently on purpose. The partial-upload guard in startImport catches an interrupted transfer, but it compares against a CLIENT-SUPPLIED count and so cannot catch a user who selected the wrong subset of a folder. A clinician who can see "2 series, 300 instances" can sanity-check that against what they expect the study to contain. This display IS part of the safety story, not decoration. */} {`${study.numberOfSeries ?? 0} series, ${study.numberOfInstances ?? 0} instances`} {noteText && ( {noteText} )} ); })} )} )}
); }; export default ImagingStudies;