/** * 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}`; } /** * Rewrites 24-hour times in English text to the 12-hour clock. * * The labels this application generates itself already read "6:30 pm", while a * translated message keeps whatever the source said, because the prompt tells * the model to leave times alone — which it should, since converting a clock * is not a job for a language model. Doing it here instead keeps both * conventions from appearing side by side on the same panel. * * Only `HH:mm` is touched, and never a time that already carries am/pm, so * years and dates written 20/07 are left alone. */ export function toEnglishClock(text: string): string { return text.replace( /\b([01]?\d|2[0-3]):([0-5]\d)\b(?!\s*(?:am|pm|AM|PM))/g, (match, hours: string, minutes: string) => formatTimeEn(`${hours.padStart(2, '0')}:${minutes}`) ?? match, ); } 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)] ?? ''}`; }