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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
135 lines
3.8 KiB
TypeScript
135 lines
3.8 KiB
TypeScript
/**
|
||
* 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)] ?? ''}`;
|
||
}
|