From d8ff18948f56c581b15535bf65e92ae9486edd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:05:49 +0200 Subject: [PATCH 1/5] feat(profile): add date-format preference column Mirror the time-format preference with a per-user date display choice. AUTO follows the locale convention, DMY/MDY/YMD pin the field order. Additive, rerun-safe migration; existing rows read AUTO. --- .../0192_v1210_date_format/migration.sql | 24 +++++++++++++++++++ prisma/schema.prisma | 16 +++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 prisma/migrations/0192_v1210_date_format/migration.sql diff --git a/prisma/migrations/0192_v1210_date_format/migration.sql b/prisma/migrations/0192_v1210_date_format/migration.sql new file mode 100644 index 000000000..4cdf65164 --- /dev/null +++ b/prisma/migrations/0192_v1210_date_format/migration.sql @@ -0,0 +1,24 @@ +-- v1.21.0 — per-user date display preference. +-- +-- The UI locale alone decided whether dates rendered as dd.MM.yyyy or +-- MM/dd/yyyy, and the native date input fell back to the browser locale, +-- which left non-US self-hosters on MM/DD/YYYY with no way out. The new +-- column carries an explicit preference: AUTO follows the locale +-- convention, DMY pins day-month-year, MDY pins month-day-year, YMD pins +-- ISO yyyy-MM-dd. Display-time only — stored instants stay UTC. +-- +-- Additive + non-destructive: a new NOT NULL column with a default, no +-- backfill needed. Existing rows read AUTO. Guarded so a re-run is a +-- no-op (the enum create + the column add both skip when present). +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'date_format_preference' + ) THEN + CREATE TYPE "date_format_preference" AS ENUM ('AUTO', 'DMY', 'MDY', 'YMD'); + END IF; +END +$$; + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "date_format" "date_format_preference" NOT NULL DEFAULT 'AUTO'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e41c0f57f..18eacb45d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -228,6 +228,11 @@ model User { // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour. // Display-time only — canonical storage stays UTC instants. timeFormat TimeFormatPreference @default(AUTO) @map("time_format") + // Date display preference. AUTO follows the active locale convention + // (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year, + // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — + // canonical storage stays UTC instants. + dateFormat DateFormatPreference @default(AUTO) @map("date_format") // AI provider selection for insights. Null = OPENAI fallback. aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" aiModel String? @map("ai_model") @@ -973,6 +978,17 @@ enum TimeFormatPreference { @@map("time_format_preference") } +// Date display preference for rendered dates. AUTO defers to the active +// locale's convention; DMY/MDY/YMD pin the field order regardless of locale. +enum DateFormatPreference { + AUTO + DMY + MDY + YMD + + @@map("date_format_preference") +} + enum MeasurementSource { MANUAL WITHINGS 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 2/5] 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], ); } From 86a9ca2e1e8f79fb3721cfb01935e497f28ce44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:09 +0200 Subject: [PATCH 3/5] feat(profile): persist and expose the date-format preference Accept dateFormat on the profile PATCH field-by-field, echo it from the profile + /me reads, mirror it into the client auth user, and add the field to the OpenAPI request/response schemas. --- src/app/api/auth/me/route.ts | 4 ++++ src/app/api/user/profile/route.ts | 3 +++ .../__tests__/disable-coach-card.test.tsx | 1 + .../__tests__/modules-section.test.tsx | 1 + .../__tests__/time-format-select.test.tsx | 1 + .../__tests__/unit-preference-select.test.tsx | 1 + src/hooks/use-auth.ts | 24 ++++++++++++++++++- src/lib/auth/profile-update.ts | 6 +++++ .../__tests__/coach-user-disable.test.tsx | 1 + src/lib/openapi/routes/profile.ts | 16 +++++++++++++ 10 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 434f29221..4ef9ebe53 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -96,6 +96,10 @@ export const GET = apiHandler(async () => { // Hour-cycle display preference (AUTO follows the locale convention, // H12 / H24 pin the cycle). Clients mirror this into their formatters. timeFormat: user.timeFormat ?? "AUTO", + // Date-order display preference (AUTO follows the locale convention, + // DMY / MDY / YMD pin the field order). Clients mirror this into their + // formatters and the primitive. + dateFormat: user.dateFormat ?? "AUTO", lastReportPracticeName: user.lastReportPracticeName ?? null, // v1.4.47 W3 — per-user Coach opt-out. Default `false` if the // column is absent (partial-deploy rollback safety, see migration diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 095ade7e3..d3c211840 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -42,6 +42,7 @@ export const GET = apiHandler(async () => { locale: true, timezone: true, timeFormat: true, + dateFormat: true, moodReminderEnabled: true, fullName: true, insurerName: true, @@ -77,6 +78,7 @@ export const GET = apiHandler(async () => { locale: dbUser?.locale ?? null, timezone: dbUser?.timezone ?? "Europe/Berlin", timeFormat: dbUser?.timeFormat ?? "AUTO", + dateFormat: dbUser?.dateFormat ?? "AUTO", moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, @@ -114,6 +116,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { locale: result.user.locale, timezone: result.user.timezone, timeFormat: result.user.timeFormat, + dateFormat: result.user.dateFormat, moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index c9f40959e..945933485 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -68,6 +68,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index cb97fa27b..dbd2822da 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -70,6 +70,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 2bb55ac6b..cea538c04 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -46,6 +46,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat, + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index c5e0a3b13..e944c2ddc 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -49,6 +49,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { glucoseUnit: null, unitPreference, timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach: false, fullName: null, insurerName: null, diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 499e2795f..98f6cc052 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -7,8 +7,12 @@ import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch"; import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; -import type { TimeFormatPreference } from "@/lib/format-locale"; +import type { + TimeFormatPreference, + DateFormatPreference, +} from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; +import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format"; import type { ModuleKey } from "@/lib/modules/registry"; import type { TourProgress } from "@/lib/onboarding/tour-progress"; import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; @@ -75,6 +79,14 @@ export interface AuthUser { * "AUTO" against a stale /me payload. */ timeFormat: TimeFormatPreference; + /** + * Date-order display preference. AUTO follows the locale convention + * (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY / MDY / YMD pin the field order + * regardless of locale. Mirrored into localStorage by `fetchMe` so + * `useFormatters()` and the `` primitive render the same order. + * Coerced to "AUTO" against a stale /me payload. + */ + dateFormat: DateFormatPreference; /** * v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach * mount point (``, ``, the @@ -157,6 +169,15 @@ async function fetchMe(): Promise { ? data.timeFormat : "AUTO"; storeTimeFormat(timeFormat); + // Date-order preference: coerce against a stale /me payload, then mirror + // into localStorage so `useFormatters()` and the `` primitive + // (which cannot reach the query cache) render the same field order. + const dateFormat: DateFormatPreference = isDateFormatPreference( + data.dateFormat, + ) + ? data.dateFormat + : "AUTO"; + storeDateFormat(dateFormat); return { ...(data as AuthUser), disableCoach: data.disableCoach ?? false, @@ -167,6 +188,7 @@ async function fetchMe(): Promise { // without the field) so the display defaults to metric. unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric", timeFormat, + dateFormat, // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index e38410022..d62c3cb4e 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -27,6 +27,9 @@ const extendedProfileSchema = profileSchema.extend({ // Hour-cycle display preference. AUTO follows the locale convention, // H12 forces AM/PM, H24 forces 24-hour. timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(), + // Date-order display preference. AUTO follows the locale convention, + // DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd. + dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(), }); export interface ApplyProfileResult { @@ -43,6 +46,7 @@ export interface ApplyProfileResult { timezone: string; locale: string | null; timeFormat: "AUTO" | "H12" | "H24"; + dateFormat: "AUTO" | "DMY" | "MDY" | "YMD"; moodReminderEnabled: boolean; // v1.7.0 — patient-identity fields. `insuranceNumber` is returned in // plaintext (decrypted on read by the route) so the client can render @@ -110,6 +114,7 @@ export async function applyProfileUpdate( if (data.locale !== undefined) updates.locale = data.locale; if (data.timezone !== undefined) updates.timezone = data.timezone; if (data.timeFormat !== undefined) updates.timeFormat = data.timeFormat; + if (data.dateFormat !== undefined) updates.dateFormat = data.dateFormat; if (data.moodReminderEnabled !== undefined) { updates.moodReminderEnabled = data.moodReminderEnabled; } @@ -206,6 +211,7 @@ export async function applyProfileUpdate( timezone: updatedUser.timezone, locale: updatedUser.locale, timeFormat: updatedUser.timeFormat ?? "AUTO", + dateFormat: updatedUser.dateFormat ?? "AUTO", moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 71b725ea7..2675bc92e 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -95,6 +95,7 @@ function buildUser(disableCoach: boolean): AuthUser { glucoseUnit: null, unitPreference: "metric", timeFormat: "AUTO", + dateFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index fa378dad8..941e53245 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -185,6 +185,12 @@ const profileUpdateRequest = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .optional() + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean().optional(), fullName: z.string().max(120).nullable().optional(), insurerName: z.string().max(120).nullable().optional(), @@ -222,6 +228,11 @@ const profileResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -261,6 +272,11 @@ const profileUpdateResponse = z .describe( "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", ), + dateFormat: z + .enum(["AUTO", "DMY", "MDY", "YMD"]) + .describe( + "Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy), MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), From 9b7980d020e47ee076d19ac588b2b2f3b7fb1b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:16 +0200 Subject: [PATCH 4/5] feat(settings): add the date-format profile control Surface a Datumsformat select below Stundenformat with the four preferences, in all six locales. --- messages/de.json | 11 ++ messages/en.json | 11 ++ messages/es.json | 11 ++ messages/fr.json | 11 ++ messages/it.json | 11 ++ messages/pl.json | 11 ++ .../settings/account-section/index.tsx | 2 + .../settings/date-format-select.tsx | 144 ++++++++++++++++++ 8 files changed, 212 insertions(+) create mode 100644 src/components/settings/date-format-select.tsx diff --git a/messages/de.json b/messages/de.json index 933e6e1f5..2953dc13c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -20,6 +20,7 @@ "inactive": "Inaktiv", "disabled": "Deaktiviert", "networkError": "Netzwerkfehler", + "openDatePicker": "Datumsauswahl öffnen", "unknownError": "Unbekannter Fehler", "copied": "Kopiert!", "or": "oder", @@ -4120,6 +4121,16 @@ "saved": "Stundenformat gespeichert.", "saveError": "Speichern des Stundenformats fehlgeschlagen." }, + "dateFormat": { + "title": "Datumsformat", + "description": "Bestimmt die Reihenfolge von Tag, Monat und Jahr bei der Anzeige von Daten.", + "auto": "Automatisch (Sprache)", + "dmy": "TT.MM.JJJJ", + "mdy": "MM/TT/JJJJ", + "ymd": "JJJJ-MM-TT", + "saved": "Datumsformat gespeichert.", + "saveError": "Speichern des Datumsformats fehlgeschlagen." + }, "username": "Benutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", diff --git a/messages/en.json b/messages/en.json index e7fe5729f..dddad7809 100644 --- a/messages/en.json +++ b/messages/en.json @@ -20,6 +20,7 @@ "inactive": "Inactive", "disabled": "Disabled", "networkError": "Network error", + "openDatePicker": "Open date picker", "unknownError": "Unknown error", "copied": "Copied!", "or": "or", @@ -4120,6 +4121,16 @@ "saved": "Hour format saved.", "saveError": "Saving the hour format failed." }, + "dateFormat": { + "title": "Date format", + "description": "Controls the order of day, month and year when dates are shown.", + "auto": "Automatic (language)", + "dmy": "DD.MM.YYYY", + "mdy": "MM/DD/YYYY", + "ymd": "YYYY-MM-DD", + "saved": "Date format saved.", + "saveError": "Saving the date format failed." + }, "username": "Username", "height": "Height (cm)", "dateOfBirth": "Date of birth", diff --git a/messages/es.json b/messages/es.json index 833990236..9c7719abb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -20,6 +20,7 @@ "inactive": "Inactivo", "disabled": "Desactivado", "networkError": "Error de red", + "openDatePicker": "Abrir el selector de fecha", "unknownError": "Error desconocido", "copied": "¡Copiado!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato horario guardado.", "saveError": "No se pudo guardar el formato horario." }, + "dateFormat": { + "title": "Formato de fecha", + "description": "Determina el orden de día, mes y año al mostrar las fechas.", + "auto": "Automático (idioma)", + "dmy": "DD.MM.AAAA", + "mdy": "MM/DD/AAAA", + "ymd": "AAAA-MM-DD", + "saved": "Formato de fecha guardado.", + "saveError": "No se pudo guardar el formato de fecha." + }, "username": "Nombre de usuario", "height": "Altura (cm)", "dateOfBirth": "Fecha de nacimiento", diff --git a/messages/fr.json b/messages/fr.json index 8a3453623..dfabd04d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -20,6 +20,7 @@ "inactive": "Inactif", "disabled": "Désactivé", "networkError": "Erreur réseau", + "openDatePicker": "Ouvrir le sélecteur de date", "unknownError": "Erreur inconnue", "copied": "Copié !", "or": "ou", @@ -4120,6 +4121,16 @@ "saved": "Format horaire enregistré.", "saveError": "Échec de l’enregistrement du format horaire." }, + "dateFormat": { + "title": "Format de date", + "description": "Détermine l’ordre du jour, du mois et de l’année lors de l’affichage des dates.", + "auto": "Automatique (langue)", + "dmy": "JJ.MM.AAAA", + "mdy": "MM/JJ/AAAA", + "ymd": "AAAA-MM-JJ", + "saved": "Format de date enregistré.", + "saveError": "Échec de l’enregistrement du format de date." + }, "username": "Nom d'utilisateur", "height": "Taille (cm)", "dateOfBirth": "Date de naissance", diff --git a/messages/it.json b/messages/it.json index 734f6011a..53fd5ec0d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -20,6 +20,7 @@ "inactive": "Inattivo", "disabled": "Disabilitato", "networkError": "Errore di rete", + "openDatePicker": "Apri il selettore di data", "unknownError": "Errore sconosciuto", "copied": "Copiato!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato orario salvato.", "saveError": "Salvataggio del formato orario non riuscito." }, + "dateFormat": { + "title": "Formato data", + "description": "Determina l’ordine di giorno, mese e anno nella visualizzazione delle date.", + "auto": "Automatico (lingua)", + "dmy": "GG.MM.AAAA", + "mdy": "MM/GG/AAAA", + "ymd": "AAAA-MM-GG", + "saved": "Formato data salvato.", + "saveError": "Salvataggio del formato data non riuscito." + }, "username": "Nome utente", "height": "Altezza (cm)", "dateOfBirth": "Data di nascita", diff --git a/messages/pl.json b/messages/pl.json index b8a448c23..5afe3bc9b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -20,6 +20,7 @@ "inactive": "Nieaktywne", "disabled": "Wyłączone", "networkError": "Błąd sieci", + "openDatePicker": "Otwórz wybór daty", "unknownError": "Nieznany błąd", "copied": "Skopiowano!", "or": "lub", @@ -4120,6 +4121,16 @@ "saved": "Zapisano format godziny.", "saveError": "Nie udało się zapisać formatu godziny." }, + "dateFormat": { + "title": "Format daty", + "description": "Określa kolejność dnia, miesiąca i roku przy wyświetlaniu dat.", + "auto": "Automatycznie (język)", + "dmy": "DD.MM.RRRR", + "mdy": "MM/DD/RRRR", + "ymd": "RRRR-MM-DD", + "saved": "Zapisano format daty.", + "saveError": "Nie udało się zapisać formatu daty." + }, "username": "Nazwa użytkownika", "height": "Wzrost (cm)", "dateOfBirth": "Data urodzenia", diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index c6d917ae1..57c68ce83 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -27,6 +27,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; +import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone } from "@/lib/tz/format"; @@ -416,6 +417,7 @@ export function AccountSection() { + {/* v1.7.0 — optional patient-identity fields surfaced on the diff --git a/src/components/settings/date-format-select.tsx b/src/components/settings/date-format-select.tsx new file mode 100644 index 000000000..0b3b222b7 --- /dev/null +++ b/src/components/settings/date-format-select.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useAuth } from "@/hooks/use-auth"; +import type { DateFormatPreference } from "@/lib/format-locale"; +import { DATE_FORMAT_OPTIONS, storeDateFormat } from "@/lib/date-format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** + * Date-order (DD.MM / MM/DD / ISO) preference as a Profile dropdown. + * + * Sits directly below the hour-format select because it is the same kind of + * personal display preference; the active locale otherwise pins the date + * order with no way out. AUTO follows the locale convention, DMY/MDY/YMD pin + * the field order regardless of locale. + * + * Persistence mirrors ``: a change PATCHes the profile + * endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage + * mirror is written synchronously so every `useFormatters()` consumer and the + * `` primitive repaint without waiting for the refetch. + */ +export function DateFormatSelect({ + isAuthenticated, + id = "date-format", +}: { + isAuthenticated: boolean; + id?: string; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState( + null, + ); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO"; + + const mutation = useMutation({ + mutationFn: async (next: DateFormatPreference) => { + const res = await apiFetchRaw("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dateFormat: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: (next) => { + // Repaint every formatter consumer right away; the /me refetch below + // re-asserts the same value once it settles. + storeDateFormat(next); + setMsg(t("settings.dateFormat.saved")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error ? err.message : t("settings.dateFormat.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: DateFormatPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + return ( +
+ + handleSelect(e.target.value as DateFormatPreference)} + > + {DATE_FORMAT_OPTIONS.map((opt) => ( + + ))} + +

+ {msg ?? t("settings.dateFormat.description")} +

+
+ ); +} From d6430528e9cc139febeaf42d6a2a107b6a62711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:22 +0200 Subject: [PATCH 5/5] feat(ui): add the DateField date input Dependency-free date input that displays the value in the user's date-format preference while editing rides the native picker behind a calendar button. The committed value stays ISO yyyy-MM-dd, so it is a drop-in for DateInput. Height/target-size parity, disabled/min/max/ placeholder, and progressive typed entry parsed against the order. --- .../ui/__tests__/date-field.test.tsx | 87 ++++++ src/components/ui/date-field.tsx | 288 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/components/ui/__tests__/date-field.test.tsx create mode 100644 src/components/ui/date-field.tsx diff --git a/src/components/ui/__tests__/date-field.test.tsx b/src/components/ui/__tests__/date-field.test.tsx new file mode 100644 index 000000000..b789f42da --- /dev/null +++ b/src/components/ui/__tests__/date-field.test.tsx @@ -0,0 +1,87 @@ +/** + * `` contract (v1.21.0). + * + * The SSR-only convention (no `@testing-library/react`, `environment: "node"`) + * means the interactive picker / typed-entry paths can't be driven here — those + * ride `showPicker()` + change events the static renderer never fires. What the + * static markup CAN pin is the load-bearing contract: + * + * - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden native date input, + * so this is a drop-in for `` and react-hook-form is unchanged; + * - the visible overlay paints the value formatted per the preference; + * - disabled / min / max / placeholder thread through; + * - height + target-size parity classes are present. + * + * Under SSR `useDateFormatPreference()` resolves AUTO (no `window`), so the + * overlay reflects the locale order — de → dd.MM.yyyy, en → MM/dd/yyyy. The + * per-preference order itself is covered exhaustively in + * `src/lib/__tests__/date-format.test.ts` against the pure formatter. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { DateField } from "../date-field"; + +function render(node: React.ReactNode, locale: "de" | "en" = "en"): string { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("keeps the ISO value on a hidden native date input", () => { + const html = render(); + // The real value lives on a type=date input (the form's source of truth). + expect(html).toMatch(/]*type="date"[^>]*value="2026-02-19"/); + expect(html).toContain('name="dob"'); + // …and it is visually hidden so only the overlay shows. + expect(html).toMatch(/]*type="date"[^>]*class="[^"]*sr-only/); + }); + + it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => { + const html = render(, "en"); + expect(html).toContain('value="02/19/2026"'); + }); + + it("paints the AUTO-locale display string over the value (de → dd.MM.yyyy)", () => { + const html = render(, "de"); + expect(html).toContain('value="19.02.2026"'); + }); + + it("renders the placeholder shape when empty", () => { + const html = render(); + expect(html).toContain('placeholder="Pick a date"'); + }); + + it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => { + const html = render(, "en"); + expect(html).toContain('placeholder="MM/DD/YYYY"'); + }); + + it("threads disabled, min and max onto the native input", () => { + const html = render( + , + ); + expect(html).toMatch(/]*type="date"[^>]*min="2020-01-01"/); + expect(html).toMatch(/]*type="date"[^>]*max="2030-12-31"/); + expect(html).toMatch(/]*type="date"[^>]*disabled/); + }); + + it("ships the WCAG target-size + height-parity classes", () => { + const html = render(); + // Same 44px mobile / 40px sm+ floor as . + expect(html).toContain("min-h-11"); + expect(html).toContain("sm:h-10"); + }); + + it("exposes a labelled calendar picker affordance", () => { + const html = render(); + expect(html).toContain('aria-label="Open date picker"'); + }); +}); diff --git a/src/components/ui/date-field.tsx b/src/components/ui/date-field.tsx new file mode 100644 index 000000000..049fe5884 --- /dev/null +++ b/src/components/ui/date-field.tsx @@ -0,0 +1,288 @@ +"use client"; + +import * as React from "react"; +import { Calendar } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context"; +import { formatDate, parseIsoDate } from "@/lib/date-format"; + +/** + * Dependency-free date input that DISPLAYS the value in the user's date-order + * preference (AUTO follows the locale; DMY / MDY / YMD pin the field order) + * while editing rides the browser's native date picker. + * + * The committed VALUE is always ISO `yyyy-MM-dd` — identical to the native + * `` contract `` ships — so this is a drop-in + * for `` and existing react-hook-form wiring is unchanged. + * + * How it works: + * - A visually-hidden native `` holds the real value and + * emits the change events the form listens to. Tapping the field (or its + * calendar button) calls `showPicker()` on that input, falling back to + * `focus()` where the API is unavailable. + * - A text overlay paints the formatted display string (or the placeholder). + * - As a progressive enhancement the user can type a date directly: the + * overlay is an editable text input; on blur / Enter we parse what they + * typed against the active locale order and, on a clean parse, write the + * ISO value back through the hidden input so the form sees a normal change. + * + * Height / a11y / target-size parity with ``: the same + * `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5 — 44 px on mobile, + * 40 px on sm+) and focus vocabulary as the rest of the input primitives. + */ + +// Mirrors the `` height contract (minus the date-shadow rule, which +// only applies to the native value text — here the native input is hidden). +const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10"; + +const FIELD_BASE_CLASSES = + "border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none"; + +export interface DateFieldProps { + id?: string; + name?: string; + /** ISO `yyyy-MM-dd` (controlled). */ + value?: string; + /** Initial ISO `yyyy-MM-dd` (uncontrolled). */ + defaultValue?: string; + onChange?: (value: string) => void; + onBlur?: () => void; + disabled?: boolean; + required?: boolean; + /** ISO `yyyy-MM-dd`. */ + min?: string; + /** ISO `yyyy-MM-dd`. */ + max?: string; + placeholder?: string; + className?: string; + "aria-label"?: string; + "aria-describedby"?: string; + "aria-invalid"?: boolean | "true" | "false"; + "data-testid"?: string; +} + +export const DateField = React.forwardRef( + function DateField( + { + id, + name, + value, + defaultValue, + onChange, + onBlur, + disabled, + required, + min, + max, + placeholder, + className, + "aria-label": ariaLabel, + "aria-describedby": ariaDescribedBy, + "aria-invalid": ariaInvalid, + "data-testid": dataTestId, + ...rest + }, + forwardedRef, + ) { + const { locale, t } = useTranslations(); + const dateFormat = useDateFormatPreference(); + + const innerRef = React.useRef(null); + const setRefs = React.useCallback( + (node: HTMLInputElement | null) => { + innerRef.current = node; + if (typeof forwardedRef === "function") forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }, + [forwardedRef], + ); + + // Track the ISO value so the overlay reflects controlled + uncontrolled + // updates. Controlled `value` always wins on render. + const [internal, setInternal] = React.useState(defaultValue ?? ""); + const iso = value ?? internal; + + // Mirror what the user is typing into the overlay; cleared back to the + // formatted string whenever the committed ISO value changes. + const [typed, setTyped] = React.useState(null); + + const display = formatDate(iso, dateFormat, locale); + const overlayValue = typed ?? display; + + function commitIso(next: string) { + if (value === undefined) setInternal(next); + setTyped(null); + onChange?.(next); + } + + function openPicker() { + const el = innerRef.current; + if (!el || disabled) return; + // showPicker() throws if not user-activated or unsupported — fall back + // to focusing the (hidden) input, which still opens the picker on most + // mobile engines. + try { + (el as HTMLInputElement & { showPicker?: () => void }).showPicker?.(); + } catch { + el.focus(); + } + } + + function handleNativeChange(e: React.ChangeEvent) { + commitIso(e.target.value); + } + + // Progressive enhancement: parse free-typed text against the active locale + // order. We feed the typed string to the browser's own parser via a throw- + // away Date only when it matches the ISO shape; otherwise we accept a small + // set of separator-flexible numeric forms keyed off the preference. + function commitTyped() { + if (typed === null) return; + const trimmed = typed.trim(); + if (trimmed === "") { + commitIso(""); + return; + } + const parsed = parseTypedDate(trimmed, dateFormat, locale); + if (parsed) { + commitIso(parsed); + } else { + // Unparseable — discard the draft and snap back to the committed value. + setTyped(null); + } + } + + return ( +
+ {/* Visually-hidden native date input — owns the real value + events. */} + + {/* Editable display overlay. */} + setTyped(e.target.value)} + onClick={openPicker} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitTyped(); + } + }} + onBlur={() => { + commitTyped(); + onBlur?.(); + }} + /> + +
+ ); + }, +); + +/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */ +function formatPlaceholder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string { + // 2026-12-31 renders unambiguously in every order, so its formatted shape + // doubles as a self-describing placeholder hint. + return formatDate("2026-12-31", dateFormat, locale) + .replace(/31/g, "DD") + .replace(/12/g, "MM") + .replace(/2026/g, "YYYY"); +} + +/** + * Best-effort parse of a free-typed date into ISO `yyyy-MM-dd`. Accepts the + * active preference's field order with `.`, `/`, or `-` separators, plus a + * bare ISO string. Returns null when the parts don't form a real calendar + * date so the caller can reject the draft. + */ +function parseTypedDate( + input: string, + dateFormat: ReturnType, + locale: ReturnType["locale"], +): string | null { + // A clean ISO string always wins. + const isoDirect = parseIsoDate(input); + if (isoDirect) return toIso(isoDirect); + + const parts = input.split(/[./-]/).map((p) => p.trim()); + if (parts.length !== 3 || parts.some((p) => p === "" || !/^\d+$/.test(p))) { + return null; + } + const nums = parts.map(Number) as [number, number, number]; + + const order = resolveOrder(dateFormat, locale); + let year: number, month: number, day: number; + if (order === "MDY") [month, day, year] = nums; + else if (order === "YMD") [year, month, day] = nums; + else [day, month, year] = nums; // DMY + + if (year < 100) year += year >= 70 ? 1900 : 2000; + const candidate = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`; + const round = parseIsoDate(candidate); + return round ? candidate : null; +} + +function resolveOrder( + dateFormat: ReturnType, + locale: ReturnType["locale"], +): "DMY" | "MDY" | "YMD" { + if (dateFormat === "DMY") return "DMY"; + if (dateFormat === "MDY") return "MDY"; + if (dateFormat === "YMD") return "YMD"; + // AUTO: en → MDY, every other shipped locale → DMY. + return locale === "en" ? "MDY" : "DMY"; +} + +function toIso(date: Date): string { + return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad( + date.getUTCDate(), + 2, + )}`; +} + +function pad(value: number, width: number): string { + return String(value).padStart(width, "0"); +}