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
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
formatTimeEn,
|
formatTimeEn,
|
||||||
shortDayLabelEn,
|
shortDayLabelEn,
|
||||||
shortDayLabelFr,
|
shortDayLabelFr,
|
||||||
|
toEnglishClock,
|
||||||
} from './format';
|
} from './format';
|
||||||
|
|
||||||
describe('formatDateFr / formatDateEn', () => {
|
describe('formatDateFr / formatDateEn', () => {
|
||||||
@@ -126,3 +127,49 @@ describe('formatNextChange', () => {
|
|||||||
expect(formatNextChangeEn(null, '2026-09-20')).toBe('');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -97,6 +97,26 @@ export function formatTimeEn(time: string): string {
|
|||||||
return `${hour12}:${minute.toString().padStart(2, '0')} ${suffix}`;
|
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 {
|
export function formatNextChangeFr(change: NextChange | null, today: CivilDate): string {
|
||||||
if (!change) {
|
if (!change) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { prisma } from '@/lib/db';
|
import { prisma } from '@/lib/db';
|
||||||
|
import { toEnglishClock } from '@/lib/schedule/format';
|
||||||
|
|
||||||
import { LoxiTranslationProvider, loxiConfigFromEnv } from './loxi';
|
import { LoxiTranslationProvider, loxiConfigFromEnv } from './loxi';
|
||||||
import type { TranslationOutcome, TranslationProvider } from './provider';
|
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 };
|
return { ok: true, text: hit.translatedText, model: provider.model, cached: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await provider.translate({ text: source });
|
const raw = await provider.translate({ text: source });
|
||||||
if (!outcome.ok) {
|
if (!raw.ok) {
|
||||||
return outcome;
|
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
|
// Two concurrent saves of the same text would race here; the upsert makes
|
||||||
// the second a no-op instead of a crash.
|
// the second a no-op instead of a crash.
|
||||||
await prisma.translationCache.upsert({
|
await prisma.translationCache.upsert({
|
||||||
|
|||||||
Reference in New Issue
Block a user