import type { Appointment } from "fhir/r4"; import { cancelledAsDeclinedRequest } from "../../../utils/appointmentCancellation"; import { compareVersions } from "../../../utils/writeThroughHold"; import { AWAITING_APPROVAL_STATUSES } from "../ClinicianOverviewPage/encounterLifecycle"; export const AKASHIC_APPOINTMENT_ARRIVAL_TIMELINESS_EXTENSION = "https://akashichealthrecords.com/fhir/StructureDefinition/appointment-arrival-timeliness"; // Kept only so older records are cleaned up the next time their status changes. const LEGACY_AKASHIC_APPOINTMENT_ARRIVAL_TIME_EXTENSION = "https://akashichealthrecords.com/fhir/StructureDefinition/appointment-arrival-time"; export type ReceptionistAppointmentStatus = | "pending" | "booked" | "arrived-on-time" | "arrived-late" | "fulfilled" | "cancelled" | "noshow"; /** * - THE LIST HAD NO WAY TO SAY THE VISIT HAPPENED * * It read **Booked - Arrived-On Time - Arrived-Late - Cancelled - No Show**. So when the board raised * *"Jacob Miller is at least 30 minutes late. Please update the appointment status"* about a patient the * clinician's own board listed under SEEN TODAY, the receptionist followed that instruction, opened the * only control they have, and found **the two exits from `booked` were both false statements about a * patient who had been seen** - and both are written into the record, where billing and no-show * reporting read them. * The button that opens this dialog is literally called **"Check out"**, which is exactly the state it * could not set. * * **"Completed", not "Fulfilled".** `fulfilled` is FHIR's word; this dialog's own header has always * rendered it as *Completed*, so the option and the summary above it now agree. (The Appointments page * said *Fulfilled* - a third spelling of one status, corrected with this.) * It sits after the arrivals and before the exceptions: **it is how a visit normally ends**, and * Cancelled / No Show are the two ways it does not. */ export const RECEPTIONIST_APPOINTMENT_STATUS_OPTIONS: Array<{ value: ReceptionistAppointmentStatus; label: string; }> = [ { value: "booked", label: "Booked" }, { value: "arrived-on-time", label: "Arrived — On Time" }, { value: "arrived-late", label: "Arrived — Late" }, { value: "fulfilled", label: "Completed" }, { value: "cancelled", label: "Cancelled" }, { value: "noshow", label: "No Show" }, ]; /** * A NO-SHOW IS A FACT ABOUT A TIME THAT HAS PASSED, so it cannot be asserted about one that has not * arrived. Every other status here is a claim the desk can make at any moment - a booking, an arrival, a * cancellation - and this is the only one whose truth depends on the clock having gone past the * appointment. * * It matters more than a tidy dropdown because of what reads the status downstream: the relationship * rollup records the moment as the patient's last missed visit, the recall campaign segments on it, and * the fees catalogue attaches a charge to it. A no-show entered against tomorrow's appointment puts a * missed visit, a marketing send and a fee on a patient for a visit they may still attend. * * ABSENT `start` READS AS "NOT KNOWN", NEVER AS "IN THE FUTURE". An appointment with no time on it says * nothing about whether it has happened, and refusing on that would tell the receptionist the visit has * not happened yet - a statement this function cannot support. So it allows, and the desk keeps its exit. * * Returns the sentence to show WHERE THE CONTROL IS, so the refusal names itself rather than presenting * as an option that quietly does nothing. */ export const noShowUnavailableReason = ( appointment: Appointment, now: Date = new Date(), ): string | undefined => { if (!appointment.start) return undefined; const start = new Date(appointment.start).getTime(); if (!Number.isFinite(start)) return undefined; return start > now.getTime() ? "not until the appointment time has passed" : undefined; }; const ARRIVAL_OPTIONS = new Set(["arrived-on-time", "arrived-late"]); const dayIn = (instant: Date, timeZone: string): string | undefined => { try { return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant); } catch { return undefined; } }; /** * An arrival opens the check-in and starts care, so it belongs to the visit day, read on the visit's clock. * Mirrors the server's refusal; an unknown start or zone offers it, as the server allows. */ export const arrivalDayUnavailableReason = ( appointment: Appointment, option: ReceptionistAppointmentStatus, timeZone: string | null | undefined, now: Date = new Date(), ): string | undefined => { if (!ARRIVAL_OPTIONS.has(option)) return undefined; if (appointment.status === "arrived" || appointment.status === "checked-in") return undefined; if (!appointment.start || !timeZone) return undefined; const start = new Date(appointment.start); if (!Number.isFinite(start.getTime())) return undefined; const visitDay = dayIn(start, timeZone); const today = dayIn(now, timeZone); return visitDay && today && visitDay !== today ? "only on the day of the appointment" : undefined; }; export const getAppointmentArrivalTimeliness = ( appointment: Appointment, ): "on-time" | "late" | undefined => { const value = appointment.extension?.find( (extension) => extension.url === AKASHIC_APPOINTMENT_ARRIVAL_TIMELINESS_EXTENSION, )?.valueCode; return value === "on-time" || value === "late" ? value : undefined; }; export const getReceptionistAppointmentStatus = ( appointment: Appointment, ): ReceptionistAppointmentStatus => { if (appointment.status === "cancelled") return "cancelled"; if (appointment.status === "noshow") return "noshow"; /* * - `fulfilled` USED TO READ BACK AS "Arrived - On Time", and that is worse than the * missing option it sits beside. * * The control showed an arrival state for a visit that is over, so a receptionist opening a completed * appointment to change anything else and pressing save wrote **`arrived`** back over it - silently * REOPENING a closed visit and putting the patient back on the late board. Nothing errors, and the * screen looked correct both before and after. * This is live the moment the encounterappointment transition deploys, because that is what starts * producing `fulfilled` appointments in the first place. */ if (appointment.status === "fulfilled") return "fulfilled"; if ( appointment.status === "arrived" || appointment.status === "checked-in" ) { return getAppointmentArrivalTimeliness(appointment) === "late" ? "arrived-late" : "arrived-on-time"; } // Unconfirmed appointments (patient self-booked / requested) read as "Pending" until a // receptionist confirms them ( booked). if ( appointment.status === "proposed" || appointment.status === "pending" || appointment.status === "waitlist" ) { return "pending"; } return "booked"; }; export const getReceptionistAppointmentStatusLabel = ( appointment: Appointment, ): string => { const status = getReceptionistAppointmentStatus(appointment); if (status === "pending") return appointment.status === "proposed" ? "Requested" : "Pending"; return ( RECEPTIONIST_APPOINTMENT_STATUS_OPTIONS.find( (option) => option.value === status, )?.label || "Booked" ); }; export const applyReceptionistAppointmentStatus = ( appointment: Appointment, nextStatus: ReceptionistAppointmentStatus, ): Appointment => { const remainingExtensions = (appointment.extension || []).filter( (extension) => extension.url !== AKASHIC_APPOINTMENT_ARRIVAL_TIMELINESS_EXTENSION && extension.url !== LEGACY_AKASHIC_APPOINTMENT_ARRIVAL_TIME_EXTENSION, ); const nextAppointment: Appointment = { ...appointment, status: nextStatus === "arrived-on-time" || nextStatus === "arrived-late" ? "arrived" : nextStatus, extension: remainingExtensions, }; if (nextStatus === "arrived-on-time" || nextStatus === "arrived-late") { nextAppointment.extension = [ ...remainingExtensions, { url: AKASHIC_APPOINTMENT_ARRIVAL_TIMELINESS_EXTENSION, valueCode: nextStatus === "arrived-late" ? "late" : "on-time", }, ]; } /* * - COMPLETING A VISIT MUST NOT ERASE THAT THE PATIENT ARRIVED LATE. * * Every other transition here deliberately drops the arrival-timeliness extension, because every other * transition REPLACES the arrival fact. Completion does not: it happens AFTER the arrival and says * nothing about it. And the loss would have been silent AND WRONG IN THE FLATTERING DIRECTION - * `getAppointmentTimelineIndicator` reads this same extension for `fulfilled`, and an absent one reads * as **"On time"**, so checking out a patient who arrived 40 minutes late would have relabelled them * on time, on a record a practice reports from. * The LEGACY url is still dropped: no reader has ever consulted it, so cleaning it loses nothing. */ if (nextStatus === "fulfilled") { const timeliness = (appointment.extension || []).find( (extension) => extension.url === AKASHIC_APPOINTMENT_ARRIVAL_TIMELINESS_EXTENSION, ); if (timeliness) { nextAppointment.extension = [...remainingExtensions, timeliness]; } } if (!nextAppointment.extension?.length) { delete nextAppointment.extension; } if (nextStatus !== "cancelled" && nextStatus !== "noshow") { delete nextAppointment.cancelationReason; } /* * DECLINING A REQUEST IS NOT THE SAME ACT AS CANCELLING A VISIT, and the record has to be able to * tell them apart afterwards - because the patient's portal is what reads it. * * A booking in review mode holds the patient's chosen time and tells them so, in the product's own * words: "your time is held while they do", "until then it shows as pending on your appointments". * When the desk declines it, the row simply left every patient surface and nothing anywhere said the * answer was no. An absence carrying a message, and the message a patient reads from it is "I * imagined it" or "something broke". * * The PRIOR status is what makes it a decline: cancelling a BOOKED visit is an ordinary cancellation * and keeps whatever reason it had. Asked here rather than at the two call sites so the details * dialog and the booking form cannot disagree about what just happened. */ if (nextStatus === "cancelled" && AWAITING_APPROVAL_STATUSES.has(appointment.status)) { return cancelledAsDeclinedRequest(nextAppointment); } return nextAppointment; }; /** Said of a superseded change: neither saved nor unsaved, and the visit as it is now open to a new choice. */ export const STATUS_CHANGE_UNREADABLE = "We could not read this appointment, so we cannot confirm whether this change was saved."; export const STATUS_CHANGE_UNCONFIRMED = "We could not confirm whether this change was saved."; export const STATUS_CHANGE_SUPERSEDED = "We could not confirm whether this change was saved before the appointment changed again. It is shown as it is now."; export type StatusChangeOutcome = /** `recovered`: found saved by a re-read, so what the save did beside the visit was never reported. */ | { state: "saved"; appointment: Appointment; recovered?: boolean } | { state: "unsaved" } /** `unreadable`: the appointment could not be read at all, as opposed to read without the change. */ | { state: "unknown"; unreadable?: true } /** The visit moved on before the answer was known: whether the change landed first is still unknown. */ | { state: "superseded"; current: Appointment } | { state: "refused"; message: string; changed?: boolean }; /** * Settles one status change. A definite refusal leaves the visit where it was. An uncertain failure * (a timeout or a dropped connection) re-reads the appointment first: if the server already holds the * requested status the change was saved, and a retry would be a second write. If that read fails too, * the outcome is unknown: nothing may claim a status or write again until a fresh read succeeds. */ /** A fresh read decides an unknown outcome; a failed read leaves it unknown. */ export const checkStatusChange = async ({ requested, reread, basedOn, }: { requested: ReceptionistAppointmentStatus; reread: () => Promise; /** The version the change was written over. */ basedOn?: string; }): Promise => { try { const current = await reread(); if (getReceptionistAppointmentStatus(current) === requested) return { state: "saved", appointment: current, recovered: true }; // Without the change, it may still be on its way while the visit stands at the version it was written over. // A proved newer version can no longer take it, but it may itself be the change followed by another, so it is // the basis for a new decision without saying the change was not saved. An older or incomparable copy proves // nothing about the pending write, so the outcome stays unknown. const newer = basedOn && compareVersions(current, { meta: { versionId: basedOn } }) === 1; return newer ? { state: "superseded", current } : { state: "unknown" }; } catch { return { state: "unknown", unreadable: true }; } }; export const settleStatusChange = async ({ requested, write, reread, isDefinite, refusal, changed, basedOn, }: { requested: ReceptionistAppointmentStatus; write: () => Promise; reread: () => Promise; isDefinite: (error: unknown) => boolean; /** The server's own sentence when it refused the change outright: nothing is re-read or retried. */ refusal?: (error: unknown) => string | undefined; /** The refusal was because the visit changed: nothing is chosen again until it is read as it is now. */ changed?: (error: unknown) => boolean; basedOn?: string; }): Promise => { try { return { state: "saved", appointment: await write() }; } catch (error) { const refused = refusal?.(error); if (refused) return { state: "refused", message: refused, ...(changed?.(error) ? { changed: true } : {}) }; if (isDefinite(error)) return { state: "unsaved" }; return checkStatusChange({ requested, reread, basedOn }); } }; export type TreatmentAgreementsCheck = | { state: "checking" } | { state: "known"; mayTreat: boolean } | { state: "unreadable" } | { state: "missing-context" }; const APPOINTMENT_CARE_STATUSES = new Set(["arrived", "checked-in", "fulfilled"]); // The options that write one of those statuses; see applyReceptionistAppointmentStatus. const OPTIONS_THAT_BEGIN_CARE = new Set(["arrived-on-time", "arrived-late", "fulfilled"]); /** * Why an option that begins care is not offered yet. Mirrors the server: only a move into care from * outside it is held, and only the server's `mayTreat` releases it. A visit whose patient or practice * cannot be identified is held too, because the server refuses to begin care without them. */ export const careUnavailableReason = ( appointment: Appointment, option: ReceptionistAppointmentStatus, agreements: TreatmentAgreementsCheck, ): string | undefined => { if (!OPTIONS_THAT_BEGIN_CARE.has(option)) return undefined; if (APPOINTMENT_CARE_STATUSES.has(appointment.status)) return undefined; if (agreements.state === "missing-context") return "patient or practice not identified"; if (agreements.state === "checking") return "checking agreements"; if (agreements.state === "unreadable") return "agreements could not be checked"; return agreements.mayTreat ? undefined : "agreements outstanding below"; };