The labels this application writes itself already read "6:30 pm", while a translated notice kept whatever the French said, so the screen could show "Closes at 6:30 pm" above "Delayed opening at 14:00". Two conventions side by side on one panel. The conversion is done in code, not by the prompt. The model is still told to leave times alone — reading a clock and rewriting it is deterministic work, and asking a language model to do it introduces a failure mode for no benefit. Only HH:mm is touched, never a time that already carries am or pm, so the pass is idempotent and years, dates written 20/07 and percentages are left alone. Hand-written translations are not rewritten: that wording was somebody's explicit choice. Verified against the live service: "Ouvert 10:00 – 13:00 puis 14:00 – 18:30" now comes back as "Open 10:00 am – 1:00 pm then 2:00 pm – 6:30 pm". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
155 lines
4.7 KiB
TypeScript
155 lines
4.7 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}`;
|
||
}
|
||
|
||
/**
|
||
* 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)] ?? ''}`;
|
||
}
|