import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import type { Appointment } from "fhir/r4"; import { useAppointmentServiceContext, useAuthServiceContext } from "../../services"; import { HttpError } from "../../services/HttpService"; import { Button } from "../ui/button"; import { beginBookingOperation, BookingUnconfirmedError, closeBookingOperation, findOpenBookingOperation, isOwnedNotMadeAnswer, noteBookingUnconfirmed, type BookingOpening, type BookingOperationBinding, type BookingOperationEntry, } from "../../utils/booking/bookingOperation"; import { viewerEpoch } from "../../utils/writeThroughHold"; export type BookingRecoveryPhase = "checking" | "unconfirmed" | "booked" | "not-made"; export interface BookingRecovery { entry: BookingOperationEntry; phase: BookingRecoveryPhase; appointment?: Appointment; message?: string; } export interface BookingOperationScope { opening: BookingOpening; practice?: string; patient?: string; } const refusalMessage = (error: HttpError): string => { const body = error.body as { issue?: Array<{ diagnostics?: string }>; message?: string } | undefined; return body?.issue?.[0]?.diagnostics || body?.message || "This booking was not made. Choose a time again."; }; const provesNotMade = (error: unknown, operationId: string): error is HttpError => error instanceof HttpError && error.status >= 400 && error.status < 500 && isOwnedNotMadeAnswer(error.body, operationId); /** The page moved on before a booking it began was sent: nothing was sent, and nothing is kept for it. */ export class BookingViewChangedError extends Error { constructor() { super("This booking was not sent because the page changed. Choose the time again."); this.name = "BookingViewChangedError"; } } /** A booking settled for a view no longer shown: it stands as it was answered, and nothing more is said of it here. */ export class BookingRetiredError extends Error { constructor() { super("The answer to this booking belongs to a page that is no longer shown."); this.name = "BookingRetiredError"; } } export const isBookingRetired = (error: unknown): boolean => error instanceof BookingViewChangedError || error instanceof BookingRetiredError; /** A send answer: shown by the view that began it, or settled for a view that has since moved on. */ export type BookingSendResult = { kind: "current"; appointment: Appointment } | { kind: "retired" }; /** * One booking surface operation: kept before the send, sent under that id, and, when its answer is lost or * unconfirmed, checked by that id alone until it is known. An operation already open for this person, practice, * patient and surface is found again on open and checked before any new booking can start. */ export function useBookingOperation(scope: BookingOperationScope) { const { currentUser, actingAs, actingAsSessionRef } = useAuthServiceContext(); const { checkBookingOperation } = useAppointmentServiceContext(); const account = currentUser?.cognito?.sub; const binding = useMemo( () => (account && scope.practice && scope.patient ? { account, actingAs: actingAs?.relatedPersonId, practice: scope.practice, patient: scope.patient, opening: scope.opening } : null), [account, actingAs?.relatedPersonId, scope.practice, scope.patient, scope.opening], ); const [recovery, setRecovery] = useState(null); const bookedRef = useRef<((appointment: Appointment) => void) | null>(null); // Operations whose create was handed to the server at least once; only these can have made a booking. const sentRef = useRef(new Set()); // The presentation each operation was begun under; its send and answers speak only while that one is shown. const admittedRef = useRef(new Map boolean>()); const generationRef = useRef(0); const renderedRef = useRef(null); const renderedKey = binding ? [binding.account, binding.actingAs ?? "", binding.practice, binding.patient, binding.opening].join("|") : ""; // Retired in the render that shows another person, practice, patient or opening, before its callbacks change. if (renderedRef.current !== renderedKey) { renderedRef.current = renderedKey; generationRef.current += 1; } useLayoutEffect(() => () => { generationRef.current += 1; }, []); // A step stays current only while its view is shown under the live viewer and acting-as session it began under. const fence = useCallback(() => { const generation = generationRef.current; const live = () => viewerEpoch() + "|" + (actingAsSessionRef?.current ?? ""); const began = live(); return () => generationRef.current === generation && live() === began; }, [actingAsSessionRef]); const keepUnconfirmed = useCallback(async (entry: BookingOperationEntry, current: () => boolean, message?: string) => { await noteBookingUnconfirmed(entry); if (current()) setRecovery({ entry, phase: "unconfirmed", message }); }, []); const check = useCallback(async (entry: BookingOperationEntry) => { const current = fence(); setRecovery({ entry, phase: "checking" }); const found = await checkBookingOperation(entry.operationId, binding?.practice); if (found.kind === "booked") { await closeBookingOperation(entry); if (!current()) return; setRecovery({ entry, phase: "booked", appointment: found.appointment }); bookedRef.current?.(found.appointment); return; } if (found.kind === "failed" && provesNotMade(found.error, entry.operationId)) { await closeBookingOperation(entry); if (current()) setRecovery({ entry, phase: "not-made", message: refusalMessage(found.error) }); return; } // Not found, refused without the operation's own outcome, or unreachable: the booking may still be made. const unreachable = found.kind === "failed" && !(found.error instanceof HttpError && found.error.status < 500); await keepUnconfirmed(entry, current, unreachable ? "We could not reach the practice to check. Check again in a moment." : undefined); }, [binding?.practice, checkBookingOperation, fence, keepUnconfirmed]); // An operation left open by an earlier visit is found and checked before anything else is offered. useEffect(() => { const current = fence(); setRecovery(null); if (!binding) return; void findOpenBookingOperation(binding).then((open) => { if (current() && open) void check(open); }); // check is keyed to the same binding; re-running it on its own identity would re-check endlessly. // eslint-disable-next-line react-hooks/exhaustive-deps }, [binding]); /** * Keeps a new operation before anything is sent. Refuses when this device cannot keep it, and when an earlier * operation is still open: that one is checked instead, so its outcome is known before another time is chosen. */ const begin = useCallback(async (): Promise => { if (!binding) throw new Error("This booking could not be prepared. Reload the page and try again."); const current = fence(); const { entry, resumed } = await beginBookingOperation(binding); if (!current()) { if (!resumed) await closeBookingOperation(entry); throw new BookingViewChangedError(); } if (resumed) { void check(entry); throw new BookingUnconfirmedError(entry.operationId); } admittedRef.current.set(entry.operationId, current); return entry; }, [binding, check, fence]); /** Forgets an operation that ended before its create was sent: nothing can have been booked under it. */ const abandonIfUnsent = useCallback(async (entry: BookingOperationEntry | undefined) => { if (entry && !sentRef.current.has(entry.operationId)) await closeBookingOperation(entry); }, []); /** * Sends the booking under its operation, for the view that began it. Its answer always settles the operation; * a view that has since moved on gets `retired` and shows nothing, and an unknown outcome keeps the check. */ const send = useCallback(async ( entry: BookingOperationEntry, create: (operationId: string) => Promise, ): Promise => { const current = admittedRef.current.get(entry.operationId) ?? (() => false); if (!current()) { await abandonIfUnsent(entry); throw new BookingViewChangedError(); } sentRef.current.add(entry.operationId); try { const created = await create(entry.operationId); await closeBookingOperation(entry); return current() ? { kind: "current", appointment: created } : { kind: "retired" }; } catch (error) { if (provesNotMade(error, entry.operationId)) { await closeBookingOperation(entry); if (!current()) return { kind: "retired" }; throw error; } await keepUnconfirmed(entry, current); if (!current()) return { kind: "retired" }; throw error instanceof BookingUnconfirmedError ? error : new BookingUnconfirmedError(entry.operationId, error); } }, [abandonIfUnsent, keepUnconfirmed]); /** Leaves a known outcome; an unknown one cannot be dismissed. */ const dismiss = useCallback(() => { setRecovery((current) => (current && (current.phase === "booked" || current.phase === "not-made") ? null : current)); }, []); const onBooked = useCallback((handler: ((appointment: Appointment) => void) | null) => { bookedRef.current = handler; }, []); return { binding, recovery, begin, send, check, abandonIfUnsent, dismiss, onBooked }; } export type BookingOperationControl = ReturnType; export const isBookingUnconfirmed = (error: unknown): error is BookingUnconfirmedError => error instanceof BookingUnconfirmedError; const describeStart = (appointment?: Appointment): string | undefined => { if (!appointment?.start) return undefined; const at = new Date(appointment.start); return Number.isNaN(at.getTime()) ? undefined : new Intl.DateTimeFormat(undefined, { dateStyle: "full", timeStyle: "short" }).format(at); }; /** * The booking's own state while its outcome is unknown, and what it turned out to be. It stands in for the booking * form: no other time can be chosen until this booking is known to be made or not made. */ export function CheckingYourBooking({ control, onDone, bookedMessage = "Your booking is confirmed.", }: { control: BookingOperationControl; onDone?: () => void; bookedMessage?: string; }) { const { recovery } = control; if (!recovery) return null; const when = describeStart(recovery.appointment); return (

{recovery.phase === "booked" ? "Booking confirmed" : recovery.phase === "not-made" ? "This booking was not made" : "Checking your booking"}

{recovery.phase === "checking" &&

Checking with the practice whether this booking was made…

} {recovery.phase === "unconfirmed" && (

{recovery.message ?? "This booking is not confirmed yet. It may already be made, so do not book another time until it is checked."}

)} {recovery.phase === "booked" && (

{when ? `${bookedMessage} ${when}.` : bookedMessage}

)} {recovery.phase === "not-made" &&

{recovery.message}

}
{recovery.phase === "unconfirmed" && ( )} {recovery.phase === "not-made" && ( )} {recovery.phase === "booked" && ( )}
); }