feat(settings): add the date-format profile control

Surface a Datumsformat select below Stundenformat with the four
preferences, in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:15:16 +02:00
1 parent 86a9ca2e1e
commit 9b7980d020
8 files changed
+212

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",
@@ -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>
);
}