Files
HealthLog/src/components/settings/date-format-select.tsx
T
Marc-André Bombeck 9b7980d020 feat(settings): add the date-format profile control
Surface a Datumsformat select below Stundenformat with the four
preferences, in all six locales.
2026-06-26 12:15:16 +02:00

145 lines
4.5 KiB
TypeScript

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