feat(i18n): land locale-native date format ordering for FR/ES/IT/PL

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 17:12:54 +02:00
1 parent 2960f7352c
commit 19b5129306
7 files changed
+99

No files matched your search

+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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é",
+5
View File
@@ -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",
+5
View File
@@ -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",
@@ -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}");
});
});