From 19b5129306b8672abae8172d558d21a24489c984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 17:12:54 +0200 Subject: [PATCH] feat(i18n): land locale-native date format ordering for FR/ES/IT/PL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `format` namespace across every locale bundle that documents the locale's native date / time pattern ordering as template strings (`{day}.{month}.{year}`, `{month}/{day}/{year}`, …). DE keeps DD.MM.YYYY with dot separators, EN keeps MM/DD/YYYY with slashes, FR/ES/IT adopt DD/MM/YYYY with slashes (the previous EN-shaped fallback was visibly wrong on French weekly-report PDFs), PL adopts DD.MM.YYYY with dots. The runtime `makeFormatters()` helper still routes through `Intl.*`; the keys exist so PDF / CSV / email surfaces that render outside a React context can still pick the right ordering hint. A new smoke test at `src/lib/i18n/__tests__/format-locale-order.test.ts` pins the ordering per locale so a copy-paste regression can't silently flip DD/MM to MM/DD on a non-English bundle. --- messages/de.json | 5 ++ messages/en.json | 5 ++ messages/es.json | 5 ++ messages/fr.json | 5 ++ messages/it.json | 5 ++ messages/pl.json | 5 ++ .../__tests__/format-locale-order.test.ts | 69 +++++++++++++++++++ 7 files changed, 99 insertions(+) create mode 100644 src/lib/i18n/__tests__/format-locale-order.test.ts diff --git a/messages/de.json b/messages/de.json index 48edcbc33..e2adaf298 100644 --- a/messages/de.json +++ b/messages/de.json @@ -33,6 +33,11 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen" }, + "format": { + "dateShort": "{day}.{month}.{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{day}.{month}.{year} {hour}:{minute}" + }, "doctorReport": { "title": "Gesundheitsbericht", "subtitle": "HealthLog — Persönlicher Gesundheitsbericht", diff --git a/messages/en.json b/messages/en.json index b2025cd78..b960cf2a4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -33,6 +33,11 @@ "showPassword": "Show password", "hidePassword": "Hide password" }, + "format": { + "dateShort": "{month}/{day}/{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{month}/{day}/{year} {hour}:{minute}" + }, "doctorReport": { "title": "Health Report", "subtitle": "HealthLog — Personal Health Report", diff --git a/messages/es.json b/messages/es.json index e63275486..62fcb7227 100644 --- a/messages/es.json +++ b/messages/es.json @@ -33,6 +33,11 @@ "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña" }, + "format": { + "dateShort": "{day}/{month}/{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{day}/{month}/{year} {hour}:{minute}" + }, "doctorReport": { "title": "Informe de salud", "subtitle": "HealthLog — Informe personal de salud", diff --git a/messages/fr.json b/messages/fr.json index 2a1fbb713..189234806 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -33,6 +33,11 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe" }, + "format": { + "dateShort": "{day}/{month}/{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{day}/{month}/{year} {hour}:{minute}" + }, "doctorReport": { "title": "Rapport de santé", "subtitle": "HealthLog — Rapport personnel de santé", diff --git a/messages/it.json b/messages/it.json index dce3009df..7200c7675 100644 --- a/messages/it.json +++ b/messages/it.json @@ -33,6 +33,11 @@ "showPassword": "Mostra password", "hidePassword": "Nascondi password" }, + "format": { + "dateShort": "{day}/{month}/{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{day}/{month}/{year} {hour}:{minute}" + }, "doctorReport": { "title": "Rapporto sanitario", "subtitle": "HealthLog — Rapporto personale di salute", diff --git a/messages/pl.json b/messages/pl.json index 552484481..3028723dc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -33,6 +33,11 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło" }, + "format": { + "dateShort": "{day}.{month}.{year}", + "timeShort": "{hour}:{minute}", + "dateTime": "{day}.{month}.{year} {hour}:{minute}" + }, "doctorReport": { "title": "Raport zdrowotny", "subtitle": "HealthLog — Osobisty raport zdrowotny", diff --git a/src/lib/i18n/__tests__/format-locale-order.test.ts b/src/lib/i18n/__tests__/format-locale-order.test.ts new file mode 100644 index 000000000..22dbf80c4 --- /dev/null +++ b/src/lib/i18n/__tests__/format-locale-order.test.ts @@ -0,0 +1,69 @@ +/** + * v1.4.27 B6 — `format.dateShort` / `timeShort` / `dateTime` ordering. + * + * Each shipped locale carries a `format.*` namespace that documents + * its native date-pattern ordering as i18n strings. The actual + * formatting still routes through `Intl.*` via + * `src/lib/format-locale.ts`; the format keys exist so downstream + * surfaces (PDF export, email templates, CSV docstrings) can render + * the date hint without a JS context. + * + * This smoke test pins the ordering per locale so a copy-paste + * regression that flips DD/MM to MM/DD on the French bundle would + * fail fast. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const MESSAGES = join(__dirname, "../../../../messages"); + +function loadFormat(locale: string): { + dateShort: string; + timeShort: string; + dateTime: string; +} { + const bundle = JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ); + const fmt = bundle.format as + | { dateShort?: string; timeShort?: string; dateTime?: string } + | undefined; + if (!fmt || !fmt.dateShort || !fmt.timeShort || !fmt.dateTime) { + throw new Error(`Locale ${locale} is missing the format namespace`); + } + return fmt as { dateShort: string; timeShort: string; dateTime: string }; +} + +describe("locale-native format ordering", () => { + it("DE renders day-month-year with dot separators", () => { + const fmt = loadFormat("de"); + expect(fmt.dateShort).toBe("{day}.{month}.{year}"); + expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}"); + expect(fmt.timeShort).toBe("{hour}:{minute}"); + }); + + it("EN renders month-day-year with slash separators", () => { + const fmt = loadFormat("en"); + expect(fmt.dateShort).toBe("{month}/{day}/{year}"); + expect(fmt.dateTime).toBe("{month}/{day}/{year} {hour}:{minute}"); + expect(fmt.timeShort).toBe("{hour}:{minute}"); + }); + + it.each([["fr"], ["es"], ["it"]])( + "%s renders day-month-year with slash separators (native ordering)", + (locale) => { + const fmt = loadFormat(locale); + expect(fmt.dateShort).toBe("{day}/{month}/{year}"); + expect(fmt.dateTime).toBe("{day}/{month}/{year} {hour}:{minute}"); + expect(fmt.timeShort).toBe("{hour}:{minute}"); + }, + ); + + it("PL renders day-month-year with dot separators (native ordering)", () => { + const fmt = loadFormat("pl"); + expect(fmt.dateShort).toBe("{day}.{month}.{year}"); + expect(fmt.dateTime).toBe("{day}.{month}.{year} {hour}:{minute}"); + expect(fmt.timeShort).toBe("{hour}:{minute}"); + }); +});