From 37e539d0bf0888dedb445af3aaec12a89cbf72fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:14:56 +0200 Subject: [PATCH] feat(format): resolve and apply the date-format preference Add a date-order resolver + formatter mirroring the hour-cycle path: AUTO defers to the active locale, DMY/MDY/YMD pin the field order through a canonical Intl locale. Thread it through makeFormatters and expose useDateFormatPreference from the i18n context, backed by the same localStorage mirror as the time-format preference. --- src/lib/__tests__/date-format.test.ts | 110 +++++++++++++++++ src/lib/date-format.ts | 162 ++++++++++++++++++++++++++ src/lib/format-locale.ts | 50 +++++++- src/lib/i18n/context.tsx | 22 +++- 4 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 src/lib/__tests__/date-format.test.ts create mode 100644 src/lib/date-format.ts diff --git a/src/lib/__tests__/date-format.test.ts b/src/lib/__tests__/date-format.test.ts new file mode 100644 index 000000000..43e35fe45 --- /dev/null +++ b/src/lib/__tests__/date-format.test.ts @@ -0,0 +1,110 @@ +/** + * Date-order preference resolver + formatter (v1.21.0). + * + * `formatDate` and `resolveDateLocale` are the single source of truth for the + * field order the `` primitive and `useFormatters().date()` render. + * AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless. + */ +import { describe, expect, it } from "vitest"; + +import { + formatDate, + parseIsoDate, + resolveDateLocale, + isDateFormatPreference, + DATE_FORMAT_PREFERENCES, + DATE_FORMAT_OPTIONS, +} from "../date-format"; + +const ISO = "2026-02-19"; + +describe("resolveDateLocale", () => { + it("AUTO follows the active locale", () => { + expect(resolveDateLocale("AUTO", "de")).toBe("de-DE"); + expect(resolveDateLocale("AUTO", "en")).toBe("en-US"); + expect(resolveDateLocale("AUTO", "fr")).toBe("fr-FR"); + }); + + it("DMY / MDY / YMD pin a canonical order locale regardless of UI locale", () => { + expect(resolveDateLocale("DMY", "en")).toBe("de-DE"); + expect(resolveDateLocale("MDY", "de")).toBe("en-US"); + expect(resolveDateLocale("YMD", "de")).toBe("en-CA"); + }); +}); + +describe("formatDate", () => { + it("renders DMY as dd.MM.yyyy", () => { + expect(formatDate(ISO, "DMY", "en")).toBe("19.02.2026"); + }); + + it("renders MDY as MM/dd/yyyy", () => { + expect(formatDate(ISO, "MDY", "de")).toBe("02/19/2026"); + }); + + it("renders YMD as yyyy-MM-dd (ISO)", () => { + expect(formatDate(ISO, "YMD", "de")).toBe("2026-02-19"); + }); + + it("AUTO follows the locale convention", () => { + // de → dd.MM.yyyy, en → MM/dd/yyyy + expect(formatDate(ISO, "AUTO", "de")).toBe("19.02.2026"); + expect(formatDate(ISO, "AUTO", "en")).toBe("02/19/2026"); + }); + + it("accepts a Date and an ISO string identically", () => { + const d = new Date(Date.UTC(2026, 1, 19)); + expect(formatDate(d, "DMY", "en")).toBe(formatDate(ISO, "DMY", "en")); + }); + + it("does not drift across a timezone boundary (UTC calendar date)", () => { + // A bare yyyy-MM-dd is a calendar date; it must render the same day in + // every order, never the day before/after. + expect(formatDate("2026-01-01", "DMY", "en")).toBe("01.01.2026"); + expect(formatDate("2026-12-31", "YMD", "en")).toBe("2026-12-31"); + }); + + it("returns an empty string for empty / unparseable input", () => { + expect(formatDate("", "AUTO", "en")).toBe(""); + expect(formatDate(null, "AUTO", "en")).toBe(""); + expect(formatDate(undefined, "AUTO", "en")).toBe(""); + expect(formatDate("not-a-date", "AUTO", "en")).toBe(""); + }); +}); + +describe("parseIsoDate", () => { + it("parses a valid ISO calendar date to a UTC midnight Date", () => { + const d = parseIsoDate("2026-02-19"); + expect(d).not.toBeNull(); + expect(d!.getUTCFullYear()).toBe(2026); + expect(d!.getUTCMonth()).toBe(1); + expect(d!.getUTCDate()).toBe(19); + }); + + it("rejects a non-ISO shape and an impossible date", () => { + expect(parseIsoDate("19.02.2026")).toBeNull(); + expect(parseIsoDate("2026-02-31")).toBeNull(); + expect(parseIsoDate("2026-13-01")).toBeNull(); + }); +}); + +describe("preference list", () => { + it("isDateFormatPreference is a strict guard", () => { + expect(isDateFormatPreference("AUTO")).toBe(true); + expect(isDateFormatPreference("DMY")).toBe(true); + expect(isDateFormatPreference("XYZ")).toBe(false); + expect(isDateFormatPreference(null)).toBe(false); + }); + + it("exposes all four preferences and matching options", () => { + expect(DATE_FORMAT_PREFERENCES).toEqual(["AUTO", "DMY", "MDY", "YMD"]); + expect(DATE_FORMAT_OPTIONS.map((o) => o.value)).toEqual([ + "AUTO", + "DMY", + "MDY", + "YMD", + ]); + for (const opt of DATE_FORMAT_OPTIONS) { + expect(opt.labelKey).toMatch(/^settings\.dateFormat\./); + } + }); +}); diff --git a/src/lib/date-format.ts b/src/lib/date-format.ts new file mode 100644 index 000000000..db035d2dc --- /dev/null +++ b/src/lib/date-format.ts @@ -0,0 +1,162 @@ +/** + * Client-side mirror of the per-user date-order preference, plus the + * display helpers the `` primitive renders through. + * + * The server-authoritative value lives on the user row (`users.date_format`, + * surfaced by `/api/auth/me` and editable via the profile PATCH). The mirror + * follows the `time-format.ts` pattern exactly: `fetchMe` writes the resolved + * value into localStorage so that + * + * 1. `useDateFormatPreference()` can read it through `useSyncExternalStore` + * without requiring a QueryClient in the tree, and + * 2. `` (and any non-context helper) renders the same field + * order as the locale-aware `useFormatters().date()`. + * + * SSR always resolves AUTO (`window` is undefined); the same caveat as the + * locale formatters applies — call sites render their formatted strings + * post-fetch, so there is no hydration-mismatch path today. + */ + +import type { Locale } from "./i18n/config"; +import { type DateFormatPreference, resolveIntlLocale } from "./format-locale"; + +export type { DateFormatPreference } from "./format-locale"; + +const STORAGE_KEY = "healthlog-date-format"; +const CHANGE_EVENT = "healthlog:date-format-change"; + +export const DATE_FORMAT_PREFERENCES = ["AUTO", "DMY", "MDY", "YMD"] as const; + +/** + * Ordered option list for the profile dropdown. `labelKey` resolves through + * `t()` against the `settings.dateFormat.*` bundle; mirrors how the + * hour-format select hard-codes its option order in JSX. + */ +export const DATE_FORMAT_OPTIONS: ReadonlyArray<{ + value: DateFormatPreference; + labelKey: string; +}> = [ + { value: "AUTO", labelKey: "settings.dateFormat.auto" }, + { value: "DMY", labelKey: "settings.dateFormat.dmy" }, + { value: "MDY", labelKey: "settings.dateFormat.mdy" }, + { value: "YMD", labelKey: "settings.dateFormat.ymd" }, +]; + +export function isDateFormatPreference( + value: unknown, +): value is DateFormatPreference { + return ( + typeof value === "string" && + (DATE_FORMAT_PREFERENCES as readonly string[]).includes(value) + ); +} + +/** Best-effort read of the mirrored preference. AUTO on SSR / no mirror. */ +export function readStoredDateFormat(): DateFormatPreference { + if (typeof window === "undefined") return "AUTO"; + try { + const stored = window.localStorage?.getItem(STORAGE_KEY); + return isDateFormatPreference(stored) ? stored : "AUTO"; + } catch { + return "AUTO"; + } +} + +/** + * Persist the mirror and notify same-tab subscribers. Cross-tab updates ride + * the browser's native `storage` event. + */ +export function storeDateFormat(value: DateFormatPreference): void { + if (typeof window === "undefined") return; + try { + const previous = window.localStorage?.getItem(STORAGE_KEY); + if (previous === value) return; + window.localStorage?.setItem(STORAGE_KEY, value); + } catch { + return; + } + window.dispatchEvent(new Event(CHANGE_EVENT)); +} + +/** `useSyncExternalStore`-shaped subscription for the mirror. */ +export function subscribeDateFormat(onChange: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(CHANGE_EVENT, onChange); + window.addEventListener("storage", onChange); + return () => { + window.removeEventListener(CHANGE_EVENT, onChange); + window.removeEventListener("storage", onChange); + }; +} + +/** + * The Intl locale tag a (preference, locale) pair renders numeric dates + * through. Mirrors `dateOrderLocale` in `format-locale.ts`: AUTO keeps the + * user's own locale, DMY/MDY/YMD pin a canonical locale whose default + * numeric order matches (de-DE → dd.MM.yyyy, en-US → MM/dd/yyyy, en-CA → + * yyyy-MM-dd). Exported so `` and the formatter agree on order. + */ +export function resolveDateLocale( + pref: DateFormatPreference, + locale: Locale, +): string { + const localeTag = resolveIntlLocale(locale); + switch (pref) { + case "DMY": + return "de-DE"; + case "MDY": + return "en-US"; + case "YMD": + return "en-CA"; + default: + return localeTag; + } +} + +/** + * Format an ISO `yyyy-MM-dd` date string (or a `Date`) in the field order + * the (preference, locale) pair selects — the user-visible string the + * `` paints over its hidden native input. Returns "" for an + * empty / unparseable value so the field renders its placeholder. + * + * The instant is read in UTC (a bare `yyyy-MM-dd` has no zone) so the day + * never drifts across a timezone boundary — the value contract is a plain + * calendar date, not an instant. + */ +export function formatDate( + value: Date | string | null | undefined, + pref: DateFormatPreference, + locale: Locale, +): string { + if (value === null || value === undefined || value === "") return ""; + const date = value instanceof Date ? value : parseIsoDate(value); + if (date === null || Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString(resolveDateLocale(pref, locale), { + timeZone: "UTC", + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +/** + * Parse a `yyyy-MM-dd` string to a UTC `Date` at midnight. Returns null on + * a value that is not exactly the ISO calendar-date shape so callers can + * fall back to the placeholder rather than rendering "Invalid Date". + */ +export function parseIsoDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return null; + const [, y, m, d] = match; + const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d))); + // Guard against rollover (e.g. 2026-02-31 → Mar 03): a real calendar date + // round-trips its parts unchanged. + if ( + date.getUTCFullYear() !== Number(y) || + date.getUTCMonth() !== Number(m) - 1 || + date.getUTCDate() !== Number(d) + ) { + return null; + } + return date; +} diff --git a/src/lib/format-locale.ts b/src/lib/format-locale.ts index b57bb591c..cbe3f1ffe 100644 --- a/src/lib/format-locale.ts +++ b/src/lib/format-locale.ts @@ -50,6 +50,14 @@ export function resolveIntlLocale(locale: Locale): string { */ export type TimeFormatPreference = "AUTO" | "H12" | "H24"; +/** + * Date-order display preference (mirrors the `DateFormatPreference` Prisma + * enum). AUTO defers to the locale's own field order (de-DE → dd.MM.yyyy, + * en-US → MM/dd/yyyy); DMY pins day-month-year, MDY pins month-day-year, + * YMD pins ISO yyyy-MM-dd. Display-time only — stored instants stay UTC. + */ +export type DateFormatPreference = "AUTO" | "DMY" | "MDY" | "YMD"; + /** * Intl options for the requested hour cycle. AUTO contributes nothing so * `Intl.DateTimeFormat` falls back to the locale default. @@ -67,6 +75,34 @@ function hourCycleOptions( } } +/** + * The Intl locale tag a non-AUTO date preference renders through. The + * field order is a property of the BCP-47 locale, not a `DateTimeFormat` + * option, so we pin a canonical locale whose default numeric date order + * matches the requested preference and reuse the user's own locale only + * for AUTO. DMY is rendered through de-DE so it carries the app-wide + * dotted separator (dd.MM.yyyy); MDY through en-US (MM/dd/yyyy); YMD + * through the ISO-canonical en-CA / sv (yyyy-MM-dd). The day/month/year + * `2-digit`/`numeric` field set is supplied by the caller. + */ +function dateOrderLocale( + dateFormat: DateFormatPreference, + localeTag: string, +): string { + switch (dateFormat) { + case "DMY": + return "de-DE"; + case "MDY": + return "en-US"; + case "YMD": + // en-CA renders numeric dates as yyyy-MM-dd across engines; it is the + // ISO-8601 default order without pulling a locale the app doesn't ship. + return "en-CA"; + default: + return localeTag; + } +} + type DateInput = Date | string | number; function asDate(value: DateInput): Date { @@ -98,10 +134,16 @@ export function makeFormatters( locale: Locale, userTz?: string, timeFormat: TimeFormatPreference = "AUTO", + dateFormat: DateFormatPreference = "AUTO", ): Formatters { const intlLocale = resolveIntlLocale(locale); const tz = userTz && userTz.length > 0 ? userTz : DISPLAY_TIMEZONE; const hourOpts = hourCycleOptions(timeFormat); + // Field-order locale for the numeric date renderers. AUTO keeps the + // user's own locale; DMY/MDY/YMD pin a canonical locale whose default + // numeric order matches. `monthShort` deliberately stays on `intlLocale` + // so axis month names follow the UI language, not the date-order pin. + const dateLocale = dateOrderLocale(dateFormat, intlLocale); return { number: (value, fractionDigits) => @@ -123,7 +165,7 @@ export function makeFormatters( }).format(value), date: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", @@ -131,14 +173,14 @@ export function makeFormatters( }), dateShort: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", }), dateTime: (value) => - asDate(value).toLocaleString(intlLocale, { + asDate(value).toLocaleString(dateLocale, { timeZone: tz, day: "2-digit", month: "2-digit", @@ -157,7 +199,7 @@ export function makeFormatters( }), dateWithWeekday: (value) => - asDate(value).toLocaleDateString(intlLocale, { + asDate(value).toLocaleDateString(dateLocale, { timeZone: tz, weekday: "short", day: "2-digit", diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 3b5674c34..7c2dd1349 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -16,8 +16,10 @@ import { makeFormatters, type Formatters, type TimeFormatPreference, + type DateFormatPreference, } from "../format-locale"; import { readStoredTimeFormat, subscribeTimeFormat } from "../time-format"; +import { readStoredDateFormat, subscribeDateFormat } from "../date-format"; import { resolveKey } from "./resolve-key"; import { fallbackMessages, @@ -248,6 +250,21 @@ export function useTimeFormatPreference(): TimeFormatPreference { ); } +/** + * The user's date-order preference (AUTO / DMY / MDY / YMD), reactive to + * changes. Backed by the same localStorage-mirror pattern as the hour-cycle + * preference — `useAuth`'s `/api/auth/me` fetch and the profile date-format + * select keep it in sync with the server value, no QueryClient required. + * SSR resolves AUTO. + */ +export function useDateFormatPreference(): DateFormatPreference { + return useSyncExternalStore( + subscribeDateFormat, + readStoredDateFormat, + () => "AUTO" as const, + ); +} + /** * Locale-aware formatters tied to the active UI locale. Use for every number, * date, and time rendered in the UI so regional conventions (70,5 vs 70.5, @@ -258,8 +275,9 @@ export function useTimeFormatPreference(): TimeFormatPreference { export function useFormatters(): Formatters { const { locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const dateFormat = useDateFormatPreference(); return useMemo( - () => makeFormatters(locale, undefined, timeFormat), - [locale, timeFormat], + () => makeFormatters(locale, undefined, timeFormat, dateFormat), + [locale, timeFormat, dateFormat], ); }