import type { Extension, Location, Organization } from 'fhir/r4'; // // The practice's clock vs the viewer's clock. // // WHY THIS EXISTS. A schedule's hours are FHIR `time` values - `"09:00"`, a WALL CLOCK with no // timezone, which is correct FHIR. Booking used to turn those into instants with `Date.setHours(...)`, // which silently means "09:00 in whatever timezone the browser happens to be in". Nothing consulted // the practice's own timezone, even though the Organization records one. // // The result was that a patient in California booking an Iowa practice was offered 09:00-17:00 // PACIFIC, and a pick of "4:30 PM" landed at 6:30 PM Central - AFTER the practice had closed. The // availability check could not catch it, because it validated against windows built in the same wrong // basis. Self-consistent, and wrong. // // SPENCER'S RULING (2026-08-10) IS OPTION B: the patient sees THEIR OWN timezone, labelled, and the // conversion happens underneath. His reason is the case that makes A untenable - *"telehealth patients // that might be on the west coast booking an appointment with someone like a behavioral therapist on // the east coast"*. For a video visit there is no physical site to anchor "local" to, and telling a // Californian "9:00 AM" when the therapist means 9:00 Eastern is a missed appointment. // // Two conversions, and everything else follows from keeping them apart: // - the PRACTICE's wall clock an absolute instant (build availability, write the appointment) // - an absolute instant the VIEWER's wall clock (render, always labelled) // // NO NEW DEPENDENCY, deliberately. `Intl.DateTimeFormat` with a `timeZone` is in every browser we // support and is IANA-accurate including DST; a date library would be a coordinated lockfile change // for arithmetic the platform already does correctly. // /** * Exported so the Location transform writes the SAME url this module reads. * * Two copies of a string that must match is a defect waiting for someone to reword one of them - * proven the same day by the campaign SMS opt-out footer, where a duplicated constant drifted and * both repositories' suites stayed green. One definition, imported. */ export const TIMEZONE_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/extensions/tz-code'; /** * The practice's IANA timezone (`America/Chicago`), or `null` when the org has not set one. * * NULL IS A REAL STATE, NOT AN ERROR - `hasOrganizationTimezone` exists precisely because the org * setup flow can be incomplete. Callers must decide what to do about it explicitly rather than * defaulting to the viewer's zone, which is the bug this module exists to remove. The value comes from * the `http://hl7.org/fhir/ValueSet/timezones` ValueSet, which is the IANA tz database. */ export function practiceTimeZoneOf(organization: Organization | null | undefined): string | null { const code = (organization?.extension ?? []) .find((extension) => extension.url === TIMEZONE_EXTENSION_URL)?.valueCode; return code && code.trim() ? code.trim() : null; } /** * The Location's own IANA timezone, or `null`. * * THE LOCATION IS THE AUTHORITY, not the Organization - Spencer's ruling, 2026-08-10: *"the timezone * belongs on the location, single site orgs use the org but honestly every org has a location even if * it's single site so technically it could all be on the location just for uniformity."* A schedule's * hours are the hours of a SITE, and a multi-state org on one org-level zone would put every site on * the wrong clock but one. */ export function locationTimeZoneOf(location: Location | null | undefined): string | null { const code = (location?.extension ?? []) .find((extension) => extension.url === TIMEZONE_EXTENSION_URL)?.valueCode; return code && code.trim() ? code.trim() : null; } /** The Location's zone when this runtime can keep time in it; a missing or unknown zone is `null`. */ export function validLocationTimeZoneOf(location: Location | null | undefined): string | null { const zone = locationTimeZoneOf(location); if (!zone) return null; try { new Intl.DateTimeFormat('en-US', { timeZone: zone }); return zone; } catch { return null; } } /** * The clock a booking should be authored in: the site's, falling back to the org's. * * THE VIRTUAL LOCATION IS WHY THIS TAKES THE SITE SEPARATELY RATHER THAN READING THE SCHEDULE'S * FIRST ACTOR. Telehealth IS a Location here, and a video-enabled block carries TWO - the physical * site and the org's virtual one. A virtual Location has no inherent clock (that is what makes it * virtual), so the caller must pass the PHYSICAL site: `onlySites(...)`, never `location[0]`. * For Spencer's case - a west-coast patient booking an east-coast therapist - the therapist's * physical site is what carries Eastern, and the video visit inherits it. * * Returns `null` when neither is configured. Callers must NOT silently fall back to the viewer's * zone: that fallback is precisely the defect this module exists to remove, and it fails in the * direction that books appointments outside the practice's opening hours. */ export function resolveBookingTimeZone(args: { site?: Location | null; organization?: Organization | null; }): string | null { return locationTimeZoneOf(args.site) ?? practiceTimeZoneOf(args.organization); } /** * The extension array to store: `existing` with the timezone entry set, replaced, or removed. * * PURE, AND HERE RATHER THAN IN THE SERVICE, because of what it protects. `transformToFhirLocation` * REBUILDS a Location from a fixed field list and a FHIR PUT REPLACES THE WHOLE RESOURCE - so an * extension the form does not carry is one the next save DELETES. That is how the timezone would * quietly disappear on an unrelated edit, leaving booking on the org's clock with nothing to show it. * * Non-timezone extensions are passed through untouched, which is not speculative generality: it is * what stops the next person adding a Location extension from re-living this. * * An empty/blank timezone REMOVES the entry rather than writing a blank `valueCode` - "not set" * must stay distinguishable from "set to nothing", because `resolveBookingTimeZone` falls back to the * organization only on ABSENCE. */ export function withTimezoneExtension( existing: Extension[] | undefined, timezone: string | undefined | null, ): Extension[] { const others = (existing ?? []).filter((ext) => ext.url !== TIMEZONE_EXTENSION_URL); const trimmed = typeof timezone === 'string' ? timezone.trim() : ''; return trimmed ? [...others, { url: TIMEZONE_EXTENSION_URL, valueCode: trimmed }] : others; } /** The viewer's own IANA zone, as the browser reports it. */ export function viewerTimeZone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone; } /** * Offset of `timeZone` from UTC, in minutes, AT a specific instant. * * "At an instant" is the whole point - an offset is not a property of a zone, it is a property of a * zone at a moment. `America/Chicago` is -360 in January and -300 in July. Anything that caches one * offset per zone is wrong twice a year. */ function offsetMinutesAt(instantMs: number, timeZone: string): number { const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).formatToParts(new Date(instantMs)); const at = (type: string): number => Number(parts.find((p) => p.type === type)?.value); // `hour` comes back as 24 for midnight under hour12:false in some engines; normalise it. const hour = at('hour') % 24; const asIfUtc = Date.UTC(at('year'), at('month') - 1, at('day'), hour, at('minute'), at('second')); return (asIfUtc - instantMs) / 60_000; } /** * Interpret `YYYY-MM-DD` + `HH:mm` as a wall clock IN `timeZone`, and return the absolute instant. * * THE TWO-PASS CORRECTION IS FOR DST, and it is why this is not a one-liner. The offset we need is * the offset *at the answer*, which we do not have until we have the answer. So: guess by treating the * wall clock as UTC, measure the zone's offset near that guess, correct, then re-measure - a second * pass is enough because a transition moves the offset by at most a couple of hours, never enough to * cross another transition. * * Behaviour at the two pathological instants, chosen deliberately rather than left to chance: * - SPRING FORWARD (02:30 never happens): resolves to the same instant 03:30 does - the clock skips * over it, so the appointment lands at the first real moment after the gap. * - FALL BACK (01:30 happens twice): resolves to the FIRST (daylight) occurrence, the earlier of the * two, which is the conventional reading and the one a patient means by "1:30". * Both are pinned by tests, so a future change to this arithmetic cannot move them silently. */ export function zonedWallClockToInstant(dateStr: string, timeStr: string, timeZone: string): Date { const [year, month, day] = dateStr.split('-').map(Number); const [hour, minute] = timeStr.split(':').map(Number); const asIfUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0); const firstGuess = asIfUtc - offsetMinutesAt(asIfUtc, timeZone) * 60_000; const corrected = asIfUtc - offsetMinutesAt(firstGuess, timeZone) * 60_000; // THE GAP CHECK, AND ITS DIRECTION IS A SAFETY CHOICE. If reading the answer back does not give // the time that was asked for, the requested wall clock does not exist - it fell in the // spring-forward hour. The two-pass correction alone resolves that BACKWARD (02:30 lands at 01:30), // which would book an appointment an hour EARLIER than the practice asked for: the patient arrives // at the time they were shown and it is already past. Landing after the gap instead means the worst // case is arriving early and waiting. // `firstGuess` is exactly that instant - the pre-transition offset applied to the requested clock // reads as the first real moment on the other side of the gap. if (wallClockInZone(new Date(corrected), timeZone) !== `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`) { return new Date(firstGuess); } return new Date(corrected); } /** * The calendar date (`YYYY-MM-DD`) an instant falls on, AS READ IN `timeZone`. * * THE CALENDAR DAY IS TIMEZONE-DEPENDENT, and that is the trap this exists to close. At 23:00 * Pacific it is already tomorrow in New York - so iterating a planning horizon with `date.getDay()` * asks the VIEWER which day it is, and a schedule's "Monday" hours can land on the wrong date entirely. */ export function calendarDateInZone(instant: Date, timeZone: string): string { const parts = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', }).formatToParts(instant); const at = (type: string): string => parts.find((p) => p.type === type)?.value ?? ''; return `${at('year')}-${at('month')}-${at('day')}`; } /** * The calendar date a FHIR `date` OR `dateTime` denotes, read in `timeZone`. * * A FHIR `date` - `"2026-08-29"` - CARRIES NO ZONE. It already IS a calendar date, and the practice * means that day wherever anybody is standing. Handing it to `new Date()` parses it as UTC midnight, * so every zone west of UTC then reads it back as the day BEFORE. * * Measured 2026-08-28 on staging, in Chicago: a schedule block whose planning horizon ran * 2026-08-28 to 2026-08-29 generated no hours at all for Saturday the 29th, because the loop's last * date came back as the 28th. The horizon was shifted a day earlier at BOTH ends - so a schedule's * final working day was unbookable by anyone, and the day before its start was offered. * * A full `dateTime` is a real instant and still has to be read in the practice's zone. */ export function fhirCalendarDate(value: string, timeZone: string): string { return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : calendarDateInZone(new Date(value), timeZone); } /** * Calendar arithmetic on a `YYYY-MM-DD` string, with no timezone involved at all. * * Done in UTC deliberately: a calendar date has no zone, and using a local Date here would make * "add one day" occasionally add 23 or 25 hours across a DST boundary and skip or repeat a date. */ export function addCalendarDays(dateStr: string, days: number): string { const [year, month, day] = dateStr.split('-').map(Number); const shifted = new Date(Date.UTC(year, month - 1, day + days)); return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(shifted.getUTCDate()).padStart(2, '0')}`; } /** The date an interval ends on: the next day when its end clock is at or before its start clock. */ export function intervalEndDate(dateStr: string, startTime: string, endTime: string): string { return endTime <= startTime ? addCalendarDays(dateStr, 1) : dateStr; } /** Day of week (0 = Sunday) for a calendar date. A property of the DATE, so no zone is involved. */ export function dayOfWeekOf(dateStr: string): number { const [year, month, day] = dateStr.split('-').map(Number); return new Date(Date.UTC(year, month - 1, day)).getUTCDay(); } /** `HH:mm` of an instant as read in `timeZone` - the inverse direction, for rendering a grid. */ export function wallClockInZone(instant: Date, timeZone: string): string { const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour12: false, hour: '2-digit', minute: '2-digit', }).formatToParts(instant); const at = (type: string): string => parts.find((p) => p.type === type)?.value ?? '00'; return `${String(Number(at('hour')) % 24).padStart(2, '0')}:${at('minute')}`; } /** * A time for display, in the VIEWER's zone, ALWAYS carrying the zone label. * * THE LABEL IS NOT DECORATION - it is what makes option B safe. Showing a converted time without * saying which clock it is on is strictly worse than showing the practice's time, because the patient * has no way to notice the conversion happened. "2:00 PM" is a guess; "2:00 PM EDT" is an appointment. */ export function formatInstantForViewer(instant: Date, timeZone: string = viewerTimeZone()): string { return new Intl.DateTimeFormat('en-US', { timeZone, hour: 'numeric', minute: '2-digit', hour12: true, timeZoneName: 'short', }).format(instant); } /** * The viewer's short zone label (`PDT`) when their clock differs from the practice's, else `null`. * * THE READING SURFACES NEED THIS AS MUCH AS THE BOOKING ONES, and that is easy to miss. Booking is * where the mistake is MADE, but the appointment list and detail dialog are where it is READ - a remote * receptionist looking at tomorrow's schedule sees "7:00am" for an appointment the practice thinks is at * 9:00, with nothing to say whose clock it is. Same defect class, later in the day, with no form to * catch it. * * ONE PLACE FOR THE "ONLY WHEN IT DIFFERS" RULE so the surfaces cannot drift apart on when to warn. * Returning `null` rather than a label is what keeps the ordinary same-timezone case clean: a suffix on * every time everywhere is noise, and noise is how people stop reading the one that matters. */ export function viewerZoneLabelFor(instant: Date, practiceZone: string | null | undefined): string | null { if (!practiceZone) return null; if (!viewerDiffersFromPractice(instant, practiceZone)) return null; return new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }) .formatToParts(instant).find((p) => p.type === 'timeZoneName')?.value ?? viewerTimeZone(); } /** * True when the viewer is on a different clock from the practice AT that instant. * * Compared by OFFSET at the instant, not by zone NAME. `America/New_York` and `America/Detroit` are * different names for the same clock, and warning a Detroit patient that they are "in a different * timezone" from a New York practice would be noise that trains people to ignore the notice. */ export function viewerDiffersFromPractice( instant: Date, practiceZone: string, viewerZone: string = viewerTimeZone(), ): boolean { return offsetMinutesAt(instant.getTime(), practiceZone) !== offsetMinutesAt(instant.getTime(), viewerZone); }