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
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|