mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge(DATEFMT-1): date-format preference foundation
This commit is contained in:
commit
8c09970d4a
26 files changed
+1022
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user