From ed89d08172930c217f7aeb97fa0f94417d417451 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 22:37:59 +0200 Subject: [PATCH] fix: show one clock convention in English on the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- lib/schedule/format.test.ts | 47 +++++++++++++++++++++++++++++++++++++ lib/schedule/format.ts | 20 ++++++++++++++++ lib/translation/service.ts | 14 ++++++++--- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/lib/schedule/format.test.ts b/lib/schedule/format.test.ts index 4812e8b..54cd3a8 100644 --- a/lib/schedule/format.test.ts +++ b/lib/schedule/format.test.ts @@ -9,6 +9,7 @@ import { formatTimeEn, shortDayLabelEn, shortDayLabelFr, + toEnglishClock, } from './format'; describe('formatDateFr / formatDateEn', () => { @@ -126,3 +127,49 @@ describe('formatNextChange', () => { expect(formatNextChangeEn(null, '2026-09-20')).toBe(''); }); }); + +describe('toEnglishClock', () => { + it('converts a single time', () => { + expect(toEnglishClock('Delayed opening at 14:00 today')).toBe( + 'Delayed opening at 2:00 pm today', + ); + }); + + it('converts every time in a range', () => { + expect(toEnglishClock('Open 10:00 – 13:00 · 14:00 – 18:30')).toBe( + 'Open 10:00 am – 1:00 pm · 2:00 pm – 6:30 pm', + ); + }); + + it('handles times written without a space around the dash', () => { + expect(toEnglishClock('10:00-18:30')).toBe('10:00 am-6:30 pm'); + }); + + it('names noon and midnight', () => { + expect(toEnglishClock('Closing at 12:00')).toBe('Closing at 12:00 pm'); + expect(toEnglishClock('Until 00:30')).toBe('Until 12:30 am'); + }); + + it('leaves a time that already has am or pm alone', () => { + // Otherwise a second pass would mangle what the first one produced. + expect(toEnglishClock('Closes at 6:30 pm')).toBe('Closes at 6:30 pm'); + expect(toEnglishClock('Closes at 6:30 PM')).toBe('Closes at 6:30 PM'); + }); + + it('is stable when applied twice', () => { + const once = toEnglishClock('Open 10:00 – 18:30'); + expect(toEnglishClock(once)).toBe(once); + }); + + it('leaves years, dates and prices alone', () => { + expect(toEnglishClock('Annual leave from July 20 to August 10')).toBe( + 'Annual leave from July 20 to August 10', + ); + expect(toEnglishClock('Collection 2026')).toBe('Collection 2026'); + expect(toEnglishClock('Sale: 30% off')).toBe('Sale: 30% off'); + }); + + it('leaves text with no time untouched', () => { + expect(toEnglishClock('New collection in store')).toBe('New collection in store'); + }); +}); diff --git a/lib/schedule/format.ts b/lib/schedule/format.ts index 9a29c3c..12e660a 100644 --- a/lib/schedule/format.ts +++ b/lib/schedule/format.ts @@ -97,6 +97,26 @@ export function formatTimeEn(time: string): string { 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 ''; diff --git a/lib/translation/service.ts b/lib/translation/service.ts index 60a22ee..1705238 100644 --- a/lib/translation/service.ts +++ b/lib/translation/service.ts @@ -9,6 +9,7 @@ import { createHash } from 'node:crypto'; import { prisma } from '@/lib/db'; +import { toEnglishClock } from '@/lib/schedule/format'; import { LoxiTranslationProvider, loxiConfigFromEnv } from './loxi'; import type { TranslationOutcome, TranslationProvider } from './provider'; @@ -69,11 +70,18 @@ export async function translateToEnglish( return { ok: true, text: hit.translatedText, model: provider.model, cached: true }; } - const outcome = await provider.translate({ text: source }); - if (!outcome.ok) { - return outcome; + const raw = await provider.translate({ text: source }); + if (!raw.ok) { + return raw; } + // The prompt tells the model to leave times untouched, because converting a + // clock is not a job for a language model. It is done here instead, so a + // translated notice and the labels this application writes itself never show + // two different conventions on the same panel. A hand-written translation is + // not touched: that wording was somebody's explicit choice. + const outcome = { ...raw, text: toEnglishClock(raw.text) }; + // Two concurrent saves of the same text would race here; the upsert makes // the second a no-op instead of a crash. await prisma.translationCache.upsert({