import type { EventInput } from '@fullcalendar/core'; import type { PractitionerRole } from 'fhir/r4'; import type { StaffScheduleRecord } from '../../../utils/staffScheduleRecord'; import { getScheduleAvailability, getScheduleBreaks, subtractBreaksFromWindow, } from '../../../services/ScheduleService.tsx'; import { addCalendarDays, calendarDateInZone, dayOfWeekOf, viewerTimeZone, zonedWallClockToInstant, } from '../../../utils/practiceTimezone'; import { resolveZonedDayWindow } from './scheduleDayWindow'; /** * The staff calendar's events. Weekly hours are wall clock at the site, so every window is built in * the site's zone and shown in the viewer's. With no zone recorded the viewer's zone stands in. */ export type StaffScheduleStatus = 'scheduled' | 'unavailable' | 'break'; export type StaffScheduleEventProps = { scheduleId: string; staffName: string; roleLabel: string; locationLabel: string; servicesLabel: string; status: StaffScheduleStatus; statusLabel: string; description?: string; }; const DAY_CODES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/; const overlaps = (firstStart: Date, firstEnd: Date, secondStart: Date, secondEnd: Date) => firstStart < secondEnd && firstEnd > secondStart; const clock = (time?: string) => (time || '00:00').slice(0, 5); const startOfDate = (dateStr: string, zone: string) => zonedWallClockToInstant(dateStr, '00:00', zone); /** The calendar date a FHIR date or dateTime falls on at the site. */ const dateAtSite = (value: string, zone: string) => DATE_ONLY.test(value) ? value : calendarDateInZone(new Date(value), zone); /** A FHIR date or dateTime as an instant; a bare date is the start of that day at the site. */ const startInstant = (value: string | undefined, zone: string) => { if (!value) return undefined; if (DATE_ONLY.test(value)) return startOfDate(value, zone); const instant = new Date(value); return Number.isNaN(instant.getTime()) ? undefined : instant; }; /** The exclusive end of a period: a bare date runs through that day; a dateTime is that instant. */ const endInstant = (value: string | undefined, zone: string) => { if (!value) return undefined; if (DATE_ONLY.test(value)) return startOfDate(addCalendarDays(value, 1), zone); const instant = new Date(value); return Number.isNaN(instant.getTime()) ? undefined : instant; }; export const buildStaffScheduleEvents = ( records: StaffScheduleRecord[], visibleRange: { start: Date; end: Date } | undefined, zoneOf: (record: StaffScheduleRecord) => string | null = () => null, ): EventInput[] => { if (!visibleRange) { return []; } const events: EventInput[] = []; records.forEach((record) => { const { schedule, role, staffName, roleLabel, locationLabel, servicesLabel, active } = record; const scheduleId = schedule.id; if (!scheduleId || !role || !active) { return; } const zone = zoneOf(record) ?? viewerTimeZone(); const props = { scheduleId, staffName, roleLabel, locationLabel, servicesLabel }; let firstDate = calendarDateInZone(visibleRange.start, zone); let lastDate = calendarDateInZone(new Date(visibleRange.end.getTime() - 1), zone); if (schedule.planningHorizon?.start) { const horizonStart = dateAtSite(schedule.planningHorizon.start, zone); if (horizonStart > firstDate) firstDate = horizonStart; } if (schedule.planningHorizon?.end) { const horizonEnd = dateAtSite(schedule.planningHorizon.end, zone); if (horizonEnd < lastDate) lastDate = horizonEnd; } const scheduleAvailability = getScheduleAvailability(schedule); const scheduleBreaks = getScheduleBreaks(schedule); const availableTimes = (scheduleAvailability.availableTime.length ? scheduleAvailability.availableTime : (role.availableTime || [])) as Array< NonNullable[number] & { available?: boolean } >; const notAvailableSource = scheduleAvailability.notAvailable.length ? scheduleAvailability.notAvailable : (role.notAvailable || []); // The same whole-day ends as the leave band below, so the last day of leave is both painted and // cleared of working hours. const notAvailableRanges = notAvailableSource .map((na) => ({ start: startInstant(na.during?.start, zone), end: endInstant(na.during?.end, zone) })) .filter((range): range is { start: Date; end: Date } => Boolean(range.start && range.end)); for (let date = firstDate; date <= lastDate; date = addCalendarDays(date, 1)) { const dayCode = DAY_CODES[dayOfWeekOf(date)]; const dayStart = startOfDate(date, zone); const dayEnd = startOfDate(addCalendarDays(date, 1), zone); // Time off overrides scheduled availability for that day. if (notAvailableRanges.some((range) => overlaps(dayStart, dayEnd, range.start, range.end))) { continue; } availableTimes.forEach((availableTime, availableIndex) => { if (availableTime.available === false || !availableTime.daysOfWeek?.includes(dayCode as never)) { return; } if (availableTime.allDay) { if (!overlaps(dayStart, dayEnd, visibleRange.start, visibleRange.end)) return; events.push({ id: `${scheduleId}:scheduled:${date}:${availableIndex}:0`, title: staffName, // A whole day is a calendar DATE at the site, drawn on that date for everybody. start: date, end: addCalendarDays(date, 1), allDay: true, backgroundColor: 'var(--green-soft)', borderColor: 'var(--success)', textColor: 'var(--success-active)', extendedProps: { ...props, status: 'scheduled', statusLabel: 'Scheduled', description: schedule.comment?.trim() || undefined, } satisfies StaffScheduleEventProps, }); return; } if (!availableTime.availableStartTime || !availableTime.availableEndTime) { return; } // An end before the start runs past midnight (22:00 to 06:00 is real); an end EQUAL to // the start is a day with no hours and draws nothing. const { start: windowStart, end: windowEnd } = resolveZonedDayWindow( date, availableTime.availableStartTime, availableTime.availableEndTime, zone, ); if (windowEnd.getTime() === windowStart.getTime()) return; if (!overlaps(windowStart, windowEnd, visibleRange.start, visibleRange.end)) { return; } const pushScheduled = (segStart: Date, segEnd: Date, segIndex: number) => { events.push({ id: `${scheduleId}:scheduled:${date}:${availableIndex}:${segIndex}`, title: staffName, start: segStart, end: segEnd, allDay: false, backgroundColor: 'var(--green-soft)', borderColor: 'var(--success)', textColor: 'var(--success-active)', extendedProps: { ...props, status: 'scheduled', statusLabel: 'Scheduled', description: schedule.comment?.trim() || undefined, } satisfies StaffScheduleEventProps, }); }; // Recurring breaks split the timed window and show as "On break". const dayBreaks = scheduleBreaks .map((breakTime) => ({ start: zonedWallClockToInstant(date, clock(breakTime.startTime), zone), end: zonedWallClockToInstant(date, clock(breakTime.endTime), zone), description: breakTime.description, })) .filter((interval) => interval.start < interval.end && overlaps(interval.start, interval.end, windowStart, windowEnd)); if (dayBreaks.length === 0) { pushScheduled(windowStart, windowEnd, 0); return; } subtractBreaksFromWindow(windowStart, windowEnd, dayBreaks).forEach((segment, segIndex) => pushScheduled(segment.start, segment.end, segIndex), ); dayBreaks.forEach((interval, breakIndex) => { events.push({ id: `${scheduleId}:break:${date}:${availableIndex}:${breakIndex}`, title: interval.description?.trim() || 'On break', start: interval.start < windowStart ? windowStart : interval.start, end: interval.end > windowEnd ? windowEnd : interval.end, backgroundColor: 'var(--amber-soft)', borderColor: 'var(--amber-line)', textColor: 'var(--amber-ink)', extendedProps: { ...props, status: 'break', statusLabel: 'On break', description: interval.description?.trim() || undefined, } satisfies StaffScheduleEventProps, }); }); }); } notAvailableSource.forEach((notAvailable, unavailableIndex) => { const unavailableStart = startInstant(notAvailable.during?.start, zone); const unavailableEnd = endInstant(notAvailable.during?.end, zone); if ( !unavailableStart || !unavailableEnd || !overlaps(unavailableStart, unavailableEnd, visibleRange.start, visibleRange.end) ) { return; } events.push({ id: `${scheduleId}:unavailable:${unavailableIndex}:${unavailableStart.toISOString()}`, title: staffName, start: unavailableStart, end: unavailableEnd, backgroundColor: 'var(--red-soft)', borderColor: 'var(--danger)', textColor: 'var(--danger)', extendedProps: { ...props, status: 'unavailable', statusLabel: 'Unavailable', description: notAvailable.description?.trim() || undefined, } satisfies StaffScheduleEventProps, }); }); }); return events.sort((left, right) => { const leftStart = left.start ? new Date(left.start as string | Date).getTime() : 0; const rightStart = right.start ? new Date(right.start as string | Date).getTime() : 0; if (leftStart !== rightStart) { return leftStart - rightStart; } return String(left.title || '').localeCompare(String(right.title || '')); }); };