import type { Appointment, Encounter, PractitionerRole } from 'fhir/r4'; import type { ClinicalTier } from '../../../utils/roleTier'; // Local copy (keeps this module dependency-free - the receptionist's copy lives in a React-coupled hook file). const getAppointmentPatientId = (appointment: Appointment): string | undefined => appointment.participant ?.find((p) => p.actor?.reference?.startsWith('Patient/')) ?.actor?.reference?.split('/')[1]; export type QueueStatus = | 'in-progress' | 'onleave' | 'triaged' | 'arrived' | 'scheduled' | 'finished'; export type BucketKey = | 'in-progress' | 'ready' | 'later' | 'seen' | 'to-room' | 'roomed' | 'with-provider'; export interface QueueItem { key: string; // stable react key patientId: string; patientName?: string; appointment?: Appointment; encounter?: Encounter; status: QueueStatus; reason?: string; room?: string; // Encounter.location room (Slice 2); undefined in S1 atISO?: string; // appointment.start or encounter.period.start } const ENCOUNTER_STATUS_TO_QUEUE: Record = { 'in-progress': 'in-progress', onleave: 'onleave', triaged: 'triaged', arrived: 'arrived', finished: 'finished', }; // The encounter is the source of truth for status; an appointment with no encounter is 'scheduled'. export const deriveStatus = (_appointment?: Appointment, encounter?: Encounter): QueueStatus => { if (encounter?.status && ENCOUNTER_STATUS_TO_QUEUE[encounter.status]) { return ENCOUNTER_STATUS_TO_QUEUE[encounter.status]; } return 'scheduled'; }; // A room is not readiness: an arrived patient with a room is Roomed until someone marks them ready. export const statusToBucket = (status: QueueStatus, tier: ClinicalTier = 'provider', hasRoom = false): BucketKey => { if (tier === 'support') { if (status === 'arrived') return hasRoom ? 'roomed' : 'to-room'; if (status === 'triaged') return 'roomed'; if (status === 'in-progress' || status === 'onleave') return 'with-provider'; if (status === 'finished') return 'seen'; return 'later'; } if (status === 'in-progress' || status === 'onleave') return 'in-progress'; if (status === 'arrived' || status === 'triaged') return 'ready'; if (status === 'finished') return 'seen'; return 'later'; }; export const practitionerReferenceSet = ( practitionerId: string, practitionerRoleIds: string[] = [], ): Set => new Set([ `Practitioner/${practitionerId}`, ...practitionerRoleIds.map((id) => `PractitionerRole/${id}`), ]); export const appointmentMatchesPractitioner = ( appointment: Appointment, refs: Set, ): boolean => Boolean(appointment.participant?.some( (p) => p.actor?.reference && refs.has(p.actor.reference), )); /** * Rooming staff room the practice's patients, not their own, so their day is the practice's visits: those * at one of the user's locations when their roles name any, and those that name no location at all. */ export const appointmentAtLocations = ( appointment: Appointment, locationRefs: Set, ): boolean => { if (locationRefs.size === 0) return true; const named = (appointment.participant ?? []) .map((p) => p.actor?.reference) .filter((ref): ref is string => Boolean(ref?.startsWith('Location/'))); return named.length === 0 || named.some((ref) => locationRefs.has(ref)); }; export const encounterMatchesPractitioner = ( encounter: Encounter, refs: Set, ): boolean => Boolean(encounter.participant?.some( (p) => p.individual?.reference && refs.has(p.individual.reference), )); /** * The reference HOSTING this encounter, or undefined if it names none: the first participant naming a Practitioner * or a PractitionerRole, as every producer writes it. A later participant is never taken in its place. */ export const encounterHostReference = (encounter?: Encounter): string | undefined => encounter?.participant ?.map((p) => p.individual?.reference) .find((ref): ref is string => Boolean(ref?.startsWith('Practitioner/') || ref?.startsWith('PractitionerRole/'))); /** * May this practitioner write the CLINICAL note for this encounter? * * > Spencer, 2026-08-13: *"Only the practitioner hosting the encounter should be using the actual clinical * > notes piece, all other practitioners should just have private notes all separate from each other."* * * The server refuses anyone else, so a visit that names no host is written by nobody here. */ const dayEnd = (value: string): number => (/^\d{4}-\d{2}-\d{2}$/.test(value) ? Date.parse(value) + 86_400_000 : Date.parse(value)); const ORGANIZATION_TAG = 'https://akashichealthrecords.com/fhir/tags/organization'; // Every practice fact the role and visit carry names one practice, as the server's own check requires: the visit's // provider, the role's organization and each of the role's practice tags. A malformed fact names none. const practiceFactsAgree = (role: PractitionerRole, encounter: Encounter | undefined): boolean => { const references = [encounter?.serviceProvider?.reference, role.organization?.reference].filter((ref) => ref !== undefined); const tags = (role.meta?.tag ?? []).filter((tag) => tag.system === ORGANIZATION_TAG).map((tag) => tag.code); if (references.some((ref) => !/^Organization\/[^/]+$/.test(ref as string)) || tags.some((code) => !code)) return false; return new Set([...references.map((ref) => (ref as string).slice('Organization/'.length)), ...tags]).size <= 1; }; /** The caller's roles that can host this visit now: in force, theirs, and with no practice fact naming another practice. */ export const hostingRoleIds = ( roles: PractitionerRole[], practitionerId: string | undefined, encounter: Encounter | undefined, now: Date = new Date(), ): string[] => roles.filter((role) => { if (!role.id || role.active === false || role.practitioner?.reference !== `Practitioner/${practitionerId}`) return false; if (!practiceFactsAgree(role, encounter)) return false; if (role.period?.start && !(Date.parse(role.period.start) <= now.getTime())) return false; return !(role.period?.end && !(dayEnd(role.period.end) > now.getTime())); }).map((role) => role.id as string); export const mayWriteClinicalNote = ( encounter: Encounter | undefined, practitionerId: string | undefined, practitionerRoleIds: string[] = [], ): boolean => { const host = encounterHostReference(encounter); if (!host || !practitionerId) return false; return practitionerReferenceSet(practitionerId, practitionerRoleIds).has(host); }; /** * Awaiting a receptionist's approval: a patient self-booked into an org running review or * request-only mode. * * IT LIVES HERE RATHER THAN IN THE QUEUE MODULE because both need it and the queue module already * imports from this one. Defining it there and importing it up would be a cycle. */ export const AWAITING_APPROVAL_STATUSES = new Set([ 'pending', 'proposed', 'waitlist', ]); const encounterAppointmentIds = (encounter: Encounter): string[] => (encounter.appointment ?? []) .map((ref) => ref.reference?.split('/')[1]) .filter((id): id is string => Boolean(id)); // The server creates the check-in's Encounter at this id, and a search lags the write by ~30s. // `recent` holds check-ins this tab made, which the Appointment search may still report as booked. export const checkInsToRead = (appointments: Appointment[], encounters: Encounter[], recent: Set = new Set()): string[] => { const paired = new Set(encounters.flatMap(encounterAppointmentIds)); return appointments .filter((a) => a.id && !paired.has(a.id) && (a.status === 'arrived' || a.status === 'checked-in' || recent.has(a.id))) .map((a) => `checkin-${a.id}`); }; const encounterPatientId = (encounter: Encounter): string | undefined => encounter.subject?.reference?.split('/')[1]; const appointmentReason = (a?: Appointment): string | undefined => a?.reasonCode?.[0]?.text ?? a?.reasonCode?.[0]?.coding?.[0]?.display ?? a?.description; const appointmentPatientName = (a?: Appointment): string | undefined => a?.participant?.find((p) => p.actor?.reference?.startsWith('Patient/'))?.actor?.display; // Prefer a location tagged physicalType room; fall back to the first location. Returns its display label. const encounterRoomLabel = (encounter?: Encounter): string | undefined => { const locations = encounter?.location ?? []; const roomEntry = locations.find((l) => (l.physicalType?.coding ?? []).some((c) => c.code === 'ro')) ?? locations[0]; return roomEntry?.location?.display; }; /** * Merge today's appointments + active encounters into one queue. An encounter linked to an * appointment (or sharing its patient) collapses into a single item (encounter wins for status). * Encounters with no matching appointment still appear (e.g. a walk-in started from the chart). */ export const buildQueue = ({ appointments, encounters, }: { appointments: Appointment[]; encounters: Encounter[] }): QueueItem[] => { const encByApptId = new Map(); const encByPatientId = new Map(); for (const enc of encounters) { for (const apptId of encounterAppointmentIds(enc)) encByApptId.set(apptId, enc); const pid = encounterPatientId(enc); if (pid && !encByPatientId.has(pid)) encByPatientId.set(pid, enc); } const usedEncounterIds = new Set(); const items: QueueItem[] = []; for (const appointment of appointments) { const patientId = getAppointmentPatientId(appointment); if (!patientId) continue; /* AN APPOINTMENT NOBODY HAS APPROVED YET CANNOT HAVE BEEN ATTENDED, so it never borrows an encounter from its patient. The by-id link is exact and stays. The fallback beneath it exists so a walk-in started from the chart still collapses onto the patient's appointment when nothing links them - but it matches on the PATIENT alone, so it also attached the patient's one open encounter to every OTHER appointment they hold. Measured on the board: a review-mode booking for Friday and a request-only one for Saturday both appeared under "In the rooms" as "with provider", carrying the same 11h 48m as the visit actually in progress that morning - three rows, one encounter, two of them for days that had not happened. And because the bucket is decided before the appointment's own status is consulted, they never reached the awaiting-approval branch that would have put them in Needs you. A pending, proposed or waitlisted appointment is by definition one the practice has not yet agreed to. Whatever encounter that patient has open belongs to a different visit. */ /* AND AN ENCOUNTER THAT NAMES AN APPOINTMENT ALREADY BELONGS TO ONE, so no other appointment may borrow it - whatever either of their statuses is. The guard above fences the appointments the practice has not agreed to yet, which was one way to be holding somebody else's encounter. It is not the only one. Measured 2026-09-07: a video visit booked for 7:00 PM, for a patient whose morning visit was still open, appeared as In progress the moment it was booked, carried that visit's 15h 51m, and OPENED ITS CHART - the morning's note, the morning's diagnosis. Both rows were `booked`, so the status guard never applied. A booked row is worse than a pending one, because a pending row is something staff look at and a booked row today is one a clinician opens, documents into and finishes: Finish encounter on the second visit would have finished the first. The fallback keeps the case it was written for. A walk-in started from the chart has no appointment on its encounter at all, so it still collapses onto the patient's appointment; only an encounter that has already declared where it belongs is withheld. */ const mayBorrowPatientEncounter = !appointment.status || !AWAITING_APPROVAL_STATUSES.has(appointment.status); const patientEncounter = mayBorrowPatientEncounter ? encByPatientId.get(patientId) : undefined; const unclaimed = patientEncounter && encounterAppointmentIds(patientEncounter).length === 0 ? patientEncounter : undefined; const encounter = (appointment.id && encByApptId.get(appointment.id)) || unclaimed; if (encounter?.id) usedEncounterIds.add(encounter.id); const status = deriveStatus(appointment, encounter); items.push({ key: appointment.id ?? encounter?.id ?? patientId, patientId, patientName: appointmentPatientName(appointment), appointment, encounter, status, reason: appointmentReason(appointment), room: encounterRoomLabel(encounter), atISO: appointment.start ?? encounter?.period?.start, }); } // Active encounters with no appointment on the schedule (walk-ins). for (const encounter of encounters) { if (encounter.id && usedEncounterIds.has(encounter.id)) continue; const patientId = encounterPatientId(encounter); if (!patientId) continue; items.push({ key: encounter.id ?? patientId, patientId, patientName: encounter.subject?.display, encounter, status: deriveStatus(undefined, encounter), reason: encounter.reasonCode?.[0]?.text, room: encounterRoomLabel(encounter), atISO: encounter.period?.start, }); } return items.sort((a, b) => (a.atISO ?? '').localeCompare(b.atISO ?? '')); }; /** * sub-task 0 - ONE ACTIVE ENCOUNTER AT A TIME. * * Spencer, 2026-08-05: *"there should only ever be one 'active' encounter at a time even though they may * have several with the status of the practitioner coming back to them."* That parked status is `onleave`. * * Decides whether entering `nextEncounterId` must park the previously-active encounter. Returns the id to * demote to `onleave`, or `undefined` when nothing should be written. * * ONLY `in-progress` is demotable, and that guard is load-bearing: demoting a `finished` encounter would * REOPEN a closed visit (a status regression on a completed clinical record, and it trips a fresh care-view * anchor). `arrived`/`triaged` were never started, and `onleave` is already parked. * * "Active" is EXPLICIT STATE, never derived from status. This function is the *consequence* of a switch, * not a way to infer which encounter is active - read the active slot for that. Deriving activeness from * `status === 'in-progress'` re-creates the ambiguity this exists to remove the moment the data holds two. */ export const DEMOTABLE_ACTIVE_STATUS = 'in-progress'; export function planEncounterDemotion( previous: Encounter | undefined, nextEncounterId: string | undefined, ): { demoteId: string } | undefined { if (!previous?.id) return undefined; // nothing addressable to PUT if (previous.status !== DEMOTABLE_ACTIVE_STATUS) return undefined; if (previous.id === nextEncounterId) return undefined; // re-entering the same one is not a switch return { demoteId: previous.id }; } export type EncounterActionKind = | 'start' | 'resume' | 'set-onleave' | 'bring-back' | 'finish' | 'open-chart' | 'preview' | 'reopen' | 'room' | 'mark-ready' | 'prepare-video'; export interface ActionDescriptor { kind: EncounterActionKind; label: string; variant: 'go' | 'primary' | 'outline' | 'amber'; primary: boolean; } /** * `hasRoom` only affects the triaged label. Mark ready reaches triaged WITHOUT choosing a room, so * both paths land here and one of them has no room to change. */ // Booking leaves consent pending until the desk checks the patient in; a visit started before that // opened a note the server refused. export const NOT_CHECKED_IN_NOTE = 'Not checked in yet. The visit can start once the front desk checks the patient in.'; // A video visit is never checked in at the desk: the server starts it once both are in the call. export const VIDEO_STARTS_NOTE = 'The video visit starts when you and the patient both join the call.'; export const availableActions = ( status: QueueStatus, tier: ClinicalTier = 'provider', hasRoom = false, rooming = true, virtual = false, ): ActionDescriptor[] => { // A practice that does not room patients is never offered a room; Mark ready is the step. if (tier === 'support' && !rooming) { if (status === 'arrived') { return [ { kind: 'mark-ready', label: 'Mark ready', variant: 'go', primary: true }, { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; } if (status === 'triaged') return [{ kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: true }]; } if (tier === 'support') { switch (status) { // The deck's order: to room is Room patient then Open chart; once roomed, Mark ready then // Change room. Rooming records the room only, so Mark ready is still the step to take. case 'arrived': return hasRoom ? [ { kind: 'mark-ready', label: 'Mark ready', variant: 'outline', primary: true }, { kind: 'room', label: 'Change room', variant: 'outline', primary: false }, ] : [ { kind: 'room', label: 'Room patient', variant: 'go', primary: true }, { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; case 'triaged': return [ { kind: 'room', label: hasRoom ? 'Change room' : 'Room patient', variant: 'outline', primary: true }, { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; case 'scheduled': return [ { kind: 'preview', label: 'Preview chart', variant: 'outline', primary: true }, ]; case 'in-progress': case 'onleave': return [ { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: true }, ]; case 'finished': return [ { kind: 'open-chart', label: 'View encounter', variant: 'outline', primary: true }, ]; default: return []; } } // A virtual visit has no Start: the server starts it once clinician and patient are both in the video. if (virtual && (status === 'scheduled' || status === 'arrived' || status === 'triaged')) { return [ { kind: 'prepare-video', label: 'Prepare video visit', variant: 'go', primary: true }, status === 'scheduled' ? { kind: 'preview', label: 'Preview chart', variant: 'outline', primary: false } : { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; } switch (status) { case 'in-progress': return [ { kind: 'resume', label: 'Resume documentation', variant: 'primary', primary: true }, { kind: 'set-onleave', label: 'Set on-leave', variant: 'amber', primary: false }, { kind: 'finish', label: 'Finish encounter', variant: 'outline', primary: false }, ]; case 'onleave': return [ { kind: 'bring-back', label: 'Bring back in', variant: 'go', primary: true }, { kind: 'finish', label: 'Finish encounter', variant: 'outline', primary: false }, ]; case 'triaged': return [ { kind: 'start', label: 'Start encounter', variant: 'go', primary: true }, { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; case 'arrived': return [ { kind: 'start', label: 'Start encounter', variant: 'go', primary: true }, { kind: 'open-chart', label: 'Open chart', variant: 'outline', primary: false }, ]; case 'scheduled': return [ { kind: 'preview', label: 'Preview chart', variant: 'outline', primary: true }, ]; case 'finished': return [ { kind: 'reopen', label: 'Reopen encounter', variant: 'go', primary: true }, { kind: 'open-chart', label: 'View encounter', variant: 'outline', primary: false }, ]; default: return []; } };