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:
2026-09-20 17:38:01 +02:00
co-authored by Claude Opus 5
parent 42ce09100e
commit 7a1e833971
7 changed files with 1069 additions and 2 deletions
+113
View File
@@ -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));
}