diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a9cf72af..24ea46653 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,46 @@ preference, then the operator's default, then English. This was the last copy of the fallback that the nightly writers were moved off earlier. +- **A failing AI provider says what came back.** The provider health card in + the operator console could report a provider as failing without saying + whose problem it was. The ledger has recorded the HTTP status of every + failure since v1.11 and nothing read it. The card names it beside the + failure it belongs to now, which is the difference between a dead key the + operator has to replace and a provider having a bad afternoon. Blank where + there was no status to record, as with a connection that never completed. +- **Blood glucose can be shown in mmol/L. It never could before.** The + account has carried a glucose display unit since v1.2 and about thirty + places read it: the dashboard tiles, the glucose page, the targets panel, + the CSV and FHIR exports, the doctor report, the Coach's own notes, the + low-reading alert. Nothing could ever set it. There was no control, no + endpoint and no field on any form, so every account sat on the mg/dL + default and anyone who reads glucose in mmol/L got a number they had to + convert in their head on every screen. The unit is now a dropdown in + Settings under your profile, beside the metric/imperial one and + deliberately not folded into it, because metric countries are split on + which unit they read glucose in and one control would get half of them + wrong. Nothing already recorded changes: readings are stored in mg/dL and + stay stored in mg/dL, and the unit only picks how they are shown. The + entry form moved with it, which is the half that mattered. It now asks in + the unit you chose and converts on the way in, so a 5.3 typed by an mmol/L + reader is filed as 95 mg/dL instead of as 5.3, which is inside the + plausible range, passes without a word, and reads back on every surface as + a severe hypo. The measurements list and its edit sheet moved with it for + the same reason, so correcting a typo cannot rewrite a reading into the + other unit. +- **The same reading converted two different ways.** A glucose value + imported in mmol/L was multiplied by 18.016; one displayed in mmol/L was + divided by 18.0182. A value imported in mmol/L therefore did not come back + out as the number it went in as. Both ends take the same factor now, and + the test names the constant rather than repeating the digits, which is how + the two came apart to begin with. +- **An Apple Health import records which export it came from.** The import + status has always carried the instant the Health app stamped on the + archive, the API contract has always promised it, and it was always empty: + the element sat on the parser's list of things to skip. The parser reads + it now and writes it the moment it goes past, so a run that later fails + still says which export it was working from. + ### Internal - The column-reader sweep was wrong in both directions and had been for as long as it existed. It answers "does this column have a consumer" for every column in the schema, and it is the tool this project leans on to catch a schema change that ships its writer and forgets its reader. Its idea of a write was any `field:` key anywhere, so `where: { ticketHash }` counted as writing `ticketHash`: all twelve columns it reported were a lookup key or a cron's discovery predicate being filtered on, every one a false alarm. And because it only ever reported a column that HAD a write, a column with no write at all was the one thing it could not see. It now understands which Prisma argument a key sits in, follows a payload assembled into a variable before the call, credits raw SQL against the mapped column name, and reports a column no code touches at all as its own category. Twelve false alarms became nineteen findings that each hold up, four of them worth acting on. The matcher underneath is pinned by a test that was checked by breaking it three ways. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c08418792..d911295b0 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 229068fe7..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "{count} Nutzer erfasst", "lastOk": "letzter Erfolg {at}", "lastFailure": "letzter Fehlschlag {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "In Ordnung", "statusFailing": "{failing} von {tracked} scheitern, {run} in Folge", "typeAdminOpenai": "Server-Schlüssel (Betreiber)", diff --git a/messages/en.json b/messages/en.json index a0de65aed..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "{count} users tracked", "lastOk": "last success {at}", "lastFailure": "last failure {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Healthy", "statusFailing": "{failing} of {tracked} failing, {run} in a row", "typeAdminOpenai": "Server key (operator)", diff --git a/messages/es.json b/messages/es.json index e40df4240..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "{count} usuarios registrados", "lastOk": "último éxito {at}", "lastFailure": "último fallo {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Correcto", "statusFailing": "{failing} de {tracked} fallan, {run} seguidos", "typeAdminOpenai": "Clave del servidor (operador)", diff --git a/messages/fr.json b/messages/fr.json index da5b9e58c..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "{count} utilisateurs suivis", "lastOk": "dernier succès {at}", "lastFailure": "dernier échec {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "En bon état", "statusFailing": "{failing} sur {tracked} en échec, {run} d'affilée", "typeAdminOpenai": "Clé du serveur (opérateur)", diff --git a/messages/it.json b/messages/it.json index 9410399b1..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "{count} utenti tracciati", "lastOk": "ultimo successo {at}", "lastFailure": "ultimo errore {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Regolare", "statusFailing": "{failing} su {tracked} in errore, {run} di fila", "typeAdminOpenai": "Chiave del server (operatore)", diff --git a/messages/ko.json b/messages/ko.json index e128a90ca..d82cc8fe9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5681,6 +5681,12 @@ "imperial": "야드파운드법", "saved": "단위 설정을 저장했어요", "saveError": "단위 설정을 저장하지 못했어요." + }, + "glucoseUnit": { + "title": "혈당", + "description": "혈당 수치를 표시할 단위입니다. 어느 쪽을 골라도 저장 방식은 같습니다.", + "saved": "혈당 단위를 저장했습니다", + "saveError": "혈당 단위를 저장하지 못했습니다." } }, "withings": "Withings", @@ -7083,6 +7089,7 @@ "trackedUsers": "{count}명 사용자 추적됨", "lastOk": "마지막 성공 {at}", "lastFailure": "마지막 실패 {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "정상", "statusFailing": "{tracked}개 중 {failing}개 실패, {run}회 연속", "typeAdminOpenai": "서버 키 (운영자)", diff --git a/messages/pl.json b/messages/pl.json index 1e4a83fc1..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", @@ -6501,6 +6507,7 @@ "trackedUsers": "Śledzeni użytkownicy: {count}", "lastOk": "ostatni sukces {at}", "lastFailure": "ostatnia awaria {at}", + "lastFailureStatus": "HTTP {status}", "statusHealthy": "Sprawny", "statusFailing": "{failing} z {tracked} zawodzi, {run} z rzędu", "typeAdminOpenai": "Klucz serwera (operator)", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 311b0c061..0c6e0a00a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1302,6 +1302,20 @@ model UserKnownDevice { userId String @map("user_id") deviceHash String @map("device_hash") label String? + /// @internal: the row's own creation instant, supplied by this default — + /// `recordSignInDevice` (`src/lib/auth/login-alert.ts`) inserts the + /// fingerprint and the label and passes no timestamp. Nothing reads it. The + /// first sighting a USER sees is the `auth.login.new_device` audit row the + /// same function writes, rendered by the security-activity card, and that + /// row is removed on the audit-retention schedule. This column is the + /// ledger's own copy of the same instant, kept because the ledger outlives + /// the audit trail and an operator asking when a device first appeared on an + /// account has nowhere else to look. Deliberately reachable through the + /// database only: the ledger stores salted hashes and a coarse label + /// precisely so it has no user-facing surface. The backup does NOT select it + /// — the whole model is excluded (`src/lib/export/backup-plan.ts`), because + /// restoring a device-recognition ledger would pre-trust devices on a host + /// that has never seen them. firstSeenAt DateTime @default(now()) @map("first_seen_at") lastSeenAt DateTime @default(now()) @map("last_seen_at") @@ -4968,8 +4982,16 @@ model ImportJob { /// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older /// revision never short-circuits a fresh upload of the same bytes. parserRevision Int @default(1) @map("parser_revision") - /// Apple's `ExportDate` attribute string-formatted, parsed during - /// the unpack phase. NULL until the parser sees it. + /// The instant the Health app stamped on the archive itself — the + /// `value` attribute of the `` element that opens + /// `export.xml`. Written mid-run by the parse phase the moment the + /// element goes past (`onExportDate` in + /// `src/lib/measurements/import-apple-health-export.ts`, persisted by + /// `src/lib/jobs/apple-health-import-worker.ts`), so a job that later + /// fails still records which export it was working from. Consumer: the + /// status endpoint (`src/app/api/import/apple-health-export/[jobId]/ + /// status/route.ts`). NULL until the parser reaches the element, and + /// for an archive that omits it or writes an unreadable value. exportedAt DateTime? @map("exported_at") startedAt DateTime @default(now()) @map("started_at") completedAt DateTime? @map("completed_at") @@ -5062,7 +5084,13 @@ model ProviderHealth { userId String @map("user_id") providerType String @map("provider_type") // ProviderChainType tag lastResult String @map("last_result") // "ok" | "hard_failed" | "auth_failed" - lastStatus Int? @map("last_status") // HTTP status of the last failure; null on ok/network + /// HTTP status the last failure came back with; null on a success and on + /// a network-class failure that never had a response to read one from. + /// Consumer: the operator readout (`src/app/api/admin/provider-health/ + /// route.ts`), which carries it beside the failure instant it belongs to — + /// it is what separates a dead credential the operator has to replace from + /// a provider having a bad afternoon. + lastStatus Int? @map("last_status") consecutiveFailures Int @default(0) @map("consecutive_failures") lastOkAt DateTime? @map("last_ok_at") lastFailureAt DateTime? @map("last_failure_at") diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 0ce584510..03e0fb8de 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -5,6 +5,11 @@ * provider type, failing counted from the LAST result only, the worst * uninterrupted failure run taken across failing users, central types * sorted first, and no per-user data in the response. + * + * It also pins the HTTP status the ledger has recorded on every failure + * since v1.11.0 and nothing read until now: it must describe the SAME + * failure the row's instant names, or the card tells the operator to go + * looking at the wrong thing. */ import { describe, it, expect, vi, beforeEach } from "vitest"; @@ -49,11 +54,13 @@ function group( consecutiveFailures?: number; lastFailureAt?: Date | null; lastOkAt?: Date | null; + lastStatus?: number | null; } = {}, ) { return { providerType, lastResult, + lastStatus: max.lastStatus ?? null, _count: { _all: count }, _max: { consecutiveFailures: max.consecutiveFailures ?? 0, @@ -86,6 +93,7 @@ describe("GET /api/admin/provider-health", () => { group("admin-openai", "hard_failed", 2, { consecutiveFailures: 3334, lastFailureAt: new Date("2026-08-27T06:00:00Z"), + lastStatus: 503, }), ] as never); @@ -99,10 +107,64 @@ describe("GET /api/admin/provider-health", () => { maxConsecutiveFailures: 3334, lastOkAt: "2026-08-27T07:00:00.000Z", lastFailureAt: "2026-08-27T06:00:00.000Z", + lastFailureStatus: 503, }, ]); }); + it("reports the status of the newest failure, not the highest number", async () => { + // A 500 an hour ago and a 401 a minute ago: the operator needs the + // 401, because that is the one that will not clear on its own. Taking + // a MAX over the column would hand back the 500. + groupBy.mockResolvedValue([ + group("openai", "hard_failed", 4, { + consecutiveFailures: 2, + lastFailureAt: new Date("2026-08-27T05:00:00Z"), + lastStatus: 500, + }), + group("openai", "auth_failed", 1, { + consecutiveFailures: 1, + lastFailureAt: new Date("2026-08-27T06:59:00Z"), + lastStatus: 401, + }), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.providers[0]).toMatchObject({ + lastFailureAt: "2026-08-27T06:59:00.000Z", + lastFailureStatus: 401, + }); + }); + + it("leaves the status out for a network-class failure that never had one", async () => { + groupBy.mockResolvedValue([ + group("local", "hard_failed", 1, { + consecutiveFailures: 9, + lastFailureAt: new Date("2026-08-27T06:00:00Z"), + lastStatus: null, + }), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.providers[0]).toMatchObject({ + lastFailureAt: "2026-08-27T06:00:00.000Z", + lastFailureStatus: null, + }); + }); + + it("asks the ledger to split the fold by status rather than aggregate it", async () => { + groupBy.mockResolvedValue([] as never); + await GET(); + + expect(groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ["providerType", "lastResult", "lastStatus"], + }), + ); + }); + it("counts auth_failed as failing and keeps an all-ok type at zero", async () => { groupBy.mockResolvedValue([ group("codex", "auth_failed", 1, { consecutiveFailures: 7 }), diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 49b4660c7..c0163dece 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -15,6 +15,12 @@ export const dynamic = "force-dynamic"; * folds the ledger into one row per provider type so the console can show * it. Read-only, admin-cookie-only, and it exposes counts and timestamps * only — never which user a row belongs to. + * + * The ledger also records the HTTP status each failure came back with, and + * that is the difference between the two questions an operator actually has + * when a provider goes red: a 401 means the key is dead and only the operator + * can fix it, a 429 or a 5xx means the provider is having a day and waiting is + * the right move. The fold carries it beside the instant it belongs to. */ interface ProviderHealthSummary { @@ -27,6 +33,13 @@ interface ProviderHealthSummary { maxConsecutiveFailures: number; lastOkAt: string | null; lastFailureAt: string | null; + /** + * HTTP status the failure at `lastFailureAt` came back with. Null when + * that failure was a network-class one (no response to read a status + * from), and null when the user it belongs to has since recovered — a + * success clears the status along with the rest of the failure state. + */ + lastFailureStatus: number | null; } /** The two operator-managed tags sort first — they affect every user on the chain. */ @@ -37,7 +50,11 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "admin.provider-health.get" } }); const groups = await prisma.providerHealth.groupBy({ - by: ["providerType", "lastResult"], + // `lastStatus` joins the grouping key rather than an aggregate: the + // MAX of two HTTP statuses is not a status, it is arithmetic on a + // label. Grouping by it splits each result bucket per status and the + // fold below picks the one belonging to the newest failure. + by: ["providerType", "lastResult", "lastStatus"], _count: { _all: true }, _max: { consecutiveFailures: true, @@ -55,6 +72,7 @@ export const GET = apiHandler(async () => { maxConsecutiveFailures: 0, lastOkAt: null, lastFailureAt: null, + lastFailureStatus: null, }; row.tracked += g._count._all; if (g.lastResult !== "ok") { @@ -69,6 +87,10 @@ export const GET = apiHandler(async () => { const fail = g._max.lastFailureAt?.toISOString() ?? null; if (fail && (!row.lastFailureAt || fail > row.lastFailureAt)) { row.lastFailureAt = fail; + // Tied to the instant, not taken from whichever group happens to + // have one: a status that describes a different failure than the + // one on screen is worse than no status at all. + row.lastFailureStatus = g.lastStatus; } byType.set(g.providerType, row); } 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/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 70e47ed3e..2ae1143a7 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -59,6 +59,7 @@ vi.mock("@/lib/rollups/measurement-rollups", async () => { import { NextRequest } from "next/server"; import { POST } from "../route"; +import { MGDL_PER_MMOL } from "@/lib/glucose"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; @@ -369,7 +370,7 @@ describe("POST /api/import/csv — contextless blood glucose", () => { externalId: "sensor-1", glucoseContext: null, }); - expect(row.value).toBeCloseTo(95.4848, 3); + expect(row.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3); expect((row.measuredAt as Date).toISOString()).toBe( "2024-04-03T02:15:00.000Z", ); diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index 9f8cb64ff..d8dcdddb2 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -7,7 +7,7 @@ import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clin import { useAuth } from "@/hooks/use-auth"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; -import { resolveGlucoseUnit } from "@/lib/glucose"; +import { MGDL_PER_MMOL, resolveGlucoseUnit } from "@/lib/glucose"; /** * v1.7.0 — `/insights/blood-glucose`. @@ -25,8 +25,6 @@ import { resolveGlucoseUnit } from "@/lib/glucose"; * exports. mg/dL-preference users are unaffected (scale = 1, integer * precision). */ -const MGDL_PER_MMOL = 18.0182; - export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); diff --git a/src/components/admin/__tests__/provider-health-failure-status.test.tsx b/src/components/admin/__tests__/provider-health-failure-status.test.tsx new file mode 100644 index 000000000..018ecac80 --- /dev/null +++ b/src/components/admin/__tests__/provider-health-failure-status.test.tsx @@ -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 = {}) { + 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( + + + , + ); +} + +describe(" — 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"); + }); +}); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx index 70e27328c..d4568c20f 100644 --- a/src/components/admin/provider-health-section.tsx +++ b/src/components/admin/provider-health-section.tsx @@ -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, + })}` + : ""} {p.failing === 0 ? ( 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/__tests__/measurement-list-glucose-unit.test.tsx b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx new file mode 100644 index 000000000..97765257f --- /dev/null +++ b/src/components/measurements/__tests__/measurement-list-glucose-unit.test.tsx @@ -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( + + + , + ); +} + +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"/, + ); + }); +}); 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/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index b9667f9e0..4433ceae1 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -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) ) { 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 `