/** * Deriving a one-off day from the usual one. * * "Opening late today" and "closing early today" are the two changes the shop * actually makes, and both are relative to whatever that day normally does — * including days with a lunch break, where clipping the wrong slot produces * hours nobody meant. Pure, so every shape is tested. */ import { addCivilDays, compareCivil, minutesOfTime, type CivilDate } from './civil'; import type { Slot } from './types'; /** A range longer than this is a holiday period, not an exception. */ export const MAX_RANGE_DAYS = 92; /** * The day's hours if it opens at `from` instead of its usual time. * * Slots that finish before the new opening disappear — a shop opening at 14:00 * does not keep its 10:00-13:00 morning — and the slot straddling it is * trimmed rather than dropped. */ export function lateOpening(slots: readonly Slot[], from: string): Slot[] { const opensAt = minutesOfTime(from); return slots .filter((slot) => minutesOfTime(slot.close) > opensAt) .map((slot) => (minutesOfTime(slot.open) < opensAt ? { ...slot, open: from } : { ...slot })); } /** * The day's hours if it closes at `until` instead of its usual time. * * The mirror of `lateOpening`: slots starting after the new closing time go, * and the one straddling it is trimmed. */ export function earlyClosing(slots: readonly Slot[], until: string): Slot[] { const closesAt = minutesOfTime(until); return slots .filter((slot) => minutesOfTime(slot.open) < closesAt) .map((slot) => (minutesOfTime(slot.close) > closesAt ? { ...slot, close: until } : { ...slot })); } /** * Every date in `[start, end]`, inclusive. * * Bounded: a typo in a year field should produce an error, not thirty thousand * rows. */ export function expandRange(start: CivilDate, end: CivilDate): CivilDate[] { if (compareCivil(start, end) > 0) { return []; } const dates: CivilDate[] = []; let cursor = start; while (compareCivil(cursor, end) <= 0) { dates.push(cursor); if (dates.length > MAX_RANGE_DAYS) { return []; } cursor = addCivilDays(cursor, 1); } return dates; } export type RangeProblem = 'EMPTY' | 'REVERSED' | 'TOO_LONG' | null; export function checkRange(start: CivilDate, end: CivilDate): RangeProblem { if (!start || !end) { return 'EMPTY'; } if (compareCivil(start, end) > 0) { return 'REVERSED'; } if (expandRange(start, end).length === 0) { return 'TOO_LONG'; } return null; }