Files
HealthLog/src/components/settings/unit-preference-select.tsx
T
Marc-André Bombeck 3b480b9885 perf(i18n,shell): ship one locale and stop blocking the first paint
The client bundle carries only the active locale — resolved on the
server and handed to the provider with no hydration flash, halving the
dashboard route to under a megabyte parsed (288 KiB gzip). The auth
shell renders the app chrome immediately instead of a spinner while the
session resolves in parallel, the widgets request folds into the
snapshot it duplicated, and insight pills prefetch their sub-pages —
a first visit to a sub-page lands in about ninety milliseconds instead
of nearly a second.
2026-06-11 02:19:27 +02:00

143 lines
4.4 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 { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
type UnitPreference = "metric" | "imperial";
/**
* v1.12.x — unit system (metric/imperial) as a Profile dropdown.
*
* The metric/imperial preference is a personal setting like language
* and timezone, so it lives in the Profile form beside the timezone
* picker rather than in a standalone card (it replaces the retired
* `<UnitPreferenceCard>`). Persistence is unchanged: a change PATCHes
* `/api/auth/me/unit-preference` immediately and invalidates
* `queryKeys.authMe()` + `queryKeys.userUnitPreference()` so every chart
* display transform (km/h vs mph, km vs mi) re-renders on the next /me
* refetch — no manual reload, no contract change.
*/
export function UnitPreferenceSelect({
isAuthenticated,
id = "unit-preference",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<UnitPreference | 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: UnitPreference = optimistic ?? user?.unitPreference ?? "metric";
const mutation = useMutation({
mutationFn: async (next: UnitPreference) => {
const res = await apiFetchRaw("/api/auth/me/unit-preference", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unitPreference: next }),
});
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? `HTTP ${res.status}`);
}
return next;
},
onSuccess: () => {
setMsg(t("settings.dashboard.units.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userUnitPreference(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.units.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: UnitPreference) {
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.dashboard.units.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-unit-preference-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as UnitPreference)}
>
<option value="metric">{t("settings.dashboard.units.metric")}</option>
<option value="imperial">
{t("settings.dashboard.units.imperial")}
</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.units.description")}
</p>
</div>
);
}