diff --git a/src/lib/__tests__/format-active-locale.test.ts b/src/lib/__tests__/format-active-locale.test.ts new file mode 100644 index 000000000..6c7c11cba --- /dev/null +++ b/src/lib/__tests__/format-active-locale.test.ts @@ -0,0 +1,117 @@ +/** + * v1.4.43 QoL (M8) — pin the widened cookie / localStorage decoder in + * `src/lib/format.ts`. Pre-fix a French user reading the dashboard saw + * "12/24/2025, 2:30 PM" via the silent `en` fallback; the formatter + * now honours every supported locale via the full `Locale` union. + * + * `activeLocale()` itself is module-private — exercise it through the + * exported `formatDate` helper, which composes the cookie read with + * `makeFormatters(locale)`. We stub the bare-minimum `document` / + * `window` shape the helper actually reads, so the test runs under + * the default node environment without pulling in `jsdom`. + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +interface Globals { + document?: { cookie: string }; + window?: { localStorage?: { getItem: (key: string) => string | null } }; +} + +describe("formatDate (cookie-driven locale)", () => { + const globalAny = globalThis as unknown as Globals; + let cookieValue = ""; + let storageValue: string | null = null; + + beforeEach(() => { + vi.resetModules(); + cookieValue = ""; + storageValue = null; + globalAny.document = { + get cookie() { + return cookieValue; + }, + set cookie(value: string) { + cookieValue = value; + }, + }; + globalAny.window = { + localStorage: { + getItem: () => storageValue, + }, + }; + }); + + afterEach(() => { + delete globalAny.document; + delete globalAny.window; + }); + + function setCookie(locale: string) { + cookieValue = `healthlog-locale=${locale}; path=/`; + } + + const sample = new Date("2026-04-18T10:00:00Z"); + + it("returns the de format when the cookie reads 'de'", async () => { + setCookie("de"); + const { formatDate } = await import("../format"); + // German short-date is dd.mm.yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + }); + + it("returns the fr format when the cookie reads 'fr'", async () => { + setCookie("fr"); + const { formatDate } = await import("../format"); + // French short-date is dd/mm/yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + it("returns the es format when the cookie reads 'es'", async () => { + setCookie("es"); + const { formatDate } = await import("../format"); + // Spanish short-date is dd/mm/yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + it("returns the it format when the cookie reads 'it'", async () => { + setCookie("it"); + const { formatDate } = await import("../format"); + // Italian short-date is dd/mm/yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + it("returns the pl format when the cookie reads 'pl'", async () => { + setCookie("pl"); + const { formatDate } = await import("../format"); + // Polish short-date is dd.mm.yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\.\d{2}\.\d{4}$/); + }); + + it("falls back to en for unrecognised cookie values", async () => { + setCookie("xx"); + const { formatDate } = await import("../format"); + // English short-date is mm/dd/yyyy. + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + it("falls back to en when the cookie is missing and storage is unset", async () => { + // cookieValue starts as "" — no match. + const { formatDate } = await import("../format"); + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); + + it("falls back to localStorage when the cookie is unrecognised", async () => { + setCookie("xx"); + storageValue = "fr"; + const { formatDate } = await import("../format"); + // French short-date is dd/mm/yyyy — confirms the storage path is hit. + expect(formatDate(sample)).toMatch(/^\d{2}\/\d{2}\/\d{4}$/); + }); +}); diff --git a/src/lib/format.ts b/src/lib/format.ts index b32a85f3b..c08b5b048 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -13,10 +13,22 @@ * no hydration-mismatch path today. If you add a new caller that renders * pre-fetch data (e.g. a static prop), migrate it to `useFormatters()` to * stay consistent with the server-rendered locale. + * + * v1.4.43 QoL (M8) — `activeLocale()` now reads the full `Locale` union + * (`de | en | fr | es | it | pl`). Pre-fix, a sub-locale user (fr / es / + * it / pl) saw French strings but the legacy SSR formatters silently + * fell back to `en`, producing "12/24/2025, 2:30 PM" in a French shell. + * `Intl.DateTimeFormat` already handles every supported locale via the + * `INTL_LOCALE_MAP`, so the only change is to widen the cookie / + * localStorage decoder. */ import { makeFormatters } from "./format-locale"; -import type { Locale } from "./i18n/config"; +import { locales, type Locale } from "./i18n/config"; + +function isLocale(value: string | null | undefined): value is Locale { + return !!value && (locales as readonly string[]).includes(value); +} function activeLocale(): Locale { if (typeof document === "undefined") return "en"; @@ -26,9 +38,10 @@ function activeLocale(): Locale { /(?:^|;\s*)healthlog-locale=([^;]+)/, ); const fromCookie = cookieMatch?.[1]; - if (fromCookie === "de" || fromCookie === "en") return fromCookie; + if (isLocale(fromCookie)) return fromCookie; const fromStorage = window.localStorage?.getItem("healthlog-locale"); - return fromStorage === "de" ? "de" : "en"; + if (isLocale(fromStorage)) return fromStorage; + return "en"; } function formatters() {