Files
ita-ito-horaires/lib/schedule/format.ts
T
vliaudatandClaude Opus 5 399a353284 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:39:26 +02:00

135 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)] ?? ''}`;
}