From 399a3532847b4664ee5e54fd11aac3eaead75aed Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 17:39:26 +0200 Subject: [PATCH] feat: validate opening slots and format dates in French and English Slot validation reports every problem at once rather than the first, so someone fixing a day's hours on a phone in the shop is not sent round the loop three times. It covers the format, the ordering, overlaps and the three-slot ceiling. Slots that merely touch are accepted: 13:00 closing and 13:00 reopening is pointless but not contradictory, and refusing it would only annoy whoever typed it. Month and day names are spelled out rather than taken from Intl. The screen text feeds a content hash that decides whether the e-ink panel redraws at all, so it has to be byte-stable and must not shift because a container image ships different ICU locale data. English uses the twelve-hour clock, as the payload contract specifies: "Closes at 6:30 pm". Noon and midnight are covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- lib/schedule/format.test.ts | 128 ++++++++++++++++++++++++++++++++ lib/schedule/format.ts | 134 ++++++++++++++++++++++++++++++++++ lib/schedule/validate.test.ts | 86 ++++++++++++++++++++++ lib/schedule/validate.ts | 63 ++++++++++++++++ 4 files changed, 411 insertions(+) create mode 100644 lib/schedule/format.test.ts create mode 100644 lib/schedule/format.ts create mode 100644 lib/schedule/validate.test.ts create mode 100644 lib/schedule/validate.ts diff --git a/lib/schedule/format.test.ts b/lib/schedule/format.test.ts new file mode 100644 index 0000000..4812e8b --- /dev/null +++ b/lib/schedule/format.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatDateEn, + formatDateFr, + formatHoursText, + formatNextChangeEn, + formatNextChangeFr, + formatTimeEn, + shortDayLabelEn, + shortDayLabelFr, +} from './format'; + +describe('formatDateFr / formatDateEn', () => { + it('writes the day and month in full', () => { + expect(formatDateFr('2026-09-20')).toBe('Dimanche 20 septembre'); + expect(formatDateEn('2026-09-20')).toBe('Sunday 20 September'); + }); + + it('does not pad the day number', () => { + expect(formatDateFr('2026-09-01')).toBe('Mardi 1 septembre'); + expect(formatDateEn('2026-09-01')).toBe('Tuesday 1 September'); + }); + + it('handles every month', () => { + expect(formatDateFr('2026-01-15')).toBe('Jeudi 15 janvier'); + expect(formatDateFr('2026-08-15')).toBe('Samedi 15 août'); + expect(formatDateFr('2026-12-25')).toBe('Vendredi 25 décembre'); + expect(formatDateEn('2026-12-25')).toBe('Friday 25 December'); + }); +}); + +describe('short day labels', () => { + it('uses three letters, Monday first on the screen', () => { + expect([1, 2, 3, 4, 5, 6, 0].map(shortDayLabelFr)).toEqual([ + 'Lun', + 'Mar', + 'Mer', + 'Jeu', + 'Ven', + 'Sam', + 'Dim', + ]); + expect([1, 2, 3, 4, 5, 6, 0].map(shortDayLabelEn)).toEqual([ + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + 'Sun', + ]); + }); +}); + +describe('formatHoursText', () => { + it('renders a single slot', () => { + expect(formatHoursText([{ open: '10:00', close: '18:30' }])).toBe('10:00 – 18:30'); + }); + + it('joins several slots with a middle dot', () => { + expect( + formatHoursText([ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, + ]), + ).toBe('10:00 – 13:00 · 14:00 – 18:30'); + }); + + it('returns an empty string when the shop is closed', () => { + expect(formatHoursText([])).toBe(''); + }); +}); + +describe('formatTimeEn', () => { + it('uses the twelve-hour clock', () => { + expect(formatTimeEn('18:30')).toBe('6:30 pm'); + expect(formatTimeEn('10:00')).toBe('10:00 am'); + }); + + it('names noon and midnight correctly', () => { + expect(formatTimeEn('12:00')).toBe('12:00 pm'); + expect(formatTimeEn('00:30')).toBe('12:30 am'); + }); +}); + +describe('formatNextChange', () => { + it('announces today’s closing time', () => { + expect(formatNextChangeFr({ kind: 'CLOSES', date: '2026-09-20', time: '18:30' }, '2026-09-20')).toBe( + 'Ferme à 18:30', + ); + expect(formatNextChangeEn({ kind: 'CLOSES', date: '2026-09-20', time: '18:30' }, '2026-09-20')).toBe( + 'Closes at 6:30 pm', + ); + }); + + it('announces a reopening later today', () => { + expect(formatNextChangeFr({ kind: 'OPENS', date: '2026-09-20', time: '14:00' }, '2026-09-20')).toBe( + 'Ouvre à 14:00', + ); + expect(formatNextChangeEn({ kind: 'OPENS', date: '2026-09-20', time: '14:00' }, '2026-09-20')).toBe( + 'Opens at 2:00 pm', + ); + }); + + it('announces tomorrow by name', () => { + expect(formatNextChangeFr({ kind: 'OPENS', date: '2026-09-21', time: '10:00' }, '2026-09-20')).toBe( + 'Ouvre demain à 10:00', + ); + expect(formatNextChangeEn({ kind: 'OPENS', date: '2026-09-21', time: '10:00' }, '2026-09-20')).toBe( + 'Opens tomorrow at 10:00 am', + ); + }); + + it('names the weekday when the reopening is further out', () => { + expect(formatNextChangeFr({ kind: 'OPENS', date: '2026-09-22', time: '10:00' }, '2026-09-20')).toBe( + 'Ouvre mardi à 10:00', + ); + expect(formatNextChangeEn({ kind: 'OPENS', date: '2026-09-22', time: '10:00' }, '2026-09-20')).toBe( + 'Opens Tuesday at 10:00 am', + ); + }); + + it('says nothing when there is nothing to announce', () => { + expect(formatNextChangeFr(null, '2026-09-20')).toBe(''); + expect(formatNextChangeEn(null, '2026-09-20')).toBe(''); + }); +}); diff --git a/lib/schedule/format.ts b/lib/schedule/format.ts new file mode 100644 index 0000000..9a29c3c --- /dev/null +++ b/lib/schedule/format.ts @@ -0,0 +1,134 @@ +/** + * French and English rendering of dates, times and opening hours. + * + * The month and day names are spelled out here rather than obtained from Intl + * on purpose. The screen text has to be byte-stable: it feeds a content hash + * that decides whether the e-ink panel redraws at all, so it must not shift + * because a container image ships different ICU locale data. + */ + +import { addCivilDays, civilDayOfWeek, minutesOfTime, type CivilDate } from './civil'; +import type { Slot } from './types'; + +const MONTHS_FR = [ + 'janvier', + 'février', + 'mars', + 'avril', + 'mai', + 'juin', + 'juillet', + 'août', + 'septembre', + 'octobre', + 'novembre', + 'décembre', +]; + +const MONTHS_EN = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/** Indexed by `getDay()`: 0 = Sunday. */ +const DAYS_FR = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; +const DAYS_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const SHORT_DAYS_FR = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']; +const SHORT_DAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +/** What the next change is: the door closing, or the door opening. */ +export type NextChange = { + kind: 'OPENS' | 'CLOSES'; + date: CivilDate; + time: string; +}; + +function parts(date: CivilDate): { day: number; monthIndex: number } { + const [, month, day] = date.split('-'); + return { day: Number(day), monthIndex: Number(month) - 1 }; +} + +function capitalise(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +/** "Dimanche 20 septembre" */ +export function formatDateFr(date: CivilDate): string { + const { day, monthIndex } = parts(date); + return `${capitalise(DAYS_FR[civilDayOfWeek(date)] ?? '')} ${day} ${MONTHS_FR[monthIndex] ?? ''}`; +} + +/** "Sunday 20 September" */ +export function formatDateEn(date: CivilDate): string { + const { day, monthIndex } = parts(date); + return `${DAYS_EN[civilDayOfWeek(date)] ?? ''} ${day} ${MONTHS_EN[monthIndex] ?? ''}`; +} + +export function shortDayLabelFr(dayOfWeek: number): string { + return SHORT_DAYS_FR[dayOfWeek] ?? ''; +} + +export function shortDayLabelEn(dayOfWeek: number): string { + return SHORT_DAYS_EN[dayOfWeek] ?? ''; +} + +/** "10:00 – 13:00 · 14:00 – 18:30", or an empty string when closed. */ +export function formatHoursText(slots: Slot[]): string { + return slots.map((slot) => `${slot.open} – ${slot.close}`).join(' · '); +} + +/** "6:30 pm" — English readers expect the twelve-hour clock. */ +export function formatTimeEn(time: string): string { + const minutes = minutesOfTime(time); + const hour24 = Math.floor(minutes / 60); + const minute = minutes % 60; + const suffix = hour24 < 12 ? 'am' : 'pm'; + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12; + return `${hour12}:${minute.toString().padStart(2, '0')} ${suffix}`; +} + +export function formatNextChangeFr(change: NextChange | null, today: CivilDate): string { + if (!change) { + return ''; + } + const verb = change.kind === 'CLOSES' ? 'Ferme' : 'Ouvre'; + return `${verb}${whenFr(change.date, today)} à ${change.time}`; +} + +export function formatNextChangeEn(change: NextChange | null, today: CivilDate): string { + if (!change) { + return ''; + } + const verb = change.kind === 'CLOSES' ? 'Closes' : 'Opens'; + return `${verb}${whenEn(change.date, today)} at ${formatTimeEn(change.time)}`; +} + +function whenFr(date: CivilDate, today: CivilDate): string { + if (date === today) { + return ''; + } + if (date === addCivilDays(today, 1)) { + return ' demain'; + } + return ` ${DAYS_FR[civilDayOfWeek(date)] ?? ''}`; +} + +function whenEn(date: CivilDate, today: CivilDate): string { + if (date === today) { + return ''; + } + if (date === addCivilDays(today, 1)) { + return ' tomorrow'; + } + return ` ${DAYS_EN[civilDayOfWeek(date)] ?? ''}`; +} diff --git a/lib/schedule/validate.test.ts b/lib/schedule/validate.test.ts new file mode 100644 index 0000000..d29e39e --- /dev/null +++ b/lib/schedule/validate.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { MAX_SLOTS_PER_DAY, validateSlots } from './validate'; + +describe('validateSlots', () => { + it('accepts a single slot', () => { + expect(validateSlots([{ open: '10:00', close: '18:30' }])).toEqual([]); + }); + + it('accepts a lunch break', () => { + expect( + validateSlots([ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, + ]), + ).toEqual([]); + }); + + it('accepts an empty list, which simply means closed', () => { + expect(validateSlots([])).toEqual([]); + }); + + it('rejects a malformed time', () => { + expect(validateSlots([{ open: '10h00', close: '18:30' }])).toContain( + 'Heure invalide : « 10h00 ». Format attendu : HH:mm.', + ); + }); + + it('rejects an hour outside the clock', () => { + expect(validateSlots([{ open: '25:00', close: '26:00' }]).length).toBeGreaterThan(0); + expect(validateSlots([{ open: '10:60', close: '18:00' }]).length).toBeGreaterThan(0); + }); + + it('rejects a slot that closes before it opens', () => { + expect(validateSlots([{ open: '18:30', close: '10:00' }])).toContain( + "L'heure de fermeture doit suivre l'heure d'ouverture : 18:30 – 10:00.", + ); + }); + + it('rejects a slot of zero length', () => { + expect(validateSlots([{ open: '10:00', close: '10:00' }]).length).toBeGreaterThan(0); + }); + + it('rejects overlapping slots', () => { + expect( + validateSlots([ + { open: '10:00', close: '14:00' }, + { open: '13:00', close: '18:30' }, + ]), + ).toContain('Les plages 10:00 – 14:00 et 13:00 – 18:30 se chevauchent.'); + }); + + it('detects an overlap even when the slots are given out of order', () => { + expect( + validateSlots([ + { open: '13:00', close: '18:30' }, + { open: '10:00', close: '14:00' }, + ]).length, + ).toBeGreaterThan(0); + }); + + it('accepts slots that merely touch', () => { + // 13:00 closing and 13:00 reopening is pointless but not contradictory, + // and refusing it would only annoy whoever typed it. + expect( + validateSlots([ + { open: '10:00', close: '13:00' }, + { open: '13:00', close: '18:30' }, + ]), + ).toEqual([]); + }); + + it('rejects more slots than a shop day can hold', () => { + const tooMany = Array.from({ length: MAX_SLOTS_PER_DAY + 1 }, (_, index) => ({ + open: `${(8 + index * 2).toString().padStart(2, '0')}:00`, + close: `${(9 + index * 2).toString().padStart(2, '0')}:00`, + })); + expect(validateSlots(tooMany)).toContain( + `Trois plages horaires au maximum par jour (${tooMany.length} fournies).`, + ); + }); + + it('reports several problems at once', () => { + expect(validateSlots([{ open: 'nope', close: '10:00' }]).length).toBeGreaterThan(0); + }); +}); diff --git a/lib/schedule/validate.ts b/lib/schedule/validate.ts new file mode 100644 index 0000000..fef47e4 --- /dev/null +++ b/lib/schedule/validate.ts @@ -0,0 +1,63 @@ +/** + * Validation of opening slots. + * + * Returns the list of problems in French, ready to show next to the field that + * caused them. It returns every problem rather than the first, so someone + * fixing a day's hours on a phone is not sent round the loop three times. + */ + +import { minutesOfTime } from './civil'; +import type { Slot } from './types'; + +/** A shop day realistically has a morning, an afternoon, and at most one more. */ +export const MAX_SLOTS_PER_DAY = 3; + +const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export function validateSlots(slots: Slot[]): string[] { + const errors: string[] = []; + + if (slots.length > MAX_SLOTS_PER_DAY) { + errors.push(`Trois plages horaires au maximum par jour (${slots.length} fournies).`); + } + + const wellFormed: Slot[] = []; + + for (const slot of slots) { + let malformed = false; + for (const time of [slot.open, slot.close]) { + if (!TIME_PATTERN.test(time)) { + errors.push(`Heure invalide : « ${time} ». Format attendu : HH:mm.`); + malformed = true; + } + } + if (malformed) { + continue; + } + if (minutesOfTime(slot.close) <= minutesOfTime(slot.open)) { + errors.push( + `L'heure de fermeture doit suivre l'heure d'ouverture : ${slot.open} – ${slot.close}.`, + ); + continue; + } + wellFormed.push(slot); + } + + // Overlaps are checked in chronological order, so the message names the + // slots the way the user reads them rather than the way they were typed. + const ordered = [...wellFormed].sort((a, b) => minutesOfTime(a.open) - minutesOfTime(b.open)); + for (let index = 1; index < ordered.length; index += 1) { + const previous = ordered[index - 1]; + const current = ordered[index]; + if (!previous || !current) { + continue; + } + if (minutesOfTime(current.open) < minutesOfTime(previous.close)) { + errors.push( + `Les plages ${previous.open} – ${previous.close} et ${current.open} – ${current.close} se chevauchent.`, + ); + } + } + + return errors; +}