/** * The scheduling core. * * This module is pure: it performs no I/O, touches no database and reads no * clock of its own. Everything it needs arrives in a `ScheduleContext`, which * `lib/schedule/context.ts` is alone responsible for loading. That is what * makes the rules below exhaustively testable, and it is the reason the * opening-hours screen can be trusted not to regress. */ import { addCivilDays, civilDayOfWeek, isWithinCivilRange, minutesOfTime, toCivilDate, toCivilTime, type CivilDate, } from './civil'; import type { DivergenceKind, ExceptionKind, ExceptionReason, ResolvedDay, ScheduleContext, ShopStatus, ShopStatusKind, Slot, WeeklyScheduleEntry, } from './types'; /** * How close a change has to be to count as "soon". Strictly less than, so a * change exactly thirty minutes away still reads as plain OPEN or CLOSED. */ export const SOON_MINUTES = 30; /** * How far ahead the resolver is willing to look for the next opening. * * A shop that is closed forever must not make the server spin, so the search * is bounded rather than open-ended. Two weeks is well past any normal * closure; beyond it the screen simply says nothing about reopening. */ export const MAX_LOOKAHEAD_DAYS = 14; const CLOSED_DAY: WeeklyScheduleEntry = { dayOfWeek: -1, isClosed: true, slots: [] }; /** Resolves the day the given instant falls on, in the shop's timezone. */ export function resolveDay(date: Date, ctx: ScheduleContext): ResolvedDay { return resolveCivilDay(toCivilDate(date, ctx.timezone), ctx); } /** Resolves seven consecutive days, starting on the day `fromDate` falls on. */ export function resolveWeek(fromDate: Date, ctx: ScheduleContext): ResolvedDay[] { const start = toCivilDate(fromDate, ctx.timezone); return Array.from({ length: 7 }, (_, offset) => resolveCivilDay(addCivilDays(start, offset), ctx)); } /** * Applies the priority order to a single civil date: * * 1. an explicit dated exception, * 2. a vacation period covering it, * 3. a public holiday the shop closes for, * 4. the reference week, * 5. closed. */ export function resolveCivilDay(date: CivilDate, ctx: ScheduleContext): ResolvedDay { const dayOfWeek = civilDayOfWeek(date); const exception = ctx.exceptions.find((candidate) => candidate.date === date); if (exception) { const slots = exception.isClosed ? [] : sortSlots(exception.slots ?? []); return { date, dayOfWeek, isOpen: !exception.isClosed && slots.length > 0, slots, isException: true, exceptionKind: kindOfReason(exception.reason), noteFr: exception.noteFr ?? null, noteEn: exception.noteEn ?? null, }; } const vacation = ctx.vacations.find((candidate) => isWithinCivilRange(date, candidate.startDate, candidate.endDate), ); if (vacation) { return { date, dayOfWeek, isOpen: false, slots: [], isException: true, exceptionKind: 'VACATION', noteFr: vacation.labelFr, noteEn: vacation.labelEn ?? null, }; } const holiday = ctx.holidays.find((candidate) => candidate.date === date); if (holiday?.isAutoClosed) { return { date, dayOfWeek, isOpen: false, slots: [], isException: true, exceptionKind: 'HOLIDAY', noteFr: holiday.nameFr, noteEn: holiday.nameEn, }; } const reference = referenceDay(dayOfWeek, ctx); const slots = reference.isClosed ? [] : sortSlots(reference.slots); return { date, dayOfWeek, isOpen: !reference.isClosed && slots.length > 0, slots, isException: false, exceptionKind: null, noteFr: null, noteEn: null, }; } /** Where the shop stands right now, and what happens next. */ export function getCurrentStatus(now: Date, ctx: ScheduleContext): ShopStatus { const todayDate = toCivilDate(now, ctx.timezone); const nowMinutes = minutesOfTime(toCivilTime(now, ctx.timezone)); const today = resolveCivilDay(todayDate, ctx); const nextOpening = findNextOpening(todayDate, nowMinutes, ctx); // Inside a slot: the next change is that slot closing. for (const slot of today.slots) { const opensAt = minutesOfTime(slot.open); const closesAt = minutesOfTime(slot.close); if (nowMinutes >= opensAt && nowMinutes < closesAt) { const status: ShopStatusKind = closesAt - nowMinutes < SOON_MINUTES ? 'CLOSING_SOON' : 'OPEN'; return { status, today, nextChangeAt: { date: todayDate, time: slot.close }, nextOpening, }; } } // Outside every slot: the next change is the next opening, whenever that is. const opensToday = nextOpening?.date === todayDate; const minutesUntilOpening = opensToday ? minutesOfTime(nextOpening.time) - nowMinutes : null; const status: ShopStatusKind = minutesUntilOpening !== null && minutesUntilOpening < SOON_MINUTES ? 'OPENING_SOON' : 'CLOSED'; return { status, today, nextChangeAt: nextOpening, nextOpening }; } /** * Which automatic banner the screen should carry, comparing the coming seven * days against the reference week. * * Only today differs -> TODAY. Anything else differs -> WEEK. This is the * rule that turns a one-off closure into "Changement d'horaire aujourd'hui" * without anyone having to write the message. */ export function detectDivergence(fromDate: Date, ctx: ScheduleContext): DivergenceKind { const start = toCivilDate(fromDate, ctx.timezone); let todayDiverges = false; let laterDiverges = false; for (let offset = 0; offset < 7; offset += 1) { const date = addCivilDays(start, offset); if (!divergesFromReference(date, ctx)) { continue; } if (offset === 0) { todayDiverges = true; } else { laterDiverges = true; } } if (laterDiverges) { return 'WEEK'; } return todayDiverges ? 'TODAY' : 'NONE'; } function divergesFromReference(date: CivilDate, ctx: ScheduleContext): boolean { const resolved = resolveCivilDay(date, ctx); const reference = referenceDay(civilDayOfWeek(date), ctx); const referenceSlots = reference.isClosed ? [] : sortSlots(reference.slots); if (resolved.isOpen !== (!reference.isClosed && referenceSlots.length > 0)) { return true; } return !slotsEqual(resolved.slots, referenceSlots); } /** * The next moment the door opens, strictly after `fromMinutes` on `fromDate`. * Returns `null` when nothing is scheduled within the search horizon. */ function findNextOpening( fromDate: CivilDate, fromMinutes: number, ctx: ScheduleContext, ): { date: CivilDate; time: string } | null { for (let offset = 0; offset <= MAX_LOOKAHEAD_DAYS; offset += 1) { const date = addCivilDays(fromDate, offset); const day = resolveCivilDay(date, ctx); for (const slot of day.slots) { const opensAt = minutesOfTime(slot.open); if (offset > 0 || opensAt > fromMinutes) { return { date, time: slot.open }; } } } return null; } function referenceDay(dayOfWeek: number, ctx: ScheduleContext): WeeklyScheduleEntry { return ctx.weekly.find((entry) => entry.dayOfWeek === dayOfWeek) ?? CLOSED_DAY; } /** * The exception's stated reason tells the UI where the day came from, so a * holiday imported by the sync is badged as a holiday rather than as something * a person typed. */ function kindOfReason(reason: ExceptionReason): ExceptionKind { switch (reason) { case 'HOLIDAY': return 'HOLIDAY'; case 'VACATION': return 'VACATION'; default: return 'MANUAL'; } } /** Slots are compared and displayed in chronological order, whatever the input. */ function sortSlots(slots: Slot[]): Slot[] { return [...slots].sort((a, b) => minutesOfTime(a.open) - minutesOfTime(b.open)); } function slotsEqual(a: Slot[], b: Slot[]): boolean { if (a.length !== b.length) { return false; } return a.every((slot, index) => slot.open === b[index]?.open && slot.close === b[index]?.close); }