import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useHttpService } from "../../../services/HttpService.tsx"; import { environment } from "../../../environments/environment.ts"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { onViewerChange, viewerEpoch } from "../../../utils/writeThroughHold"; import type { OrderableRadiology, OrderableRadiologyState } from "./orderableRadiology.ts"; export type ScheduleRoom = { id: string; name?: string; site?: { id: string; name: string } }; export type ScheduleRoomsState = | { status: "loading" } | { status: "failed"; reason: string } | { status: "read" | "incomplete"; list: ScheduleRoom[] }; export interface ScheduleDetails { /** Whose answer this is: viewer (with practice and act-as), order and opening. A room chosen under another scope is not this one's. */ scope: string; terminology: OrderableRadiologyState; rooms: ScheduleRoomsState; /** Reads again, replacing any read still in flight. */ retry: () => void; } type Answer = { scope: string; terminology: OrderableRadiologyState; rooms: ScheduleRoomsState }; // The read is answered or given up within this, so the page never waits on it without saying so. const READ_BUDGET_MS = 15000; const TERMINOLOGY_UNREAD = "The terminology server could not be read for this order."; const ROOMS_UNREAD = "The practice's rooms could not be read just now."; const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; const textOf = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value : undefined); const terminologyFrom = (value: unknown): OrderableRadiologyState => { if (!isRecord(value)) return { status: "failed", reason: TERMINOLOGY_UNREAD }; if (value.status === "none") return { status: "none" }; if (value.status !== "read" || typeof value.code !== "string") return { status: "failed", reason: TERMINOLOGY_UNREAD }; if (value.radiology !== true) return { status: "none" }; const { status: _status, ...parts } = value; return { status: "read", parts: parts as unknown as OrderableRadiology }; }; const roomsFrom = (value: unknown): ScheduleRoomsState => { if (!isRecord(value) || (value.status !== "read" && value.status !== "incomplete") || !Array.isArray(value.list)) { return { status: "failed", reason: ROOMS_UNREAD }; } // Only checked text is kept, so nothing else reaches the option: a name or site that is not text is left off. const list = value.list.flatMap((room): ScheduleRoom[] => { const id = isRecord(room) ? textOf(room.id) : undefined; if (!isRecord(room) || !id) return []; const name = textOf(room.name); const site = isRecord(room.site) ? { id: textOf(room.site.id), name: textOf(room.site.name) } : undefined; return [{ id, ...(name ? { name } : {}), ...(site?.id && site.name ? { site: { id: site.id, name: site.name } } : {}) }]; }); // A room that cannot be read is not one the practice lacks: the list is then not all there, or not known at all. if (list.length < value.list.length) return list.length ? { status: "incomplete", list } : { status: "failed", reason: ROOMS_UNREAD }; return { status: value.status, list }; }; /** * What the schedule page shows for one order: the ordered study's terminology and this practice's rooms, in one * read made for that order. An answer is shown only under the scope it was read for; a new viewer, practice, order * or opening shows loading until its own answer lands, and a late answer for an earlier one is dropped. */ export const useScheduleDetails = (orderId: string): ScheduleDetails => { const { get } = useHttpService(); // The read belongs to its scope, not to the transport's identity: a new `get` alone never reads again. const transport = useRef(get); transport.current = get; const epoch = useSyncExternalStore(onViewerChange, viewerEpoch); const [attempt, setAttempt] = useState(0); const key = `${epoch}|${orderId}|${attempt}`; const opening = useRef({ key: "", count: 0 }); if (opening.current.key !== key) opening.current = { key, count: opening.current.count + 1 }; const scope = `${key}|${opening.current.count}`; const [answer, setAnswer] = useState(); useEffect(() => { let live = true; const controller = new AbortController(); // The first of answer, failure or the budget settles this read; anything after it is dropped. const keep = (terminology: OrderableRadiologyState, rooms: ScheduleRoomsState) => { if (!live) return; live = false; clearTimeout(timer); if (viewerEpoch() === epoch) setAnswer({ scope, terminology, rooms }); }; const timer = setTimeout(() => { const words = "The schedule details took too long to read."; keep({ status: "failed", reason: words }, { status: "failed", reason: words }); controller.abort(); }, READ_BUDGET_MS); transport.current(`${environment.orgAuthorizationApi}/diagnostic-order/${encodeURIComponent(orderId)}/schedule-details`, { signal: controller.signal }) .then((body) => { if (!isRecord(body)) { return keep({ status: "failed", reason: TERMINOLOGY_UNREAD }, { status: "failed", reason: ROOMS_UNREAD }); } keep(terminologyFrom(body.terminology), roomsFrom(body.rooms)); }) .catch((reason) => { const words = relayServerFailure(reason, "The schedule details could not be read."); keep({ status: "failed", reason: words }, { status: "failed", reason: words }); }); return () => { live = false; clearTimeout(timer); controller.abort(); }; }, [scope, epoch, orderId]); const retry = useCallback(() => setAttempt((count) => count + 1), []); if (answer?.scope !== scope) return { scope, terminology: { status: "loading" }, rooms: { status: "loading" }, retry }; return { scope, terminology: answer.terminology, rooms: answer.rooms, retry }; };