diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 168c050e7..77eca7aa4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7759,6 +7759,62 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/me/glucose-unit: + get: + tags: + - Auth + summary: Read the blood-glucose display unit + description: Which unit blood-glucose readings are rendered in — `mg/dL` or `mmol/L`. A separate choice from the + metric/imperial preference, because metric countries are split on which one they read glucose in. Canonical + storage is mg/dL on every reading and every ingest path; this changes presentation only. Any stored value other + than `mmol/L` resolves to `mg/dL`, including a null on an account that never set it. + responses: + "200": + description: The resolved unit. + content: + application/json: + schema: + $ref: "#/components/schemas/GetGlucoseUnitEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + patch: + tags: + - Auth + summary: Set the blood-glucose display unit + description: >- + Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user. + + + Nothing already stored is converted or reinterpreted: readings stay mg/dL, and a value typed into the entry form + in mmol/L is inverted to mg/dL before it is written. + + + The body is capped at 1 KB and must carry `Content-Type: application/json`. Anything larger is 413, a wrong + content type is 415, and a body that is not JSON at all is 400 — the same three the other scalars in this group + answer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/GlucoseUnitPatchRequest" + responses: + "200": + description: The resolved next unit. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" + "401": *a1 + "413": + description: Request body exceeds 1024 bytes. Nothing was written. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/me/documents-auto-ai-read: get: tags: @@ -23141,6 +23197,17 @@ components: required: - unitPreference description: Which display-unit branch to render. Presentation only — the stored measurement rows stay SI either way. + GlucoseUnitPatchRequest: + type: object + properties: + glucoseUnit: + type: string + enum: + - mg/dL + - mmol/L + required: + - glucoseUnit + description: Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way. DocumentsAutoAiReadPatchRequest: type: object properties: @@ -45799,6 +45866,51 @@ components: - data - error additionalProperties: false + GetGlucoseUnitEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GlucoseUnitResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GlucoseUnitResponse: + type: object + properties: + glucoseUnit: + type: string + enum: + - mg/dL + - mmol/L + required: + - glucoseUnit + additionalProperties: false + PatchGlucoseUnitEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GlucoseUnitResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetDocumentsAutoAiReadEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index b437210a3..58a32857f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Einheiten-Einstellung gespeichert", "saveError": "Einheiten-Einstellung konnte nicht gespeichert werden." + }, + "glucoseUnit": { + "title": "Blutzucker", + "description": "In welcher Einheit Blutzuckerwerte angezeigt werden. Gespeichert werden sie so oder so gleich.", + "saved": "Blutzucker-Einheit gespeichert", + "saveError": "Die Blutzucker-Einheit konnte nicht gespeichert werden." } }, "withings": "Withings", diff --git a/messages/en.json b/messages/en.json index 047e8a121..3ba64450b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Unit preference saved", "saveError": "Could not save your unit preference." + }, + "glucoseUnit": { + "title": "Blood glucose", + "description": "Which unit blood-glucose readings are shown in. Readings are stored the same way either way.", + "saved": "Glucose unit saved", + "saveError": "Could not save your glucose unit." } }, "withings": "Withings", diff --git a/messages/es.json b/messages/es.json index 259f30ae2..9515185a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5681,6 +5681,12 @@ "imperial": "Imperial", "saved": "Preferencia de unidades guardada", "saveError": "No se pudo guardar tu preferencia de unidades." + }, + "glucoseUnit": { + "title": "Glucosa en sangre", + "description": "La unidad en la que se muestran las lecturas de glucosa. Se guardan igual en ambos casos.", + "saved": "Unidad de glucosa guardada", + "saveError": "No se pudo guardar la unidad de glucosa." } }, "withings": "Withings", diff --git a/messages/fr.json b/messages/fr.json index 9615e31bf..8724f604f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5681,6 +5681,12 @@ "imperial": "Impérial", "saved": "Préférence d'unités enregistrée", "saveError": "Impossible d'enregistrer votre préférence d'unités." + }, + "glucoseUnit": { + "title": "Glycémie", + "description": "L'unité dans laquelle les mesures de glycémie sont affichées. L'enregistrement est le même dans les deux cas.", + "saved": "Unité de glycémie enregistrée", + "saveError": "Impossible d'enregistrer l'unité de glycémie." } }, "withings": "Withings", diff --git a/messages/it.json b/messages/it.json index a341ddc18..c2b0a981e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5681,6 +5681,12 @@ "imperial": "Imperiale", "saved": "Preferenza unità salvata", "saveError": "Impossibile salvare la preferenza delle unità." + }, + "glucoseUnit": { + "title": "Glicemia", + "description": "L'unità in cui vengono mostrate le misurazioni della glicemia. Il salvataggio resta lo stesso in entrambi i casi.", + "saved": "Unità della glicemia salvata", + "saveError": "Impossibile salvare l'unità della glicemia." } }, "withings": "Withings", diff --git a/messages/ko.json b/messages/ko.json index 918359ecd..d82cc8fe9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5681,6 +5681,12 @@ "imperial": "야드파운드법", "saved": "단위 설정을 저장했어요", "saveError": "단위 설정을 저장하지 못했어요." + }, + "glucoseUnit": { + "title": "혈당", + "description": "혈당 수치를 표시할 단위입니다. 어느 쪽을 골라도 저장 방식은 같습니다.", + "saved": "혈당 단위를 저장했습니다", + "saveError": "혈당 단위를 저장하지 못했습니다." } }, "withings": "Withings", diff --git a/messages/pl.json b/messages/pl.json index b1aa8e00e..4ebd23e32 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5681,6 +5681,12 @@ "imperial": "Imperialne", "saved": "Zapisano preferencję jednostek", "saveError": "Nie udało się zapisać preferencji jednostek." + }, + "glucoseUnit": { + "title": "Glukoza we krwi", + "description": "Jednostka, w której pokazywane są pomiary glukozy. Zapis pozostaje taki sam w obu przypadkach.", + "saved": "Zapisano jednostkę glukozy", + "saveError": "Nie udało się zapisać jednostki glukozy." } }, "withings": "Withings", diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts new file mode 100644 index 000000000..943f37294 --- /dev/null +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -0,0 +1,210 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The blood-glucose display unit — the writer the column never had. + * + * `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but + * no route, form or schema could ever set it, so every account stayed on the + * mg/dL default and the mmol/L half of the app was dead code. This pins the + * missing end: a PATCH writes the column and echoes the resolved state, a GET + * reads it back, and an unrecognised unit is refused rather than stored — + * a stray string in that column would resolve to mg/dL and quietly put every + * mmol/L reader back on the wrong number. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function mkGet(): Request { + return new Request("http://localhost/api/auth/me/glucose-unit"); +} + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/glucose-unit", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/glucose-unit", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("resolves an account that never chose to mg/dL", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: null, + } as never); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { glucoseUnit: string } }; + expect(env.data.glucoseUnit).toBe("mg/dL"); + }); + + it("reads back the unit the account chose", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + + const res = await (GET as (r: Request) => Promise)(mkGet()); + const env = (await res.json()) as { data: { glucoseUnit: string } }; + expect(env.data.glucoseUnit).toBe("mmol/L"); + }); +}); + +describe("PATCH /api/auth/me/glucose-unit", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("moves an account to mmol/L, echoes it, and audits the transition", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { glucoseUnit: string } }; + expect(env.data.glucoseUnit).toBe("mmol/L"); + + // The write must actually land, scoped to the caller, field-by-field. + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { glucoseUnit: "mmol/L" }, + }); + + expect(auditLog).toHaveBeenCalledWith( + "user.glucose-unit.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: "mg/dL", + next: "mmol/L", + }), + }), + ); + }); + + it("moves an account back to mg/dL", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mg/dL" }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { glucoseUnit: "mg/dL" }, + }); + }); + + it("refuses a unit outside the two the app renders", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) { + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: bad }), + ); + expect(res.status).toBe(422); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a missing glucoseUnit field with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ otherField: "mmol/L" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts new file mode 100644 index 000000000..144fa8df6 --- /dev/null +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -0,0 +1,122 @@ +/** + * Per-user blood-glucose display unit. + * + * GET /api/auth/me/glucose-unit — current unit. + * PATCH /api/auth/me/glucose-unit — body + * `{ glucoseUnit: "mg/dL" | "mmol/L" }`. + * + * The column has existed since v1.2 and roughly thirty surfaces read it — + * the dashboard tiles, the glucose insight page, the targets panel, the CSV + * and FHIR exports, the doctor report, the Coach snapshot, the safety-floor + * push copy. None of it was reachable, because nothing could ever write the + * column: every account was NULL, and NULL resolves to mg/dL. For anyone in + * a country that reads glucose in mmol/L that was the wrong number on every + * screen. This is the missing end. + * + * Canonical storage stays mg/dL on every row and every ingest path. The unit + * selects a display branch, exactly like `unit-preference` next door; the + * entry form inverts through `toCanonicalMgdl` so a value TYPED in mmol/L + * still persists as mg/dL. Nothing already stored is reinterpreted. + * + * Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate limit, + * `safeJson` with a 1 KB cap, Zod safeParse → 422 via `returnAllZodIssues`, + * audit-log row, field-by-field write. Idempotent — the endpoint always + * returns the resolved next state so the client can hard-set the optimistic + * update without an extra round-trip. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; +import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type GlucoseUnitResponse = { + glucoseUnit: GlucoseUnit; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.glucose-unit.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { glucoseUnit: true }, + }); + const payload: GlucoseUnitResponse = { + glucoseUnit: resolveGlucoseUnit(row?.glucoseUnit), + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `glucose-unit:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + // The body is a single enum value — bound the parse so a malformed or + // oversized payload is rejected before it is materialised. + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = glucoseUnitPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "auth.me.glucose-unit.patch.invalid_shape" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.glucoseUnit; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { glucoseUnit: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { glucoseUnit: next }, + }); + + await auditLog("user.glucose-unit.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: resolveGlucoseUnit(previous?.glucoseUnit), + next, + }, + }); + + annotate({ + action: { name: "auth.me.glucose-unit.patch" }, + meta: { glucoseUnit: next }, + }); + + const payload: GlucoseUnitResponse = { glucoseUnit: next }; + return apiSuccess(payload); +}); diff --git a/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx new file mode 100644 index 000000000..af91ed46d --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-glucose-unit.test.tsx @@ -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( + + + , + ); +} + +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\)/, + ); + }); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 93d1da4f5..40ea77036 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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 + : "", })} > 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" diff --git a/src/components/settings/__tests__/glucose-unit-select.test.tsx b/src/components/settings/__tests__/glucose-unit-select.test.tsx new file mode 100644 index 000000000..8893020e8 --- /dev/null +++ b/src/components/settings/__tests__/glucose-unit-select.test.tsx @@ -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( + "@/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( + + + + + , + ); +} + +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 `