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:
@@ -24,5 +24,5 @@ jobs:
|
||||
- run: npx prisma generate
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm run test
|
||||
- run: npm run coverage
|
||||
- run: npm run build
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
addCivilDays,
|
||||
civilDayOfWeek,
|
||||
compareCivil,
|
||||
minutesOfTime,
|
||||
toCivilDate,
|
||||
toCivilTime,
|
||||
} from './civil';
|
||||
|
||||
const ZURICH = 'Europe/Zurich';
|
||||
|
||||
describe('toCivilDate / toCivilTime', () => {
|
||||
it('converts an instant to the wall clock of the shop', () => {
|
||||
// 2026-09-20T08:15:00Z is 10:15 in Zurich (CEST, UTC+2).
|
||||
const instant = new Date('2026-09-20T08:15:00Z');
|
||||
expect(toCivilDate(instant, ZURICH)).toBe('2026-09-20');
|
||||
expect(toCivilTime(instant, ZURICH)).toBe('10:15');
|
||||
});
|
||||
|
||||
it('keeps the local day when UTC has already rolled over', () => {
|
||||
// 22:30 UTC is 00:30 the next day in Zurich: the civil date must follow
|
||||
// the shop, not the server.
|
||||
const instant = new Date('2026-09-20T22:30:00Z');
|
||||
expect(toCivilDate(instant, ZURICH)).toBe('2026-09-21');
|
||||
expect(toCivilTime(instant, ZURICH)).toBe('00:30');
|
||||
});
|
||||
|
||||
it('reads the same instant differently in winter and in summer', () => {
|
||||
// CET (UTC+1) in January, CEST (UTC+2) in July.
|
||||
expect(toCivilTime(new Date('2026-01-15T12:00:00Z'), ZURICH)).toBe('13:00');
|
||||
expect(toCivilTime(new Date('2026-07-15T12:00:00Z'), ZURICH)).toBe('14:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCivilDays', () => {
|
||||
it('walks forward across a month boundary', () => {
|
||||
expect(addCivilDays('2026-09-30', 1)).toBe('2026-10-01');
|
||||
});
|
||||
|
||||
it('walks forward across a year boundary', () => {
|
||||
expect(addCivilDays('2026-12-31', 1)).toBe('2027-01-01');
|
||||
});
|
||||
|
||||
it('handles a leap day', () => {
|
||||
expect(addCivilDays('2028-02-28', 1)).toBe('2028-02-29');
|
||||
expect(addCivilDays('2028-02-29', 1)).toBe('2028-03-01');
|
||||
});
|
||||
|
||||
it('is unaffected by the spring daylight-saving switch', () => {
|
||||
// 2026-03-29 is the last Sunday of March: Zurich loses an hour that night.
|
||||
// Civil dates must still advance by exactly one day.
|
||||
expect(addCivilDays('2026-03-28', 1)).toBe('2026-03-29');
|
||||
expect(addCivilDays('2026-03-29', 1)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('is unaffected by the autumn daylight-saving switch', () => {
|
||||
// 2026-10-25 is the last Sunday of October: that day has 25 hours.
|
||||
expect(addCivilDays('2026-10-24', 1)).toBe('2026-10-25');
|
||||
expect(addCivilDays('2026-10-25', 1)).toBe('2026-10-26');
|
||||
});
|
||||
|
||||
it('walks backwards', () => {
|
||||
expect(addCivilDays('2026-01-01', -1)).toBe('2025-12-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('civilDayOfWeek', () => {
|
||||
it('matches JS getDay(), Sunday first', () => {
|
||||
expect(civilDayOfWeek('2026-09-20')).toBe(0); // Sunday
|
||||
expect(civilDayOfWeek('2026-09-21')).toBe(1); // Monday
|
||||
expect(civilDayOfWeek('2026-09-26')).toBe(6); // Saturday
|
||||
});
|
||||
|
||||
it('is stable across the daylight-saving switches', () => {
|
||||
expect(civilDayOfWeek('2026-03-29')).toBe(0);
|
||||
expect(civilDayOfWeek('2026-10-25')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareCivil', () => {
|
||||
it('orders dates chronologically', () => {
|
||||
expect(compareCivil('2026-09-20', '2026-09-21')).toBeLessThan(0);
|
||||
expect(compareCivil('2026-09-21', '2026-09-20')).toBeGreaterThan(0);
|
||||
expect(compareCivil('2026-09-20', '2026-09-20')).toBe(0);
|
||||
});
|
||||
|
||||
it('orders across year boundaries', () => {
|
||||
expect(compareCivil('2026-12-31', '2027-01-01')).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('minutesOfTime', () => {
|
||||
it('converts a wall-clock time to minutes since midnight', () => {
|
||||
expect(minutesOfTime('00:00')).toBe(0);
|
||||
expect(minutesOfTime('10:00')).toBe(600);
|
||||
expect(minutesOfTime('18:30')).toBe(1110);
|
||||
expect(minutesOfTime('23:59')).toBe(1439);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectDivergence, getCurrentStatus, resolveDay, resolveWeek } from './resolver';
|
||||
import type { ScheduleContext, Slot } from './types';
|
||||
|
||||
const ZURICH = 'Europe/Zurich';
|
||||
|
||||
const FULL_DAY: Slot[] = [{ open: '10:00', close: '18:30' }];
|
||||
const SPLIT_DAY: Slot[] = [
|
||||
{ open: '10:00', close: '13:00' },
|
||||
{ open: '14:00', close: '18:30' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The seeded reference week: closed Sunday and Monday, 10:00-18:30 Tuesday to
|
||||
* Saturday, with a lunch break on Wednesday.
|
||||
*
|
||||
* Anchor dates used throughout: 2026-09-20 is a Sunday, so 09-21 is Monday,
|
||||
* 09-22 Tuesday, 09-23 Wednesday, 09-26 Saturday.
|
||||
*/
|
||||
function context(overrides: Partial<ScheduleContext> = {}): ScheduleContext {
|
||||
return {
|
||||
timezone: ZURICH,
|
||||
weekly: [
|
||||
{ dayOfWeek: 0, isClosed: true, slots: [] },
|
||||
{ dayOfWeek: 1, isClosed: true, slots: [] },
|
||||
{ dayOfWeek: 2, isClosed: false, slots: FULL_DAY },
|
||||
{ dayOfWeek: 3, isClosed: false, slots: SPLIT_DAY },
|
||||
{ dayOfWeek: 4, isClosed: false, slots: FULL_DAY },
|
||||
{ dayOfWeek: 5, isClosed: false, slots: FULL_DAY },
|
||||
{ dayOfWeek: 6, isClosed: false, slots: FULL_DAY },
|
||||
],
|
||||
exceptions: [],
|
||||
vacations: [],
|
||||
holidays: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** An instant that reads as the given wall clock in Zurich. */
|
||||
function at(date: string, time: string, offset = '+02:00'): Date {
|
||||
return new Date(`${date}T${time}:00${offset}`);
|
||||
}
|
||||
|
||||
describe('resolveDay — the reference week', () => {
|
||||
it('opens a normal trading day', () => {
|
||||
const day = resolveDay(at('2026-09-22', '09:00'), context());
|
||||
expect(day).toMatchObject({
|
||||
date: '2026-09-22',
|
||||
dayOfWeek: 2,
|
||||
isOpen: true,
|
||||
slots: FULL_DAY,
|
||||
isException: false,
|
||||
exceptionKind: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('closes a normal closed day', () => {
|
||||
const day = resolveDay(at('2026-09-21', '09:00'), context());
|
||||
expect(day.isOpen).toBe(false);
|
||||
expect(day.slots).toEqual([]);
|
||||
expect(day.isException).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps both slots of a day with a lunch break', () => {
|
||||
const day = resolveDay(at('2026-09-23', '09:00'), context());
|
||||
expect(day.slots).toEqual(SPLIT_DAY);
|
||||
});
|
||||
|
||||
it('falls back to closed when the week has no entry for that day', () => {
|
||||
const day = resolveDay(at('2026-09-22', '09:00'), context({ weekly: [] }));
|
||||
expect(day.isOpen).toBe(false);
|
||||
expect(day.slots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDay — priority order', () => {
|
||||
const holiday = {
|
||||
date: '2026-09-22',
|
||||
nameFr: 'Jeûne genevois',
|
||||
nameEn: 'Geneva Fast',
|
||||
isAutoClosed: true,
|
||||
};
|
||||
|
||||
it('closes on a public holiday marked as closing', () => {
|
||||
const day = resolveDay(at('2026-09-22', '09:00'), context({ holidays: [holiday] }));
|
||||
expect(day.isOpen).toBe(false);
|
||||
expect(day.exceptionKind).toBe('HOLIDAY');
|
||||
expect(day.noteFr).toBe('Jeûne genevois');
|
||||
expect(day.noteEn).toBe('Geneva Fast');
|
||||
});
|
||||
|
||||
it('keeps normal hours on a public holiday the shop trades through', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({ holidays: [{ ...holiday, isAutoClosed: false }] }),
|
||||
);
|
||||
expect(day.isOpen).toBe(true);
|
||||
expect(day.slots).toEqual(FULL_DAY);
|
||||
expect(day.isException).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a vacation period beat the reference week', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({
|
||||
vacations: [
|
||||
{
|
||||
startDate: '2026-09-21',
|
||||
endDate: '2026-09-27',
|
||||
labelFr: 'Congés annuels',
|
||||
labelEn: 'Annual leave',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(day.isOpen).toBe(false);
|
||||
expect(day.exceptionKind).toBe('VACATION');
|
||||
expect(day.noteFr).toBe('Congés annuels');
|
||||
});
|
||||
|
||||
it('includes the first and last day of a vacation period', () => {
|
||||
const vacations = [
|
||||
{ startDate: '2026-09-22', endDate: '2026-09-24', labelFr: 'Congés', labelEn: 'Leave' },
|
||||
];
|
||||
expect(resolveDay(at('2026-09-22', '09:00'), context({ vacations })).isOpen).toBe(false);
|
||||
expect(resolveDay(at('2026-09-24', '09:00'), context({ vacations })).isOpen).toBe(false);
|
||||
// The day after the period ends is a normal Friday again.
|
||||
expect(resolveDay(at('2026-09-25', '09:00'), context({ vacations })).isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('lets a manual exception beat a public holiday', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({
|
||||
holidays: [holiday],
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: false,
|
||||
slots: [{ open: '14:00', close: '18:00' }],
|
||||
reason: 'SPECIAL_EVENT',
|
||||
noteFr: 'Ouverture spéciale',
|
||||
noteEn: 'Special opening',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(day.isOpen).toBe(true);
|
||||
expect(day.slots).toEqual([{ open: '14:00', close: '18:00' }]);
|
||||
expect(day.exceptionKind).toBe('MANUAL');
|
||||
expect(day.noteFr).toBe('Ouverture spéciale');
|
||||
});
|
||||
|
||||
it('lets a manual exception beat a vacation period', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({
|
||||
vacations: [
|
||||
{ startDate: '2026-09-21', endDate: '2026-09-27', labelFr: 'Congés', labelEn: 'Leave' },
|
||||
],
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: false,
|
||||
slots: FULL_DAY,
|
||||
reason: 'SPECIAL_EVENT',
|
||||
noteFr: null,
|
||||
noteEn: null,
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(day.isOpen).toBe(true);
|
||||
expect(day.exceptionKind).toBe('MANUAL');
|
||||
});
|
||||
|
||||
it('treats an exception with no slots as a full-day closure', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Fermeture exceptionnelle',
|
||||
noteEn: 'Exceptionally closed',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(day.isOpen).toBe(false);
|
||||
expect(day.slots).toEqual([]);
|
||||
expect(day.exceptionKind).toBe('MANUAL');
|
||||
});
|
||||
|
||||
it('badges an imported holiday exception by its reason, not by its author', () => {
|
||||
const day = resolveDay(
|
||||
at('2026-09-22', '09:00'),
|
||||
context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'HOLIDAY',
|
||||
noteFr: 'Férié',
|
||||
noteEn: 'Holiday',
|
||||
source: 'HOLIDAY_API',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(day.isOpen).toBe(false);
|
||||
// The row wins the same way a hand-typed one would, but the UI badge has
|
||||
// to say where it came from.
|
||||
expect(day.exceptionKind).toBe('HOLIDAY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDay — daylight saving and calendar boundaries', () => {
|
||||
it('keeps the usual hours on the spring forward day', () => {
|
||||
// 2026-03-29, last Sunday of March. Sunday is closed, so check the Monday
|
||||
// and the Tuesday around it keep their normal shape.
|
||||
const ctx = context();
|
||||
expect(resolveDay(at('2026-03-29', '12:00', '+02:00'), ctx).isOpen).toBe(false);
|
||||
expect(resolveDay(at('2026-03-31', '12:00', '+02:00'), ctx).slots).toEqual(FULL_DAY);
|
||||
});
|
||||
|
||||
it('keeps the usual hours on the autumn fall back day', () => {
|
||||
// 2026-10-25, last Sunday of October: a 25-hour day.
|
||||
const ctx = context();
|
||||
expect(resolveDay(at('2026-10-25', '12:00', '+01:00'), ctx).date).toBe('2026-10-25');
|
||||
expect(resolveDay(at('2026-10-27', '12:00', '+01:00'), ctx).slots).toEqual(FULL_DAY);
|
||||
});
|
||||
|
||||
it('resolves the ambiguous hour of the fall back night to the right day', () => {
|
||||
// 02:30 happens twice on 2026-10-25. Both readings are still that Sunday.
|
||||
expect(resolveDay(new Date('2026-10-25T00:30:00Z'), context()).date).toBe('2026-10-25');
|
||||
expect(resolveDay(new Date('2026-10-25T01:30:00Z'), context()).date).toBe('2026-10-25');
|
||||
});
|
||||
|
||||
it('resolves the last minute and the first minute of a day', () => {
|
||||
expect(resolveDay(at('2026-09-22', '23:59'), context()).date).toBe('2026-09-22');
|
||||
expect(resolveDay(at('2026-09-23', '00:00'), context()).date).toBe('2026-09-23');
|
||||
});
|
||||
|
||||
it('crosses the year boundary', () => {
|
||||
// 2026-12-31 is a Thursday, 2027-01-01 a Friday: both trading days here.
|
||||
expect(resolveDay(at('2026-12-31', '12:00', '+01:00'), context()).dayOfWeek).toBe(4);
|
||||
expect(resolveDay(at('2027-01-01', '12:00', '+01:00'), context()).dayOfWeek).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWeek', () => {
|
||||
it('returns seven consecutive days starting on the given date', () => {
|
||||
const week = resolveWeek(at('2026-09-21', '09:00'), context());
|
||||
expect(week).toHaveLength(7);
|
||||
expect(week.map((day) => day.date)).toEqual([
|
||||
'2026-09-21',
|
||||
'2026-09-22',
|
||||
'2026-09-23',
|
||||
'2026-09-24',
|
||||
'2026-09-25',
|
||||
'2026-09-26',
|
||||
'2026-09-27',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries exceptions through into the week', () => {
|
||||
const week = resolveWeek(
|
||||
at('2026-09-21', '09:00'),
|
||||
context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-24',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Fermé',
|
||||
noteEn: 'Closed',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(week[3]?.isException).toBe(true);
|
||||
expect(week[3]?.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('crosses a month boundary', () => {
|
||||
const week = resolveWeek(at('2026-09-28', '09:00'), context());
|
||||
expect(week.map((day) => day.date)).toContain('2026-10-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentStatus', () => {
|
||||
it('reports OPEN in the middle of a slot', () => {
|
||||
const status = getCurrentStatus(at('2026-09-22', '12:00'), context());
|
||||
expect(status.status).toBe('OPEN');
|
||||
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '18:30' });
|
||||
});
|
||||
|
||||
it('reports OPEN exactly at opening time', () => {
|
||||
expect(getCurrentStatus(at('2026-09-22', '10:00'), context()).status).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('reports CLOSED exactly at closing time', () => {
|
||||
expect(getCurrentStatus(at('2026-09-22', '18:30'), context()).status).toBe('CLOSED');
|
||||
});
|
||||
|
||||
it('reports CLOSING_SOON within thirty minutes of closing', () => {
|
||||
const status = getCurrentStatus(at('2026-09-22', '18:05'), context());
|
||||
expect(status.status).toBe('CLOSING_SOON');
|
||||
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '18:30' });
|
||||
});
|
||||
|
||||
it('still reports OPEN thirty-one minutes before closing', () => {
|
||||
expect(getCurrentStatus(at('2026-09-22', '17:59'), context()).status).toBe('OPEN');
|
||||
});
|
||||
|
||||
it('reports OPENING_SOON within thirty minutes of opening', () => {
|
||||
const status = getCurrentStatus(at('2026-09-22', '09:45'), context());
|
||||
expect(status.status).toBe('OPENING_SOON');
|
||||
expect(status.nextChangeAt).toEqual({ date: '2026-09-22', time: '10:00' });
|
||||
expect(status.nextOpening).toEqual({ date: '2026-09-22', time: '10:00' });
|
||||
});
|
||||
|
||||
it('reports CLOSED during the lunch break and points at the afternoon slot', () => {
|
||||
const status = getCurrentStatus(at('2026-09-23', '13:30'), context());
|
||||
expect(status.status).toBe('CLOSED');
|
||||
expect(status.nextChangeAt).toEqual({ date: '2026-09-23', time: '14:00' });
|
||||
expect(status.nextOpening).toEqual({ date: '2026-09-23', time: '14:00' });
|
||||
});
|
||||
|
||||
it('reports OPENING_SOON near the end of the lunch break', () => {
|
||||
expect(getCurrentStatus(at('2026-09-23', '13:45'), context()).status).toBe('OPENING_SOON');
|
||||
});
|
||||
|
||||
it('skips closed days when looking for the next opening', () => {
|
||||
// Saturday evening, after closing: the next opening is Tuesday, because
|
||||
// Sunday and Monday are closed.
|
||||
const status = getCurrentStatus(at('2026-09-26', '19:00'), context());
|
||||
expect(status.status).toBe('CLOSED');
|
||||
expect(status.nextOpening).toEqual({ date: '2026-09-29', time: '10:00' });
|
||||
expect(status.nextChangeAt).toEqual({ date: '2026-09-29', time: '10:00' });
|
||||
});
|
||||
|
||||
it('skips a vacation period when looking for the next opening', () => {
|
||||
const status = getCurrentStatus(
|
||||
at('2026-09-22', '19:00'),
|
||||
context({
|
||||
vacations: [
|
||||
{ startDate: '2026-09-23', endDate: '2026-09-25', labelFr: 'Congés', labelEn: 'Leave' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(status.nextOpening).toEqual({ date: '2026-09-26', time: '10:00' });
|
||||
});
|
||||
|
||||
it('gives up rather than looping when the shop is never open', () => {
|
||||
const alwaysClosed = context({
|
||||
weekly: [0, 1, 2, 3, 4, 5, 6].map((dayOfWeek) => ({ dayOfWeek, isClosed: true, slots: [] })),
|
||||
});
|
||||
const status = getCurrentStatus(at('2026-09-22', '12:00'), alwaysClosed);
|
||||
expect(status.status).toBe('CLOSED');
|
||||
expect(status.nextOpening).toBeNull();
|
||||
expect(status.nextChangeAt).toBeNull();
|
||||
});
|
||||
|
||||
it('gives up when the only opening is beyond the search horizon', () => {
|
||||
// A vacation covering the next three weeks pushes the reopening out of the
|
||||
// fourteen-day window the resolver is willing to scan.
|
||||
const status = getCurrentStatus(
|
||||
at('2026-09-22', '19:00'),
|
||||
context({
|
||||
vacations: [
|
||||
{ startDate: '2026-09-23', endDate: '2026-10-20', labelFr: 'Congés', labelEn: 'Leave' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(status.nextOpening).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the resolved day alongside the status', () => {
|
||||
const status = getCurrentStatus(at('2026-09-23', '12:00'), context());
|
||||
expect(status.today.date).toBe('2026-09-23');
|
||||
expect(status.today.slots).toEqual(SPLIT_DAY);
|
||||
});
|
||||
|
||||
it('finds the next opening on a closed day', () => {
|
||||
// Monday: closed all day, so the next opening is Tuesday morning.
|
||||
const status = getCurrentStatus(at('2026-09-21', '12:00'), context());
|
||||
expect(status.status).toBe('CLOSED');
|
||||
expect(status.nextOpening).toEqual({ date: '2026-09-22', time: '10:00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectDivergence', () => {
|
||||
it('reports nothing when the week matches the reference', () => {
|
||||
expect(detectDivergence(at('2026-09-21', '09:00'), context())).toBe('NONE');
|
||||
});
|
||||
|
||||
it('reports TODAY when only today departs from the reference week', () => {
|
||||
const ctx = context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Fermé',
|
||||
noteEn: 'Closed',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('TODAY');
|
||||
});
|
||||
|
||||
it('reports WEEK when a later day departs from the reference week', () => {
|
||||
const ctx = context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-24',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Fermé',
|
||||
noteEn: 'Closed',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('WEEK');
|
||||
});
|
||||
|
||||
it('reports WEEK when today and a later day both depart', () => {
|
||||
const ctx = context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: null,
|
||||
noteEn: null,
|
||||
source: 'MANUAL',
|
||||
},
|
||||
{
|
||||
date: '2026-09-24',
|
||||
isClosed: true,
|
||||
slots: null,
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: null,
|
||||
noteEn: null,
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('WEEK');
|
||||
});
|
||||
|
||||
it('notices a change of hours, not just a closure', () => {
|
||||
const ctx = context({
|
||||
exceptions: [
|
||||
{
|
||||
date: '2026-09-22',
|
||||
isClosed: false,
|
||||
slots: [{ open: '14:00', close: '18:30' }],
|
||||
reason: 'TEMPORARY',
|
||||
noteFr: 'Ouverture retardée',
|
||||
noteEn: 'Late opening',
|
||||
source: 'MANUAL',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('TODAY');
|
||||
});
|
||||
|
||||
it('ignores a public holiday the shop trades through', () => {
|
||||
const ctx = context({
|
||||
holidays: [
|
||||
{ date: '2026-09-24', nameFr: 'Férié', nameEn: 'Holiday', isAutoClosed: false },
|
||||
],
|
||||
});
|
||||
expect(detectDivergence(at('2026-09-22', '09:00'), ctx)).toBe('NONE');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { CivilDate, CivilTime } from './civil';
|
||||
|
||||
/** One continuous period the shop is open, wall clock, `open` strictly before `close`. */
|
||||
export type Slot = {
|
||||
open: CivilTime;
|
||||
close: CivilTime;
|
||||
};
|
||||
|
||||
export type ExceptionReason = 'TEMPORARY' | 'HOLIDAY' | 'VACATION' | 'SPECIAL_EVENT';
|
||||
export type ExceptionSource = 'MANUAL' | 'HOLIDAY_API';
|
||||
|
||||
export type WeeklyScheduleEntry = {
|
||||
/** 0 = Sunday .. 6 = Saturday. */
|
||||
dayOfWeek: number;
|
||||
isClosed: boolean;
|
||||
slots: Slot[];
|
||||
};
|
||||
|
||||
export type ScheduleExceptionEntry = {
|
||||
date: CivilDate;
|
||||
isClosed: boolean;
|
||||
/** `null` when the day is closed outright. */
|
||||
slots: Slot[] | null;
|
||||
reason: ExceptionReason;
|
||||
noteFr?: string | null;
|
||||
noteEn?: string | null;
|
||||
source: ExceptionSource;
|
||||
};
|
||||
|
||||
export type VacationPeriodEntry = {
|
||||
/** Inclusive. */
|
||||
startDate: CivilDate;
|
||||
/** Inclusive. */
|
||||
endDate: CivilDate;
|
||||
labelFr: string;
|
||||
labelEn?: string | null;
|
||||
};
|
||||
|
||||
export type PublicHolidayEntry = {
|
||||
date: CivilDate;
|
||||
nameFr: string;
|
||||
nameEn: string;
|
||||
/** The shop's own call: some public holidays are trading days. */
|
||||
isAutoClosed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the resolver needs, already loaded. The resolver performs no I/O;
|
||||
* `lib/schedule/context.ts` is the only place that talks to the database.
|
||||
*/
|
||||
export type ScheduleContext = {
|
||||
timezone: string;
|
||||
weekly: WeeklyScheduleEntry[];
|
||||
exceptions: ScheduleExceptionEntry[];
|
||||
vacations: VacationPeriodEntry[];
|
||||
holidays: PublicHolidayEntry[];
|
||||
};
|
||||
|
||||
/** Which rule won for a given day, or `null` when the reference week applies. */
|
||||
export type ExceptionKind = 'MANUAL' | 'VACATION' | 'HOLIDAY' | null;
|
||||
|
||||
export type ResolvedDay = {
|
||||
date: CivilDate;
|
||||
/** 0 = Sunday .. 6 = Saturday. */
|
||||
dayOfWeek: number;
|
||||
isOpen: boolean;
|
||||
slots: Slot[];
|
||||
isException: boolean;
|
||||
exceptionKind: ExceptionKind;
|
||||
noteFr: string | null;
|
||||
noteEn: string | null;
|
||||
};
|
||||
|
||||
export type ShopStatusKind = 'OPEN' | 'CLOSED' | 'CLOSING_SOON' | 'OPENING_SOON';
|
||||
|
||||
/** A moment named the way the shop would name it, rather than as an instant. */
|
||||
export type CivilMoment = {
|
||||
date: CivilDate;
|
||||
time: CivilTime;
|
||||
};
|
||||
|
||||
export type ShopStatus = {
|
||||
status: ShopStatusKind;
|
||||
today: ResolvedDay;
|
||||
/** When the current state flips; `null` when nothing is scheduled ahead. */
|
||||
nextChangeAt: CivilMoment | null;
|
||||
/** The next time the door opens, skipping closed days; `null` if none in range. */
|
||||
nextOpening: CivilMoment | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Which automatic banner the screen should carry when no free-text message is
|
||||
* active: only today departs from the reference week, some other day in the
|
||||
* coming seven does, or nothing does.
|
||||
*/
|
||||
export type DivergenceKind = 'NONE' | 'TODAY' | 'WEEK';
|
||||
+6
-1
@@ -14,7 +14,12 @@ export default defineConfig({
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
include: ['lib/**'],
|
||||
exclude: ['lib/**/*.test.ts', 'lib/**/*.test.tsx'],
|
||||
exclude: [
|
||||
'lib/**/*.test.ts',
|
||||
'lib/**/*.test.tsx',
|
||||
// Generated by `prisma generate`; not ours to cover.
|
||||
'lib/generated/**',
|
||||
],
|
||||
// The business core must stay covered; the CI fails below these.
|
||||
thresholds: {
|
||||
'lib/schedule/**': { statements: 85, branches: 85, functions: 85, lines: 85 },
|
||||
|
||||
Reference in New Issue
Block a user