// Weekly block hours are wall clock in the block's own zone, so two blocks can only be compared // once one is moved into the other's zone. import { addCalendarDays, dayOfWeekOf, fhirCalendarDate, zonedWallClockToInstant } from './practiceTimezone'; import { resolveZonedDayWindow } from '../pages/DashboardPage/SchedulesPage/scheduleDayWindow'; export type WeekDaySlot = { day: string; startMin: number; endMin: number }; const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; const DAY_MIN = 24 * 60; const WEEK_MIN = 7 * DAY_MIN; /** Minutes `timeZone` is ahead of UTC at `instant`. */ export const zoneOffsetMinutes = (instant: Date, timeZone: string): number => { const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).formatToParts(instant).map((p) => [p.type, p.value])); const asUtc = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute); return Math.round((asUtc - Math.floor(instant.getTime() / 60_000) * 60_000) / 60_000); }; /** Moves slots by `minutes` around the week, splitting any that cross midnight. */ export const shiftDaySlots = (slots: WeekDaySlot[], minutes: number): WeekDaySlot[] => { if (minutes === 0) return slots; const out: WeekDaySlot[] = []; for (const slot of slots) { const dayIndex = DAYS.indexOf(slot.day); if (dayIndex < 0) { out.push(slot); continue; } let start = (((dayIndex * DAY_MIN + slot.startMin + minutes) % WEEK_MIN) + WEEK_MIN) % WEEK_MIN; let remaining = slot.endMin - slot.startMin; while (remaining > 0) { const day = Math.floor(start / DAY_MIN); const startMin = start % DAY_MIN; const take = Math.min(remaining, DAY_MIN - startMin); out.push({ day: DAYS[day], startMin, endMin: startMin + take }); remaining -= take; start = (start + take) % WEEK_MIN; } } return out; }; /** `slots` read in `fromZone`, re-expressed in `toZone` as of `at`. Unknown zones are left as they are. */ export const alignDaySlots = ( slots: WeekDaySlot[], fromZone: string | null | undefined, toZone: string | null | undefined, at: Date, ): WeekDaySlot[] => { if (!fromZone || !toZone || fromZone === toZone) return slots; return shiftDaySlots(slots, zoneOffsetMinutes(at, toZone) - zoneOffsetMinutes(at, fromZone)); }; /** A block's weekly hours, the zone they are read in and its planning horizon (FHIR date or dateTime). */ export type DatedBlock = { slots: WeekDaySlot[]; zone: string | null | undefined; start: string | undefined; end: string | undefined; }; type Occurrence = { slot: WeekDaySlot; start: number; end: number }; // Zones differ by at most 26 hours, so a date's hours can only meet the hours of the dates two either side. const NEIGHBOUR_DAYS = 2; const maxDate = (a: string, b: string): string => (a > b ? a : b); const minDate = (a: string, b: string): string => (a < b ? a : b); const clockOf = (minutes: number): string => `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; // The staff calendar's own window rule, so an hour inside a spring-forward gap lands after it and an end // that then falls before its start runs on into the next date, as the calendar shows it. An end of 24:00 // is the next date's midnight. const windowOf = (date: string, slot: WeekDaySlot, timeZone: string): { start: number; end: number } => { if (slot.endMin >= DAY_MIN) { return { start: zonedWallClockToInstant(date, clockOf(slot.startMin), timeZone).getTime(), end: zonedWallClockToInstant(addCalendarDays(date, 1), '00:00', timeZone).getTime(), }; } const window = resolveZonedDayWindow(date, clockOf(slot.startMin), clockOf(slot.endMin), timeZone); return { start: window.start.getTime(), end: window.end.getTime() }; }; /** Every dated occurrence of `slots` from `first` to `last` inclusive, as instants. */ const occurrences = (slots: WeekDaySlot[], timeZone: string, first: string, last: string): Occurrence[] => { const out: Occurrence[] = []; for (let date = first; date <= last; date = addCalendarDays(date, 1)) { const day = DAYS[dayOfWeekOf(date)]; for (const slot of slots) { if (slot.day !== day) continue; out.push({ slot, ...windowOf(date, slot, timeZone) }); } } return out; }; /** * The first of `next`'s slots that runs at the same moment as `other` on a date each block's own period * reaches. Periods are read as calendar dates in each block's zone with the last day included, as the * booking calendar reads them, and touching hours do not collide. A block with no known zone is read on * the other block's clock, and a block with no period runs on no date. */ export const findOccurrenceOverlap = (next: DatedBlock, other: DatedBlock): WeekDaySlot | undefined => { if (!next.start || !next.end || !other.start || !other.end) return undefined; const nextZone = next.zone || other.zone || 'UTC'; const otherZone = other.zone || next.zone || 'UTC'; const nextFirst = fhirCalendarDate(next.start, nextZone); const nextLast = fhirCalendarDate(next.end, nextZone); const otherFirst = fhirCalendarDate(other.start, otherZone); const otherLast = fhirCalendarDate(other.end, otherZone); const nextRuns = occurrences(next.slots, nextZone, maxDate(nextFirst, addCalendarDays(otherFirst, -NEIGHBOUR_DAYS)), minDate(nextLast, addCalendarDays(otherLast, NEIGHBOUR_DAYS))); if (nextRuns.length === 0) return undefined; const otherRuns = occurrences(other.slots, otherZone, maxDate(otherFirst, addCalendarDays(nextFirst, -NEIGHBOUR_DAYS)), minDate(otherLast, addCalendarDays(nextLast, NEIGHBOUR_DAYS))); return nextRuns.find((run) => otherRuns.some((o) => run.start < o.end && o.start < run.end))?.slot; };