/** * WHEN DOES A WEEKLY BLOCK ACTUALLY START AND END ON A GIVEN DAY? * * Extracted from the schedules calendar so the answer can be ASSERTED rather than trusted. It was three * lines inline, and the one that mattered was wrong in a way no test could reach. * * WHAT WENT WRONG (, measured on staging) * Toggling Wednesday on defaulted it to 09:00 open and 09:00 close. The calendar treated `end <= start` * as a block running past midnight, so it drew **Wednesday 9:00am-12:00am plus a Thursday 12:00am-9:00am * that nobody had turned on** - while the dialog that saved it still summarised "Wednesday only". * Three surfaces, three answers, and the one the person was looking at was the one that hid it. */ import { addCalendarDays, zonedWallClockToInstant } from '../../../utils/practiceTimezone'; const addDays = (value: Date, days: number): Date => { const date = new Date(value); date.setDate(date.getDate() + days); return date; }; const setTimeOnDate = (date: Date, time?: string): Date => { const next = new Date(date); const [hours = 0, minutes = 0, seconds = 0] = (time || '00:00:00').split(':').map(Number); next.setHours(hours, minutes, seconds, 0); return next; }; export interface DayWindow { start: Date; end: Date; /** True when the block genuinely runs past midnight into the following day. */ crossesMidnight: boolean; } /** * The concrete window a weekly `availableTime` entry occupies on one calendar day. * * AN END BEFORE THE START IS AN OVERNIGHT BLOCK, deliberately - 22:00 to 06:00 is a real schedule * shape and the booking side handles it on purpose (urgent care, sleep studies). That is why the wrap * exists at all, and it stays. * * AN END EQUAL TO THE START IS NOT OVERNIGHT. It is a day with no hours in it, and reading it as a * 24-hour block is what manufactured a phantom day. It returns a zero-length window, which draws as * nothing. **Drawing nothing for a day nobody gave hours to is the honest answer; drawing a full day is * the harmful one.** * * Do not "improve" this by defaulting a zero-length pair to the hours used elsewhere in the period. * A sensible-looking default would hide the degenerate entry rather than surface it, and the entry is * refused at save time now precisely so it never reaches here. */ export const resolveDayWindow = (day: Date, startTime?: string, endTime?: string): DayWindow => { const start = setTimeOnDate(day, startTime); const end = setTimeOnDate(day, endTime); const crossesMidnight = end < start; return { start, end: crossesMidnight ? addDays(end, 1) : end, crossesMidnight, }; }; /** * The same window on the SITE's clock: `dateStr` is a calendar date at the site and both times are * wall clock there. The rules are the ones above - an end before the start runs into the next day, an * end equal to the start is a day with no hours - applied to instants built in the site's zone. */ export const resolveZonedDayWindow = ( dateStr: string, startTime: string, endTime: string, timeZone: string, ): DayWindow => { const start = zonedWallClockToInstant(dateStr, startTime.slice(0, 5), timeZone); const sameDayEnd = zonedWallClockToInstant(dateStr, endTime.slice(0, 5), timeZone); const crossesMidnight = sameDayEnd < start; return { start, end: crossesMidnight ? zonedWallClockToInstant(addCalendarDays(dateStr, 1), endTime.slice(0, 5), timeZone) : sameDayEnd, crossesMidnight, }; };