import { useSyncExternalStore } from 'react'; import { onViewerChange, viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; /** * WHETHER A VISIT MAY FINISH WHILE ITS RECORDING IS UNDECIDED. Finishing tears the recording owner down, and that * releases the audio unsent, so Finish refuses while a recording for the visit is starting, capturing, closing, * sending or held. In memory only: no audio, no persistence, no server permission. */ export type RecordingUnsettled = 'recording' | 'sending' | 'held'; /** Finish holds its visit from admission to its own end, and only while the opening it was pressed in lasts. */ export interface FinishAdmission { target: string; holds: () => boolean; release: () => void; } /** Refused: a recording is undecided, or another Finish of the same visit is under way. */ export interface FinishRefusal { blocked: RecordingUnsettled | 'finishing'; } interface Registration { target: () => string; read: () => RecordingUnsettled | null; } interface Reservation { token: object; alive: () => boolean; } const owners = new Set(); const reserved = new Map(); /** * One visit as one signing-in sees it. The epoch moves on every change of person, acted-for subject or practice, so * a return to the same viewer is a new target and nothing from before it carries over. */ export const recordingTarget = ( patientId?: string, encounterId?: string, epoch = viewerEpoch(), viewer = writeThroughViewer(), ): string => [`${viewer ?? ''}#${epoch}`, patientId ?? '', encounterId ?? ''].join('|'); /** The viewer epoch, re-rendering on change, for hosts that must replace a recording owner when it moves. */ export const useViewerEpoch = (): number => useSyncExternalStore(onViewerChange, viewerEpoch); /** Registers one owner's live reading under its target; the returned function withdraws that owner only. */ export const registerRecording = (target: () => string, read: () => RecordingUnsettled | null): (() => void) => { const registration: Registration = { target, read }; owners.add(registration); return () => { owners.delete(registration); }; }; /** What the visit's recordings are doing now; sending outranks the rest, since it settles by itself. */ export const unsettledRecording = (target: string): RecordingUnsettled | null => { let found: RecordingUnsettled | null = null; for (const owner of owners) { if (owner.target() !== target) continue; const state = owner.read(); if (state === 'sending') return 'sending'; found = found ?? state; } return found; }; // A hold whose opening has ended is retired here, whether or not the work it began has answered. const liveReservation = (target: string): Reservation | undefined => { const reservation = reserved.get(target); if (reservation && !reservation.alive()) { reserved.delete(target); return undefined; } return reservation; }; /** * Checks and reserves in one step, so a recording cannot start between the check and the Finish. `alive` is the * pressing opening's lifetime; the viewer epoch is always part of it. */ export const admitFinish = (target: string, alive: () => boolean = () => true): FinishAdmission | FinishRefusal => { if (liveReservation(target)) return { blocked: 'finishing' }; const unsettled = unsettledRecording(target); if (unsettled) return { blocked: unsettled }; const epoch = viewerEpoch(); const reservation: Reservation = { token: {}, alive: () => viewerEpoch() === epoch && alive() }; reserved.set(target, reservation); return { target, holds: () => reserved.get(target) === reservation && reservation.alive(), release: () => { if (reserved.get(target) === reservation) reserved.delete(target); }, }; }; export const isFinishRefusal = (value: unknown): value is FinishRefusal => typeof value === 'object' && value !== null && 'blocked' in value; /** A recording may not start while its visit is being finished. */ export const finishReserved = (target: string): boolean => liveReservation(target) !== undefined; /** What a refused Finish tells the clinician; a second press of an admitted Finish says nothing. */ export const finishBlockedMessage = (blocked: FinishRefusal['blocked']): string | null => { if (blocked === 'finishing') return null; if (blocked === 'sending') return 'The recording is still sending. Wait for it to finish sending, then finish this visit.'; return 'Finish or discard the recording before finishing this visit.'; };