diff --git a/CHANGELOG.md b/CHANGELOG.md index 8877d02f6..da9672701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog -## [Unreleased] +## [1.38.6] — 2026-09-03 + +Blood glucose can be shown in mmol/L, which it never could before, and the +weekly backup finally sits inside key rotation. Text the server writes now +arrives in the language you actually read the app in. + +### Changed + +- `greetingHour` is no longer part of the dashboard snapshot response. Any + client that decoded it as a required field needs the field made optional or + dropped; the `timezone` it sits beside is unchanged and is what the greeting + should be derived from. +- The rotation script says what it did NOT look at. A zero that means "nothing + left" and a zero that means "never looked" read the same on a summary, and an + operator acts on that zero by dropping a key. Each run now ends with the + count of registered columns it walked, names any it skipped, and exits + non-zero when the two disagree. The runbook's retire-the-key step was + rewritten around that signal instead of around "the script ran cleanly", and + it tells an operator who already rotated on an older release how to get their + backups back. ### Fixed @@ -28,17 +47,160 @@ endpoints are required by their specifications to ignore parameters they do not recognise, and an import file comes from another version or another tool by definition. +- **The greeting could contradict the rest of the page.** Three separate + pieces of code answered the question "what hour is it" for the time-of-day + salutation. The dashboard snapshot computed one server-side and put it on + the payload so that clients would not have to; no client ever read it. The + insights hero worked it out from the profile timezone. The dashboard header + worked it out from a helper of its own that quietly fell back to the + device's clock whenever the runtime could not resolve the configured zone, + which is what happens to a zone the browser's timezone data does not yet + carry. At one instant, for one account, those three returned 20, 21 and 05, + so the header said good morning while the hero said good evening and every + date beneath them belonged to a third zone. The two greetings now read the + same helper, which stays on the configured zone and falls back to the same + default as the rest of the app rather than to the device. The server value + is gone: it is a clock reading, not a fact about the record, and the + snapshot body is served from cache for up to an hour, long enough for a + stored hour to name a salutation the clock had already left behind. What + the server resolves and every client reads is the timezone itself, which + travels on the same payload as before. +- **Some accounts had their generated text written in a language they do not + read.** Everything the app writes without a request in front of it — the + nightly briefing, the Coach nudge, the reminder messages — resolves its + language from one column on the user row, because a background pass has no + cookie and no browser to ask. Everything the user looks at resolves from the + request instead, and the request prefers the language cookie, then the + column, then what the browser asked for. So the two can disagree, and while + they do, nothing on screen looks wrong: the menus, the buttons and the + labels are all correct, and the disagreement shows up as a single generated + paragraph in the wrong language sitting inside an otherwise correct page. + That is how it was reported, on a page where the sentence above the health + score came back in English on an account read entirely in German. + Keeping the column in step was the browser's job, through a best-effort + request sent once when a page mounted and never checked. Anything that + interrupts it — the paint before sign-in, an offline moment, a tab closed + while it was still in flight — loses the write, and the next page load makes + the same unchecked attempt rather than noticing the column is still wrong, + so the wrong language can stand for the life of the account. The server now + keeps the two in step itself: when a request arrives carrying a language the + column does not agree with, the column is corrected, once, on the spot. It + needs nothing from the browser, it cannot be dropped in transit, and it + fixes accounts that have been diverged for months on their next page load. + Accounts that never opened the language picker at all are covered the same + way. Nobody's stored preference is guessed at: only a language the app was + actually being read in can reach the column. +- **The dashboard aggregate greeted some users in English on a German + instance.** The salutation and the streak label are translated on the + server for the native client, and that request carries no language cookie, + so the stored preference is the only signal. When an account had never + opened the language picker the column is empty, and the fallback stopped at + English instead of asking what language the instance is configured for. + It now follows the same order every background writer uses: the user's own + 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. +- **The weekly backup no longer takes the instance down with it.** The + compression that went out last release was real, and it was measured in the + wrong place. On a development machine with a multi-gigabyte heap the pass + finished; the container it actually runs in is capped at 1 GB, which gives + Node a 524 MB heap, and there the same pass died with + `Reached heap limit — JavaScript heap out of memory` about fifteen seconds + in. Because the job shares the application process, that was not one failed + backup: it was a restart, and every signed-in session on the host went with + it. The document is now written incrementally. The three tables that grow + without bound — readings, doses, mood entries — are read a page at a time and + serialised straight into the compressor and the cipher, every other section + is released as soon as its own JSON exists, and nothing between the database + rows and the stored value is ever held whole. On a seeded account of 445 000 + readings the pass used to exhaust a 546 MB heap; the same account doubled to + 890 000 now finishes inside 296 MB. The writer also watches its own memory + and stops at 80 percent of the limit, so an account too large for its host is + a backup that failed for that one account and is counted in the run's meta, + not a restart for everybody. Backups written under either older shape still + restore, which is checked both ways rather than asserted. +- **Backups were outside key rotation.** `DataBackup.data` holds every weekly + disaster-recovery snapshot and every uploaded pack, encrypted at rest like + everything else — but under a column called `data`, and the rotation registry + was keyed on the `*Encrypted` naming convention. So the rotation script never + read a backup, reported zero rows remaining, and the runbook told the + operator that zero meant the previous key was safe to retire. Following those + instructions to the letter made every stored backup permanently + undecryptable, which is the last thing that should break. The registry now + covers it, rotation walks it in bounded batches (one row is a whole + compressed account), and it re-seals the ciphertext without reading the + plaintext, so both stored envelopes — the plain JSON and the compressed one — + come back byte-identical, as will any later one. The same sweep found one + more: the idempotent-replay response cache, now rotated too. +- **Rotation crashed a third of the way through and said nothing.** Running the + script against a seeded database — rather than reading it — showed it dying + on the mood day-context note. That table is keyed on the entry it belongs to, + not on an `id`, and the walk asked for a column the database does not have. + Everything after it in the pass, the backups included, was never reached. The + walk now takes the model's actual key, and a check fails the build if a + registered column's key does not match the schema. +- The document index's verbatim text was rotating but never appeared in the + summary, so the new coverage check read it as skipped. It has its own line + now, with its own counts. ### Internal +- A column holding ciphertext is now recognised by what the code writes into + it, not by what it is called. A new guard traces the encryption helpers + through their wrappers, walks every Prisma write payload, and fails when a + column receives ciphertext without being registered for rotation — the + check that would have caught the backup blob nine releases ago. It asserts a + non-zero, anchored match count, so a matcher that quietly stops matching + fails instead of passing, and it was verified by breaking it three ways. + - 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. - **Two columns have a reader and no writer.** The glucose unit preference is read across twenty-nine files, the dashboard and both exports and the doctor report and the FHIR resources among them, and no surface anywhere writes it, so an account cannot leave mg/dL. An import job's export date is described in the schema as parsed during unpack, is returned by the import status endpoint and is in the published contract, but the parser skips Apple's `ExportDate` element outright: the field has always been null. - **Two columns have a writer and no reader.** The provider-health ledger records the HTTP status of a failed call and nothing reads it back, and the known-device ledger stamps a first-seen date that no query, page or export ever asks for. ## [1.38.5] — 2026-09-03 -The weekly backup finishes again, the dashboard stops scanning the two -largest tables, and several settings now do what they say. +The weekly backup costs a fraction of the memory it did, the dashboard +stops scanning the two largest tables, and several settings now do what +they say. ### Security @@ -62,12 +224,23 @@ largest tables, and several settings now do what they say. which is four full copies of the record at the same moment. The stored copy is now compressed before it is encrypted, which takes an order of magnitude off everything after it, and the pass releases each stage before allocating - the next. A seeded 445 000-reading account went from dying of memory to - finishing in about twenty seconds, with a stored copy of 25 MB instead of - 322 MB. The pass also has an explicit two-hour window now, so a genuinely - slow disk cannot be mistaken for a broken run. The nightly off-host copy is - built the same way and got the same treatment; files written before this - still restore, and so do backups already sitting in the database. + the next. A seeded 445 000-reading account, on a machine with + a generous heap, went from dying of memory to finishing in about twenty + seconds, with a stored copy of 25 MB instead of 322 MB. The pass also has an + explicit two-hour window now, so a genuinely slow disk cannot be mistaken for + a broken run. The nightly off-host copy is built the same way and got the + same treatment; files written before this still restore, and so do backups + already sitting in the database. + + **That measurement does not carry to a small container, and this entry + originally over-claimed from it.** A container capped at 1 GB gives the + runtime a 524 MB heap, and against one of those the pass still runs out of + memory, roughly three times further along than before but short of the end. + Worse, it takes the whole process down with it, so a failed backup becomes an + outage for everyone else on the instance. Building the record incrementally + rather than whole is the actual fix and has not landed yet. Until it does, an + instance backing up a large record wants more than 1 GB. + - **A failed backup run is now visible on the backups page.** The weekly pass can stop and leave no trace: the page listed whatever copies existed, and a copy from six weeks ago carries a timestamp exactly like Sunday's. The page diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 168c050e7..511396968 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.5 + version: 1.38.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -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: @@ -48650,10 +48762,6 @@ components: - type: "null" onboardingTourCompleted: type: boolean - greetingHour: - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 required: - username - timezone @@ -48662,7 +48770,6 @@ components: - gender - glucoseUnit - onboardingTourCompleted - - greetingHour additionalProperties: false layout: type: object diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index b465f2b5d..b2d060a80 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -122,6 +122,59 @@ The full per-model reasoning lives in `src/lib/export/backup-plan.ts`, where every excluded model carries a written verdict and a structural test refuses to let a new model land without one. +## The weekly in-database backup (`data-backup`) + +Separate from the off-host job above, and easy to confuse with it. A second +pg-boss job writes one `WEEKLY_AUTO` row per user into `data_backups.data` — +the same JSON document, gzipped and then encrypted under `ENCRYPTION_KEY` / +`ENCRYPTION_KEYS`, staying inside the instance. It is what +`/api/admin/backups//restore` reads. + +### Container memory + +This is the part that bites. The job runs inside the app process, so V8's heap +limit is the app's heap limit, and a container capped at 1 GB gives Node a +524 MB old-space limit by default. A long-lived record is bigger than it looks: +several hundred thousand measurements serialise to a JSON document of a few +hundred megabytes, and the writer used to need the object graph and that +document resident at the same time. On a seeded account of 445 000 measurements +under a 546 MB limit that is `FATAL ERROR: Reached heap limit` about thirty +seconds in — and since the job shares the process, the whole instance restarted +and every signed-in session on it went with it. + +Two things changed, and both matter to an operator: + +- **The writer streams.** The three tables that grow without bound — + measurements, intake events, mood entries — are read a page at a time and + serialised straight into gzip and the cipher, and every other section is + released as soon as its JSON exists. On the same fixture doubled to 890 000 + measurements, the pass completes inside a 296 MB heap limit; before the + change, half that record did not fit in 546 MB. +- **It stops itself.** The writer watches its own live heap and aborts at 80 % + of V8's limit. That backup then fails for that one account, is counted in the + run's `users_failed` and `heap_budget_trips` meta, and the pass carries on + with everybody else. A memory failure is now a failed job rather than a + restart for every user on the host. + +If `heap_budget_trips` is non-zero in `job.data_backup`, the answer is more +memory, not a retry: raise the container's limit, or set +`NODE_OPTIONS=--max-old-space-size=` to something under it. + +### The stored column is the remaining ceiling + +`data_backups.data` is a single `text` column, so the finished artifact has to +exist as one value before it can be written — that copy cannot be streamed +away. It is the only thing left in the job that grows with the record: at a +compressed blob of ~42 MB the pass peaks around 236 MB, and the growth from +there is roughly twice the blob's size (the base64 answer, plus the driver's +copy of it on the way to the wire). A blob past roughly 150 MB — an account +several times larger than any seen so far — will hit a 524 MB limit again, and +the fix at that point is to stop storing the artifact in one column: chunk it +across rows, or keep only the off-host copy. Reading it back has the same +shape, and worse: a restore parses the whole document, so the read path needs +several times the blob in heap. An operator restoring a very large account +should give the container more memory for the duration. + ## Monthly restore drill (automatic) Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` — diff --git a/docs/ops/encryption-key-rotation.md b/docs/ops/encryption-key-rotation.md index 9c28dad2a..007dd4fb9 100644 --- a/docs/ops/encryption-key-rotation.md +++ b/docs/ops/encryption-key-rotation.md @@ -41,35 +41,76 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`). Restart the app. New writes are now encrypted under `v2`; existing `v1`-keyed and legacy bare rows still decrypt because the `v1` entry is retained. -3. **Run the rotation script** to re-encrypt every existing encrypted column +3. **Run the rotation script** to re-encrypt every registered encrypted column under the new active key: + ``` pnpm dlx tsx scripts/rotate-encryption-key.ts v2 ``` + The script is idempotent — running it again is a no-op for rows already - prefixed with `v2.`. It rotates every registered encrypted column across - all models, plus the two non-`*Encrypted` members: - `IntegrationStatus.lastError` and `CoachMessage.encryptedContent` - (Bytes). The canonical registry lives in - `src/lib/crypto/encrypted-columns.ts`; a guard test fails CI if any - `*Encrypted` column in the schema is missing from the registry or not - referenced by the script. The summary line at the - end shows `scanned`, `rotated`, and `errors` per table+field; treat - `errors > 0` as a hard failure and re-run after fixing the cause. -4. **Drop the old key (optional, recommended).** Once the script has run - cleanly: + prefixed with `v2.`. It rotates every column in the canonical registry + (`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted` + columns plus the ones whose names say nothing about their contents: + `IntegrationStatus.lastError`, `CoachMessage.encryptedContent`, + `NotificationChannel.config`, the OAuth `accessToken` / `refreshToken` + columns, the web-push `p256dh` / `auth` secrets, the idempotent-replay + `IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account + backup blob. + + Read three things off the output before going further: + + - the per-column line, `scanned` / `rotated` / `errors` / `dropped`. Treat + `errors > 0` as a hard failure and re-run after fixing the cause. A + `dropped` count is only ever a cache row the run could not read and + deleted rather than leave unreadable. + - `Columns walked: N/M registered`. The two numbers must match. + - a `NOT WALKED` block, if one appears. It lists registered columns this + run skipped, and the run exits non-zero. Rotation is incomplete; do not + go on to step 4. + + Two guard tests keep the registry honest in CI: one scans the schema for + `*Encrypted` columns, the other derives the ciphertext-bearing columns from + the Prisma write payloads, which is what catches a column that holds + ciphertext under an ordinary name. + +4. **Confirm nothing is left on the old key, THEN drop it.** This is the step + that destroys data if it is taken on a bad signal, so check the corpus + rather than the absence of complaints. Either re-run the script — a clean + second pass reports `rotated=0`, `errors=0` and a full `Columns walked` + line — or open **Admin → Encryption** and confirm the status view reports + zero rows outside the active key. Both read the same registry the rotation + walks. + + A zero only means "nothing left" when the run also says it walked every + registered column. A zero from a run that skipped columns means "never + looked", and dropping the old key on it makes those rows permanently + undecryptable — `decrypt()` is fail-closed and there is no recovery path. + Only once the corpus reads clean: + ``` ENCRYPTION_KEYS='{"v2":""}' ENCRYPTION_ACTIVE_KEY_ID="v2" # ENCRYPTION_KEY can now be removed ``` + Restart. The legacy single-key fallback is now disconnected; only `v2` exists. +> **Backups are covered, and were not always.** `DataBackup.data` holds every +> weekly disaster-recovery snapshot and every uploaded pack, encrypted like +> everything else but under a column called `data`. It was outside the +> registry until v1.38.6, so a rotation before that release reported zero +> rows remaining without ever reading a backup. If you rotated on an older +> release and dropped the previous key, the stored backups are encrypted +> under the key you removed: put that key back into `ENCRYPTION_KEYS` and +> re-run the rotation on this release before removing it again. + ## Adding a third key (v2 → v3) -Same procedure, just shift the labels — keep `v2` in the map until the -script reports zero `v2.`-prefixed rows remaining. +Same procedure, just shift the labels — keep `v2` in the map until a full +run (every registered column walked) reports zero `v2.`-prefixed rows +remaining. ``` ENCRYPTION_KEYS='{"v2":"","v3":""}' diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 77e4a11dd..c8df7de9c 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -258,7 +258,6 @@ export function buildMockSnapshot( gender: "MALE", glucoseUnit: "mg/dL", onboardingTourCompleted: true, - greetingHour: 9, }, layout: DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), 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/package.json b/package.json index a4a5d9f9f..f50f07648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.5", + "version": "1.38.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", 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/public/sw.js b/public/sw.js index 28e41459e..f8b4331e1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.5"; + /* @sw-version-fallback */ "v1.38.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 811f55c2b..ff2367ff7 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -6,13 +6,16 @@ * already carries the active key id prefix are skipped. * * The set of columns this script rotates is the canonical registry in - * `src/lib/crypto/encrypted-columns.ts`. The guard test - * `src/lib/crypto/__tests__/encrypted-columns.test.ts` fails CI if any - * encrypted column in `prisma/schema.prisma` is missing from the registry, - * or if any registry column is not referenced here — so a new `*Encrypted` - * column can never silently skip rotation (which would make those rows - * permanently undecryptable once the legacy key is dropped, since decrypt is - * fail-closed). + * `src/lib/crypto/encrypted-columns.ts`. Two guard tests keep it honest: + * `encrypted-columns.test.ts` fails CI if a `*Encrypted`-named schema column + * is missing from the registry or is not referenced here, and + * `encrypted-column-writers.test.ts` derives the ciphertext-bearing columns + * from the Prisma write payloads instead of from names — the check that would + * have caught `DataBackup.data`, ciphertext under a column called `data`. + * + * The run ends by naming every registered column it did NOT walk and exiting + * non-zero, because an operator reads a zero here as permission to drop the + * previous key, and "nothing left" and "never looked" must not print alike. * * Usage: * ENCRYPTION_KEYS='{"v1":"","v2":""}' \ @@ -28,6 +31,11 @@ import { rotateColumn, type CorpusClient, } from "@/lib/crypto/encryption-corpus"; +import { + ENCRYPTED_COLUMNS, + encryptedColumnKey, + type EncryptedColumn, +} from "@/lib/crypto/encrypted-columns"; import { tokeniseAndHash } from "@/lib/documents/content-index"; const DATABASE_URL = process.env.DATABASE_URL; @@ -55,6 +63,42 @@ interface RotationResult { scanned: number; rotated: number; errors: number; + /** Unreadable rows deleted from a `disposable` column (cache entries only). */ + dropped: number; +} + +/** + * Look a column up in the canonical registry so the walk honours the flags + * recorded there (`batched`, `disposable`, `codecField`) rather than a second + * hand-written copy of them here. + */ +function registryColumn(model: string, field: string): EncryptedColumn { + const col = ENCRYPTED_COLUMNS.find( + (c) => c.model === model && c.field === field, + ); + if (!col) { + throw new Error( + `${model}.${field} is not in the encrypted-column registry`, + ); + } + return col; +} + +/** Rotate one column through the shared registry-driven corpus walk. */ +async function rotateRegistryColumn( + model: string, + field: string, + client: CorpusClient, +): Promise { + const r = await rotateColumn(client, registryColumn(model, field)); + return { + table: r.model, + field: r.field, + scanned: r.scanned, + rotated: r.rotated, + errors: r.errors, + dropped: r.dropped, + }; } function shouldRotate(value: string | null): boolean { @@ -89,6 +133,7 @@ async function rotateStringColumn( scanned: rows.length, rotated: 0, errors: 0, + dropped: 0, }; for (const row of rows) { const v = row[field] as string | null; @@ -131,6 +176,7 @@ async function rotateBytesColumn( scanned: rows.length, rotated: 0, errors: 0, + dropped: 0, }; for (const row of rows) { const buf = row[field] as Uint8Array | null; @@ -487,13 +533,14 @@ async function main() { // v1.38 — the day-context note on a mood entry. Its own table, so its own // walk; a context row exists only where somebody added one, and a row with // no note carries NULL here and is skipped like every other nullable Bytes - // column. + // column. It goes through the registry-driven walk rather than the generic + // `id`-keyed one above: the table is keyed on `moodEntryId`, and selecting a + // column Prisma does not know threw mid-pass and abandoned every column + // after it — a rotation that reported nothing at all rather than a gap. results.push( - await rotateBytesColumn( - "MoodContext", - "notesEncrypted", - prisma.moodContext, - ), + await rotateRegistryColumn("MoodContext", "notesEncrypted", { + moodContext: prisma.moodContext, + } as unknown as CorpusClient), ); // ───── v1.25 medication free-text notes (Bytes columns) ───── @@ -601,6 +648,7 @@ async function main() { scanned: docResult.scanned, rotated: docResult.rotated, errors: docResult.errors, + dropped: docResult.dropped, }); } // The staged extracted-fact payloads: the FHIR-staged clinical values and the @@ -650,6 +698,19 @@ async function main() { scanned: 0, rotated: 0, errors: 0, + dropped: 0, + }; + // The verbatim sibling rides the same update, but it needs its own line in + // the summary: the coverage check reads the results to decide which + // registered columns this run actually looked at, and a column that + // rotates without ever being named there reads as skipped. + const verbatim: RotationResult = { + table: "DocumentContentIndex", + field: "verbatimTextEncrypted", + scanned: 0, + rotated: 0, + errors: 0, + dropped: 0, }; let cursor: string | null = null; // v1.27.33 (Document vault P4) — re-encrypt the string-shaped BYTEA into a @@ -676,18 +737,19 @@ async function main() { if (!buf || buf.byteLength === 0) continue; const asString = Buffer.from(buf).toString("utf8"); if (!shouldRotate(asString)) continue; + // The "verbatimTextEncrypted" column (nullable; written together with + // "textEncrypted" so it shares the same key) rotates in the same + // update when present. Read outside the try so the catch can count it. + const verbatimBuf = row.verbatimTextEncrypted as Uint8Array | null; + const hasVerbatim = Boolean(verbatimBuf && verbatimBuf.byteLength > 0); + if (hasVerbatim) verbatim.scanned++; try { const plaintext = decrypt(asString); const nextBytes = reEncryptBytes(asString); const searchTokens = tokeniseAndHash(plaintext); - // The "verbatimTextEncrypted" column (nullable; written together with - // "textEncrypted" so it shares the same key) rotates in the same - // update when present. - const verbatimBuf = row.verbatimTextEncrypted as Uint8Array | null; - const verbatimNext = - verbatimBuf && verbatimBuf.byteLength > 0 - ? reEncryptBytes(Buffer.from(verbatimBuf).toString("utf8")) - : undefined; + const verbatimNext = hasVerbatim + ? reEncryptBytes(Buffer.from(verbatimBuf!).toString("utf8")) + : undefined; await prisma.documentContentIndex.update({ where: { id: row.id }, data: { @@ -697,8 +759,10 @@ async function main() { }, }); result.rotated++; + if (verbatimNext) verbatim.rotated++; } catch (err) { result.errors++; + if (hasVerbatim) verbatim.errors++; console.error( `[DocumentContentIndex.textEncrypted] row ${row.id}: ${(err as Error).message}`, ); @@ -708,6 +772,7 @@ async function main() { if (rows.length < 100) break; } results.push(result); + results.push(verbatim); } // ───── Document preview thumbnails (Bytes column) ───── @@ -723,18 +788,71 @@ async function main() { ), ); + // ───── Whole-account backup blob (String, batched) ───── + // `DataBackup.data` holds `packBackupBlob()` output: AES-256-GCM ciphertext + // under a column that does NOT carry the `*Encrypted` suffix, which is why + // it sat outside rotation until v1.38.6. Missing it was the worst possible + // miss — the script reported zero rows remaining, the runbook told the + // operator that zero meant safe to drop the old key, and every stored backup + // became undecryptable. Walked in bounded id-cursor batches because one row + // is an entire compressed account, and re-sealed WITHOUT reading the + // plaintext, so both stored envelopes (the plain backup JSON and the + // `HLZ1:` gzip form) — and any later one — rotate unchanged. + results.push( + await rotateRegistryColumn("DataBackup", "data", { + dataBackup: prisma.dataBackup, + } as unknown as CorpusClient), + ); + + // ───── Idempotent-replay response cache (String, disposable) ───── + // `IdempotencyKey.responseBody` is the cached response body, encrypted + // because the PHI-returning creates echo their own decrypted DTO. Rotating + // it keeps a key drop from handing a replaying client a body it cannot + // parse; a row that cannot be read is deleted rather than counted as an + // error, because a cache miss only costs a re-run. + results.push( + await rotateRegistryColumn("IdempotencyKey", "responseBody", { + idempotencyKey: prisma.idempotencyKey, + } as unknown as CorpusClient), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; + let totalDropped = 0; for (const r of results) { console.log( - `${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} errors=${r.errors}`, + `${r.table}.${r.field}: scanned=${r.scanned} rotated=${r.rotated} ` + + `errors=${r.errors} dropped=${r.dropped}`, ); totalRotated += r.rotated; totalErrors += r.errors; + totalDropped += r.dropped; + } + console.log( + `\nTOTAL rotated=${totalRotated} errors=${totalErrors} dropped=${totalDropped}`, + ); + + // ───── Coverage: what was NOT looked at ───── + // A zero that means "nothing left" and a zero that means "never looked" read + // the same on the summary above, and the operator acts on that zero by + // dropping the old key. So say which registered columns this run actually + // walked, and refuse to report success if any of them was skipped. + const walked = new Set(results.map((r) => `${r.table}.${r.field}`)); + const notWalked = ENCRYPTED_COLUMNS.filter( + (c) => !walked.has(encryptedColumnKey(c)), + ).map(encryptedColumnKey); + console.log( + `\nColumns walked: ${walked.size}/${ENCRYPTED_COLUMNS.length} registered`, + ); + if (notWalked.length > 0) { + console.error( + `\nNOT WALKED (${notWalked.length}) — rotation is INCOMPLETE, do not ` + + `drop the previous key:\n ${notWalked.join("\n ")}`, + ); } - console.log(`\nTOTAL rotated=${totalRotated} errors=${totalErrors}`); await prisma.$disconnect(); + if (notWalked.length > 0) process.exit(4); process.exit(totalErrors > 0 ? 3 : 0); } diff --git a/src/__tests__/greeting-hour-single-source.test.ts b/src/__tests__/greeting-hour-single-source.test.ts new file mode 100644 index 000000000..98766ce71 --- /dev/null +++ b/src/__tests__/greeting-hour-single-source.test.ts @@ -0,0 +1,131 @@ +/** + * The time-of-day greeting has ONE evaluator. + * + * The dashboard header and the insights hero both open with a salutation + * bucketed by the wall-clock hour. For a long time three different pieces + * of code answered "what hour is it": + * + * - the dashboard snapshot builder, which computed a `greetingHour` in + * the user's zone and shipped it on the payload "so the client never + * has to run its own Intl" — and which no client ever read; + * - the insights hero, through the shared `hourInTz` (profile zone, + * Europe/Berlin when the zone will not resolve); + * - the dashboard header, through a local helper that fell back to the + * DEVICE clock whenever the zone would not resolve. + * + * At one instant, for one account, those three returned 20, 21 and 05. + * + * The server value lost. It is not a derived fact about the record, it is + * a clock read, and the snapshot body is served stale-while-revalidate for + * up to an hour — long enough for a baked-in hour to name a salutation the + * clock had left behind. What the server IS authoritative for is the zone, + * and that already reaches every client on the same payload. + * + * This guard fails if either end drifts back: + * T1 — the snapshot builder or its OpenAPI schema starts carrying a + * greeting hour again. + * T2 — a greeting surface reads a clock that is not `hourInTz` — the + * device zone via `Date#getHours`, or a hand-rolled `Intl` walk. + * T3 — behaviourally, the shared helper stays on the profile zone even + * when the device sits elsewhere and the zone will not resolve. + * + * T1/T2 are grep-shaped and assert a non-zero match count, so an empty + * sweep fails loudly instead of passing silently. + */ +import { describe, it, expect, afterAll, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const SRC = join(process.cwd(), "src"); +const read = (rel: string) => readFileSync(join(SRC, rel), "utf8"); + +const SERVER_FILES = [ + "lib/dashboard/snapshot.ts", + "lib/openapi/routes/insights/schemas.ts", +] as const; + +/** Every surface that paints a time-of-day salutation. */ +const GREETING_SURFACES = [ + "components/dashboard/dashboard-header.tsx", + "components/insights/hero-strip.tsx", +] as const; + +describe("T1 — the snapshot payload carries no wall-clock hour", () => { + it.each(SERVER_FILES)("%s declares no greeting hour", (rel) => { + const src = read(rel); + expect(src.length).toBeGreaterThan(0); + expect(src).not.toMatch(/greetingHour/); + expect(src).not.toMatch(/hourInTimezone/); + }); + + it("still resolves the zone the clients read", () => { + const src = read("lib/dashboard/snapshot.ts"); + expect(src).toMatch(/timezone:\s*userTz/); + }); +}); + +describe("T2 — every greeting surface reads the shared helper", () => { + it.each(GREETING_SURFACES)("%s derives its hour from hourInTz", (rel) => { + const src = read(rel); + expect(src.length).toBeGreaterThan(0); + // Imported from the shared module, not re-implemented next door. + expect(src).toMatch( + /import[\s\S]*?\bhourInTz\b[\s\S]*?from "@\/lib\/tz\/format"/, + ); + const calls = src.match(/\bhourInTz\s*\(/g) ?? []; + expect(calls.length).toBeGreaterThan(0); + }); + + it.each(GREETING_SURFACES)("%s reads no device-local clock", (rel) => { + const src = read(rel); + // `getHours()` is the device zone by definition; a hand-rolled + // `Intl.DateTimeFormat` for the hour is the second evaluator this + // guard exists to prevent. + expect(src).not.toMatch(/\.getHours\s*\(/); + expect(src).not.toMatch(/Intl\.DateTimeFormat/); + expect(src).not.toMatch(/getHourForTimeZone/); + }); + + it("the retired header helper is gone from the tree", () => { + const src = read("components/dashboard/range-display.tsx"); + expect(src.length).toBeGreaterThan(0); + expect(src).not.toMatch(/getHourForTimeZone/); + }); +}); + +describe("T3 — the profile zone beats the device zone", () => { + const INSTANT = new Date("2026-03-01T20:30:00Z"); + const originalTz = process.env.TZ; + + afterAll(() => { + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("follows the configured zone while the device sits ten hours away", () => { + process.env.TZ = "Asia/Tokyo"; + vi.useFakeTimers(); + vi.setSystemTime(INSTANT); + + // The device says 05:30 (morning). The account is configured for + // Berlin, where it is 21:30 (evening). + expect(new Date().getHours()).toBe(5); + expect(hourInTz(new Date(), "Europe/Berlin")).toBe(21); + }); + + it("falls back to the shared default, never to the device clock", () => { + process.env.TZ = "Asia/Tokyo"; + vi.useFakeTimers(); + vi.setSystemTime(INSTANT); + + // A zone this runtime cannot resolve — a recently added IANA entry on + // an older engine is the realistic case. The header helper used to + // answer with the device hour here, splitting the two greetings by + // sixteen hours for the same account at the same instant. + const unresolvable = hourInTz(new Date(), "Europe/Atlantis"); + expect(unresolvable).toBe(hourInTz(new Date(), DEFAULT_TIMEZONE)); + expect(unresolvable).not.toBe(new Date().getHours()); + }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts index d824dd30b..5d52faa1a 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/owner-mismatch.test.ts @@ -36,7 +36,12 @@ vi.mock("@/lib/db", () => ({ toJson: (value: T) => value, })); -vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") })); +vi.mock("@/lib/crypto", () => ({ + decrypt: vi.fn(() => "{}"), + // The envelope reader asks which stored shape it was handed first. + isStreamCiphertext: () => false, + decryptStream: vi.fn(), +})); vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() })); vi.mock("@/lib/validations/backup", () => ({ parseBackupPayload: vi.fn(), diff --git a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts index d06486115..51441643c 100644 --- a/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts +++ b/src/app/api/admin/backups/[id]/summary/__tests__/route.test.ts @@ -30,8 +30,12 @@ vi.mock("@/lib/db", () => ({ })); const decryptMock = vi.fn(); +// The envelope reader asks which of the stored shapes it was handed before it +// decrypts anything, so a stub of this module has to answer that too. vi.mock("@/lib/crypto", () => ({ decrypt: (...a: unknown[]) => decryptMock(...a), + isStreamCiphertext: () => false, + decryptStream: vi.fn(), })); const parseMock = vi.fn(); 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/dashboard/snapshot/__tests__/route.test.ts b/src/app/api/dashboard/snapshot/__tests__/route.test.ts index 634de7dc8..7d68a97f1 100644 --- a/src/app/api/dashboard/snapshot/__tests__/route.test.ts +++ b/src/app/api/dashboard/snapshot/__tests__/route.test.ts @@ -76,7 +76,6 @@ const SNAPSHOT_BODY = { gender: "MALE", glucoseUnit: "mg/dL", onboardingTourCompleted: true, - greetingHour: 9, }, layout: { version: 1, widgets: [] }, tiles: { @@ -121,7 +120,12 @@ describe("GET /api/dashboard/snapshot", () => { expect(json.data.briefingState).toBe("preparing"); expect(json.data.tiles).toBeDefined(); expect(json.data.extras).toBeNull(); - expect(json.data.user.greetingHour).toBe(9); + // Inverted 2026-09: the payload no longer carries a wall-clock hour. + // The body is served stale for up to an hour, so a baked-in hour could + // name a salutation the clock had long left behind. Clients read the + // resolved `timezone` and their own live clock instead. + expect(json.data.user).not.toHaveProperty("greetingHour"); + expect(json.data.user.timezone).toBe("Europe/Berlin"); // bfcache-friendly directive present. expect(res.headers.get("Cache-Control")).toContain("private"); }); diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 1005fd8ea..57fb570ad 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -45,6 +45,12 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), }, + // The greeting salutation is one of the two strings this route still + // translates server-side; `resolveJobLocale` reads the operator's + // configured default from here when the user's column is NULL. + appSettings: { + findUnique: vi.fn(async () => null), + }, // v1.4.38 W-F — the route now uses `$queryRaw` for the per-type // latest reading, the 7-day rollup-bucket sparkline, and the // 365-day distinct-day streak set. Three raw calls per request, @@ -1089,6 +1095,46 @@ describe("GET /api/dashboard/summary", () => { * source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the * client consumes a finished figure instead of re-deriving one. */ +describe("GET /api/dashboard/summary — greeting locale", () => { + // The salutation and the streak label are translated on the server, and + // the native client is this route's only caller: a Bearer request carries + // no locale cookie, so `User.locale` is the sole per-user signal. When it + // is NULL the operator's configured default is the answer — the same + // fallback order every background writer uses. The coercion this replaces + // stopped at English, so a German instance greeted a user who never opened + // the language picker in English while the rest of their screen was German. + it("falls back to the operator default, not to English", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultLocale: "de", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { greeting: { salutation: string } }; + }; + expect(body.data.greeting.salutation).toBe("Hallo, testuser"); + }); + + it("still prefers the user's own stored locale", async () => { + vi.mocked(getSession).mockResolvedValue({ + ...SESSION_OK, + user: { ...SESSION_OK.user, locale: "en" }, + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultLocale: "de", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { greeting: { salutation: string } }; + }; + expect(body.data.greeting.salutation).toBe("Hi, testuser"); + }); +}); + describe("GET /api/dashboard/summary — mood + BMI cards", () => { function cardsOf(body: unknown): Array> { return (body as { data: { metrics: Array> } }).data diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index b596e17fb..a80dbc6de 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -61,7 +61,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { type Locale } from "@/lib/i18n/config"; +import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; @@ -343,7 +344,8 @@ export const GET = apiHandler(async () => { Awaited> >, `${user.id}|dashboard-summary`, - () => buildDashboardSummary(user.id, userTz, buildContext(user)), + async () => + buildDashboardSummary(user.id, userTz, await buildContext(user)), annotate, ); @@ -383,23 +385,22 @@ interface SummaryBuilderContext { locale: Locale; } -function buildContext(user: { +async function buildContext(user: { displayName: string | null; username: string; locale: string | null; -}): SummaryBuilderContext { +}): Promise { const greetingName = user.displayName ?? user.username; - // v1.5.x — accept every shipped locale (de / en / es / fr / it / pl) - // so the greeting + streak label resolve against the user's - // configured language. The legacy narrowing dropped fr/es/it/pl back - // to the default; that bug is masked for the title/unit fields by - // the new key-based wire shape but still mattered for the strings - // that stay translated server-side. - const locale: Locale = (locales as readonly string[]).includes( - user.locale ?? "", - ) - ? (user.locale as Locale) - : defaultLocale; + // The greeting salutation and the streak label are the two strings this + // route still translates server-side, and the native client is its only + // caller — a Bearer request carries no locale cookie and no meaningful + // Accept-Language, so the stored column is the only per-user signal. + // That is exactly the shape `resolveJobLocale` exists for: stored user + // locale, then the operator's configured default, then English. The + // hand-rolled coercion this replaces stopped at English, so a German + // instance whose user never touched the language picker greeted them in + // English while every other string on the same screen was German. + const locale: Locale = await resolveJobLocale(user.locale); return { greetingName, locale }; } 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/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index e502f9456..4440bab28 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -25,7 +25,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; -import { getHourForTimeZone } from "@/components/dashboard/range-display"; +import { hourInTz } from "@/lib/tz/format"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -47,9 +47,16 @@ export function DashboardHeader({ // hydrates — the text must match the name-less SSR output during // hydration (React #418) and may only personalise from the first // client re-render. + // + // The hour comes from the shared `hourInTz`, the same helper the + // insights hero greeting uses. Its predecessor fell back to the + // DEVICE clock whenever the runtime could not resolve the profile + // zone, so a zone this browser's ICU does not carry (a recently added + // one, an older engine) split the two greetings by hours while every + // other date on the page stayed on the profile zone. const userTimezone = mounted ? user?.timezone : undefined; const hour = useMemo( - () => (userTimezone ? getHourForTimeZone(userTimezone) : null), + () => (userTimezone ? hourInTz(new Date(), userTimezone) : null), [userTimezone], ); const timeGreeting = diff --git a/src/components/dashboard/range-display.tsx b/src/components/dashboard/range-display.tsx index 66256bac1..58268184f 100644 --- a/src/components/dashboard/range-display.tsx +++ b/src/components/dashboard/range-display.tsx @@ -46,24 +46,6 @@ export function toHoursSummary(s: DataSummary): DataSummary { }; } -export function getHourForTimeZone(timeZone?: string): number { - const now = new Date(); - if (!timeZone) return now.getHours(); - - try { - const parts = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - hour12: false, - timeZone, - }).formatToParts(now); - const hourPart = parts.find((part) => part.type === "hour")?.value; - const parsed = hourPart ? Number(hourPart) : Number.NaN; - return Number.isNaN(parsed) ? now.getHours() : parsed; - } catch { - return now.getHours(); - } -} - export function getRangeColorClass( value: number | null | undefined, config: RangeDisplayConfig, 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 `