import type { Appointment, Practitioner, PractitionerRole } from 'fhir/r4'; /** * - WHAT DEACTIVATING A STAFF MEMBER LEAVES BEHIND. * * THE DEFECT THIS EXISTS FOR: deactivation flips `PractitionerRole.active` to `false`, which blocks * login and blocks NEW bookings - and touches nothing already on the calendar. Deactivate on Friday and * Monday's patients still hold appointments with someone who cannot log in. The appointment renders * completely normally, so this is found by a patient in a waiting room rather than by anyone who could * have prevented it. * * Spencer, 2026-08-14: *"going on maternity leave really wouldn't be a reason to deactivate an account. * The correct way to handle that would be to just update their schedule reflecting the time off."* * **DEACTIVATION MEANS GONE, NOT AWAY.** Temporarily absent is a `Schedule` concern. That ruling is what * makes this answerable at all: if deactivation could mean "away", nothing here could know whether those * appointments should stand. * * IT NOW ANSWERS FOR TWO CHANGES, NOT ONE. Taking a clinician SEAT away has the same shape: the person * keeps their login, but the Practitioner workspace is dropped at the next token mint, so the visits * already on the calendar are left with somebody who can no longer open them. The question - which * appointments does this strand, and how many - is identical, so it is asked in one place. Two * implementations of "which appointments still matter" is how those two screens would come to disagree, * and the disagreement would show up as an undercount, which is the failure nobody reports. */ /** * Statuses that mean the visit is already over or already called off, so deactivation does not affect it. * * Deliberately an EXCLUDE list rather than an include list of "live" statuses. FHIR's appointment status * set can grow, and an unknown status must count as STILL HAPPENING - an appointment wrongly counted is a * number an admin double-checks, while one wrongly omitted is the exact silent failure this card is about. */ export const SETTLED_APPOINTMENT_STATUSES: ReadonlySet = new Set([ 'cancelled', 'noshow', 'entered-in-error', 'fulfilled', ]); /** * Every reference under which this staff member's appointments can be filed. * * BOTH SHAPES, ALWAYS, AND THIS IS MEASURED RATHER THAN DEFENSIVE. On staging, **171 of 254** * appointments name only a `Practitioner/`, **2** name only a `PractitionerRole/`, and **80** name both. * Searching one shape alone therefore misses real appointments - and an undercount here reads as * reassurance ("only 2 to worry about") in the one dialog that must not reassure wrongly. */ export function actorReferencesForStaff( practitioner: Pick, roles: ReadonlyArray>, ): string[] { const refs: string[] = []; if (practitioner.id) refs.push(`Practitioner/${practitioner.id}`); for (const role of roles) { if (role.id) refs.push(`PractitionerRole/${role.id}`); } return refs; } export interface StaffAppointmentImpact { /** Appointments that are still going to happen, soonest first. */ readonly affected: Appointment[]; readonly count: number; /** ISO date of the soonest affected appointment, or undefined when there are none. */ readonly earliest?: string; /** ISO date of the furthest affected appointment, or undefined when there are none. */ readonly latest?: string; /** * True when the search could not be read to the end. The count is then a FLOOR, not a total, and the * dialog must say so - "14 appointments" that is really 140 is worse than admitting the number is * partial. */ readonly partial: boolean; } /** * Reduce the fetched appointments to the ones deactivation would strand. * * `now` is a parameter, never `Date.now()` inside. An appointment starting in four minutes is affected * and one that started four minutes ago is not, and a boundary that cannot be driven from a test is a * boundary nobody checks. */ export function staffAppointmentImpact( appointments: ReadonlyArray, now: Date, options: { partial?: boolean } = {}, ): StaffAppointmentImpact { const startOf = (appointment: Appointment): number => { const parsed = Date.parse(appointment.start ?? ''); // An appointment with NO start is a patient's pending time-window REQUEST - it has no time yet, // which makes it upcoming by definition rather than expired. Treating an unparseable start as "in // the past" would silently drop exactly the appointments nobody else can see either. return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; }; const affected = appointments .filter((appointment) => !SETTLED_APPOINTMENT_STATUSES.has(appointment.status ?? '')) .filter((appointment) => startOf(appointment) >= now.getTime()) .sort((a, b) => startOf(a) - startOf(b)); const dated = affected.map(startOf).filter((time) => Number.isFinite(time)); return { affected, count: affected.length, earliest: dated.length ? new Date(Math.min(...dated)).toISOString() : undefined, latest: dated.length ? new Date(Math.max(...dated)).toISOString() : undefined, partial: options.partial === true, }; } /** * The sentence the confirm dialog leads with. * * THE COUNT, NOT THE POLICY. *"Dr. Ellis has 14 appointments still booked"* is a different decision from * *"this will cancel appointments"* - a number changes behaviour, a policy sentence gets clicked through. */ export function impactSummary(staffName: string, impact: StaffAppointmentImpact): string { if (impact.count === 0) return ''; const noun = impact.count === 1 ? 'appointment' : 'appointments'; const atLeast = impact.partial ? 'at least ' : ''; return `${staffName} still has ${atLeast}${impact.count} ${noun} on the calendar.`; }