import { Appointment } from "fhir/r4"; import { wasDeclinedByPractice } from "./appointmentCancellation"; import { isAwaitingPractice } from "./appointmentAwaitingPractice"; // Never shown to the patient - a cancelled / no-show / error record isn't a real upcoming or past // visit. (Cancellation/no-show history is out of scope for the patient surfaces for now.) const HIDDEN_STATUSES = new Set([ "cancelled", "noshow", "entered-in-error", ]); export interface CategorizedAppointments { upcoming: Appointment[]; past: Appointment[]; /** * REQUESTS THE PRACTICE ANSWERED WITH NO, and only while the time they asked for is still ahead. * * A PRODUCT THAT EXPLAINS THE WAIT OWES THE OUTCOME. Review mode tells the patient at every stage * what is happening - their time is held, they will hear back, it shows as pending until then - and * the one stage it never reported is the only one where the answer is unwelcome. The booking left * Upcoming and nothing anywhere said why. * * IT AGES OUT ON ITS OWN RATHER THAN BEING DISMISSED, which is what keeps it from becoming a nag. * Once the time they asked for has passed there is nothing left to tell them, so it stops being * shown - no state to store, nothing to re-ask, and an answered thing is never re-asked. */ notConfirmed: Appointment[]; } /** * How long an appointment is assumed to run when it says nothing. * * - THIS CONSTANT IS THE ANSWER TO THE CARD'S OPEN QUESTION, so it is a decision rather than a * magic number. `end` is OPTIONAL in FHIR and some of this data carries none. A naive `end >= now` files * every one of those under history **forever** - the same defect this card is fixing, moved. * * The ordered fallback is `end` `start + minutesDuration` `start + this`. Sixty minutes is the * booking flow's own default duration, so an appointment created without an explicit end is being treated * exactly as the form that created it would have. * * ERRING LONG IS THE SAFE DIRECTION HERE, and deliberately so: too long leaves a finished visit in the * list for a while, which is untidy; too short takes a LIVE video visit off the only screen that offers a * way into it. One is a tidiness cost, the other is a missed appointment. */ export const ASSUMED_APPOINTMENT_MINUTES = 60; /** When this appointment stops being current - its own `end`, its duration, or the assumed one. */ const endOf = (appointment: Appointment): number => { const startMs = new Date(appointment.start as string).getTime(); if (Number.isNaN(startMs)) return Number.NaN; const declaredEnd = appointment.end ? new Date(appointment.end).getTime() : Number.NaN; if (!Number.isNaN(declaredEnd)) return declaredEnd; const minutes = typeof appointment.minutesDuration === 'number' && appointment.minutesDuration > 0 ? appointment.minutesDuration : ASSUMED_APPOINTMENT_MINUTES; return startMs + minutes * 60_000; }; /** * IS THERE STILL SOMETHING TO TELL THEM about a request the practice declined? * * A request that named a TIME stops being worth reporting once that time has passed - the docstring on * `notConfirmed` is the reasoning, and it is the whole of why that notice needs no dismiss control. * * A REQUEST THAT NAMED NO TIME HAS NO SUCH MOMENT, and that is structural rather than an oversight: the * "preferred days & times" style carries a RECURRING preference - `day=tue`, `timeOfDay=morning` - and * a weekday is not a date. `endOf` is `NaN` for those, `now < NaN` is false, and the answer was * dropped from upcoming, past and notConfirmed all three. The patient's own request, answered, reported * nowhere. * * SO IT IS REPORTED UNTIL THE RECORD BEHIND IT CHANGES, which is the product's settled shape for a * patient notice rather than a new rule invented here - see `urgentNotice.ts`, Spencer 2026-08-13: * "Each trigger clears itself because the record behind it changes ... which is also why there is no * dismiss button - dismissing would change nothing except our ability to tell the patient." * * The narrow reading was deliberate: only a row carrying the decline coding reaches this, so an * ordinary cancellation is unaffected either way. */ const stillWorthTelling = (appointment: Appointment, now: Date): boolean => { const until = endOf(appointment); return Number.isNaN(until) ? true : now.getTime() < until; }; /** * Is this visit RUNNING right now - started, not yet over, not already closed out? * * Exported because a video visit is the one kind you join DURING, so "it is happening now" is the most * useful thing a patient surface can say about it - and the surfaces that show it are the only door in. */ export const isAppointmentHappeningNow = (appointment: Appointment, now: Date = new Date()): boolean => { if (HIDDEN_STATUSES.has(appointment.status) || appointment.status === 'fulfilled') return false; const startMs = new Date(appointment.start as string).getTime(); if (Number.isNaN(startMs)) return false; return startMs <= now.getTime() && now.getTime() < endOf(appointment); }; /** * Split a patient's appointments into upcoming vs past for the patient-facing surfaces (the home * "Upcoming Appointments" card and the Appointments page), so both stay consistent: * - hidden (cancelled / no-show / entered-in-error): shown in neither list. * - `fulfilled`, or any active appointment already in the past: past/history. * - active (booked / pending / proposed / arrived / checked-in / waitlist) in the future: upcoming. * - cancelled because the PRACTICE declined the request, for a time still ahead: notConfirmed. */ export const categorizePatientAppointments = ( appointments: Appointment[], // Injectable ONLY so the boundary this function exists to police can be exercised at a fixed // instant. whole subject is a visit that is *part way through*, and a window that opens // and closes with the wall clock cannot be asserted against `new Date()`. Every caller passes // nothing and gets the old behaviour. now: Date = new Date(), ): CategorizedAppointments => { const upcoming: Appointment[] = []; const past: Appointment[] = []; const notConfirmed: Appointment[] = []; for (const appointment of appointments) { if (HIDDEN_STATUSES.has(appointment.status)) { // A DECLINED REQUEST IS STILL HIDDEN FROM BOTH LISTS - it is not an upcoming visit and it is not // a visit that happened. It gets its own answer, while the time it asked for is still ahead. if (wasDeclinedByPractice(appointment) && stillWorthTelling(appointment, now)) { notConfirmed.push(appointment); } continue; } // - THIS COMPARED `start` AND NEVER LOOKED AT `end`. // // A 60-minute visit that began 40 minutes ago still has 20 minutes to run, and was filed as HISTORY. // Nothing about the visit being in progress entered the decision. // // WHY IT IS WORSE FOR TELEHEALTH THAN FOR AN IN-PERSON BOOKING: for an in-person visit this list is // a REMINDER - you have already driven there, and it dropping into history is untidy. For a video // visit the list is THE DOOR. You join *during* the appointment, which is the exact moment the old // rule threw it away. The failure landed on whoever was running late, closed the tab, or fumbled the // join the first time - and their visit was not merely hard to find, it was under **past // appointments**, which reads as *this already happened*. // A REQUEST WITH NO TIME IS NOT HISTORY. // // A request-only practice on the "preferred days & times" style takes SLOT-LESS requests: no // practitioner, no Slot, and no `start` at all - the patient gives a reason and their general // availability, and the practice decides the actual time. `endOf` opens with // `new Date(start).getTime()`, which is `NaN` for those, and `now < NaN` is FALSE - so every one of // them landed in `past` the moment it was made. // // This is the same class the constant below already guards against - "a naive `end >= now` files // every one of those under history forever" - one field further back: that fallback rescues a // missing `end`, and nothing rescued a missing `start`. // // Gated on `isAwaitingPractice` rather than on "no start" alone, deliberately: the only rows the // product creates without a time are requests waiting for an answer, and a dateless row in any other // status is not something this function should quietly promote into a patient's upcoming list. if (isAwaitingPractice(appointment) && Number.isNaN(new Date(appointment.start as string).getTime())) { upcoming.push(appointment); continue; } const stillRunning = now.getTime() < endOf(appointment); if (appointment.status !== "fulfilled" && stillRunning) { upcoming.push(appointment); } else { past.push(appointment); } } // A SLOT-LESS REQUEST HAS NO TIME TO SORT BY, and it must not sort FIRST. // // Several surfaces read `upcoming[0]` as "your next appointment" - NextAppointment takes exactly that. // Subtracting `NaN` returns `NaN` from the comparator, which is not an ordering at all, and a dateless // request that floated to the top would be announced as the patient's next visit while carrying no // date and nobody's name. It sorts LAST, so a real booking always wins that slot, and the comparator // stays total: two dateless requests compare equal rather than to `NaN`. const startOrLast = (appointment: Appointment): number => { const startMs = new Date(appointment.start as string).getTime(); return Number.isNaN(startMs) ? Number.POSITIVE_INFINITY : startMs; }; upcoming.sort((a, b) => { const left = startOrLast(a); const right = startOrLast(b); if (left === right) return 0; return left < right ? -1 : 1; }); past.sort( (a, b) => new Date(b.start as string).getTime() - new Date(a.start as string).getTime(), ); // SAME TOTALITY PROBLEM AS `upcoming` ABOVE, and it arrives here the moment a dateless declined // request is allowed in: subtracting `NaN` is not an ordering. A request that named no time sorts // last, so a declined row the patient can place in their week is always read first. notConfirmed.sort((a, b) => { const left = startOrLast(a); const right = startOrLast(b); if (left === right) return 0; return left < right ? -1 : 1; }); return { upcoming, past, notConfirmed }; };