import type { Appointment, Bundle, Schedule, Slot } from 'fhir/r4'; /** A span the practitioner is held anywhere, as the held-times read answers it: bare start and end. */ export type HeldTime = { start: string; end: string }; export interface BusyTimes { slots: Slot[]; held: HeldTime[]; } /** * Each schedule's busy Slots and its practitioner's held time anywhere, read together for one window. Either * failing fails the whole read: a half-read day is not free time, and the picker offers nothing until it knows. */ export async function readBusyTimes( schedules: Schedule[], start: string, end: string, reads: { fetchSlots: (schedule: Schedule, start: string, end: string, organizationId?: string) => Promise | Bundle>; fetchHeldTimes: (schedule: Schedule, start: string, end: string, organizationId?: string) => Promise; unpack: (bundle: Bundle) => Slot[]; }, organizationId?: string, ): Promise { const [slotBundles, held] = await Promise.all([ Promise.all(schedules.map((schedule) => reads.fetchSlots(schedule, start, end, organizationId))), Promise.all(schedules.map((schedule) => reads.fetchHeldTimes(schedule, start, end, organizationId))), ]); return { slots: slotBundles.flatMap((bundle) => reads.unpack(bundle as Bundle)), held: held.flat() }; } /** Held time as the busy entries the pickers already subtract: a picker-side shape, never a FHIR Slot. */ export const heldAsBusy = (held: HeldTime[]): { start: string; end: string; status: 'busy' }[] => held.map(({ start, end }) => ({ start, end, status: 'busy' })); /** Held time without the visit being moved, whose own cells come back held: it may keep its own time. */ export function withoutOwnVisit(held: HeldTime[], own: { start?: string; end?: string } | undefined): HeldTime[] { const ownStart = Date.parse(own?.start ?? ''); const ownEnd = Date.parse(own?.end ?? ''); if (!Number.isFinite(ownStart) || !Number.isFinite(ownEnd) || ownEnd <= ownStart) return held; return held.flatMap(({ start, end }) => { const from = Date.parse(start); const to = Date.parse(end); if (to <= ownStart || from >= ownEnd) return [{ start, end }]; return [ ...(from < ownStart ? [{ start, end: new Date(ownStart).toISOString() }] : []), ...(to > ownEnd ? [{ start: new Date(ownEnd).toISOString(), end }] : []), ]; }); } const INVITEE_CODE = 'collaboration-team-invitee'; /** The visit's host clinician: its one Practitioner who is not an invited guest, as the server reads the host. */ export function hostPractitionerOf(appointment: Appointment | undefined): string | undefined { const hosts = new Set((appointment?.participant ?? []) .filter((participant) => !participant.type?.some((type) => type.coding?.some((coding) => coding.code === INVITEE_CODE))) .map((participant) => participant.actor?.reference) .filter((reference): reference is string => Boolean(reference?.startsWith('Practitioner/'))) .map((reference) => reference.slice('Practitioner/'.length))); return hosts.size === 1 ? [...hosts][0] : undefined; } /** A visit being moved keeps its own time only while its own host is the practitioner being booked, on any Schedule. */ export function ownVisitFor(appointment: Appointment | undefined, practitionerId: string | undefined): HeldTime | undefined { if (!appointment?.id || !appointment.start || !appointment.end || !practitionerId) return undefined; return hostPractitionerOf(appointment) === practitionerId ? { start: appointment.start, end: appointment.end } : undefined; } /** * The desk's busy [startMin, endMin) on the selected day's grid clock: this Schedule's busy Slots, its own Slot left * out when the visit being moved is on it, and the practitioner's held time anywhere less the moved visit's own. */ export function deskBusyRanges(params: { slots: Slot[]; held: HeldTime[]; appointment?: Appointment; originalSlotId?: string; practitionerId?: string; selectedDate?: string; dayStartMs?: number; }): { startMin: number; endMin: number }[] { const { slots, held, appointment, originalSlotId, practitionerId, selectedDate, dayStartMs } = params; // Its own Slot on this Schedule still names its time when the host cannot be read. const ownSlot = originalSlotId ? slots.find((slot) => slot.id === originalSlotId) : undefined; const own = ownVisitFor(appointment, practitionerId) ?? (ownSlot?.start && ownSlot.end ? { start: ownSlot.start, end: ownSlot.end } : undefined); const busy = [...slots, ...heldAsBusy(withoutOwnVisit(held, own))]; if (!selectedDate || dayStartMs === undefined || busy.length === 0) return []; // Minutes from the selected day's start on the grid clock, so an overnight slot keeps its length. const toMin = (iso: string) => Math.round((new Date(iso).getTime() - dayStartMs) / 60_000); return busy .filter((slot) => { if (originalSlotId && 'id' in slot && slot.id === originalSlotId) return false; if (!slot.start || !slot.status.includes('busy')) return false; const startMin = toMin(slot.start); const endMin = slot.end ? toMin(slot.end) : startMin; return startMin < 24 * 60 && Math.max(endMin, startMin + 1) > 0; }) .map((slot) => { // Interval-based, so a longer appointment blocks every grid start it overlaps, not just its exact start. const startMin = toMin(slot.start as string); const endMin = slot.end ? toMin(slot.end as string) : startMin; return { startMin, endMin: Math.max(endMin, startMin + 1) }; }); } /** * What a picked time may do now. Booked only while the current read is ready and still offers it; while a read is * pending it waits; a ready read that no longer offers it retires the pick; a failed read retires the plan made from it. */ export function pickedTimeStanding( read: 'idle' | 'pending' | 'ready' | 'failed', picked: string | undefined, offered: readonly string[], ): 'none' | 'actionable' | 'wait' | 'retire-pick' | 'retire-plan' { if (!picked) return 'none'; if (read === 'pending') return 'wait'; if (read === 'ready') return offered.includes(picked) ? 'actionable' : 'retire-pick'; return 'retire-plan'; } export type SendStanding = { epoch: string; standing: string; picked?: string }; /** * The page's standing for a Confirm to hold: the same object while nothing changed, a new one on any change of read, * standing or pick. A Confirm compares identity, so a read replaced and then repeated still retires it. */ export const nextSendStanding = (previous: SendStanding, now: SendStanding): SendStanding => previous.epoch === now.epoch && previous.standing === now.standing && previous.picked === now.picked ? previous : now;