mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
86a9ca2e1e
commit
9b7980d020
8 files changed
+212
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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user