From 9b7980d020e47ee076d19ac588b2b2f3b7fb1b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:15:16 +0200 Subject: [PATCH] feat(settings): add the date-format profile control Surface a Datumsformat select below Stundenformat with the four preferences, in all six locales. --- messages/de.json | 11 ++ messages/en.json | 11 ++ messages/es.json | 11 ++ messages/fr.json | 11 ++ messages/it.json | 11 ++ messages/pl.json | 11 ++ .../settings/account-section/index.tsx | 2 + .../settings/date-format-select.tsx | 144 ++++++++++++++++++ 8 files changed, 212 insertions(+) create mode 100644 src/components/settings/date-format-select.tsx diff --git a/messages/de.json b/messages/de.json index 933e6e1f5..2953dc13c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -20,6 +20,7 @@ "inactive": "Inaktiv", "disabled": "Deaktiviert", "networkError": "Netzwerkfehler", + "openDatePicker": "Datumsauswahl öffnen", "unknownError": "Unbekannter Fehler", "copied": "Kopiert!", "or": "oder", @@ -4120,6 +4121,16 @@ "saved": "Stundenformat gespeichert.", "saveError": "Speichern des Stundenformats fehlgeschlagen." }, + "dateFormat": { + "title": "Datumsformat", + "description": "Bestimmt die Reihenfolge von Tag, Monat und Jahr bei der Anzeige von Daten.", + "auto": "Automatisch (Sprache)", + "dmy": "TT.MM.JJJJ", + "mdy": "MM/TT/JJJJ", + "ymd": "JJJJ-MM-TT", + "saved": "Datumsformat gespeichert.", + "saveError": "Speichern des Datumsformats fehlgeschlagen." + }, "username": "Benutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", diff --git a/messages/en.json b/messages/en.json index e7fe5729f..dddad7809 100644 --- a/messages/en.json +++ b/messages/en.json @@ -20,6 +20,7 @@ "inactive": "Inactive", "disabled": "Disabled", "networkError": "Network error", + "openDatePicker": "Open date picker", "unknownError": "Unknown error", "copied": "Copied!", "or": "or", @@ -4120,6 +4121,16 @@ "saved": "Hour format saved.", "saveError": "Saving the hour format failed." }, + "dateFormat": { + "title": "Date format", + "description": "Controls the order of day, month and year when dates are shown.", + "auto": "Automatic (language)", + "dmy": "DD.MM.YYYY", + "mdy": "MM/DD/YYYY", + "ymd": "YYYY-MM-DD", + "saved": "Date format saved.", + "saveError": "Saving the date format failed." + }, "username": "Username", "height": "Height (cm)", "dateOfBirth": "Date of birth", diff --git a/messages/es.json b/messages/es.json index 833990236..9c7719abb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -20,6 +20,7 @@ "inactive": "Inactivo", "disabled": "Desactivado", "networkError": "Error de red", + "openDatePicker": "Abrir el selector de fecha", "unknownError": "Error desconocido", "copied": "¡Copiado!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato horario guardado.", "saveError": "No se pudo guardar el formato horario." }, + "dateFormat": { + "title": "Formato de fecha", + "description": "Determina el orden de día, mes y año al mostrar las fechas.", + "auto": "Automático (idioma)", + "dmy": "DD.MM.AAAA", + "mdy": "MM/DD/AAAA", + "ymd": "AAAA-MM-DD", + "saved": "Formato de fecha guardado.", + "saveError": "No se pudo guardar el formato de fecha." + }, "username": "Nombre de usuario", "height": "Altura (cm)", "dateOfBirth": "Fecha de nacimiento", diff --git a/messages/fr.json b/messages/fr.json index 8a3453623..dfabd04d3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -20,6 +20,7 @@ "inactive": "Inactif", "disabled": "Désactivé", "networkError": "Erreur réseau", + "openDatePicker": "Ouvrir le sélecteur de date", "unknownError": "Erreur inconnue", "copied": "Copié !", "or": "ou", @@ -4120,6 +4121,16 @@ "saved": "Format horaire enregistré.", "saveError": "Échec de l’enregistrement du format horaire." }, + "dateFormat": { + "title": "Format de date", + "description": "Détermine l’ordre du jour, du mois et de l’année lors de l’affichage des dates.", + "auto": "Automatique (langue)", + "dmy": "JJ.MM.AAAA", + "mdy": "MM/JJ/AAAA", + "ymd": "AAAA-MM-JJ", + "saved": "Format de date enregistré.", + "saveError": "Échec de l’enregistrement du format de date." + }, "username": "Nom d'utilisateur", "height": "Taille (cm)", "dateOfBirth": "Date de naissance", diff --git a/messages/it.json b/messages/it.json index 734f6011a..53fd5ec0d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -20,6 +20,7 @@ "inactive": "Inattivo", "disabled": "Disabilitato", "networkError": "Errore di rete", + "openDatePicker": "Apri il selettore di data", "unknownError": "Errore sconosciuto", "copied": "Copiato!", "or": "o", @@ -4120,6 +4121,16 @@ "saved": "Formato orario salvato.", "saveError": "Salvataggio del formato orario non riuscito." }, + "dateFormat": { + "title": "Formato data", + "description": "Determina l’ordine di giorno, mese e anno nella visualizzazione delle date.", + "auto": "Automatico (lingua)", + "dmy": "GG.MM.AAAA", + "mdy": "MM/GG/AAAA", + "ymd": "AAAA-MM-GG", + "saved": "Formato data salvato.", + "saveError": "Salvataggio del formato data non riuscito." + }, "username": "Nome utente", "height": "Altezza (cm)", "dateOfBirth": "Data di nascita", diff --git a/messages/pl.json b/messages/pl.json index b8a448c23..5afe3bc9b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -20,6 +20,7 @@ "inactive": "Nieaktywne", "disabled": "Wyłączone", "networkError": "Błąd sieci", + "openDatePicker": "Otwórz wybór daty", "unknownError": "Nieznany błąd", "copied": "Skopiowano!", "or": "lub", @@ -4120,6 +4121,16 @@ "saved": "Zapisano format godziny.", "saveError": "Nie udało się zapisać formatu godziny." }, + "dateFormat": { + "title": "Format daty", + "description": "Określa kolejność dnia, miesiąca i roku przy wyświetlaniu dat.", + "auto": "Automatycznie (język)", + "dmy": "DD.MM.RRRR", + "mdy": "MM/DD/RRRR", + "ymd": "RRRR-MM-DD", + "saved": "Zapisano format daty.", + "saveError": "Nie udało się zapisać formatu daty." + }, "username": "Nazwa użytkownika", "height": "Wzrost (cm)", "dateOfBirth": "Data urodzenia", diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index c6d917ae1..57c68ce83 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -27,6 +27,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; +import { DateFormatSelect } from "@/components/settings/date-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone } from "@/lib/tz/format"; @@ -416,6 +417,7 @@ export function AccountSection() { + {/* v1.7.0 — optional patient-identity fields surfaced on the diff --git a/src/components/settings/date-format-select.tsx b/src/components/settings/date-format-select.tsx new file mode 100644 index 000000000..0b3b222b7 --- /dev/null +++ b/src/components/settings/date-format-select.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { useAuth } from "@/hooks/use-auth"; +import type { DateFormatPreference } from "@/lib/format-locale"; +import { DATE_FORMAT_OPTIONS, storeDateFormat } from "@/lib/date-format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** + * Date-order (DD.MM / MM/DD / ISO) preference as a Profile dropdown. + * + * Sits directly below the hour-format select because it is the same kind of + * personal display preference; the active locale otherwise pins the date + * order with no way out. AUTO follows the locale convention, DMY/MDY/YMD pin + * the field order regardless of locale. + * + * Persistence mirrors ``: a change PATCHes the profile + * endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage + * mirror is written synchronously so every `useFormatters()` consumer and the + * `` primitive repaint without waiting for the refetch. + */ +export function DateFormatSelect({ + isAuthenticated, + id = "date-format", +}: { + isAuthenticated: boolean; + id?: string; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [optimistic, setOptimistic] = useState( + null, + ); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + const clearTimerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + }; + }, []); + + function scheduleClear() { + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + } + clearTimerRef.current = setTimeout(() => { + clearTimerRef.current = null; + setMsg(null); + setMsgType(null); + }, 3000); + } + + const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO"; + + const mutation = useMutation({ + mutationFn: async (next: DateFormatPreference) => { + const res = await apiFetchRaw("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dateFormat: next }), + }); + if (!res.ok) { + const json = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(json.error ?? `HTTP ${res.status}`); + } + return next; + }, + onSuccess: (next) => { + // Repaint every formatter consumer right away; the /me refetch below + // re-asserts the same value once it settles. + storeDateFormat(next); + setMsg(t("settings.dateFormat.saved")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + setOptimistic(null); + scheduleClear(); + }, + onError: (err) => { + setOptimistic(null); + setMsg( + err instanceof Error ? err.message : t("settings.dateFormat.saveError"), + ); + setMsgType("error"); + scheduleClear(); + }, + }); + + function handleSelect(next: DateFormatPreference) { + if (next === value || mutation.isPending || !isAuthenticated) return; + setOptimistic(next); + setMsg(null); + setMsgType(null); + if (clearTimerRef.current !== null) { + clearTimeout(clearTimerRef.current); + clearTimerRef.current = null; + } + mutation.mutate(next); + } + + return ( +
+ + handleSelect(e.target.value as DateFormatPreference)} + > + {DATE_FORMAT_OPTIONS.map((opt) => ( + + ))} + +

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

+
+ ); +}