merge(DATEFMT-1): date-format preference foundation

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:17:06 +02:00
commit 8c09970d4a
26 files changed
+1022 -7

No files matched your search

+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
+11
View File
@@ -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",
@@ -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';
+16
View File
@@ -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
+4
View File
@@ -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 <DateField> 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
+3
View File
@@ -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,
@@ -68,6 +68,7 @@ function buildUser(disableCoach: boolean): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
@@ -70,6 +70,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -46,6 +46,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat,
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -49,6 +49,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
glucoseUnit: null,
unitPreference,
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -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() {
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -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 `<TimeFormatSelect>`: a change PATCHes the profile
* endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage
* mirror is written synchronously so every `useFormatters()` consumer and the
* `<DateField>` 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<DateFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.dateFormat.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-date-format-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as DateFormatPreference)}
>
{DATE_FORMAT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dateFormat.description")}
</p>
</div>
);
}
@@ -0,0 +1,87 @@
/**
* `<DateField>` 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 `<DateInput>` 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DateField>", () => {
it("keeps the ISO value on a hidden native date input", () => {
const html = render(<DateField id="dob" name="dob" value="2026-02-19" />);
// The real value lives on a type=date input (the form's source of truth).
expect(html).toMatch(/<input[^>]*type="date"[^>]*value="2026-02-19"/);
expect(html).toContain('name="dob"');
// …and it is visually hidden so only the overlay shows.
expect(html).toMatch(/<input[^>]*type="date"[^>]*class="[^"]*sr-only/);
});
it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => {
const html = render(<DateField value="2026-02-19" />, "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(<DateField value="2026-02-19" />, "de");
expect(html).toContain('value="19.02.2026"');
});
it("renders the placeholder shape when empty", () => {
const html = render(<DateField value="" placeholder="Pick a date" />);
expect(html).toContain('placeholder="Pick a date"');
});
it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => {
const html = render(<DateField value="" />, "en");
expect(html).toContain('placeholder="MM/DD/YYYY"');
});
it("threads disabled, min and max onto the native input", () => {
const html = render(
<DateField
value="2026-02-19"
disabled
min="2020-01-01"
max="2030-12-31"
/>,
);
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
});
it("ships the WCAG target-size + height-parity classes", () => {
const html = render(<DateField value="2026-02-19" />);
// Same 44px mobile / 40px sm+ floor as <DateInput>.
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain('aria-label="Open date picker"');
});
});
+288
View File
@@ -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
* `<input type="date">` contract `<DateInput>` ships — so this is a drop-in
* for `<DateInput>` and existing react-hook-form wiring is unchanged.
*
* How it works:
* - A visually-hidden native `<input type="date">` 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 `<DateInput>`: 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 `<DateInput>` 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<HTMLInputElement, DateFieldProps>(
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<HTMLInputElement | null>(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<string | null>(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<HTMLInputElement>) {
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 (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native date input — owns the real value + events. */}
<input
{...rest}
ref={setRefs}
id={id}
name={name}
type="date"
lang={locale}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
value={value !== undefined ? value : undefined}
defaultValue={value === undefined ? defaultValue : undefined}
disabled={disabled}
required={required}
min={min}
max={max}
onChange={handleNativeChange}
/>
{/* Editable display overlay. */}
<input
type="text"
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
onChange={(e) => setTyped(e.target.value)}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
onClick={openPicker}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
},
);
/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */
function formatPlaceholder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["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");
}
+23 -1
View File
@@ -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 `<DateField>` 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 (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
@@ -157,6 +169,15 @@ async function fetchMe(): Promise<AuthUser> {
? data.timeFormat
: "AUTO";
storeTimeFormat(timeFormat);
// Date-order preference: coerce against a stale /me payload, then mirror
// into localStorage so `useFormatters()` and the `<DateField>` 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<AuthUser> {
// 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,
+110
View File
@@ -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 `<DateField>` 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\./);
}
});
});
+6
View File
@@ -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,
+162
View File
@@ -0,0 +1,162 @@
/**
* Client-side mirror of the per-user date-order preference, plus the
* display helpers the `<DateField>` 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. `<DateField>` (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 `<DateField>` 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
* `<DateField>` 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;
}
@@ -95,6 +95,7 @@ function buildUser(disableCoach: boolean): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
+46 -4
View File
@@ -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",
+20 -2
View File
@@ -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],
);
}
+16
View File
@@ -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(),