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; +}