Files
ita-ito-horaires/lib/schedule/format.ts
T
vliaudatandClaude Opus 5 ed89d08172 fix: show one clock convention in English on the panel
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
2026-09-20 22:37:59 +02:00

155 lines
4.7 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}`;
}
/**
* 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)] ?? ''}`;
}