mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge remote-tracking branch 'origin/fix/unwired-columns' into integration/post-1385
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
7c5f49c18e
36 files changed
+1613
-45
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "서버 키 (운영자)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
+31
-3
@@ -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 `<ExportDate>` 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")
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Response>)(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<Response>)(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<Response>)(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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
mkPatch({ glucoseUnit: "mmol/L" }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+9
-1
@@ -9,7 +9,15 @@
|
||||
|
||||
export type GlucoseUnit = "mg/dL" | "mmol/L";
|
||||
|
||||
const MGDL_PER_MMOL = 18.0182;
|
||||
/**
|
||||
* mg/dL per mmol/L, per the DGIM / DDG S3 guideline. Exported because it is
|
||||
* the one factor for this conversion in the tree: the inbound alias resolver
|
||||
* normalises a mmol/L cell with it, the glucose insight page scales its chart
|
||||
* by its reciprocal, and the helpers below round with it. Two nearly-equal
|
||||
* copies of a health conversion is how the same reading comes out as two
|
||||
* different numbers on two screens.
|
||||
*/
|
||||
export const MGDL_PER_MMOL = 18.0182;
|
||||
|
||||
export function mgdlToMmol(mgdl: number): number {
|
||||
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { MGDL_PER_MMOL, mgdlToMmol } from "@/lib/glucose";
|
||||
|
||||
import {
|
||||
parseCsvMeasurements,
|
||||
@@ -98,7 +99,12 @@ describe("parseCsvMeasurements — unit conversion", () => {
|
||||
]);
|
||||
expect(out.rows[0].status).toBe("ok");
|
||||
expect(out.rows[0].row?.unit).toBe("mg/dL");
|
||||
expect(out.rows[0].row?.value).toBeCloseTo(5.3 * 18.016, 3);
|
||||
// The one factor the app converts glucose by, both directions. The
|
||||
// literal that used to sit here was 18.016 while every display path
|
||||
// divided by 18.0182, so a reading imported in mmol/L did not read back
|
||||
// as the number it went in as.
|
||||
expect(out.rows[0].row?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
|
||||
expect(mgdlToMmol(out.rows[0].row?.value ?? 0)).toBe(5.3);
|
||||
});
|
||||
|
||||
it("converts weight lb to canonical kg", () => {
|
||||
@@ -176,7 +182,7 @@ describe("parseCsvMeasurements — glucose context", () => {
|
||||
for (const result of out.rows) {
|
||||
expect(result.row).not.toHaveProperty("glucoseContext");
|
||||
expect(result.row?.unit).toBe("mg/dL");
|
||||
expect(result.row?.value).toBeCloseTo(95.4848, 3);
|
||||
expect(result.row?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
|
||||
}
|
||||
expect(out.rows[0].row?.measuredAt.toISOString()).toBe(
|
||||
"2024-04-03T02:15:00.000Z",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* `ImportJob.exportedAt` — the worker end of the wiring.
|
||||
*
|
||||
* The parse side is proved behaviourally in
|
||||
* `src/lib/measurements/__tests__/import-apple-health-export.test.ts`:
|
||||
* the parser hands the archive's own stamp to `onExportDate`. What that
|
||||
* cannot show is whether anything persists it. The column, the status
|
||||
* endpoint that returns it and the published contract that requires it
|
||||
* all existed for a year while the one line that would produce the value
|
||||
* was a `return`, so the half of the pipe worth pinning is this one.
|
||||
*
|
||||
* Source-shaped rather than run-shaped: driving the worker needs a real
|
||||
* `export.zip`, a queue and a database, which the ECG contract test
|
||||
* beside this one already declines to do for the same reason.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const workerSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/apple-health-import-worker.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const parserSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const statusRouteSource = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/app/api/import/apple-health-export/[jobId]/status/route.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("Apple Health import — the archive's export stamp reaches the column", () => {
|
||||
it("subscribes to the parser's export-date hook", () => {
|
||||
expect(workerSource).toMatch(/onExportDate:\s*async\s*\(exportedAt\)/);
|
||||
});
|
||||
|
||||
it("writes the stamp onto the job row it is running", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/onExportDate:[\s\S]{0,400}?prisma\s*\.?\s*importJob[\s\S]{0,200}?\.update\([\s\S]{0,200}?exportedAt/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the write best-effort so provenance cannot fail an import", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/onExportDate:[\s\S]{0,400}?exportedAt[\s\S]{0,120}?\.catch\(/,
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer discards the element that carries the stamp", () => {
|
||||
// The ignore list is where `ExportDate` spent its first year.
|
||||
const ignoreBlock = /Known elements we intentionally ignore/.exec(
|
||||
parserSource,
|
||||
);
|
||||
expect(ignoreBlock).not.toBeNull();
|
||||
const before = parserSource.slice(
|
||||
Math.max(0, (ignoreBlock?.index ?? 0) - 400),
|
||||
ignoreBlock?.index ?? 0,
|
||||
);
|
||||
expect(before).not.toMatch(/name === "ExportDate" \|\|/);
|
||||
});
|
||||
|
||||
it("still surfaces the column the contract promises", () => {
|
||||
expect(statusRouteSource).toMatch(/exportedAt:\s*row\.exportedAt/);
|
||||
});
|
||||
});
|
||||
@@ -417,6 +417,16 @@ export async function handleAppleHealthImport(
|
||||
},
|
||||
});
|
||||
},
|
||||
// The instant the Health app stamped on the archive. Persisted as
|
||||
// soon as the parser reads it, so the status endpoint answers with
|
||||
// it for the rest of the run and a job that later fails still
|
||||
// records which export it was working from. Best-effort: the
|
||||
// stamp is provenance, and losing it must not fail an import.
|
||||
onExportDate: async (exportedAt) => {
|
||||
await prisma.importJob
|
||||
.update({ where: { id: importJobId }, data: { exportedAt } })
|
||||
.catch(() => {});
|
||||
},
|
||||
})),
|
||||
ecg: {
|
||||
discovered: 0,
|
||||
|
||||
@@ -1226,3 +1226,55 @@ describe("streamParseExportXml — cycle module off", () => {
|
||||
expect(result.totals.recordsRead).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `ImportJob.exportedAt` — the instant the Health app stamped on the
|
||||
* archive. The status endpoint and the published contract have both
|
||||
* promised it since v1.4.34, but `<ExportDate>` sat on the parser's
|
||||
* ignore list, so the column could only ever answer null. These pin the
|
||||
* capture: remove the `onExportDate` call from the parser and the first
|
||||
* case goes red; drop the NaN guard and the third does.
|
||||
*/
|
||||
describe("streamParseExportXml — the archive's own export stamp", () => {
|
||||
async function parseFor(xml: string) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-export-date-"));
|
||||
const xmlPath = join(tmp, "export.xml");
|
||||
writeFileSync(xmlPath, xml);
|
||||
const stamps: Date[] = [];
|
||||
await streamParseExportXml({
|
||||
xmlPath,
|
||||
userId: "user-1",
|
||||
userTimezone: "Europe/Berlin",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
prisma: makeFakePrisma() as any,
|
||||
onExportDate: (exportedAt) => {
|
||||
stamps.push(exportedAt);
|
||||
},
|
||||
});
|
||||
return stamps;
|
||||
}
|
||||
|
||||
it("hands the caller the instant Apple wrote on the export", async () => {
|
||||
const stamps = await parseFor(tinyExportXml());
|
||||
|
||||
expect(stamps).toHaveLength(1);
|
||||
// "2026-05-15 14:32:01 +0200" — the fixture's own stamp.
|
||||
expect(stamps[0]?.toISOString()).toBe("2026-05-15T12:32:01.000Z");
|
||||
});
|
||||
|
||||
it("stays silent for an archive that carries no stamp", async () => {
|
||||
const stamps = await parseFor(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData locale="en_US"></HealthData>`,
|
||||
);
|
||||
|
||||
expect(stamps).toEqual([]);
|
||||
});
|
||||
|
||||
it("stays silent rather than guessing at an unreadable stamp", async () => {
|
||||
const stamps = await parseFor(
|
||||
`<?xml version="1.0" encoding="UTF-8"?>\n<HealthData locale="en_US">\n <ExportDate value="not a date"/>\n</HealthData>`,
|
||||
);
|
||||
|
||||
expect(stamps).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveToCanonicalUnit } from "../unit-aliases";
|
||||
import { MGDL_PER_MMOL, mgdlToMmol } from "@/lib/glucose";
|
||||
|
||||
describe("resolveToCanonicalUnit", () => {
|
||||
it("passes the canonical unit through case-insensitively", () => {
|
||||
@@ -53,10 +54,17 @@ describe("resolveToCanonicalUnit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("converts glucose mmol/L to canonical mg/dL", () => {
|
||||
it("converts glucose mmol/L to canonical mg/dL on the app's one factor", () => {
|
||||
// This used to assert 18.016 while every display path divided by
|
||||
// 18.0182, so a reading imported as mmol/L and read back as mmol/L did
|
||||
// not return the number it went in as. Both ends take the same factor
|
||||
// now, and the assertion names it rather than repeating a literal —
|
||||
// pinning the constant twice is how the two drifted apart.
|
||||
const out = resolveToCanonicalUnit("BLOOD_GLUCOSE", 5.3, "mmol/L");
|
||||
expect(out?.unit).toBe("mg/dL");
|
||||
expect(out?.value).toBeCloseTo(5.3 * 18.016, 3);
|
||||
expect(out?.value).toBeCloseTo(5.3 * MGDL_PER_MMOL, 3);
|
||||
// The round trip a mmol/L reader actually experiences.
|
||||
expect(mgdlToMmol(out?.value ?? 0)).toBe(5.3);
|
||||
});
|
||||
|
||||
it("refuses an unrecognised or type-mismatched unit (never mis-stores)", () => {
|
||||
|
||||
@@ -307,6 +307,13 @@ export interface StreamParseInput {
|
||||
* continues even if the hook throws.
|
||||
*/
|
||||
onProgress?: (snapshot: ImportJobProgress) => Promise<void> | void;
|
||||
/**
|
||||
* Called once, mid-run, with the instant Apple stamped on the archive
|
||||
* (`<ExportDate value="..."/>`, the first child of `<HealthData>`).
|
||||
* Never called for an archive that omits the element or writes an
|
||||
* unreadable value. Best-effort, like `onProgress`.
|
||||
*/
|
||||
onExportDate?: (exportedAt: Date) => Promise<void> | void;
|
||||
/**
|
||||
* Optional override of the spot-row flush batch size. Defaults to
|
||||
* `SPOT_FLUSH_BATCH`. Lower values make the upsert path more
|
||||
@@ -339,6 +346,7 @@ export async function streamParseExportXml(
|
||||
userTimezone,
|
||||
prisma,
|
||||
onProgress,
|
||||
onExportDate,
|
||||
spotBatchSize = SPOT_FLUSH_BATCH,
|
||||
workoutBatchSize = WORKOUT_FLUSH_BATCH,
|
||||
} = input;
|
||||
@@ -775,6 +783,21 @@ export async function streamParseExportXml(
|
||||
let pendingError: Error | null = null;
|
||||
let currentWorkout: PreparedWorkout | null = null;
|
||||
|
||||
// Apple stamps the archive with its own export instant as the first
|
||||
// child of `<HealthData>`. The element used to be on the ignore list,
|
||||
// so `ImportJob.exportedAt` — a column the status endpoint and the
|
||||
// published contract both promise — could only ever answer null. The
|
||||
// value is captured in the synchronous tag handler and handed to
|
||||
// `onExportDate` from the async drain, so the worker can persist it
|
||||
// while the run is still going rather than only on the terminal write.
|
||||
let pendingExportDate: Date | null = null;
|
||||
const flushExportDate = async (): Promise<void> => {
|
||||
const stamped = pendingExportDate;
|
||||
if (!stamped) return;
|
||||
pendingExportDate = null;
|
||||
if (onExportDate) await onExportDate(stamped);
|
||||
};
|
||||
|
||||
parser.onerror = (err) => {
|
||||
pendingError = err instanceof Error ? err : new Error(String(err));
|
||||
};
|
||||
@@ -983,8 +1006,17 @@ export async function streamParseExportXml(
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "ExportDate") {
|
||||
// `value` carries Apple's own format ("2026-05-15 14:32:01 +0200"),
|
||||
// the same shape every `Record` start/end date uses. An archive
|
||||
// without the element, or with a value the runtime cannot read,
|
||||
// leaves the column null rather than guessing at an instant.
|
||||
const stamped = new Date(attrs.value ?? "");
|
||||
if (!Number.isNaN(stamped.getTime())) pendingExportDate = stamped;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
name === "ExportDate" ||
|
||||
name === "Me" ||
|
||||
name === "Correlation" ||
|
||||
name === "ActivitySummary" ||
|
||||
@@ -1049,9 +1081,11 @@ export async function streamParseExportXml(
|
||||
}
|
||||
// Drain any batches the parser filled to keep memory bounded.
|
||||
const shouldFlush =
|
||||
pendingExportDate !== null ||
|
||||
spotBatch.length >= spotBatchSize ||
|
||||
workoutBatch.length >= workoutBatchSize;
|
||||
const drain = async (): Promise<void> => {
|
||||
await flushExportDate();
|
||||
if (spotBatch.length >= spotBatchSize) await flushSpotBatch();
|
||||
if (workoutBatch.length >= workoutBatchSize)
|
||||
await flushWorkoutBatch();
|
||||
@@ -1090,6 +1124,10 @@ export async function streamParseExportXml(
|
||||
await pipeline(readable, sink);
|
||||
if (pendingError) throw pendingError;
|
||||
|
||||
// An archive small enough to arrive in a single chunk can finish
|
||||
// before any drain ran; the stamp still has to reach the caller.
|
||||
await flushExportDate();
|
||||
|
||||
await emitProgress("upserting");
|
||||
// Drain any partial spot/workout batches that did not hit the flush
|
||||
// threshold during the parse.
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
* Pure + side-effect-free; safe to import from an API route or a script.
|
||||
*/
|
||||
import { getUnitForType } from "@/lib/validations/measurement";
|
||||
|
||||
/** Glucose mmol/L → mg/dL. */
|
||||
const GLUCOSE_MMOL_TO_MGDL = 18.016;
|
||||
import { MGDL_PER_MMOL } from "@/lib/glucose";
|
||||
/** Exact pound → kilogram (1 lb = 0.45359237 kg). */
|
||||
const LB_TO_KG = 0.45359237;
|
||||
/** Exact inch → centimetre (1 in = 2.54 cm). */
|
||||
@@ -87,7 +85,7 @@ export function resolveToCanonicalUnit(
|
||||
|
||||
// Glucose: mmol/L → mg/dL.
|
||||
if (type === "BLOOD_GLUCOSE" && MMOL_ALIASES.has(lower)) {
|
||||
return { value: value * GLUCOSE_MMOL_TO_MGDL, unit: canonical };
|
||||
return { value: value * MGDL_PER_MMOL, unit: canonical };
|
||||
}
|
||||
|
||||
// Body mass: lb → kg.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
injectionSitePrefsPatchSchema,
|
||||
labsLocalOcrPatchSchema,
|
||||
unitPreferencePatchSchema,
|
||||
glucoseUnitPatchSchema,
|
||||
useCentralCodexPatchSchema,
|
||||
} from "@/lib/validations/user-prefs";
|
||||
import {
|
||||
@@ -555,6 +556,16 @@ const unitPreferenceResponse = z
|
||||
.object({ unitPreference: z.enum(["metric", "imperial"]) })
|
||||
.meta({ id: "UnitPreferenceResponse" });
|
||||
|
||||
const glucoseUnitPatchRequest = glucoseUnitPatchSchema.meta({
|
||||
id: "GlucoseUnitPatchRequest",
|
||||
description:
|
||||
"Which unit blood-glucose readings are rendered in. Presentation only — every stored reading is mg/dL either way.",
|
||||
});
|
||||
|
||||
const glucoseUnitResponse = z
|
||||
.object({ glucoseUnit: z.enum(["mg/dL", "mmol/L"]) })
|
||||
.meta({ id: "GlucoseUnitResponse" });
|
||||
|
||||
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
|
||||
id: "DocumentsAutoAiReadPatchRequest",
|
||||
description:
|
||||
@@ -1577,6 +1588,60 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
glucoseUnitResponse,
|
||||
"GetGlucoseUnitEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Auth"],
|
||||
summary: "Set the blood-glucose display unit",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"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.\n\n" +
|
||||
"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: glucoseUnitPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The resolved next unit.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
glucoseUnitResponse,
|
||||
"PatchGlucoseUnitEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 1024 bytes. Nothing was written.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/documents-auto-ai-read": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -31,6 +31,15 @@ export const authKeys = {
|
||||
* without a manual reload.
|
||||
*/
|
||||
userUnitPreference: () => ["user", "unit-preference"] as const,
|
||||
/**
|
||||
* The mg/dL vs mmol/L glucose display control behind
|
||||
* `GET /api/auth/me/glucose-unit`. A separate axis from the
|
||||
* metric/imperial preference above — metric countries are split on
|
||||
* which one they read glucose in — so it carries its own key. The
|
||||
* PATCH also invalidates `authMe()`, since every glucose surface
|
||||
* resolves the unit from the account payload rather than from here.
|
||||
*/
|
||||
userGlucoseUnit: () => ["user", "glucose-unit"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
|
||||
@@ -20,6 +20,17 @@ export const unitPreferencePatchSchema = z.object({
|
||||
unitPreference: z.enum(["metric", "imperial"]),
|
||||
});
|
||||
|
||||
/**
|
||||
* `PATCH /api/auth/me/glucose-unit` — mg/dL vs mmol/L display branch.
|
||||
*
|
||||
* Not the metric/imperial preference: the two split along different lines.
|
||||
* Germany is metric and reads glucose in mg/dL; the UK and the Nordics read
|
||||
* it in mmol/L. Canonical storage stays mg/dL either way.
|
||||
*/
|
||||
export const glucoseUnitPatchSchema = z.object({
|
||||
glucoseUnit: z.enum(["mg/dL", "mmol/L"]),
|
||||
});
|
||||
|
||||
/** `PATCH /api/auth/me/documents-auto-ai-read` — the auto-read opt-in. */
|
||||
export const documentsAutoAiReadPatchSchema = z.object({
|
||||
documentsAutoAiRead: z.boolean(),
|
||||
|
||||
Reference in new issue
Block a user