feat: add the schedule resolution engine with its test table
This is the business core, written before any UI as the spec requires. It is pure: no I/O, no database, no clock of its own. Everything arrives in a ScheduleContext, so every rule below is exhaustively testable. The engine works on civil (wall-clock) values rather than instants. A slot that runs 10:00-18:30 runs 10:00-18:30 on the nights the clocks change too, and day arithmetic goes through UTC, which has no daylight saving. That turns the March and October switches from edge cases into non-events, and the tests pin both of them. Priority order, highest first: a dated exception, a vacation period, a public holiday the shop closes for, the reference week, then closed. Exceptions are badged by their stated reason rather than by who created them, so a holiday imported by the sync shows as a holiday in the UI. The search for the next opening is bounded to fourteen days. A shop that is closed forever must not make the server spin; past the horizon the screen simply says nothing about reopening. Both the never-open week and the beyond-the-horizon reopening are covered. "Soon" is strictly under thirty minutes, so a change exactly half an hour away still reads as plain OPEN or CLOSED. 55 tests; lib/schedule sits at 97% statements and 93% branches against an 85% floor. The thresholds were verified to actually fail the build before being committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user