Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Marc-André Bombeck committed 2026-09-03 06:35:35 +02:00
commit 7c5f49c18e
36 files changed
+1613 -45

No files matched your search

@@ -0,0 +1,73 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* The provider-health card and the HTTP status of the last failure.
*
* The ledger has recorded a status on every failure since v1.11.0 and no
* surface read it, so the card could say a provider was failing without
* saying whose problem it was. A 401 is a credential the operator has to
* replace; a 503 is the provider's afternoon. These pin that the status
* reaches the row, that it is left out when there was none to record,
* and that a healthy provider does not carry a stale one.
*/
const mockQueryState = vi.hoisted(() => ({
data: null as null | object,
isPending: false,
isError: false,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: mockQueryState.data,
isPending: mockQueryState.isPending,
isError: mockQueryState.isError,
}),
}));
import { ProviderHealthSection } from "../provider-health-section";
function row(over: Record<string, unknown> = {}) {
return {
providerType: "admin-openai",
tracked: 5,
failing: 2,
maxConsecutiveFailures: 4,
lastOkAt: null,
lastFailureAt: "2026-08-27T06:00:00.000Z",
lastFailureStatus: 401,
...over,
};
}
function render(providers: object[]) {
mockQueryState.data = { providers };
mockQueryState.isPending = false;
mockQueryState.isError = false;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ProviderHealthSection />
</I18nProvider>,
);
}
describe("<ProviderHealthSection> — the last failure's HTTP status", () => {
it("names the status beside the failure it belongs to", () => {
expect(render([row()])).toContain("HTTP 401");
});
it("says nothing where the failure never had a status to record", () => {
expect(render([row({ lastFailureStatus: null })])).not.toContain("HTTP");
});
it("carries no status for a provider that is not failing", () => {
expect(
render([
row({ failing: 0, lastFailureAt: null, lastFailureStatus: 500 }),
]),
).not.toContain("HTTP");
});
});
@@ -18,6 +18,12 @@ import { formatDateTime } from "@/lib/format";
* read the database. The card shows one row per provider type: how many
* users' chains touch it, how many of them are currently failing, the worst
* uninterrupted failure run, and the last success / failure instants.
*
* The failure line also carries the HTTP status the ledger recorded for that
* failure, because "failing" on its own does not say whose problem it is. A
* 401 is a dead credential and waiting will not fix it; a 429 or a 503 is the
* provider's day and waiting is exactly right. Absent for a network-class
* failure, which never had a status to record.
*/
interface ProviderHealthRow {
@@ -27,6 +33,7 @@ interface ProviderHealthRow {
maxConsecutiveFailures: number;
lastOkAt: string | null;
lastFailureAt: string | null;
lastFailureStatus: number | null;
}
export function ProviderHealthSection() {
@@ -106,6 +113,13 @@ export function ProviderHealthSection() {
at: formatDateTime(p.lastFailureAt),
})}`
: ""}
{p.failing > 0 &&
p.lastFailureAt &&
p.lastFailureStatus !== null
? ` · ${t("admin.providerHealth.lastFailureStatus", {
status: p.lastFailureStatus,
})}`
: ""}
</span>
</div>
{p.failing === 0 ? (
@@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The manual entry form and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
* account picked. Until the preference could be set at all, the form could
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
* plausibility band (20–800) accepts without a word and every surface then
* reads back as real. So the label and the stored number have to move
* together — the field asks in the account's unit and the submit path
* inverts to canonical mg/dL.
*
* The label half is rendered. The submit half is asserted against the source:
* this project runs SSR-only component tests (`@testing-library/react` is not
* a dependency), so nothing here can type into the field and press save. What
* the source assertion pins is the wiring — that the glucose branch inverts
* through `toCanonicalMgdl` rather than sending the typed number on — and
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
*/
let glucoseUnit: string | null = null;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: "metric",
glucoseUnit,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
}
const formSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
"utf8",
);
describe("MeasurementForm — blood-glucose entry unit", () => {
it("asks in mg/dL for an account that never chose", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
expect(html).toContain('placeholder="95"');
});
it("asks in mmol/L once the account chose it", () => {
glucoseUnit = "mmol/L";
const html = render();
expect(html).toContain("mmol/L");
// A mmol/L reader must not be shown a three-digit mg/dL example.
expect(html).toContain('placeholder="5.3"');
expect(html).not.toContain('placeholder="95"');
});
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
expect(formSource).toMatch(
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
);
});
it("resolves the unit from the account rather than assuming one", () => {
expect(formSource).toMatch(
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
);
});
});
@@ -0,0 +1,117 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The measurements list and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and rendered in the unit the account chose.
* The list is not part of the metric/imperial transform registry, so
* without a branch of its own a reader on mmol/L would see the dashboard
* tile say 5.3 and this list say 95 for the same reading.
*
* The edit sheet is the half that could do damage: its label follows the
* same resolution, so the number in the input is in the displayed unit and
* has to be inverted before the PATCH. Display and inversion are asserted
* together on purpose — converting one without the other rewrites a
* 95 mg/dL reading as 5.3 the first time somebody corrects a typo.
*/
const rows = [
{
id: "m1",
type: "BLOOD_GLUCOSE",
value: 95,
unit: "mg/dL",
source: "MANUAL",
measuredAt: "2026-05-15T10:00:00.000Z",
notes: null,
},
];
let glucoseUnit: string | null = null;
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { measurements: rows, meta: { total: 1 } },
isLoading: false,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(""),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "testuser", role: "USER", glucoseUnit },
isAuthenticated: true,
isLoading: false,
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementList } from "../measurement-list";
const listSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-list.tsx"),
"utf8",
);
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementList />
</I18nProvider>,
);
}
describe("MeasurementList — blood-glucose display unit", () => {
it("shows the stored mg/dL reading as it stands for an mg/dL account", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("95");
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
});
it("converts the same reading for an mmol/L account", () => {
glucoseUnit = "mmol/L";
const html = render();
// 95 mg/dL ÷ 18.0182 = 5.3 mmol/L.
expect(html).toContain("5.3");
expect(html).toContain("mmol/L");
expect(html).not.toContain("95 mg/dL");
});
it("inverts an edited glucose value back to mg/dL before the save", () => {
expect(listSource).toMatch(
/editsConvertedGlucose\(editing\)[\s\S]{0,400}?canonicalValue = toCanonicalMgdl\(parsedValue, "mmol\/L"\)/,
);
});
it("seeds the edit input in the unit its own label names", () => {
expect(listSource).toMatch(
/const seedValue = editsConvertedGlucose\(measurement\)\s*\?\s*rd\.value/,
);
});
it("leaves an mg/dL edit on the identity path so a note fix cannot round the value", () => {
// `toCanonicalMgdl(v, "mg/dL")` rounds, so routing the mg/dL branch
// through it would quantise a stored 95.48 on an unrelated edit.
expect(listSource).toMatch(
/editsConvertedGlucose[\s\S]{0,300}?glucoseUnit === "mmol\/L"/,
);
});
});
@@ -26,6 +26,8 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { useAuth } from "@/hooks/use-auth";
import { resolveGlucoseUnit, toCanonicalMgdl } from "@/lib/glucose";
import {
entryValueToCanonical,
parseDecimalEntry,
@@ -246,6 +248,14 @@ export function MeasurementForm({
const recordName = useActiveRecordName();
const queryClient = useQueryClient();
const unitDisplay = useUnitDisplay();
const { user } = useAuth();
// Glucose is its own display axis, separate from metric/imperial: it is
// stored in mg/dL and rendered in whichever of mg/dL and mmol/L the
// account chose. The form has to ask in the same unit it labels, and
// invert on the way out, or a reader on mmol/L would file "5.5" as five
// and a half mg/dL — a number the plausibility band happily accepts and
// every surface then reads back as a severe hypo.
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
// Normalize legacy BP types to combined mode
const normalizedDefault =
@@ -359,7 +369,9 @@ export function MeasurementForm({
return;
}
let canonicalValue = typed;
if (unitDisplay.isTransformed(type)) {
if (isGlucoseMode) {
canonicalValue = toCanonicalMgdl(typed, glucoseUnit);
} else if (unitDisplay.isTransformed(type)) {
const inverted = unitDisplay.fromDisplay(type, typed);
canonicalValue =
unitDisplay.preference === "imperial"
@@ -537,13 +549,15 @@ export function MeasurementForm({
// For a type with a metric/imperial transform the label follows the
// user's preference (kg↔lb, cm↔in, °C↔°F); everything else keeps
// its static catalogue unit.
unit: typeInfo
? unitDisplay.isTransformed(type)
? unitDisplay.unitFor(type)
: "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
unit: isGlucoseMode
? glucoseUnit
: typeInfo
? unitDisplay.isTransformed(type)
? unitDisplay.unitFor(type)
: "unitKey" in typeInfo
? t(typeInfo.unitKey)
: typeInfo.unit
: "",
})}
>
<Input
@@ -558,14 +572,16 @@ export function MeasurementForm({
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={
typeInfo
? unitDisplay.preference === "imperial" &&
"placeholderImperial" in typeInfo
? typeInfo.placeholderImperial
: "placeholder" in typeInfo
? typeInfo.placeholder
: undefined
: undefined
isGlucoseMode && glucoseUnit === "mmol/L"
? "5.3"
: typeInfo
? unitDisplay.preference === "imperial" &&
"placeholderImperial" in typeInfo
? typeInfo.placeholderImperial
: "placeholder" in typeInfo
? typeInfo.placeholder
: undefined
: undefined
}
required
aria-required="true"
@@ -72,6 +72,11 @@ import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum"
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
import { getUnitForType } from "@/lib/validations/measurement";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
convertGlucose,
resolveGlucoseUnit,
toCanonicalMgdl,
} from "@/lib/glucose";
import {
invalidateKeys,
measurementDependentKeys,
@@ -303,17 +308,40 @@ export function MeasurementList({
// transform; a legacy non-canonical row (an old MCP write whose `unit` ≠ the
// type's canonical unit) is shown verbatim so its stored unit is never
// silently relabelled. Metric users take the identity path — unchanged.
// Glucose sits on its own display axis: stored mg/dL, rendered in whichever
// of mg/dL and mmol/L the account chose. It is not part of the
// metric/imperial registry, so it needs its own branch or a reader on
// mmol/L would see the dashboard tile and this list disagree about the
// same reading.
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
// An edit whose input is NOT in the stored unit. Only the mmol/L branch
// qualifies: an mg/dL account edits in the unit the column holds, and
// routing it through the converter anyway would quantise a stored 95.48 to
// 95 the moment somebody opened the sheet to fix a note.
const editsConvertedGlucose = useCallback(
(m: { type: string; unit: string }) =>
m.type === "BLOOD_GLUCOSE" &&
glucoseUnit === "mmol/L" &&
m.unit === getUnitForType(m.type),
[glucoseUnit],
);
const rowDisplay = useCallback(
(m: { type: string; value: number; unit: string }) => {
if (m.unit !== getUnitForType(m.type)) {
return { value: m.value, unit: m.unit };
}
if (m.type === "BLOOD_GLUCOSE") {
return {
value: convertGlucose(m.value, glucoseUnit),
unit: glucoseUnit,
};
}
return {
value: unitDisplay.toDisplay(m.type, m.value),
unit: unitDisplay.unitFor(m.type),
};
},
[unitDisplay],
[unitDisplay, glucoseUnit],
);
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
// render (only CSS `display` hid one), so a dense cumulative (up to ~5000
@@ -791,10 +819,11 @@ export function MeasurementList({
// stored value, unchanged. Round the converted imperial seed so the input
// shows "210" rather than a long float.
const rd = rowDisplay(measurement);
const seedValue =
unitDisplay.preference === "imperial" &&
unitDisplay.isTransformed(measurement.type) &&
measurement.unit === getUnitForType(measurement.type)
const seedValue = editsConvertedGlucose(measurement)
? rd.value
: unitDisplay.preference === "imperial" &&
unitDisplay.isTransformed(measurement.type) &&
measurement.unit === getUnitForType(measurement.type)
? Math.round(rd.value * 100) / 100
: measurement.value;
const seed = {
@@ -852,7 +881,11 @@ export function MeasurementList({
// the PATCH so an imperial edit persists in the stored unit. Metric users
// and legacy non-canonical rows are the identity path — value unchanged.
let canonicalValue = parsedValue;
if (
if (editsConvertedGlucose(editing)) {
// The label above the input said mmol/L, so the number in it is
// mmol/L. Sending it on would rewrite a 95 mg/dL reading as 5.3.
canonicalValue = toCanonicalMgdl(parsedValue, "mmol/L");
} else if (
unitDisplay.isTransformed(editing.type) &&
editing.unit === getUnitForType(editing.type)
) {
@@ -0,0 +1,145 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
/**
* Settings → Profile blood-glucose unit dropdown.
*
* The control the preference column never had. It sits beside the
* metric/imperial select and is deliberately not a branch of it: metric
* countries are split on which unit they read glucose in, so one dropdown
* would get one of them wrong.
*
* SSR-only, like the unit-system select next to it: the render pass pins the
* options and the selected value, and the mutation contract — endpoint,
* method, body — is asserted against the source, because nothing here can
* dispatch a change event.
*/
const authSpy = vi.fn<
() => { user: AuthUser | null; isAuthenticated: boolean }
>(() => ({ user: buildUser(null), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual<typeof import("@/hooks/use-auth")>(
"@/hooks/use-auth",
);
return { ...actual, useAuth: () => authSpy() };
});
function buildUser(glucoseUnit: string | null): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
modules: {},
} as AuthUser;
}
import { GlucoseUnitSelect } from "../glucose-unit-select";
const componentSource = readFileSync(
join(process.cwd(), "src/components/settings/glucose-unit-select.tsx"),
"utf8",
);
beforeEach(() => {
authSpy.mockClear();
authSpy.mockImplementation(() => ({
user: buildUser(null),
isAuthenticated: true,
}));
});
function render(isAuthenticated = true): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("Settings — GlucoseUnitSelect", () => {
it("offers both units the app renders", () => {
const html = render();
expect(html).toContain('data-testid="settings-glucose-unit-select"');
expect(html).toContain('value="mg/dL"');
expect(html).toContain('value="mmol/L"');
});
// React renders a controlled select's current value as `selected` on the
// option under SSR, not as an attribute on the `<select>`.
function selectedOption(html: string): string | null {
return /<option[^>]*selected[^>]*>/.exec(html)?.[0] ?? null;
}
it("shows mg/dL for an account that never chose", () => {
expect(selectedOption(render())).toContain('value="mg/dL"');
});
it("shows the unit an account already chose", () => {
authSpy.mockImplementation(() => ({
user: buildUser("mmol/L"),
isAuthenticated: true,
}));
expect(selectedOption(render())).toContain('value="mmol/L"');
});
it("disables the select when unauthenticated", () => {
const select = /<select[^>]*settings-glucose-unit-select[^>]*>/.exec(
render(false),
);
expect(select).not.toBeNull();
expect(select![0]).toContain("disabled");
});
it("PATCHes the glucose-unit endpoint field-by-field", () => {
expect(componentSource).toMatch(
/apiFetchRaw\(\s*"\/api\/auth\/me\/glucose-unit"[\s\S]{0,200}?method: "PATCH"[\s\S]{0,200}?JSON\.stringify\(\{ glucoseUnit: next \}\)/,
);
});
it("refreshes the account payload every glucose surface reads from", () => {
expect(componentSource).toMatch(/queryKey: queryKeys\.authMe\(\)/);
});
it("is actually mounted on the settings page", () => {
// A control nobody can reach is the same defect as no control.
const accountSection = readFileSync(
join(process.cwd(), "src/components/settings/account-section/index.tsx"),
"utf8",
);
expect(accountSection).toMatch(/<GlucoseUnitSelect\s/);
});
});
@@ -32,6 +32,7 @@ import { storeTimezone } from "@/lib/timezone-mirror";
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 { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
@@ -421,14 +422,17 @@ export function AccountSection() {
</div>
</div>
{/* Timezone + unit system + hour format share one grid block —
all personal display preferences (like language above). The
unit and time-format dropdowns PATCH their own endpoints on
change; the timezone saves through the form's submit
handler. */}
{/* Timezone, unit system, glucose unit and the date/hour formats
share one grid block — all personal display preferences (like
language above). The glucose unit is its own dropdown rather
than a branch of the unit system because metric countries are
split on which unit they read glucose in. Every dropdown here
PATCHes its own endpoint on change; the timezone saves through
the form's submit handler. */}
<div className="grid gap-4 sm:grid-cols-2">
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<GlucoseUnitSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
@@ -0,0 +1,150 @@
"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";
import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose";
/**
* Blood-glucose display unit as a Profile dropdown.
*
* The preference column and its thirty-odd readers shipped in v1.2 with no
* control to set them, so every account stayed on the mg/dL default and the
* mmol/L half of the app was unreachable. This is the control.
*
* It sits beside the metric/imperial dropdown because it is the same kind of
* setting — how a number is shown, not what is stored — but it is a separate
* choice: metric countries are split on which unit they read glucose in, so
* folding it into metric/imperial would get one of them wrong. The option
* labels are the unit symbols themselves, which read the same in every
* locale.
*
* Persistence mirrors the unit-system select: a change PATCHes
* `/api/auth/me/glucose-unit` immediately and invalidates `queryKeys.authMe()`
* plus `queryKeys.userGlucoseUnit()`, so the dashboard tiles, the glucose
* insight page and the targets panel re-render on the next /me refetch.
*/
export function GlucoseUnitSelect({
isAuthenticated,
id = "glucose-unit",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<GlucoseUnit | 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: GlucoseUnit =
optimistic ?? resolveGlucoseUnit(user?.glucoseUnit);
const mutation = useMutation({
mutationFn: async (next: GlucoseUnit) => {
const res = await apiFetchRaw("/api/auth/me/glucose-unit", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ glucoseUnit: 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.glucoseUnit.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
queryClient.invalidateQueries({
queryKey: queryKeys.userGlucoseUnit(),
});
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error
? err.message
: t("settings.dashboard.glucoseUnit.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: GlucoseUnit) {
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.glucoseUnit.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-glucose-unit-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as GlucoseUnit)}
>
{/* Unit symbols, not translated copy — they read the same
everywhere, and a locale that "translated" one would be
naming a different measurement. */}
<option value="mg/dL">mg/dL</option>
<option value="mmol/L">mmol/L</option>
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dashboard.glucoseUnit.description")}
</p>
</div>
);
}