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,113 @@
|
||||
/**
|
||||
* Civil (wall-clock) date and time helpers.
|
||||
*
|
||||
* The whole scheduling core works on civil values — "2026-09-20" and "18:30"
|
||||
* as the shop would read them off a wall — rather than on instants. That is a
|
||||
* deliberate choice: a slot that runs 10:00-18:30 runs 10:00-18:30 on the day
|
||||
* the clocks change too, and no UTC arithmetic can accidentally shift it by an
|
||||
* hour twice a year.
|
||||
*
|
||||
* Conversions from an instant to a civil value happen exactly once, at the
|
||||
* edge, through Intl with an explicit timezone. Everything downstream is
|
||||
* string and integer arithmetic.
|
||||
*/
|
||||
|
||||
/** A civil date, `YYYY-MM-DD`. */
|
||||
export type CivilDate = string;
|
||||
|
||||
/** A civil time of day, `HH:mm`, 24-hour. */
|
||||
export type CivilTime = string;
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
const dateFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
const timeFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function dateFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let formatter = dateFormatters.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
dateFormatters.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function timeFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let formatter = timeFormatters.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
timeFormatters.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function part(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
|
||||
const found = parts.find((candidate) => candidate.type === type);
|
||||
if (!found) {
|
||||
throw new Error(`Missing "${type}" in formatted date`);
|
||||
}
|
||||
return found.value;
|
||||
}
|
||||
|
||||
/** The civil date the given instant falls on, in `timeZone`. */
|
||||
export function toCivilDate(instant: Date, timeZone: string): CivilDate {
|
||||
const parts = dateFormatter(timeZone).formatToParts(instant);
|
||||
return `${part(parts, 'year')}-${part(parts, 'month')}-${part(parts, 'day')}`;
|
||||
}
|
||||
|
||||
/** The wall-clock time the given instant shows, in `timeZone`. */
|
||||
export function toCivilTime(instant: Date, timeZone: string): CivilTime {
|
||||
const parts = timeFormatter(timeZone).formatToParts(instant);
|
||||
return `${part(parts, 'hour')}:${part(parts, 'minute')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds whole days to a civil date.
|
||||
*
|
||||
* The arithmetic goes through UTC on purpose: UTC has no daylight saving, so
|
||||
* "one day later" is always exactly 86 400 000 ms and the result is the civil
|
||||
* date a human would name, including on the nights the clocks change.
|
||||
*/
|
||||
export function addCivilDays(date: CivilDate, days: number): CivilDate {
|
||||
const shifted = new Date(civilToUtcMs(date) + days * MS_PER_DAY);
|
||||
const year = shifted.getUTCFullYear().toString().padStart(4, '0');
|
||||
const month = (shifted.getUTCMonth() + 1).toString().padStart(2, '0');
|
||||
const day = shifted.getUTCDate().toString().padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** Day of the week, 0 = Sunday .. 6 = Saturday, matching `Date.prototype.getDay`. */
|
||||
export function civilDayOfWeek(date: CivilDate): number {
|
||||
return new Date(civilToUtcMs(date)).getUTCDay();
|
||||
}
|
||||
|
||||
/** Chronological comparator; civil dates are lexicographically ordered too. */
|
||||
export function compareCivil(a: CivilDate, b: CivilDate): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/** True when `date` falls within `[start, end]`, both inclusive. */
|
||||
export function isWithinCivilRange(date: CivilDate, start: CivilDate, end: CivilDate): boolean {
|
||||
return compareCivil(date, start) >= 0 && compareCivil(date, end) <= 0;
|
||||
}
|
||||
|
||||
/** Minutes since midnight for a `HH:mm` wall-clock time. */
|
||||
export function minutesOfTime(time: CivilTime): number {
|
||||
const [hours, minutes] = time.split(':');
|
||||
return Number(hours) * 60 + Number(minutes);
|
||||
}
|
||||
|
||||
function civilToUtcMs(date: CivilDate): number {
|
||||
const [year, month, day] = date.split('-');
|
||||
return Date.UTC(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
Reference in New Issue
Block a user