mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
141 files changed
+8312
-266
No files matched your search
@@ -1,5 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
## [1.42.3] — 2026-10-15
|
||||
|
||||
Withings API plans, lab unit conversion, chronic conditions and rest mode,
|
||||
Oura scopes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Lab unit conversion (#1095).** A registry of convertible analytes with a
|
||||
hub unit per analyte, derived from molar masses (glucose, cholesterol,
|
||||
creatinine, urea and BUN, uric acid, calcium, bilirubin,
|
||||
vitamin D, HbA1c in both directions, power-of-ten pairs). Markers carry
|
||||
`analyteKey`, the catalog slug matched from name and unit and re-derived
|
||||
whenever either changes. `POST /api/labs`, the OCR commit and the
|
||||
document commit convert a reading into its marker's unit when the pair is
|
||||
known (201 with `converted: true`) and keep the printed value, unit and
|
||||
range text; unconvertible pairs still answer 422 `labs.unit.mismatch`,
|
||||
now with a `reason`. Converted ranges are stated in the marker unit
|
||||
everywhere (note, Coach, MCP, doctor report, FHIR); the printed text is
|
||||
shown as printed, with its unit. Contributed by @BernhardBuckel.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Withings API plans (since 2026-10-12).** A request for a data type
|
||||
outside the account's Withings API plan fails whole with status 277, so
|
||||
every measure sync failed. Syncs now drop the documented advanced types
|
||||
on a 277, bisect any remainder, remember unavailable types per connection
|
||||
(`withings_connections.api_plan_state`, migration 0398) and re-probe
|
||||
weekly. ECG and sleep-summary fields outside the plan are skipped
|
||||
quietly. A 277 never counts as an auth failure; connections parked by it
|
||||
are released by the fallback cron. The Withings card lists the types the
|
||||
plan excludes (`planUnavailable`).
|
||||
- **Oura scopes (#1204).** The authorize request adds `spo2` and
|
||||
`heart_health`. A 401 on single collections next to successful ones is
|
||||
recorded as a missing scope (`users.oura_scope_denied`, migration 0398),
|
||||
counts as success and shows a reconnect hint (`scopeReconnect`) instead
|
||||
of an error state.
|
||||
- **Rest mode and chronic conditions (#1216).** Only active episodes that
|
||||
are not `CHRONIC_ONGOING` trigger rest mode; Day N, the nudge pause, the
|
||||
streak freeze and the banners follow. Chronic conditions stay context for
|
||||
the Coach and for coincident vital deviations.
|
||||
- **ntfy quick answers (#1203).** Every refusal on
|
||||
`/api/notifications/action/*` is logged with a reason; a non-POST request
|
||||
gets 405 with `Allow: POST`.
|
||||
- **Lab form (#1214).** The draw date gets a full-width row.
|
||||
- **Lab edits** keep the conversion record unless the value changes.
|
||||
|
||||
### API
|
||||
|
||||
- `LabResult`: `converted`, `sourceValue`, `sourceUnit`,
|
||||
`printedReferenceText`; `sourceReferenceText` is null for a converted
|
||||
reading. `Biomarker.analyteKey`.
|
||||
- Integration status: Withings `planUnavailable`, Oura `scopeReconnect`.
|
||||
|
||||
## [1.42.2] — 2026-10-11
|
||||
|
||||
Actionable notifications, record search in the command palette, automatic
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 429,
|
||||
"/insights/page": 304,
|
||||
@@ -8,6 +8,6 @@
|
||||
"/settings/[section]/page": 403
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4760,
|
||||
"totalClientKbGz": 4790,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+144
-16
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.42.2
|
||||
version: 1.42.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -14905,11 +14905,14 @@ paths:
|
||||
"403": *a11
|
||||
"409": *a7
|
||||
"422":
|
||||
description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue
|
||||
description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue
|
||||
under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one
|
||||
its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent;
|
||||
with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven
|
||||
(`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.
|
||||
its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`,
|
||||
`meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is
|
||||
sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is
|
||||
forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and
|
||||
the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused
|
||||
otherwise, never relabelled."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -67394,6 +67397,16 @@ components:
|
||||
- type: "null"
|
||||
unit:
|
||||
type: string
|
||||
converted:
|
||||
type: boolean
|
||||
sourceValue:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
sourceUnit:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
@@ -67422,6 +67435,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
printedReferenceText:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceOrigin:
|
||||
$ref: "#/components/schemas/LabReferenceRangeOrigin"
|
||||
referenceDivergesFromCatalog:
|
||||
@@ -67446,6 +67463,9 @@ components:
|
||||
- value
|
||||
- valueText
|
||||
- unit
|
||||
- converted
|
||||
- sourceValue
|
||||
- sourceUnit
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- catalogReferenceLow
|
||||
@@ -67453,6 +67473,7 @@ components:
|
||||
- sourceReferenceLow
|
||||
- sourceReferenceHigh
|
||||
- sourceReferenceText
|
||||
- printedReferenceText
|
||||
- referenceOrigin
|
||||
- referenceDivergesFromCatalog
|
||||
- takenAt
|
||||
@@ -67476,8 +67497,14 @@ components:
|
||||
numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band.
|
||||
`referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the
|
||||
catalog band state different limits — a surface showing only one of the two is showing a partial answer. The
|
||||
client renders these values and never recomputes the range. The encrypted note is never echoed in list rows;
|
||||
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
|
||||
client renders these values and never recomputes the range. `converted` is true when the stored `value` was
|
||||
converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report
|
||||
printed (full precision, round for display), and the report's own window was converted with it:
|
||||
`sourceReferenceLow` / `sourceReferenceHigh` are then in the marker's `unit`. The printed range text is not
|
||||
converted and stays in `sourceUnit`, so for a converted reading `sourceReferenceText` is null and the verbatim
|
||||
text is in `printedReferenceText` instead, never mixed with the converted numbers. A reading stored as stated
|
||||
has `converted: false`, two nulls and a null `printedReferenceText`. The encrypted note is never echoed in list
|
||||
rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
|
||||
LabReferenceRangeOrigin:
|
||||
type: string
|
||||
enum:
|
||||
@@ -67556,6 +67583,16 @@ components:
|
||||
- type: "null"
|
||||
unit:
|
||||
type: string
|
||||
converted:
|
||||
type: boolean
|
||||
sourceValue:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
sourceUnit:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
@@ -67584,6 +67621,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
printedReferenceText:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceOrigin:
|
||||
$ref: "#/components/schemas/LabReferenceRangeOrigin"
|
||||
referenceDivergesFromCatalog:
|
||||
@@ -67610,6 +67651,9 @@ components:
|
||||
- value
|
||||
- valueText
|
||||
- unit
|
||||
- converted
|
||||
- sourceValue
|
||||
- sourceUnit
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- catalogReferenceLow
|
||||
@@ -67617,6 +67661,7 @@ components:
|
||||
- sourceReferenceLow
|
||||
- sourceReferenceHigh
|
||||
- sourceReferenceText
|
||||
- printedReferenceText
|
||||
- referenceOrigin
|
||||
- referenceDivergesFromCatalog
|
||||
- takenAt
|
||||
@@ -67820,7 +67865,8 @@ components:
|
||||
- duplicate
|
||||
- unit_mismatch
|
||||
description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other
|
||||
than the one its marker is tracked in, so it was not written."
|
||||
than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair
|
||||
that can be converted is written in the marker's unit and reported with `converted: true`."
|
||||
required:
|
||||
- analyte
|
||||
- reason
|
||||
@@ -67867,6 +67913,16 @@ components:
|
||||
- type: "null"
|
||||
unit:
|
||||
type: string
|
||||
converted:
|
||||
type: boolean
|
||||
sourceValue:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
sourceUnit:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
@@ -67895,6 +67951,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
printedReferenceText:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
referenceOrigin:
|
||||
type: string
|
||||
enum:
|
||||
@@ -67928,6 +67988,9 @@ components:
|
||||
- value
|
||||
- valueText
|
||||
- unit
|
||||
- converted
|
||||
- sourceValue
|
||||
- sourceUnit
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- catalogReferenceLow
|
||||
@@ -67935,6 +67998,7 @@ components:
|
||||
- sourceReferenceLow
|
||||
- sourceReferenceHigh
|
||||
- sourceReferenceText
|
||||
- printedReferenceText
|
||||
- referenceOrigin
|
||||
- referenceDivergesFromCatalog
|
||||
- takenAt
|
||||
@@ -67945,6 +68009,9 @@ components:
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.
|
||||
`converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the
|
||||
report printed; for a converted row the printed range text is in `printedReferenceText` (in `sourceUnit`) and
|
||||
`sourceReferenceText` is null.
|
||||
AiRunEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -68184,8 +68251,16 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "The unit the matched marker is tracked in, or null when the marker does not exist yet (it will adopt this
|
||||
row's unit). A row whose `unit` differs from it cannot be committed: the commit skips it with `reason:
|
||||
unit_mismatch`, and the review screen marks it before Save."
|
||||
row's unit). A row whose `unit` differs from it is converted on commit when the marker's analyte is known
|
||||
and the pair is in the unit-conversion registry; otherwise the commit skips it with `reason: unit_mismatch`,
|
||||
and the review screen marks it before Save."
|
||||
markerAnalyteKey:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only
|
||||
key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit`
|
||||
it tells a client whether a row in another unit can be converted on commit.
|
||||
duplicateOf:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -68202,6 +68277,7 @@ components:
|
||||
- confidence
|
||||
- biomarkerMatch
|
||||
- markerUnit
|
||||
- markerAnalyteKey
|
||||
- duplicateOf
|
||||
additionalProperties: false
|
||||
description: "One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side:
|
||||
@@ -68333,6 +68409,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
analyteKey:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
hasContext:
|
||||
type: boolean
|
||||
context:
|
||||
@@ -68352,16 +68432,21 @@ components:
|
||||
- lowerBound
|
||||
- upperBound
|
||||
- panel
|
||||
- analyteKey
|
||||
- hasContext
|
||||
- context
|
||||
- hidden
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
|
||||
presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it
|
||||
(its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
|
||||
from here.
|
||||
description: "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
|
||||
presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g.
|
||||
`fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed
|
||||
(e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never
|
||||
chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit
|
||||
is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the
|
||||
lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker
|
||||
resolves its unit + reference bounds from here."
|
||||
CreateBiomarkerResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -75326,6 +75411,36 @@ components:
|
||||
hasActivityScope:
|
||||
description: Withings only.
|
||||
type: boolean
|
||||
planUnavailable:
|
||||
description: Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12
|
||||
plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these
|
||||
and keeps importing everything else; the list refreshes when the weekly re-probe asks again.
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- spo2
|
||||
- vascularAge
|
||||
- pulseWaveVelocity
|
||||
- vo2Max
|
||||
- visceralFat
|
||||
- skinTemperature
|
||||
- ecg
|
||||
- sleepScore
|
||||
- sleepHrv
|
||||
- sleepHeartRate
|
||||
- sleepRespiratoryRate
|
||||
- sleepSpo2
|
||||
- bodyTemperature
|
||||
- bodyFat
|
||||
- fatMass
|
||||
- fatFreeMass
|
||||
- muscleMass
|
||||
- bodyWater
|
||||
- boneMass
|
||||
- heartRate
|
||||
- bloodPressure
|
||||
- weight
|
||||
needsReauth:
|
||||
description: Google Health only.
|
||||
type: boolean
|
||||
@@ -75335,6 +75450,18 @@ components:
|
||||
hasOwnCredentials:
|
||||
description: Oura / Polar / Strava only.
|
||||
type: boolean
|
||||
scopeReconnect:
|
||||
description: "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections
|
||||
succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect
|
||||
asks for the missing scopes. Empty when the grant covers everything HealthLog reads."
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- spo2
|
||||
- vo2max
|
||||
- cardio_age
|
||||
- resilience
|
||||
hasToken:
|
||||
description: Nightscout only.
|
||||
type: boolean
|
||||
@@ -75778,8 +75905,9 @@ components:
|
||||
- day
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: "Rest Mode: present while an illness episode is active and the illness module is on, else null. Additive
|
||||
since v1.40."
|
||||
description: "Rest Mode: present while an illness episode that is not chronic ongoing (acute, recurring or flare) is
|
||||
active and the illness module is on, else null. A chronic ongoing condition alone never sets it; `day`
|
||||
counts from the earliest onset among the episodes that do. Additive since v1.40."
|
||||
line:
|
||||
type: string
|
||||
description: "Push / lock-screen line: cached-AI lead with a deterministic floor. Carries no number (no reading, dose or
|
||||
|
||||
@@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands
|
||||
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
|
||||
arrive via the sleep series endpoint.
|
||||
|
||||
## Withings API plans (October 2026)
|
||||
|
||||
On 12 October 2026 Withings split its public API into plans
|
||||
([plans page](https://static.withings.com/content/api/plans/plans.html)).
|
||||
The developer app a self-hoster registers for HealthLog sits on the free
|
||||
**Start for Free** plan, which covers the core biomarkers: activity, sleep,
|
||||
body and heart. The FDA/CE-cleared biomarkers and a few body-composition
|
||||
extras now need the paid **Advanced Biomarkers** plan.
|
||||
|
||||
A request that names anything outside the plan is refused as a whole with
|
||||
status 277 ("One of the data type requested in this API request is not
|
||||
available in your API plan"). Before v1.42.3 HealthLog asked for every type
|
||||
in one request, so from that date no Withings reading imported at all, and
|
||||
the connection showed "Reauthorisation required" although reconnecting could
|
||||
not help.
|
||||
|
||||
**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle
|
||||
mass, bone mass, body water, blood pressure, heart rate, a manual SpO2
|
||||
reading, body temperature, steps, distance and calories, and the sleep
|
||||
stages (duration, deep, light, REM, awake, wake-ups).
|
||||
|
||||
**Needs the paid Advanced Biomarkers plan** (per Withings'
|
||||
[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/)
|
||||
table): pulse wave velocity (meastype 91), vascular age (155), visceral fat
|
||||
(170), VO2 max (123), skin temperature (73), ECG recordings and AFib
|
||||
verdicts, and the sleep score, overnight HRV and overnight breathing rate
|
||||
from the sleep summary.
|
||||
|
||||
**What HealthLog does about it, automatically:**
|
||||
|
||||
- When Withings refuses a request with 277, HealthLog asks again without
|
||||
the types Withings lists as advanced. If that is still refused, it
|
||||
splits the remaining types until it has found the ones the plan
|
||||
refuses, within a fixed number of requests. Everything the plan allows
|
||||
imports as before.
|
||||
- The refused types are remembered per connection, so later syncs skip
|
||||
them without a refused request. Once a week HealthLog asks for the full
|
||||
set again, so a plan upgrade is picked up on its own.
|
||||
- ECG recordings are skipped quietly while the plan refuses them.
|
||||
- A plan refusal is never counted as a sync failure and never asks you to
|
||||
reconnect. Connections that an earlier version parked over it are
|
||||
released on the next hourly sync, and that sync imports everything since
|
||||
the last successful one.
|
||||
- Settings → Integrations → Withings shows a short note naming the
|
||||
readings your plan withholds, with a link to the Withings plans page.
|
||||
|
||||
Nothing needs to be configured. If you move your developer app to the
|
||||
Advanced Biomarkers plan, the extra readings start arriving within a week,
|
||||
or right away after reconnecting Withings once.
|
||||
|
||||
## 1. Register a Withings developer app
|
||||
|
||||
1. Sign in at <https://developer.withings.com/> and **create a new
|
||||
@@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds.
|
||||
|
||||
### Sync is stuck on "Reauthorisation required"
|
||||
|
||||
If this started on or after 12 October 2026 and your version is older than
|
||||
v1.42.3, the cause is the API plan change, not your grant: see
|
||||
[Withings API plans (October 2026)](#withings-api-plans-october-2026).
|
||||
Updating is the fix; reconnecting is not needed.
|
||||
|
||||
Withings revokes refresh tokens on password change, app un-authorise,
|
||||
or after extended inactivity. The `WithingsConnection` row flips to
|
||||
`error_reauth` status and the scheduled sync no-ops until the user
|
||||
|
||||
@@ -30,7 +30,20 @@ interface StubbedMedication {
|
||||
}
|
||||
|
||||
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||||
const capture: { postBody: unknown } = { postBody: null };
|
||||
// A body that is not JSON must fail the spec where it reads `postBody`,
|
||||
// with the parse error, not throw inside the un-awaited route callback.
|
||||
const capture = {
|
||||
rawPostBody: null as unknown,
|
||||
parseError: null as string | null,
|
||||
get postBody(): unknown {
|
||||
if (this.parseError !== null) {
|
||||
throw new Error(
|
||||
`POST /api/medications sent a body that is not JSON: ${this.parseError}`,
|
||||
);
|
||||
}
|
||||
return this.rawPostBody;
|
||||
},
|
||||
};
|
||||
|
||||
const listEntry = {
|
||||
id: stubbed.id,
|
||||
@@ -68,7 +81,11 @@ export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||||
return;
|
||||
}
|
||||
if (req.method() === "POST") {
|
||||
capture.postBody = JSON.parse(req.postData() ?? "null");
|
||||
try {
|
||||
capture.rawPostBody = JSON.parse(req.postData() ?? "null");
|
||||
} catch (err) {
|
||||
capture.parseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
|
||||
"action": "Withings neu verbinden"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
|
||||
"body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
|
||||
"link": "Mehr zu den API-Plänen von Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "Gefäßalter",
|
||||
"pulseWaveVelocity": "Pulswellengeschwindigkeit",
|
||||
"vo2Max": "VO2max",
|
||||
"visceralFat": "Viszeralfett",
|
||||
"skinTemperature": "Hauttemperatur",
|
||||
"ecg": "EKG und Vorhofflimmern",
|
||||
"sleepScore": "Schlaf-Score",
|
||||
"sleepHrv": "nächtliche HRV",
|
||||
"sleepHeartRate": "nächtliche Herzfrequenz",
|
||||
"sleepRespiratoryRate": "nächtliche Atemfrequenz",
|
||||
"sleepSpo2": "nächtliche SpO2",
|
||||
"bodyTemperature": "Körpertemperatur",
|
||||
"bodyFat": "Körperfett",
|
||||
"fatMass": "Fettmasse",
|
||||
"fatFreeMass": "fettfreie Masse",
|
||||
"muscleMass": "Muskelmasse",
|
||||
"bodyWater": "Körperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"heartRate": "Herzfrequenz",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"weight": "Gewicht"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
|
||||
"action": "Oura neu verbinden",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2max",
|
||||
"cardio_age": "kardiovaskuläres Alter",
|
||||
"resilience": "Resilienz"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
|
||||
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
|
||||
"convertedFrom": "umgerechnet aus {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Befund scannen",
|
||||
"addScan": "Dokument scannen",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Bitte eine Einheit angeben.",
|
||||
"errorUnitMismatch": "Dieser Marker wird in {markerUnit} geführt. Ändere die Einheit, falls der Befund falsch gelesen wurde, oder wähle den Wert ab.",
|
||||
"unitDiffersBadge": "Geführt in {markerUnit}; dieser Befund nennt {unit}",
|
||||
"unitConvertedBadge": "Wird als {value} {markerUnit} gespeichert, umgerechnet aus {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "{count} Laborwerte gespeichert. Nicht gespeichert, weil die Einheit von der des Markers abweicht: {analytes}",
|
||||
"savedNothingUnitToast": "Nichts gespeichert: Die Einheit weicht bei {analytes} von der des Markers ab",
|
||||
"saveFailed": "Werte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Noch keinem Biomarker zugeordnet",
|
||||
"sourceRange": {
|
||||
"fromReport": "Befundbereich {range}",
|
||||
"catalogDiffers": "dein Bereich {range}"
|
||||
"catalogDiffers": "dein Bereich {range}",
|
||||
"printed": "auf dem Befund in {unit}: {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
|
||||
"action": "Reconnect Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Some readings need Withings' paid API plan",
|
||||
"body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
|
||||
"link": "About the Withings API plans",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "vascular age",
|
||||
"pulseWaveVelocity": "pulse wave velocity",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "visceral fat",
|
||||
"skinTemperature": "skin temperature",
|
||||
"ecg": "ECG and AFib",
|
||||
"sleepScore": "sleep score",
|
||||
"sleepHrv": "overnight HRV",
|
||||
"sleepHeartRate": "overnight heart rate",
|
||||
"sleepRespiratoryRate": "overnight breathing rate",
|
||||
"sleepSpo2": "overnight SpO2",
|
||||
"bodyTemperature": "body temperature",
|
||||
"bodyFat": "body fat",
|
||||
"fatMass": "fat mass",
|
||||
"fatFreeMass": "fat-free mass",
|
||||
"muscleMass": "muscle mass",
|
||||
"bodyWater": "body water",
|
||||
"boneMass": "bone mass",
|
||||
"heartRate": "heart rate",
|
||||
"bloodPressure": "blood pressure",
|
||||
"weight": "weight"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
|
||||
"action": "Reconnect Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "cardiovascular age",
|
||||
"resilience": "resilience"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
|
||||
"showingLatestOf": "Showing the latest {shown} of {total} readings",
|
||||
"convertedFrom": "converted from {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Scan a report",
|
||||
"addScan": "Scan a document",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Add a unit.",
|
||||
"errorUnitMismatch": "This marker is tracked in {markerUnit}. Change the unit if the report was misread, or deselect this reading.",
|
||||
"unitDiffersBadge": "Tracked in {markerUnit}; this report says {unit}",
|
||||
"unitConvertedBadge": "Saved as {value} {markerUnit}, converted from {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "{count} lab results saved. Not saved, because the unit differs from the marker's: {analytes}",
|
||||
"savedNothingUnitToast": "Nothing saved: the unit differs from the marker's for {analytes}",
|
||||
"saveFailed": "Couldn't save the readings. Please try again.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Not linked to a biomarker yet",
|
||||
"sourceRange": {
|
||||
"fromReport": "Report range {range}",
|
||||
"catalogDiffers": "your range {range}"
|
||||
"catalogDiffers": "your range {range}",
|
||||
"printed": "as printed in {unit}: {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
|
||||
"action": "Reconectar Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Algunos valores requieren el plan de API de pago de Withings",
|
||||
"body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
|
||||
"link": "Sobre los planes de API de Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "edad vascular",
|
||||
"pulseWaveVelocity": "velocidad de onda de pulso",
|
||||
"vo2Max": "VO2 máx.",
|
||||
"visceralFat": "grasa visceral",
|
||||
"skinTemperature": "temperatura de la piel",
|
||||
"ecg": "ECG y fibrilación auricular",
|
||||
"sleepScore": "puntuación de sueño",
|
||||
"sleepHrv": "VFC nocturna",
|
||||
"sleepHeartRate": "frecuencia cardíaca nocturna",
|
||||
"sleepRespiratoryRate": "frecuencia respiratoria nocturna",
|
||||
"sleepSpo2": "SpO2 nocturna",
|
||||
"bodyTemperature": "temperatura corporal",
|
||||
"bodyFat": "grasa corporal",
|
||||
"fatMass": "masa grasa",
|
||||
"fatFreeMass": "masa libre de grasa",
|
||||
"muscleMass": "masa muscular",
|
||||
"bodyWater": "agua corporal",
|
||||
"boneMass": "masa ósea",
|
||||
"heartRate": "frecuencia cardíaca",
|
||||
"bloodPressure": "presión arterial",
|
||||
"weight": "peso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
|
||||
"action": "Volver a conectar Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 máx.",
|
||||
"cardio_age": "edad cardiovascular",
|
||||
"resilience": "resiliencia"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
|
||||
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
|
||||
"convertedFrom": "convertido desde {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Escanear un informe",
|
||||
"addScan": "Escanear un documento",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Añade una unidad.",
|
||||
"errorUnitMismatch": "Este marcador se registra en {markerUnit}. Cambia la unidad si el informe se leyó mal o deselecciona este valor.",
|
||||
"unitDiffersBadge": "Registrado en {markerUnit}; este informe indica {unit}",
|
||||
"unitConvertedBadge": "Se guardará como {value} {markerUnit}, convertido desde {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "{count} resultados guardados. No guardados porque la unidad difiere de la del marcador: {analytes}",
|
||||
"savedNothingUnitToast": "No se guardó nada: la unidad difiere de la del marcador en {analytes}",
|
||||
"saveFailed": "No se pudieron guardar los valores. Inténtalo de nuevo.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Aún no vinculado a un biomarcador",
|
||||
"sourceRange": {
|
||||
"fromReport": "Rango del informe {range}",
|
||||
"catalogDiffers": "tu rango {range}"
|
||||
"catalogDiffers": "tu rango {range}",
|
||||
"printed": "impreso en {unit}: {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
|
||||
"action": "Reconnecter Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Certaines mesures nécessitent l’offre API payante de Withings",
|
||||
"body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
|
||||
"link": "En savoir plus sur les offres API de Withings",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
"vascularAge": "l’âge vasculaire",
|
||||
"pulseWaveVelocity": "la vitesse de l’onde de pouls",
|
||||
"vo2Max": "la VO2 max",
|
||||
"visceralFat": "la graisse viscérale",
|
||||
"skinTemperature": "la température cutanée",
|
||||
"ecg": "l’ECG et la fibrillation auriculaire",
|
||||
"sleepScore": "le score de sommeil",
|
||||
"sleepHrv": "la VFC nocturne",
|
||||
"sleepHeartRate": "la fréquence cardiaque nocturne",
|
||||
"sleepRespiratoryRate": "la fréquence respiratoire nocturne",
|
||||
"sleepSpo2": "la SpO2 nocturne",
|
||||
"bodyTemperature": "la température corporelle",
|
||||
"bodyFat": "le taux de graisse corporelle",
|
||||
"fatMass": "la masse grasse",
|
||||
"fatFreeMass": "la masse maigre",
|
||||
"muscleMass": "la masse musculaire",
|
||||
"bodyWater": "l’eau corporelle",
|
||||
"boneMass": "la masse osseuse",
|
||||
"heartRate": "la fréquence cardiaque",
|
||||
"bloodPressure": "la tension artérielle",
|
||||
"weight": "le poids"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
|
||||
"action": "Reconnecter Oura",
|
||||
"items": {
|
||||
"spo2": "la SpO2",
|
||||
"vo2max": "la VO2 max",
|
||||
"cardio_age": "l’âge cardiovasculaire",
|
||||
"resilience": "la résilience"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
|
||||
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
|
||||
"convertedFrom": "converti depuis {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Scanner un compte rendu",
|
||||
"addScan": "Scanner un document",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Indiquez une unité.",
|
||||
"errorUnitMismatch": "Ce marqueur est suivi en {markerUnit}. Modifiez l’unité si le compte rendu a été mal lu, ou désélectionnez cette valeur.",
|
||||
"unitDiffersBadge": "Suivi en {markerUnit} ; ce compte rendu indique {unit}",
|
||||
"unitConvertedBadge": "Enregistré comme {value} {markerUnit}, converti depuis {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "{count} résultats enregistrés. Non enregistrés, car l’unité diffère de celle du marqueur : {analytes}",
|
||||
"savedNothingUnitToast": "Rien n’a été enregistré : l’unité diffère de celle du marqueur pour {analytes}",
|
||||
"saveFailed": "Impossible d'enregistrer les valeurs. Veuillez réessayer.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Pas encore lié à un biomarqueur",
|
||||
"sourceRange": {
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
"catalogDiffers": "votre plage {range}"
|
||||
"catalogDiffers": "votre plage {range}",
|
||||
"printed": "imprimé en {unit} : {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
|
||||
"action": "Riconnetti Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Alcuni valori richiedono il piano API a pagamento di Withings",
|
||||
"body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
|
||||
"link": "Informazioni sui piani API di Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "età vascolare",
|
||||
"pulseWaveVelocity": "velocità dell’onda di polso",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "grasso viscerale",
|
||||
"skinTemperature": "temperatura cutanea",
|
||||
"ecg": "ECG e fibrillazione atriale",
|
||||
"sleepScore": "punteggio del sonno",
|
||||
"sleepHrv": "HRV notturna",
|
||||
"sleepHeartRate": "frequenza cardiaca notturna",
|
||||
"sleepRespiratoryRate": "frequenza respiratoria notturna",
|
||||
"sleepSpo2": "SpO2 notturna",
|
||||
"bodyTemperature": "temperatura corporea",
|
||||
"bodyFat": "grasso corporeo",
|
||||
"fatMass": "massa grassa",
|
||||
"fatFreeMass": "massa magra",
|
||||
"muscleMass": "massa muscolare",
|
||||
"bodyWater": "acqua corporea",
|
||||
"boneMass": "massa ossea",
|
||||
"heartRate": "frequenza cardiaca",
|
||||
"bloodPressure": "pressione arteriosa",
|
||||
"weight": "peso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
|
||||
"action": "Riconnetti Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "età cardiovascolare",
|
||||
"resilience": "resilienza"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
|
||||
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
|
||||
"convertedFrom": "convertito da {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Scansiona un referto",
|
||||
"addScan": "Scansiona un documento",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Inserisci un'unità.",
|
||||
"errorUnitMismatch": "Questo marcatore è registrato in {markerUnit}. Cambia l’unità se il referto è stato letto male, oppure deseleziona questo valore.",
|
||||
"unitDiffersBadge": "Registrato in {markerUnit}; questo referto indica {unit}",
|
||||
"unitConvertedBadge": "Verrà salvato come {value} {markerUnit}, convertito da {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "{count} risultati salvati. Non salvati perché l’unità è diversa da quella del marcatore: {analytes}",
|
||||
"savedNothingUnitToast": "Nulla salvato: l’unità è diversa da quella del marcatore per {analytes}",
|
||||
"saveFailed": "Impossibile salvare i valori. Riprova.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Non ancora collegato a un biomarcatore",
|
||||
"sourceRange": {
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
"catalogDiffers": "il tuo intervallo {range}"
|
||||
"catalogDiffers": "il tuo intervallo {range}",
|
||||
"printed": "stampato in {unit}: {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
|
||||
"action": "Withings 다시 연결"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
|
||||
"body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
|
||||
"link": "Withings API 요금제 알아보기",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "혈관 나이",
|
||||
"pulseWaveVelocity": "맥파 속도",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "내장 지방",
|
||||
"skinTemperature": "피부 온도",
|
||||
"ecg": "심전도와 심방세동",
|
||||
"sleepScore": "수면 점수",
|
||||
"sleepHrv": "야간 HRV",
|
||||
"sleepHeartRate": "야간 심박수",
|
||||
"sleepRespiratoryRate": "야간 호흡수",
|
||||
"sleepSpo2": "야간 SpO2",
|
||||
"bodyTemperature": "체온",
|
||||
"bodyFat": "체지방률",
|
||||
"fatMass": "체지방량",
|
||||
"fatFreeMass": "제지방량",
|
||||
"muscleMass": "근육량",
|
||||
"bodyWater": "체수분",
|
||||
"boneMass": "골량",
|
||||
"heartRate": "심박수",
|
||||
"bloodPressure": "혈압",
|
||||
"weight": "체중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
|
||||
"action": "Oura 다시 연결",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "심혈관 나이",
|
||||
"resilience": "회복탄력성"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.",
|
||||
"showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요",
|
||||
"convertedFrom": "{value} {unit}에서 변환됨",
|
||||
"ocr": {
|
||||
"scanButton": "결과지 스캔",
|
||||
"addScan": "문서 스캔",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "단위를 입력하세요.",
|
||||
"errorUnitMismatch": "이 지표는 {markerUnit} 단위로 기록됩니다. 결과지를 잘못 읽었다면 단위를 바꾸거나 이 값의 선택을 해제하세요.",
|
||||
"unitDiffersBadge": "{markerUnit} 단위로 기록 중; 이 결과지는 {unit}",
|
||||
"unitConvertedBadge": "{value} {markerUnit}(으)로 저장됩니다. {statedValue} {unit}에서 변환됨",
|
||||
"savedPartialUnitToast": "검사 결과 {count}개를 저장했습니다. 단위가 지표와 달라 저장하지 않은 항목: {analytes}",
|
||||
"savedNothingUnitToast": "저장된 항목이 없습니다: {analytes}의 단위가 지표와 다릅니다",
|
||||
"saveFailed": "측정값을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "아직 바이오마커에 연결되지 않았어요",
|
||||
"sourceRange": {
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
"catalogDiffers": "내 범위 {range}"
|
||||
"catalogDiffers": "내 범위 {range}",
|
||||
"printed": "결과지 원문({unit}): {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+45
-1
@@ -6505,6 +6505,47 @@
|
||||
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
|
||||
"action": "Połącz ponownie Withings"
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
"title": "Niektóre pomiary wymagają płatnego planu API Withings",
|
||||
"body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
|
||||
"link": "Więcej o planach API Withings",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vascularAge": "wiek naczyniowy",
|
||||
"pulseWaveVelocity": "prędkość fali tętna",
|
||||
"vo2Max": "VO2 max",
|
||||
"visceralFat": "tłuszcz trzewny",
|
||||
"skinTemperature": "temperatura skóry",
|
||||
"ecg": "EKG i migotanie przedsionków",
|
||||
"sleepScore": "wynik snu",
|
||||
"sleepHrv": "nocne HRV",
|
||||
"sleepHeartRate": "nocne tętno",
|
||||
"sleepRespiratoryRate": "nocna częstość oddechów",
|
||||
"sleepSpo2": "nocne SpO2",
|
||||
"bodyTemperature": "temperatura ciała",
|
||||
"bodyFat": "tkanka tłuszczowa",
|
||||
"fatMass": "masa tłuszczowa",
|
||||
"fatFreeMass": "beztłuszczowa masa ciała",
|
||||
"muscleMass": "masa mięśniowa",
|
||||
"bodyWater": "woda w organizmie",
|
||||
"boneMass": "masa kostna",
|
||||
"heartRate": "tętno",
|
||||
"bloodPressure": "ciśnienie krwi",
|
||||
"weight": "waga"
|
||||
}
|
||||
}
|
||||
},
|
||||
"oura": {
|
||||
"scope": {
|
||||
"body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
|
||||
"action": "Połącz ponownie Oura",
|
||||
"items": {
|
||||
"spo2": "SpO2",
|
||||
"vo2max": "VO2 max",
|
||||
"cardio_age": "wiek sercowo-naczyniowy",
|
||||
"resilience": "odporność"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9522,6 +9563,7 @@
|
||||
},
|
||||
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
|
||||
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
|
||||
"convertedFrom": "przeliczono z {value} {unit}",
|
||||
"ocr": {
|
||||
"scanButton": "Zeskanuj wynik",
|
||||
"addScan": "Zeskanuj dokument",
|
||||
@@ -9568,6 +9610,7 @@
|
||||
"errorUnit": "Podaj jednostkę.",
|
||||
"errorUnitMismatch": "Ten wskaźnik jest prowadzony w {markerUnit}. Zmień jednostkę, jeśli wynik został źle odczytany, albo odznacz tę wartość.",
|
||||
"unitDiffersBadge": "Prowadzony w {markerUnit}; ten wynik podaje {unit}",
|
||||
"unitConvertedBadge": "Zostanie zapisane jako {value} {markerUnit}, przeliczone z {statedValue} {unit}",
|
||||
"savedPartialUnitToast": "Zapisano {count} wyników. Nie zapisano, bo jednostka różni się od jednostki wskaźnika: {analytes}",
|
||||
"savedNothingUnitToast": "Nic nie zapisano: jednostka różni się od jednostki wskaźnika dla {analytes}",
|
||||
"saveFailed": "Nie udało się zapisać wyników. Spróbuj ponownie.",
|
||||
@@ -9588,7 +9631,8 @@
|
||||
"notLinkedYet": "Jeszcze niepowiązane z biomarkerem",
|
||||
"sourceRange": {
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
"catalogDiffers": "twój zakres {range}"
|
||||
"catalogDiffers": "twój zakres {range}",
|
||||
"printed": "wydrukowano w {unit}: {range}"
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.42.2",
|
||||
"version": "1.42.3",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.42.3 — remember what an integration's upstream refuses by contract.
|
||||
--
|
||||
-- Withings split its public API into plans on 2026-10-12. A self-hoster's own
|
||||
-- developer app sits on the free plan, and a request that names a meastype
|
||||
-- outside that plan is refused whole with status 277. The sync now drops the
|
||||
-- gated types and keeps the rest; this column remembers which ones per
|
||||
-- connection so later runs skip them without a refused round trip, plus when
|
||||
-- that was last checked so a plan upgrade is noticed within a week.
|
||||
--
|
||||
-- Oura's current authorization server enforces scopes per collection. A grant
|
||||
-- issued before HealthLog asked for `spo2` and `heart_health` answers 401 on
|
||||
-- the collections behind them while every other collection succeeds. This
|
||||
-- column holds those collection names so the Oura card can ask for a single
|
||||
-- reconnect instead of painting the connection as broken.
|
||||
--
|
||||
-- Additive only: two nullable columns, no default, no rewrite, no data change.
|
||||
ALTER TABLE "withings_connections" ADD COLUMN IF NOT EXISTS "api_plan_state" JSONB;
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_scope_denied" JSONB;
|
||||
+18
-4
@@ -264,6 +264,13 @@ model User {
|
||||
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
|
||||
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
|
||||
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
|
||||
/// v1.42.3 — Oura collections the current grant does not cover, as a JSON
|
||||
/// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
|
||||
/// `resilience`). Written by the sync when a collection answers 401/403 while
|
||||
/// its siblings succeed with the same token, which is a scope the grant never
|
||||
/// carried, not a dead token. The Oura card reads it to ask for one
|
||||
/// reconnect. Cleared on connect, disconnect and credential removal.
|
||||
ouraScopeDenied Json? @map("oura_scope_denied")
|
||||
|
||||
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
|
||||
// level). Strava caps every newly-created API app at single-player mode
|
||||
@@ -4149,6 +4156,13 @@ model WithingsConnection {
|
||||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||||
/// v1.42.3 — what this connection's Withings API plan refuses (status 277),
|
||||
/// per request family: `{ measures, sleepSummary, ecg }`, each
|
||||
/// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without
|
||||
/// paying a refused round trip each time, re-probe them weekly, and lets the
|
||||
/// Withings card name what the plan withholds. Versioned and validated on
|
||||
/// read (`src/lib/withings/api-plan.ts`); reset on reconnect.
|
||||
apiPlanState Json? @map("api_plan_state")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -6150,11 +6164,11 @@ model LabResult {
|
||||
sourceReferenceLow Float? @map("source_reference_low")
|
||||
sourceReferenceHigh Float? @map("source_reference_high")
|
||||
sourceReferenceText String? @map("source_reference_text")
|
||||
/// @pending-consumer(#1095) v1.42 — the value as printed on the source
|
||||
/// v1.42 — the value as printed on the source
|
||||
/// report, kept when a write converted it into the biomarker's canonical
|
||||
/// unit. NULL when no conversion happened. A later edit of `value` clears it.
|
||||
/// unit. NULL when no conversion happened. An edit that changes `value` clears it.
|
||||
sourceValue Float? @map("source_value")
|
||||
/// @pending-consumer(#1095) v1.42 — the unit the source report printed,
|
||||
/// v1.42 — the unit the source report printed,
|
||||
/// paired with `sourceValue`.
|
||||
sourceUnit String? @map("source_unit")
|
||||
/// When the sample was taken (UTC instant; wall-clock anchoring is the
|
||||
@@ -6225,7 +6239,7 @@ model Biomarker {
|
||||
/// and from the lab-entry pickers, but never deleted — its readings and
|
||||
/// canonical unit/range definition survive.
|
||||
hidden Boolean @default(false) @map("hidden")
|
||||
/// @pending-consumer(#1095) v1.42 — the catalogue analyte this marker
|
||||
/// v1.42 — the catalogue analyte this marker
|
||||
/// resolves to (alias list in `src/lib/labs/biomarker-catalog.ts`), which
|
||||
/// selects the unit-conversion table. NULL = not resolved; no conversion.
|
||||
analyteKey String? @map("analyte_key")
|
||||
|
||||
+1
-1
@@ -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.42.2";
|
||||
/* @sw-version-fallback */ "v1.42.3";
|
||||
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/*`
|
||||
|
||||
@@ -103,6 +103,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// "{hours} h {minutes} min": the SI symbols for hour and minute.
|
||||
"measurements.durationHoursMinutes",
|
||||
"settings.sections.sources.metrics.vo2Max",
|
||||
"settings.integrations.withings.plan.items.vo2Max",
|
||||
"settings.integrations.oura.scope.items.vo2max",
|
||||
// Brand / product / proper nouns.
|
||||
"settings.googleHealth",
|
||||
"admin.glitchtipDsn",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The proxy's side of a notification quick answer (#1203).
|
||||
*
|
||||
* ntfy's `http` action POSTs to `/api/notifications/action/<token>` with no
|
||||
* cookie, no Origin and an empty body. That request must pass the proxy
|
||||
* untouched. Every request the proxy refuses on this path must leave one log
|
||||
* line with a reason, because the route's own wide event never runs for it
|
||||
* and the server log is the operator's only evidence. The token must never be
|
||||
* in that line.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
|
||||
const processType = vi.hoisted(() => ({ web: true }));
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => processType.web,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
const TOKEN =
|
||||
"na1.cuserid0000000000000000.mood-good.1792017927608.ACWxZHsEVXyh-Yghadj-2Q.PRmiFi7-C4ZkQ45E9YHl95GjSXoeV3xZMwLJ90Ts4QY";
|
||||
const URL_ = `https://health.example.test/api/notifications/action/${TOKEN}`;
|
||||
|
||||
/** The request ntfy's Android app sends for an `http` action. */
|
||||
function ntfyRequest(method = "POST"): NextRequest {
|
||||
return new NextRequest(URL_, {
|
||||
method,
|
||||
headers: { "user-agent": "ntfy/1.16.0 (okhttp)", "content-length": "0" },
|
||||
...(method === "POST" ? { body: "" } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
let warn: MockInstance<(...args: unknown[]) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
processType.web = true;
|
||||
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function loggedLines(): Array<Record<string, unknown>> {
|
||||
return warn.mock.calls.map((args: unknown[]) => JSON.parse(String(args[0])));
|
||||
}
|
||||
|
||||
describe("proxy on /api/notifications/action/<token>", () => {
|
||||
it("passes ntfy's POST through to the route without a refusal", () => {
|
||||
const response = proxy(ntfyRequest());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-middleware-next")).toBe("1");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD", "PUT", "DELETE"])(
|
||||
"refuses %s with 405 and logs why, without the token",
|
||||
(method) => {
|
||||
const response = proxy(ntfyRequest(method));
|
||||
|
||||
expect(response.status).toBe(405);
|
||||
expect(response.headers.get("allow")).toBe("POST");
|
||||
const lines = loggedLines();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toMatchObject({
|
||||
level: "warn",
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "method_not_allowed" },
|
||||
http: {
|
||||
method,
|
||||
status: 405,
|
||||
path: "/api/notifications/action/[REDACTED]",
|
||||
},
|
||||
});
|
||||
expect(String(warn.mock.calls[0][0])).not.toContain(TOKEN);
|
||||
expect(String(warn.mock.calls[0][0])).not.toContain("PRmiFi7");
|
||||
},
|
||||
);
|
||||
|
||||
it("logs the demo-mode refusal", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
const response = proxy(ntfyRequest());
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(loggedLines()[0]).toMatchObject({
|
||||
meta: { reason: "demo_mode" },
|
||||
http: { status: 403 },
|
||||
});
|
||||
});
|
||||
|
||||
it("logs the worker-only refusal", () => {
|
||||
processType.web = false;
|
||||
const response = proxy(ntfyRequest());
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(loggedLines()[0]).toMatchObject({
|
||||
meta: { reason: "worker_only" },
|
||||
http: { status: 503 },
|
||||
});
|
||||
});
|
||||
|
||||
it("logs nothing for a refusal on any other path", () => {
|
||||
vi.stubEnv("DEMO_MODE", "true");
|
||||
proxy(
|
||||
new NextRequest("https://health.example.test/api/measurements", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
"inserted.value": OCR_ROW_EXCEPTION,
|
||||
"inserted.valueText": OCR_ROW_EXCEPTION,
|
||||
"inserted.unit": OCR_ROW_EXCEPTION,
|
||||
"inserted.converted": OCR_ROW_EXCEPTION,
|
||||
"inserted.sourceValue": OCR_ROW_EXCEPTION,
|
||||
"inserted.sourceUnit": OCR_ROW_EXCEPTION,
|
||||
"inserted.referenceLow": OCR_ROW_EXCEPTION,
|
||||
"inserted.referenceHigh": OCR_ROW_EXCEPTION,
|
||||
"inserted.catalogReferenceLow": OCR_ROW_EXCEPTION,
|
||||
@@ -729,6 +732,7 @@ const OCR_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
"inserted.sourceReferenceLow": OCR_ROW_EXCEPTION,
|
||||
"inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION,
|
||||
"inserted.sourceReferenceText": OCR_ROW_EXCEPTION,
|
||||
"inserted.printedReferenceText": OCR_ROW_EXCEPTION,
|
||||
"inserted.referenceOrigin": OCR_ROW_EXCEPTION,
|
||||
"inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION,
|
||||
"inserted.takenAt": OCR_ROW_EXCEPTION,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
decryptContextFromBytes,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
import { effectiveBound, isInvertedRange } from "@/lib/validations/labs";
|
||||
@@ -41,6 +42,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
@@ -53,6 +55,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: row.contextEncrypted
|
||||
? decryptContextFromBytes(row.contextEncrypted)
|
||||
@@ -174,6 +177,16 @@ export const PUT = apiHandler(
|
||||
if (d.upperBound !== undefined) data.upperBound = d.upperBound;
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.hidden !== undefined) data.hidden = d.hidden;
|
||||
// The analyte a marker is follows from its name and unit, the same match
|
||||
// the creation paths use. A rename must move it, or a marker renamed from
|
||||
// "Glucose" to "Cholesterol" would keep converting readings with the
|
||||
// glucose factor; a name that now matches nothing clears it.
|
||||
if (d.name !== undefined || d.unit !== undefined) {
|
||||
data.analyteKey = matchAnalyteKey({
|
||||
name: d.name ?? existing.name,
|
||||
unit: d.unit ?? existing.unit,
|
||||
});
|
||||
}
|
||||
|
||||
// Inverted-range guard for a PARTIAL bound update. The schema refine only
|
||||
// fires when both bounds arrive together; moving a single bound past the
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
decryptContextSoft,
|
||||
encryptContextToBytes,
|
||||
} from "@/lib/labs/biomarker-store";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
|
||||
|
||||
@@ -51,6 +52,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
hidden: boolean;
|
||||
contextEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
@@ -63,6 +65,7 @@ function serialiseBiomarker(row: {
|
||||
lowerBound: row.lowerBound,
|
||||
upperBound: row.upperBound,
|
||||
panel: row.panel,
|
||||
analyteKey: row.analyteKey,
|
||||
hasContext: row.contextEncrypted !== null,
|
||||
context: decryptContextSoft(row.contextEncrypted),
|
||||
hidden: row.hidden,
|
||||
@@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) {
|
||||
lowerBound: lowerBound ?? null,
|
||||
upperBound: upperBound ?? null,
|
||||
panel: panel ?? null,
|
||||
// Which analyte it is, for the unit conversion; null when the name is no
|
||||
// analyte the registry knows.
|
||||
analyteKey: matchAnalyteKey({ name, unit }),
|
||||
contextEncrypted: context ? encryptContextToBytes(context) : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ export const GET = apiHandler(async () => {
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
@@ -67,6 +68,7 @@ export const GET = apiHandler(async () => {
|
||||
sourceReferenceLow: r.sourceReferenceLow,
|
||||
sourceReferenceHigh: r.sourceReferenceHigh,
|
||||
sourceReferenceText: r.sourceReferenceText,
|
||||
sourceUnit: r.sourceUnit,
|
||||
takenAt: r.takenAt,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false);
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: () => polarAvailable(),
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
vi.mock("@/lib/oura/credentials", async () => ({
|
||||
readOuraScopeDenied: (
|
||||
await vi.importActual<typeof import("@/lib/oura/credentials")>(
|
||||
"@/lib/oura/credentials",
|
||||
)
|
||||
).readOuraScopeDenied,
|
||||
getOuraClientCredentials: () => ouraAvailable(),
|
||||
}));
|
||||
|
||||
@@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => {
|
||||
expect(whoop.syncHealth?.verdict).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/integrations/status — what a provider withholds by contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
|
||||
(
|
||||
prisma.withingsConnection.findUnique as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValueOnce({
|
||||
tokenExpiresAt: new Date(Date.now() + 3_600_000),
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(),
|
||||
scope: "user.metrics,user.activity",
|
||||
apiPlanState: {
|
||||
version: 1,
|
||||
measures: {
|
||||
unavailable: [155, 54],
|
||||
checkedAt: new Date().toISOString(),
|
||||
},
|
||||
ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
|
||||
},
|
||||
});
|
||||
userFind.mockResolvedValue({
|
||||
ouraAccessTokenEncrypted: "tok",
|
||||
ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
|
||||
});
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
const withings = entries.find((e) => e.integration === "withings")!;
|
||||
const oura = entries.find((e) => e.integration === "oura")!;
|
||||
|
||||
expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
|
||||
expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
|
||||
});
|
||||
|
||||
it("publishes empty lists when nothing is withheld", async () => {
|
||||
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
|
||||
|
||||
const entries = (await fetchEntries()) as Array<
|
||||
Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
|
||||
>;
|
||||
expect(
|
||||
entries.find((e) => e.integration === "withings")!.planUnavailable,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
entries.find((e) => e.integration === "oura")!.scopeReconnect,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,19 @@ import {
|
||||
resolveSyncVerdict,
|
||||
type SyncHealth,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
getOuraClientCredentials,
|
||||
readOuraScopeDenied,
|
||||
} from "@/lib/oura/credentials";
|
||||
import {
|
||||
ouraScopeReconnectList,
|
||||
type OuraScopeGatedCollection,
|
||||
} from "@/lib/oura/scope";
|
||||
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
|
||||
import {
|
||||
withingsPlanUnavailableFeatures,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
ouraScopeDenied: true,
|
||||
stravaAccessTokenEncrypted: true,
|
||||
stravaClientIdEncrypted: true,
|
||||
stravaClientSecretEncrypted: true,
|
||||
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncedAt: true,
|
||||
createdAt: true,
|
||||
scope: true,
|
||||
apiPlanState: true,
|
||||
},
|
||||
}),
|
||||
prisma.whoopConnection.findUnique({
|
||||
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
|
||||
// Null `scope` = legacy connection that predates activity-scope reads.
|
||||
scope: withingsConn?.scope ?? null,
|
||||
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
|
||||
// What this connection's Withings API plan withholds (status 277),
|
||||
// resolved here so the card only renders it.
|
||||
planUnavailable: withingsConn
|
||||
? withingsPlanUnavailableFeatures(
|
||||
parseWithingsApiPlanState(withingsConn.apiPlanState),
|
||||
)
|
||||
: [],
|
||||
...resolve("withings", withingsStatus, {
|
||||
connected: !!withingsConn,
|
||||
configured: withingsConfigured,
|
||||
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
// Collections the stored grant does not cover; one reconnect asks for
|
||||
// the missing scopes. Not a failure, so it never colours the pill.
|
||||
scopeReconnect: ouraConnected
|
||||
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
|
||||
: [],
|
||||
...resolve("oura", ouraStatus, {
|
||||
connected: ouraConnected,
|
||||
configured: ouraConnected,
|
||||
}),
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
} satisfies IntegrationViewModel &
|
||||
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
|
||||
{
|
||||
...publish(stravaStatus),
|
||||
connected: stravaConnected,
|
||||
@@ -442,6 +469,7 @@ interface WithingsExtras {
|
||||
tokenExpired: boolean | null;
|
||||
scope: string | null;
|
||||
hasActivityScope: boolean;
|
||||
planUnavailable: WithingsPlanFeature[];
|
||||
}
|
||||
|
||||
interface WhoopExtras {
|
||||
|
||||
@@ -192,7 +192,36 @@ export const PUT = apiHandler(
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.analyte !== undefined) data.analyte = d.analyte;
|
||||
if (d.value !== undefined) data.value = d.value;
|
||||
// Set when a changed value retires a conversion whose printed range text is
|
||||
// in the report's unit (see below).
|
||||
let dropPrintedText = false;
|
||||
if (d.value !== undefined) {
|
||||
data.value = d.value;
|
||||
// A number the person typed is no longer the conversion of what the
|
||||
// report printed, so the "converted from" record would now be wrong. Only
|
||||
// a CHANGED value says so: a client that sends the whole form back with
|
||||
// the value untouched (the web edit dialog does) must not erase the
|
||||
// record. Both sides are plain numbers (`value` is a Float column).
|
||||
if (
|
||||
d.value !== existing.value &&
|
||||
(existing.sourceValue !== null || existing.sourceUnit !== null)
|
||||
) {
|
||||
data.sourceValue = null;
|
||||
data.sourceUnit = null;
|
||||
// The printed range text belonged to the printed value and is written
|
||||
// in the report's unit. With `sourceUnit` gone nothing would say so,
|
||||
// and every reader would present it as the marker unit's window, so
|
||||
// it goes with the record. The bounds stay: they were converted with
|
||||
// the value and are already in the marker's unit.
|
||||
if (
|
||||
existing.sourceUnit !== null &&
|
||||
existing.sourceReferenceText !== null
|
||||
) {
|
||||
data.sourceReferenceText = null;
|
||||
dropPrintedText = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
|
||||
// qualitative row (or `valueText` on a numeric row) crosses the row's type
|
||||
// and is rejected below. An edit of the matching field updates in place.
|
||||
@@ -206,7 +235,15 @@ export const PUT = apiHandler(
|
||||
if (d.sourceReferenceHigh !== undefined) {
|
||||
data.sourceReferenceHigh = d.sourceReferenceHigh;
|
||||
}
|
||||
if (d.sourceReferenceText !== undefined) {
|
||||
// A whole-form echo of the old printed text does not bring it back once the
|
||||
// value change retired it; a text that differs is a new statement and wins.
|
||||
if (
|
||||
d.sourceReferenceText !== undefined &&
|
||||
!(
|
||||
dropPrintedText &&
|
||||
d.sourceReferenceText === existing.sourceReferenceText
|
||||
)
|
||||
) {
|
||||
data.sourceReferenceText = d.sourceReferenceText;
|
||||
}
|
||||
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { serialiseLabResult } from "@/lib/labs/serialise";
|
||||
@@ -75,7 +76,13 @@ async function isDuplicate(
|
||||
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
|
||||
takenAt: labReadingDaySearchRange(day),
|
||||
},
|
||||
select: { value: true, valueText: true, takenAt: true },
|
||||
select: {
|
||||
value: true,
|
||||
valueText: true,
|
||||
sourceValue: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day);
|
||||
@@ -83,6 +90,19 @@ async function isDuplicate(
|
||||
if (row.value !== undefined && c.value !== null && c.value === row.value) {
|
||||
return true;
|
||||
}
|
||||
// A reading stored converted holds the value in the marker's unit, so the
|
||||
// same scan committed again no longer equals it. What the report printed is
|
||||
// kept beside it: the same number in the same unit is the same reading.
|
||||
if (
|
||||
row.value !== undefined &&
|
||||
row.unit !== undefined &&
|
||||
c.sourceValue !== null &&
|
||||
c.sourceValue === row.value &&
|
||||
c.sourceUnit !== null &&
|
||||
sameLabUnit(c.sourceUnit, row.unit)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
row.valueText !== undefined &&
|
||||
c.valueText !== null &&
|
||||
@@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) {
|
||||
panel: row.panel ?? null,
|
||||
});
|
||||
|
||||
// The marker's unit is the one the reading is stored under, so a row that
|
||||
// states another unit cannot be written: the number would be kept under a
|
||||
// unit it is not in. The review screen marks such a row before Save; this
|
||||
// is the backstop for a client that did not, and for a marker whose unit
|
||||
// changed between the scan and the commit. A freshly minted marker adopted
|
||||
// this row's unit, so it cannot disagree.
|
||||
if (
|
||||
!isQualitative &&
|
||||
row.unit !== undefined &&
|
||||
!sameLabUnit(row.unit, biomarker.unit)
|
||||
) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
|
||||
continue;
|
||||
// The marker's unit is the one the reading is stored under. A row that
|
||||
// states another unit is converted when the marker's analyte is known and
|
||||
// the registry has the pair, with what the report printed kept beside it;
|
||||
// otherwise it cannot be written: the number would be kept under a unit it
|
||||
// is not in. The review screen marks such a row before Save; this is the
|
||||
// backstop for a client that did not, and for a marker whose unit changed
|
||||
// between the scan and the commit. A freshly minted marker adopted this
|
||||
// row's unit, so it cannot disagree.
|
||||
let storedValue = row.value ?? null;
|
||||
let storedSourceLow = isQualitative ? null : sourceLow;
|
||||
let storedSourceHigh = isQualitative ? null : sourceHigh;
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null =
|
||||
null;
|
||||
if (!isQualitative && row.value !== undefined) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
});
|
||||
if (!reading.ok) {
|
||||
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
|
||||
continue;
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedSourceLow = reading.sourceReferenceLow;
|
||||
storedSourceHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field — never spread the parsed row. The row stamps the resolved
|
||||
@@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) {
|
||||
biomarkerId: biomarker.id,
|
||||
panel: biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
value: row.value ?? null,
|
||||
value: storedValue,
|
||||
valueText: row.valueText ?? null,
|
||||
unit: biomarker.unit,
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
sourceReferenceLow: isQualitative ? null : sourceLow,
|
||||
sourceReferenceHigh: isQualitative ? null : sourceHigh,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : sourceText,
|
||||
takenAt: row.takenAt,
|
||||
source: "OCR",
|
||||
|
||||
+57
-27
@@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import {
|
||||
type ResolvedBiomarker,
|
||||
serialiseLabResult,
|
||||
@@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) {
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
},
|
||||
});
|
||||
if (!found) {
|
||||
@@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// The marker's unit is the one every reading is stored under, so a reading
|
||||
// that states another unit cannot be written: the number would be kept and
|
||||
// the label swapped, and nothing downstream could tell. Compared only when a
|
||||
// unit was actually sent: on the `biomarkerId` path it may be omitted, and
|
||||
// then the marker's unit is the stated one. A freshly minted marker adopted
|
||||
// the reading's own unit, so it cannot disagree with it.
|
||||
if (
|
||||
!isQualitative &&
|
||||
unit !== undefined &&
|
||||
!sameLabUnit(unit, biomarker.unit)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-mismatch" },
|
||||
meta: { biomarkerId: biomarker.id },
|
||||
// The marker's unit is the one every reading is stored under. A reading that
|
||||
// states another unit is converted when the marker's analyte is known and the
|
||||
// registry has the pair (the value, and the report's own window with it, with
|
||||
// what the report printed kept beside it); otherwise it cannot be written:
|
||||
// the number would be kept and the label swapped, and nothing downstream
|
||||
// could tell. Compared only when a unit was actually sent: on the
|
||||
// `biomarkerId` path it may be omitted, and then the marker's unit is the
|
||||
// stated one. A freshly minted marker adopted the reading's own unit, so it
|
||||
// cannot disagree with it.
|
||||
let storedValue = value ?? null;
|
||||
let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null);
|
||||
let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null);
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
|
||||
if (!isQualitative && value !== undefined) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value,
|
||||
unit,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
});
|
||||
return apiError(
|
||||
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: biomarker.unit,
|
||||
readingUnit: unit,
|
||||
},
|
||||
);
|
||||
if (!reading.ok) {
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-mismatch" },
|
||||
meta: { biomarkerId: biomarker.id, reason: reading.reason },
|
||||
});
|
||||
return apiError(
|
||||
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
|
||||
422,
|
||||
{
|
||||
errorCode: "labs.unit.mismatch",
|
||||
markerUnit: biomarker.unit,
|
||||
readingUnit: unit,
|
||||
reason: reading.reason,
|
||||
},
|
||||
);
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedSourceLow = reading.sourceReferenceLow;
|
||||
storedSourceHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
annotate({
|
||||
action: { name: "labs.create.unit-converted" },
|
||||
meta: { biomarkerId: biomarker.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field assignment — never spread `parsed.data`. The row stamps the
|
||||
@@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) {
|
||||
panel: biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
// Exactly one of value / valueText is set (the Zod XOR refine).
|
||||
value: value ?? null,
|
||||
value: storedValue,
|
||||
valueText: valueText ?? null,
|
||||
unit: biomarker.unit,
|
||||
// What the report printed, when the value above is a conversion of it.
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
// The window this reading's own report printed, kept beside the catalog
|
||||
// stamp rather than folded into it: it governs THIS reading's verdict,
|
||||
// and the catalog band stays the net for every reading without one. A
|
||||
// qualitative reading has no window to record.
|
||||
sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
|
||||
sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
|
||||
takenAt,
|
||||
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
|
||||
|
||||
@@ -71,6 +71,10 @@ export const POST = apiHandler(
|
||||
RATE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "notification.action.refused" },
|
||||
meta: { reason: "rate_limited" },
|
||||
});
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
|
||||
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
|
||||
// A new grant carries the scopes asked for now; whatever the old one
|
||||
// lacked is found out again by the first sync.
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
webhookSubscriptionState: Prisma.DbNull,
|
||||
webhookSubscriptionRetryAt: subscriptionRetryAt,
|
||||
// A reconnect may come with another developer app (another plan), so
|
||||
// what the old grant's plan refused is asked for again.
|
||||
apiPlanState: Prisma.DbNull,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -28,6 +28,7 @@ const MARKER: BiomarkerDto = {
|
||||
lowerBound: 70,
|
||||
upperBound: 99,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hasContext: false,
|
||||
context: null,
|
||||
hidden: false,
|
||||
|
||||
@@ -51,6 +51,7 @@ const MARKER: BiomarkerDto = {
|
||||
lowerBound: 0,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
hasContext: false,
|
||||
context: null,
|
||||
hidden: false,
|
||||
@@ -67,6 +68,9 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
|
||||
value,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
converted: false,
|
||||
sourceValue: null,
|
||||
sourceUnit: null,
|
||||
referenceLow: 0,
|
||||
referenceHigh: 100,
|
||||
catalogReferenceLow: 0,
|
||||
@@ -74,6 +78,7 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: null,
|
||||
printedReferenceText: null,
|
||||
referenceOrigin: "catalog",
|
||||
referenceDivergesFromCatalog: false,
|
||||
takenAt,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { LabConvertedNote } from "../lab-converted-note";
|
||||
|
||||
function render(
|
||||
reading: Parameters<typeof LabConvertedNote>[0]["reading"],
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<LabConvertedNote reading={reading} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabConvertedNote>", () => {
|
||||
it("says what the report printed beside a converted reading", () => {
|
||||
const html = render({
|
||||
converted: true,
|
||||
sourceValue: 5.6,
|
||||
sourceUnit: "mmol/L",
|
||||
});
|
||||
expect(html).toContain('data-slot="lab-converted-note"');
|
||||
expect(html).toContain("converted from 5.6 mmol/L");
|
||||
});
|
||||
|
||||
it("formats the number for the reader's language", () => {
|
||||
expect(
|
||||
render({ converted: true, sourceValue: 5.6, sourceUnit: "mmol/L" }, "de"),
|
||||
).toContain("umgerechnet aus 5,6 mmol/L");
|
||||
});
|
||||
|
||||
it("trims a long fraction the way the reading itself is trimmed", () => {
|
||||
expect(
|
||||
render({ converted: true, sourceValue: 5.6123456, sourceUnit: "mmol/L" }),
|
||||
).toContain("converted from 5.61 mmol/L");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ converted: false, sourceValue: null, sourceUnit: null }],
|
||||
[{ converted: true, sourceValue: null, sourceUnit: "mmol/L" }],
|
||||
[{ converted: true, sourceValue: 5.6, sourceUnit: null }],
|
||||
])("renders nothing for %j", (reading) => {
|
||||
expect(render(reading)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { 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";
|
||||
|
||||
/**
|
||||
* #1214 — the lab form's draw date is the same full-width, typeable date
|
||||
* field as the measurement form's timestamp.
|
||||
*
|
||||
* It already was the same component (`DateTimeField`), but it sat in the
|
||||
* second column of a two-column grid, and `DateTimeField` puts its date and
|
||||
* time halves side by side from `sm:` up, so the date half was squeezed until
|
||||
* a date could only be set through the calendar. SSR markup (the suite's
|
||||
* dialect) pins the layout: no two-column grid in the form, the date field is
|
||||
* the shared primitive, and its date half is an editable text input, not a
|
||||
* read-only picker trigger.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { LabForm } from "../lab-form";
|
||||
|
||||
function render(): string {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<LabForm />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LabForm> draw date", () => {
|
||||
it("does not halve the date field's width with a second column", () => {
|
||||
expect(render()).not.toMatch(/grid-cols-2/);
|
||||
});
|
||||
|
||||
it("uses the shared date-time field with a typeable date half", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="date-time-field"');
|
||||
// The id sits on the date half's hidden mirror; the input a person
|
||||
// types into is the overlay right after it.
|
||||
const field = html.slice(html.indexOf('id="lab-takenAt"'));
|
||||
const overlay = /<input(?![^>]*type="hidden")[^>]*>/.exec(field)?.[0];
|
||||
expect(overlay).toBeDefined();
|
||||
expect(overlay).toContain('type="text"');
|
||||
expect(overlay).not.toMatch(/\s(readonly|disabled)(=|\s|\/?>)/i);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,9 @@ function reading(over: Partial<LabResultDto>): LabResultDto {
|
||||
value: 80,
|
||||
valueText: null,
|
||||
unit: "ng/mL",
|
||||
converted: false,
|
||||
sourceValue: null,
|
||||
sourceUnit: null,
|
||||
referenceLow: 30,
|
||||
referenceHigh: 400,
|
||||
catalogReferenceLow: 30,
|
||||
@@ -165,4 +168,21 @@ describe("<LabList> columns", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("notes where a converted reading came from, and only for that reading", () => {
|
||||
const html = renderList([
|
||||
reading({
|
||||
id: "c",
|
||||
biomarkerId: "b1",
|
||||
analyte: "Glucose",
|
||||
unit: "mg/dL",
|
||||
converted: true,
|
||||
sourceValue: 5.6,
|
||||
sourceUnit: "mmol/L",
|
||||
}),
|
||||
reading({ id: "d", biomarkerId: "b2", analyte: "Ferritin" }),
|
||||
]);
|
||||
expect(count(html, 'data-slot="lab-converted-note"')).toBe(1);
|
||||
expect(html).toContain("converted from 5.6 mmol/L");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { seedReviewRows } from "../ocr-review-types";
|
||||
import {
|
||||
collectRowErrors,
|
||||
planSave,
|
||||
readingConversion,
|
||||
readingUnitBlocked,
|
||||
readingUnitDiffers,
|
||||
toCommitRow,
|
||||
validateReviewRow,
|
||||
@@ -24,6 +26,7 @@ function row(overrides: Partial<OcrReviewRow> = {}): OcrReviewRow {
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mmol/L",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
confirmed: true,
|
||||
...overrides,
|
||||
@@ -200,6 +203,7 @@ describe("a reading in another unit than its marker's", () => {
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing" as const,
|
||||
markerUnit,
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
});
|
||||
const [same, other, fresh] = seedReviewRows(
|
||||
@@ -212,3 +216,85 @@ describe("a reading in another unit than its marker's", () => {
|
||||
expect(fresh.confirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reading in another unit that can be converted", () => {
|
||||
// Glucose stated in mmol/L against a marker kept in mg/dL, and the analyte is
|
||||
// known: Save converts it, so nothing blocks and the row starts selected.
|
||||
const glucose = (overrides: Partial<OcrReviewRow> = {}) =>
|
||||
row({
|
||||
analyte: "Glucose",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("previews what Save will store, beside what the report stated", () => {
|
||||
const conversion = readingConversion(glucose());
|
||||
expect(conversion).toMatchObject({
|
||||
unit: "mg/dL",
|
||||
statedValue: 5.6,
|
||||
statedUnit: "mmol/L",
|
||||
});
|
||||
expect(conversion!.value).toBeCloseTo(5.6 * 18.0182, 9);
|
||||
});
|
||||
|
||||
it("is not blocked, and not a validation error", () => {
|
||||
expect(readingUnitDiffers(glucose())).toBe(true);
|
||||
expect(readingUnitBlocked(glucose())).toBe(false);
|
||||
expect(validateReviewRow(glucose())).toEqual({});
|
||||
});
|
||||
|
||||
it("sends what the report printed, for the server to convert", () => {
|
||||
const payload = toCommitRow(glucose());
|
||||
expect(payload).toMatchObject({
|
||||
analyte: "Glucose",
|
||||
value: 5.6,
|
||||
unit: "mmol/L",
|
||||
});
|
||||
expect(planSave([glucose()]).kind).toBe("ready");
|
||||
});
|
||||
|
||||
it("starts selected on the review screen", () => {
|
||||
const dto = {
|
||||
analyte: "Glucose",
|
||||
value: 5.6,
|
||||
valueText: null,
|
||||
unit: "mmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-10",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing" as const,
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
duplicateOf: null,
|
||||
};
|
||||
const [converts, blocked] = seedReviewRows(
|
||||
[dto, { ...dto, markerAnalyteKey: null }],
|
||||
null,
|
||||
);
|
||||
expect(converts.confirmed).toBe(true);
|
||||
expect(blocked.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it("stays blocked when the marker has no known analyte, or the pair is not in the registry", () => {
|
||||
expect(readingUnitBlocked(glucose({ markerAnalyteKey: null }))).toBe(true);
|
||||
expect(readingConversion(glucose({ markerAnalyteKey: null }))).toBeNull();
|
||||
expect(validateReviewRow(glucose({ markerAnalyteKey: null }))).toEqual({
|
||||
unitMismatch: true,
|
||||
});
|
||||
// The analyte is known but the unit is not one it reaches.
|
||||
expect(readingUnitBlocked(glucose({ unit: "%" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("has nothing to preview when the units agree or the row is qualitative", () => {
|
||||
expect(readingConversion(glucose({ unit: "mg/dL" }))).toBeNull();
|
||||
expect(
|
||||
readingConversion(glucose({ value: null, valueText: "negative" })),
|
||||
).toBeNull();
|
||||
expect(readingConversion(glucose({ value: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ const baseRow: OcrReviewRow = {
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
biomarkerMatch: "existing",
|
||||
markerUnit: "mmol/L",
|
||||
markerAnalyteKey: null,
|
||||
duplicateOf: null,
|
||||
confirmed: true,
|
||||
};
|
||||
@@ -155,3 +156,54 @@ describe("<OcrRowEditor> unit differs from the marker's", () => {
|
||||
expect(html).toContain("Geführt in mmol/L; dieser Befund nennt mg/dL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OcrRowEditor> unit differs but converts", () => {
|
||||
const converts: OcrReviewRow = {
|
||||
...baseRow,
|
||||
analyte: "Glucose",
|
||||
value: 5.6,
|
||||
takenAt: "2026-06-10",
|
||||
unit: "mmol/L",
|
||||
markerUnit: "mg/dL",
|
||||
markerAnalyteKey: "fasting-glucose",
|
||||
};
|
||||
|
||||
it("says what Save will store and what the report stated, not that it is blocked", () => {
|
||||
const html = render(undefined, "en", converts);
|
||||
expect(html).toContain('data-slot="ocr-row-converted-badge"');
|
||||
expect(html).toContain("Saved as 100.9 mg/dL, converted from 5.6 mmol/L");
|
||||
expect(html).not.toContain("Tracked in");
|
||||
expect(html).not.toContain('aria-invalid="true"');
|
||||
});
|
||||
|
||||
it("wraps the sentence instead of cutting off the printed value", () => {
|
||||
const html = render(undefined, "en", converts);
|
||||
const badge =
|
||||
/<span[^>]*data-slot="ocr-row-converted-badge"[^>]*>([\s\S]*?)<\/span><\/span>/.exec(
|
||||
html,
|
||||
);
|
||||
expect(badge).not.toBeNull();
|
||||
const tag = badge![0].slice(0, badge![0].indexOf(">"));
|
||||
expect(tag).toContain("whitespace-normal");
|
||||
expect(badge![0]).not.toContain("truncate");
|
||||
});
|
||||
|
||||
it("speaks German too", () => {
|
||||
const html = render(undefined, "de", converts);
|
||||
expect(html).toContain("umgerechnet aus 5,6 mmol/L");
|
||||
});
|
||||
|
||||
it("shows nothing when the units agree", () => {
|
||||
const html = render(undefined, "en", { ...converts, unit: "mg/dL" });
|
||||
expect(html).not.toContain("ocr-row-converted-badge");
|
||||
});
|
||||
|
||||
it("still says it is blocked when the analyte is not known", () => {
|
||||
const html = render(undefined, "en", {
|
||||
...converts,
|
||||
markerAnalyteKey: null,
|
||||
});
|
||||
expect(html).toContain("Tracked in mg/dL; this report says mmol/L");
|
||||
expect(html).not.toContain("ocr-row-converted-badge");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import { SourceRangeNote } from "../source-range-note";
|
||||
|
||||
type Reading = Parameters<typeof SourceRangeNote>[0]["reading"];
|
||||
|
||||
function render(reading: Reading) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SourceRangeNote reading={reading} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const STATED: Reading = {
|
||||
unit: "mg/dL",
|
||||
referenceLow: 70,
|
||||
referenceHigh: 99,
|
||||
catalogReferenceLow: 70,
|
||||
catalogReferenceHigh: 99,
|
||||
sourceReferenceText: "70 - 99",
|
||||
printedReferenceText: null,
|
||||
sourceUnit: null,
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: false,
|
||||
};
|
||||
|
||||
// 5.6 mmol/L stored as 100.9 mg/dL, with the report's 3.9 to 5.5 mmol/L window
|
||||
// converted alongside it.
|
||||
const CONVERTED: Reading = {
|
||||
unit: "mg/dL",
|
||||
referenceLow: 70.27,
|
||||
referenceHigh: 99.1,
|
||||
catalogReferenceLow: 70,
|
||||
catalogReferenceHigh: 100,
|
||||
sourceReferenceText: null,
|
||||
printedReferenceText: "3.9 - 5.5",
|
||||
sourceUnit: "mmol/L",
|
||||
referenceOrigin: "source",
|
||||
referenceDivergesFromCatalog: true,
|
||||
};
|
||||
|
||||
describe("<SourceRangeNote>", () => {
|
||||
it("names a stated reading's window verbatim", () => {
|
||||
const html = render(STATED);
|
||||
expect(html).toContain("Report range 70 - 99");
|
||||
expect(html).not.toContain("as printed");
|
||||
});
|
||||
|
||||
it("states a converted reading's window in the marker's unit", () => {
|
||||
const html = render(CONVERTED);
|
||||
expect(html).toContain("Report range 70.27–99.1 mg/dL");
|
||||
});
|
||||
|
||||
it("keeps the printed text as evidence, labelled with the unit it is in", () => {
|
||||
const html = render(CONVERTED);
|
||||
expect(html).toContain("as printed in mmol/L: 3.9 - 5.5");
|
||||
});
|
||||
|
||||
it("never puts the printed numbers in the statement about the stored unit", () => {
|
||||
const html = render(CONVERTED);
|
||||
// The window in force is followed by the marker's unit, not the printed text.
|
||||
expect(html).not.toContain("Report range 3.9 - 5.5");
|
||||
expect(html).toContain("your range 70–100 mg/dL");
|
||||
});
|
||||
|
||||
it("renders nothing when the window in force is not the report's", () => {
|
||||
expect(render({ ...CONVERTED, referenceOrigin: "catalog" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
|
||||
/**
|
||||
* A quiet "converted from 5.6 mmol/L" beside a reading whose stored number is a
|
||||
* conversion of what the report printed, so a number nobody typed can always be
|
||||
* traced back. Renders nothing for a reading stored as stated.
|
||||
*/
|
||||
export function LabConvertedNote({
|
||||
reading,
|
||||
className,
|
||||
}: {
|
||||
reading: {
|
||||
converted: boolean;
|
||||
sourceValue: number | null;
|
||||
sourceUnit: string | null;
|
||||
};
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const labNumber = useLabNumber();
|
||||
if (
|
||||
!reading.converted ||
|
||||
reading.sourceValue === null ||
|
||||
reading.sourceUnit === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-slot="lab-converted-note"
|
||||
className={className ?? "text-muted-foreground text-xs"}
|
||||
>
|
||||
{t("labs.convertedFrom", {
|
||||
value: formatLabValue(reading.sourceValue, labNumber),
|
||||
unit: reading.sourceUnit,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -448,7 +448,12 @@ export function LabForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* One column at every width, as in the measurement form. A second
|
||||
column halved the draw date's cell, and `DateTimeField` sets its
|
||||
date and time halves side by side from `sm:` up, so the date
|
||||
itself was squeezed until it could only be set through the
|
||||
calendar (#1214). */}
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
{resultType === "numeric" ? (
|
||||
<>
|
||||
|
||||
@@ -30,7 +30,9 @@ import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { formatLabReading } from "@/lib/labs/format-value";
|
||||
import { LabConvertedNote } from "./lab-converted-note";
|
||||
import { resolveNoteForUpdate } from "@/lib/labs/note-update";
|
||||
import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -176,7 +178,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: {
|
||||
id: string;
|
||||
// Exactly one of value / valueText is set, matching the row's type.
|
||||
// At most one of value / valueText is set, matching the row's type;
|
||||
// null leaves the field out of the PUT (an untouched value).
|
||||
value: number | null;
|
||||
valueText: string | null;
|
||||
takenAt: string;
|
||||
@@ -218,7 +221,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
|
||||
async function openEdit(reading: LabResultDto) {
|
||||
const seed = {
|
||||
value: reading.value !== null ? String(reading.value) : "",
|
||||
value: reading.value !== null ? valueEditSeed(reading.value) : "",
|
||||
valueText: reading.valueText ?? "",
|
||||
takenAt: toDateTimeLocal(reading.takenAt),
|
||||
note: "",
|
||||
@@ -274,11 +277,20 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
}
|
||||
qualitativeValue = trimmed;
|
||||
} else {
|
||||
numericValue = parseDecimal(editValue);
|
||||
if (numericValue === null) {
|
||||
const parsed = parseDecimal(editValue);
|
||||
if (parsed === null) {
|
||||
setEditError(t("labs.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
// An untouched field is left out of the PUT (null here), so the stored
|
||||
// full-precision value and a conversion record survive a date or note
|
||||
// edit.
|
||||
numericValue =
|
||||
resolveValueForUpdate({
|
||||
seed: editSeed.value,
|
||||
editorValue: editValue,
|
||||
parsed,
|
||||
}) ?? null;
|
||||
}
|
||||
const takenDate = new Date(editTakenAt);
|
||||
if (Number.isNaN(takenDate.getTime())) {
|
||||
@@ -381,6 +393,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="font-semibold tabular-nums">
|
||||
{formatLabReading(r, labNumber)}
|
||||
<LabConvertedNote
|
||||
reading={r}
|
||||
className="text-muted-foreground ml-2 text-xs font-normal"
|
||||
/>
|
||||
</div>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
@@ -453,6 +469,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
<TableCell className="font-semibold tabular-nums">
|
||||
<div className="space-y-2">
|
||||
<span>{formatLabReading(r, labNumber)}</span>
|
||||
<LabConvertedNote
|
||||
reading={r}
|
||||
className="text-muted-foreground block text-xs font-normal"
|
||||
/>
|
||||
<LabReferenceRangeBar
|
||||
value={r.value}
|
||||
referenceLow={r.referenceLow}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabReading } from "@/lib/labs/format-value";
|
||||
import { LabConvertedNote } from "./lab-converted-note";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -299,6 +300,7 @@ export function LabList({
|
||||
{formatLabReading(group.latest, labNumber)}
|
||||
</span>
|
||||
<span>{labDate(group.latest.takenAt)}</span>
|
||||
<LabConvertedNote reading={group.latest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -429,6 +431,7 @@ export function LabList({
|
||||
<span className="text-xs">
|
||||
{labDate(group.latest.takenAt)}
|
||||
</span>
|
||||
<LabConvertedNote reading={group.latest} />
|
||||
{group.readings.length > 1 ? (
|
||||
<span className="text-xs">
|
||||
{t("labs.readingsCount", {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import type { OcrExtractedRowDto } from "@/lib/validations/labs-ocr";
|
||||
|
||||
import { readingUnitDiffers } from "./ocr-review-validation";
|
||||
import { readingUnitBlocked } from "./ocr-review-validation";
|
||||
|
||||
export interface OcrReviewRow {
|
||||
/** Stable client key for the list (the analyte+index at seed time). */
|
||||
@@ -26,9 +26,12 @@ export interface OcrReviewRow {
|
||||
biomarkerMatch: "new" | "existing";
|
||||
/**
|
||||
* The unit the matched marker is tracked in; null for a marker that does not
|
||||
* exist yet. A numeric row whose `unit` differs from it cannot be saved.
|
||||
* exist yet. A numeric row whose `unit` differs from it is converted on
|
||||
* Save when it can be (see `readingConversion`) and cannot be saved when not.
|
||||
*/
|
||||
markerUnit: string | null;
|
||||
/** Which analyte the matched marker is (catalog slug or registry-only key), or null. */
|
||||
markerAnalyteKey: string | null;
|
||||
/** Non-null when the server flagged a likely duplicate of a live reading. */
|
||||
duplicateOf: string | null;
|
||||
/** Whether the row is selected for saving. Defaults false for duplicates. */
|
||||
@@ -53,17 +56,21 @@ export function seedReviewRows(
|
||||
confidence: row.confidence,
|
||||
biomarkerMatch: row.biomarkerMatch,
|
||||
markerUnit: row.markerUnit,
|
||||
markerAnalyteKey: row.markerAnalyteKey,
|
||||
duplicateOf: row.duplicateOf,
|
||||
// A flagged duplicate starts unchecked, and so does a reading in another
|
||||
// unit than its marker's (it carries a badge saying why, and ticking it
|
||||
// marks the unit field until it is fixed); everything else starts
|
||||
// confirmed.
|
||||
// unit than its marker's that cannot be converted (it carries a badge
|
||||
// saying why, and ticking it marks the unit field until it is fixed); a
|
||||
// reading that converts starts confirmed, with a badge saying what Save
|
||||
// will store; everything else starts confirmed.
|
||||
confirmed:
|
||||
row.duplicateOf === null &&
|
||||
!readingUnitDiffers({
|
||||
!readingUnitBlocked({
|
||||
value: row.value,
|
||||
valueText: row.valueText,
|
||||
unit: row.unit,
|
||||
markerUnit: row.markerUnit,
|
||||
markerAnalyteKey: row.markerAnalyteKey,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
* build the payload: it returns null exactly when `validateReviewRow` reports
|
||||
* at least one field.
|
||||
*/
|
||||
import { convertLabValue } from "@/lib/labs/unit-conversion/convert";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
@@ -46,6 +47,60 @@ export function readingUnitDiffers(row: {
|
||||
return !sameLabUnit(stated, row.markerUnit);
|
||||
}
|
||||
|
||||
/**
|
||||
* What Save will store for a numeric row whose unit differs from its marker's
|
||||
* and can be converted: the value in the marker's unit, beside what the report
|
||||
* stated. Null when the units agree, the row is qualitative, or the pair has no
|
||||
* conversion. The commit does the same conversion on the server; this only lets
|
||||
* the screen say so before Save.
|
||||
*/
|
||||
export function readingConversion(row: {
|
||||
value: number | null | undefined;
|
||||
valueText: string | null | undefined;
|
||||
unit: string | null | undefined;
|
||||
markerUnit: string | null | undefined;
|
||||
markerAnalyteKey: string | null | undefined;
|
||||
}): {
|
||||
value: number;
|
||||
unit: string;
|
||||
statedValue: number;
|
||||
statedUnit: string;
|
||||
} | null {
|
||||
if (!readingUnitDiffers(row)) return null;
|
||||
if (row.value === null || row.value === undefined) return null;
|
||||
if (!Number.isFinite(row.value)) return null;
|
||||
const statedUnit = (row.unit ?? "").trim();
|
||||
const converted = convertLabValue({
|
||||
analyteKey: row.markerAnalyteKey,
|
||||
value: row.value,
|
||||
from: statedUnit,
|
||||
to: row.markerUnit as string,
|
||||
});
|
||||
if (!converted.ok) return null;
|
||||
return {
|
||||
value: converted.value,
|
||||
unit: row.markerUnit as string,
|
||||
statedValue: row.value,
|
||||
statedUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A numeric reading in another unit than its marker's that cannot be
|
||||
* converted: the one case that still cannot be saved. A row with no number yet
|
||||
* has nothing to convert, so it is blocked here as well as on `value`.
|
||||
*/
|
||||
export function readingUnitBlocked(row: {
|
||||
value: number | null | undefined;
|
||||
valueText: string | null | undefined;
|
||||
unit: string | null | undefined;
|
||||
markerUnit: string | null | undefined;
|
||||
markerAnalyteKey: string | null | undefined;
|
||||
}): boolean {
|
||||
if (!readingUnitDiffers(row)) return false;
|
||||
return readingConversion(row) === null;
|
||||
}
|
||||
|
||||
export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
const errors: OcrRowErrors = {};
|
||||
if (!row.analyte.trim()) errors.analyte = true;
|
||||
@@ -56,7 +111,7 @@ export function validateReviewRow(row: OcrReviewRow): OcrRowErrors {
|
||||
} else {
|
||||
if (row.value === null || !Number.isFinite(row.value)) errors.value = true;
|
||||
if (!(row.unit ?? "").trim()) errors.unit = true;
|
||||
if (readingUnitDiffers(row)) errors.unitMismatch = true;
|
||||
if (readingUnitBlocked(row)) errors.unitMismatch = true;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -80,7 +135,7 @@ export function toCommitRow(row: OcrReviewRow): OcrCommitRowInput | null {
|
||||
if (row.value === null || !Number.isFinite(row.value)) return null;
|
||||
const unit = (row.unit ?? "").trim();
|
||||
if (!unit) return null;
|
||||
if (readingUnitDiffers(row)) return null;
|
||||
if (readingUnitBlocked(row)) return null;
|
||||
return {
|
||||
analyte,
|
||||
value: row.value,
|
||||
|
||||
@@ -32,7 +32,12 @@ import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { OcrReviewRow } from "./ocr-review-types";
|
||||
import { readingUnitDiffers, type OcrRowErrors } from "./ocr-review-validation";
|
||||
import {
|
||||
readingConversion,
|
||||
readingUnitBlocked,
|
||||
type OcrRowErrors,
|
||||
} from "./ocr-review-validation";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
|
||||
/** Below this per-field confidence the field is flagged for a second look. */
|
||||
const CONFIDENCE_THRESHOLD = 0.6;
|
||||
@@ -76,10 +81,13 @@ export function OcrRowEditor({
|
||||
message={t(messageKey, params)}
|
||||
/>
|
||||
) : null;
|
||||
// Stated before Save, not only after a failed attempt: a reading in another
|
||||
// unit than its marker's cannot be written, and the person should see why
|
||||
// the row is unticked (or will not save) while they are still reading it.
|
||||
const unitDiffers = readingUnitDiffers(row);
|
||||
// Stated before Save, not only after a failed attempt. A reading in another
|
||||
// unit than its marker's is either converted (the badge says what Save will
|
||||
// store) or cannot be written (the person should see why the row is unticked,
|
||||
// or will not save, while they are still reading it).
|
||||
const unitBlocked = readingUnitBlocked(row);
|
||||
const conversion = readingConversion(row);
|
||||
const labNumber = useLabNumber();
|
||||
|
||||
const isQualitative = row.valueText !== null && row.valueText !== undefined;
|
||||
const lowValueConfidence =
|
||||
@@ -169,7 +177,7 @@ export function OcrRowEditor({
|
||||
{t("labs.ocr.duplicateWarning")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{unitDiffers ? (
|
||||
{unitBlocked ? (
|
||||
<Badge variant="secondary" className="max-w-full">
|
||||
<Ruler aria-hidden />
|
||||
<span className="truncate">
|
||||
@@ -180,6 +188,25 @@ export function OcrRowEditor({
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{conversion ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
// Wraps rather than truncates: the printed value is the end
|
||||
// of the sentence, exactly what a cut at 390 px would hide.
|
||||
className="h-auto max-w-full justify-start rounded-md text-left whitespace-normal"
|
||||
data-slot="ocr-row-converted-badge"
|
||||
>
|
||||
<Ruler aria-hidden />
|
||||
<span className="min-w-0 break-words">
|
||||
{t("labs.ocr.unitConvertedBadge", {
|
||||
value: labNumber(conversion.value),
|
||||
markerUnit: conversion.unit,
|
||||
statedValue: labNumber(conversion.statedValue),
|
||||
unit: conversion.statedUnit,
|
||||
})}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
{lowRowConfidence ? (
|
||||
<Badge variant="secondary" className="text-muted-foreground">
|
||||
<AlertCircle aria-hidden />
|
||||
|
||||
@@ -18,6 +18,11 @@ import { useLabNumber } from "./use-lab-format";
|
||||
* the same number, so a surface that shows one silently is showing a partial
|
||||
* answer.
|
||||
*
|
||||
* A reading converted into its marker's unit carries bounds that were converted
|
||||
* with it, but the report's own text is still in the unit it printed. The note
|
||||
* then states the window in the marker's unit and adds the printed text as
|
||||
* evidence, labelled with its unit, so no single statement mixes two units.
|
||||
*
|
||||
* Neutral by construction: no tint, no icon, muted text. The in/out verdict is
|
||||
* the badge's job — this element states where the window came from, nothing
|
||||
* about whether the value is good news.
|
||||
@@ -34,6 +39,8 @@ export function SourceRangeNote({
|
||||
| "catalogReferenceLow"
|
||||
| "catalogReferenceHigh"
|
||||
| "sourceReferenceText"
|
||||
| "printedReferenceText"
|
||||
| "sourceUnit"
|
||||
| "referenceOrigin"
|
||||
| "referenceDivergesFromCatalog"
|
||||
>;
|
||||
@@ -43,7 +50,9 @@ export function SourceRangeNote({
|
||||
const labNumber = useLabNumber();
|
||||
if (reading.referenceOrigin !== "source") return null;
|
||||
|
||||
const printed =
|
||||
// `sourceReferenceText` is null for a converted reading, so the window is
|
||||
// then stated from the converted bounds, in the marker's unit.
|
||||
const inForce =
|
||||
reading.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
@@ -59,7 +68,13 @@ export function SourceRangeNote({
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{t("labs.sourceRange.fromReport", { range: printed })}
|
||||
{t("labs.sourceRange.fromReport", { range: inForce })}
|
||||
{reading.printedReferenceText && reading.sourceUnit
|
||||
? ` · ${t("labs.sourceRange.printed", {
|
||||
range: reading.printedReferenceText,
|
||||
unit: reading.sourceUnit,
|
||||
})}`
|
||||
: null}
|
||||
{reading.referenceDivergesFromCatalog && catalog
|
||||
? ` · ${t("labs.sourceRange.catalogDiffers", {
|
||||
range: `${catalog} ${reading.unit}`.trim(),
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface LabResultDto {
|
||||
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
|
||||
valueText: string | null;
|
||||
unit: string;
|
||||
/**
|
||||
* True when the stored number was converted into the marker's unit from
|
||||
* another one; `sourceValue` and `sourceUnit` are then what the report
|
||||
* printed. A reading stored as stated has `converted: false` and two nulls.
|
||||
*/
|
||||
converted: boolean;
|
||||
sourceValue: number | null;
|
||||
sourceUnit: string | null;
|
||||
/**
|
||||
* The window this reading is judged against and the one `rangeStatus` was
|
||||
* computed from: the range its own report printed when it carries one, the
|
||||
@@ -30,6 +38,11 @@ export interface LabResultDto {
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
/**
|
||||
* A converted reading's printed range text, verbatim and in `sourceUnit`;
|
||||
* `sourceReferenceText` is then null. Null for every other reading.
|
||||
*/
|
||||
printedReferenceText: string | null;
|
||||
/** Which window is in force. */
|
||||
referenceOrigin: ReferenceRangeOrigin;
|
||||
/** True when the report's window and the catalog band state different limits. */
|
||||
@@ -60,6 +73,11 @@ export interface BiomarkerDto {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
/**
|
||||
* Which analyte the marker is, for converting a reading in another unit
|
||||
* (the catalog slug, e.g. `ldl`, or a registry-only key such as `urea`), or null when it is none that is known.
|
||||
*/
|
||||
analyteKey: string | null;
|
||||
hasContext: boolean;
|
||||
context: string | null;
|
||||
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
|
||||
|
||||
@@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => {
|
||||
).not.toContain('data-testid="polar-sync"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthProviderCard — Oura scope note", () => {
|
||||
const scopeGap = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected" as const,
|
||||
lastSuccessAt: "2026-10-14T00:00:00.000Z",
|
||||
lastError: null,
|
||||
syncHealth: { verdict: "fresh" as const, since: null },
|
||||
scopeReconnect: ["spo2" as const, "vo2max" as const],
|
||||
};
|
||||
|
||||
function renderOura(viewModel: ViewModel) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
provider="oura"
|
||||
statusQueryKey={["oura"]}
|
||||
i18nPrefix="settings.oura"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
viewModel={viewModel}
|
||||
callbackUrl="https://app.example/api/oura/callback"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("asks for one reconnect beside a healthy pill, not an error", () => {
|
||||
const html = renderOura(scopeGap);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
|
||||
expect(html).not.toContain('data-testid="oura-error"');
|
||||
});
|
||||
|
||||
it("stays away from the other OAuth providers", () => {
|
||||
expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The two "withheld by contract" notes: the Withings card names what the
|
||||
* account's API plan refuses, the Oura card asks for one reconnect when the
|
||||
* grant lacks a scope. Both render nothing when nothing is withheld, and both
|
||||
* render the server-resolved list rather than deriving one.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WithingsPlanNote } from "../withings-plan-note";
|
||||
import { OuraScopeNote } from "../oura-scope-note";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<WithingsPlanNote>", () => {
|
||||
it("lists the withheld readings and links the Withings plans page", () => {
|
||||
const html = render(
|
||||
<WithingsPlanNote features={["spo2", "vascularAge", "ecg"]} />,
|
||||
);
|
||||
expect(html).toContain('data-testid="withings-plan-note"');
|
||||
expect(html).toContain("SpO2, vascular age, and ECG and AFib");
|
||||
expect(html).toContain("Weight, blood pressure, body composition");
|
||||
expect(html).toContain(
|
||||
"https://static.withings.com/content/api/plans/plans.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("speaks the reader's language", () => {
|
||||
const html = render(<WithingsPlanNote features={["vascularAge"]} />, "de");
|
||||
expect(html).toContain("Gefäßalter");
|
||||
expect(html).toContain("kostenpflichtigen API-Plan");
|
||||
});
|
||||
|
||||
it("renders nothing when the plan withholds nothing", () => {
|
||||
expect(render(<WithingsPlanNote features={[]} />)).toBe("");
|
||||
expect(render(<WithingsPlanNote features={undefined} />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<OuraScopeNote>", () => {
|
||||
it("asks for one reconnect naming the collections the grant lacks", () => {
|
||||
const html = render(
|
||||
<OuraScopeNote
|
||||
collections={["spo2", "vo2max", "cardio_age", "resilience"]}
|
||||
onReconnect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-note"');
|
||||
expect(html).toContain(
|
||||
"Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
|
||||
);
|
||||
expect(html).toContain('data-testid="oura-scope-reconnect"');
|
||||
});
|
||||
|
||||
it("renders nothing when the grant covers everything", () => {
|
||||
expect(
|
||||
render(<OuraScopeNote collections={[]} onReconnect={vi.fn()} />),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { OuraScopeNote } from "./oura-scope-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Oura: collections the stored grant does not cover. */
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
}
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "oura" && status?.connected && (
|
||||
<OuraScopeNote
|
||||
collections={status.scopeReconnect}
|
||||
onReconnect={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
/**
|
||||
* A calm note on the Oura card when the stored grant does not cover some
|
||||
* collections (they answer 401 while their siblings succeed). Not an error and
|
||||
* not counted toward the reconnect threshold: everything else keeps syncing,
|
||||
* and one reconnect asks Oura for the missing scopes.
|
||||
*/
|
||||
export function OuraScopeNote({
|
||||
collections,
|
||||
onReconnect,
|
||||
disabled,
|
||||
}: {
|
||||
collections: readonly OuraScopeGatedCollection[] | undefined;
|
||||
onReconnect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!collections || collections.length === 0) return null;
|
||||
|
||||
const labels: Record<OuraScopeGatedCollection, string> = {
|
||||
spo2: t("settings.integrations.oura.scope.items.spo2"),
|
||||
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
|
||||
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
|
||||
resilience: t("settings.integrations.oura.scope.items.resilience"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(collections.map((c) => labels[c]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="oura-scope-note"
|
||||
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="min-w-0 break-words">
|
||||
{t("settings.integrations.oura.scope.body", { list })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={disabled}
|
||||
data-testid="oura-scope-reconnect"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrations.oura.scope.action")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
// v1.4.15 Phase B2: shared status payload for both integration cards.
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
|
||||
// Withings activity-scope reconnect banner.
|
||||
scope?: string | null;
|
||||
hasActivityScope?: boolean;
|
||||
// Withings: readings the account's API plan withholds (server-resolved).
|
||||
planUnavailable?: WithingsPlanFeature[];
|
||||
// Oura: collections the stored grant does not cover (server-resolved).
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
// WHOOP / Fitbit backfill-in-progress note.
|
||||
backfillCompleted?: boolean | null;
|
||||
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { WithingsPlanNote } from "./withings-plan-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -316,6 +317,10 @@ export function WithingsCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<WithingsPlanNote features={status.planUnavailable} />
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={viewModel?.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
WITHINGS_API_PLANS_URL,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
|
||||
/**
|
||||
* A calm note on the Withings card when the account's API plan withholds some
|
||||
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
|
||||
* sync skips those readings and keeps importing the rest, so this sits in the
|
||||
* card body in the info wash, never in the pill.
|
||||
*/
|
||||
export function WithingsPlanNote({
|
||||
features,
|
||||
}: {
|
||||
features: readonly WithingsPlanFeature[] | undefined;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
const labels: Record<WithingsPlanFeature, string> = {
|
||||
spo2: t("settings.integrations.withings.plan.items.spo2"),
|
||||
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
|
||||
pulseWaveVelocity: t(
|
||||
"settings.integrations.withings.plan.items.pulseWaveVelocity",
|
||||
),
|
||||
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
|
||||
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
|
||||
skinTemperature: t(
|
||||
"settings.integrations.withings.plan.items.skinTemperature",
|
||||
),
|
||||
ecg: t("settings.integrations.withings.plan.items.ecg"),
|
||||
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
|
||||
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
|
||||
sleepHeartRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepHeartRate",
|
||||
),
|
||||
sleepRespiratoryRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
|
||||
),
|
||||
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
|
||||
bodyTemperature: t(
|
||||
"settings.integrations.withings.plan.items.bodyTemperature",
|
||||
),
|
||||
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
|
||||
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
|
||||
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
|
||||
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
|
||||
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
|
||||
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
|
||||
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
|
||||
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
|
||||
weight: t("settings.integrations.withings.plan.items.weight"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(features.map((f) => labels[f]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="withings-plan-note"
|
||||
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("settings.integrations.withings.plan.title")}
|
||||
</p>
|
||||
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* #1216 — the Coach illness block (which also answers the MCP
|
||||
* `get_illness_recovery` tool) keeps every active condition as context, but
|
||||
* its `restMode` flag follows Rest Mode: a chronic ongoing condition alone
|
||||
* leaves it false, an acute episode or a flare on top sets it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
illnessEpisode: { findMany },
|
||||
symptomEvent: { groupBy: async () => [] },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/illness/gate", () => ({
|
||||
isIllnessEnabled: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
function episode(lifecycle: string, label: string, onset: string) {
|
||||
return {
|
||||
label,
|
||||
type: lifecycle === "CHRONIC_ONGOING" ? "CHRONIC" : "INFECTION",
|
||||
lifecycle,
|
||||
onsetAt: new Date(onset),
|
||||
bodySiteEncrypted: null,
|
||||
laterality: null,
|
||||
};
|
||||
}
|
||||
|
||||
const CHRONIC = episode("CHRONIC_ONGOING", "Sleep apnoea", "2015-08-01");
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("buildIllnessSnapshotBlock — restMode (#1216)", () => {
|
||||
it("a chronic condition alone is context, not Rest Mode", async () => {
|
||||
findMany.mockResolvedValueOnce([CHRONIC]).mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(false);
|
||||
expect(block?.active.map((e) => e.label)).toEqual(["Sleep apnoea"]);
|
||||
});
|
||||
|
||||
it("an acute episode on top sets Rest Mode", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce([CHRONIC, episode("ACUTE", "Cold", "2026-05-29")])
|
||||
.mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(true);
|
||||
expect(block?.active).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a flare under the chronic condition sets Rest Mode", async () => {
|
||||
findMany
|
||||
.mockResolvedValueOnce([CHRONIC, episode("FLARE", "Flare", "2026-05-30")])
|
||||
.mockResolvedValueOnce([]);
|
||||
const block = await buildIllnessSnapshotBlock("u1", NOW);
|
||||
expect(block?.restMode).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,12 +12,14 @@
|
||||
* Server-authoritative and module-gated: a non-illness / opted-out account
|
||||
* gets `null` (no block, no read). Since v1.40 it also names the person's own
|
||||
* symptoms of the last two weeks (their names, sanitised, and counts), which
|
||||
* reach the model the way custom mood-tag names reach the mood analysis. The active set drives the `restMode` flag
|
||||
* reach the model the way custom mood-tag names reach the mood analysis. The active set, chronic conditions
|
||||
* included, is context; only its non-chronic part drives the `restMode` flag
|
||||
* the Coach reads; a short resolved-history tail lets it answer "how often do
|
||||
* I get sick" without re-deriving anything.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { triggersRestMode } from "@/lib/illness/rest-mode";
|
||||
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
|
||||
import {
|
||||
BASELINE_WINDOW_DAYS,
|
||||
@@ -90,7 +92,10 @@ function siteFields(row: {
|
||||
}
|
||||
|
||||
export interface CoachIllnessBlock {
|
||||
/** True when ≥ 1 episode is active right now (the Rest Mode flag). */
|
||||
/**
|
||||
* The Rest Mode flag: true when ≥ 1 active episode is not CHRONIC_ONGOING.
|
||||
* A chronic condition alone is context (it is in `active`), not Rest Mode.
|
||||
*/
|
||||
restMode: boolean;
|
||||
/** Active (unresolved) episodes — the Coach frames its reply around these. */
|
||||
active: Array<{
|
||||
@@ -255,7 +260,7 @@ export async function buildIllnessSnapshotBlock(
|
||||
}
|
||||
|
||||
return {
|
||||
restMode: activeRows.length > 0,
|
||||
restMode: activeRows.some(triggersRestMode),
|
||||
active: activeRows.map((e) => ({
|
||||
label: sanitizeLabel(e.label),
|
||||
type: e.type,
|
||||
|
||||
@@ -143,6 +143,7 @@ export async function buildLabsSnapshotBlock(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: {
|
||||
|
||||
@@ -406,11 +406,16 @@ ISO-week means.
|
||||
figure the block does not carry. Quote progress only as the block gives
|
||||
it. If a fact seems outdated, gently check it rather than assume.
|
||||
- The SNAPSHOT MAY carry an "illness" block: { restMode, active[],
|
||||
recentResolved[] }. When "restMode" is true the user has one or more
|
||||
ACTIVE conditions right now (each with a label, type, lifecycle, and
|
||||
onset date, plus "bodySite" and "laterality" when the user named where) — read an off-band vital or a low recovery as plausibly
|
||||
illness-explained rather than alarming, do not push a heavier
|
||||
measurement cadence, and lean toward rest-and-recover framing. Treat
|
||||
recentResolved[] }. "active" lists every condition ongoing right now
|
||||
(each with a label, type, lifecycle, and onset date, plus "bodySite"
|
||||
and "laterality" when the user named where), chronic ones included; an
|
||||
off-band vital may relate to any of them. When "restMode" is true at
|
||||
least one of them is acute, recurring or a flare — read an off-band
|
||||
vital or a low recovery as plausibly illness-explained rather than
|
||||
alarming, do not push a heavier measurement cadence, and lean toward
|
||||
rest-and-recover framing. A CHRONIC_ONGOING condition alone leaves
|
||||
"restMode" false: the user lives with it and is not unwell because of
|
||||
it today, so do not frame them as recovering. Treat
|
||||
the labels as the user's OWN descriptive context, never a diagnosis;
|
||||
"recentResolved" is light history for "how often do I get sick"
|
||||
questions. The block MAY also carry "symptoms": the user's OWN named
|
||||
@@ -924,12 +929,17 @@ ISO-Wochenmittel zusammen.
|
||||
Fortschritt nur so, wie der Block ihn angibt. Wirkt ein Fakt veraltet,
|
||||
frage behutsam nach, statt es anzunehmen.
|
||||
- Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[],
|
||||
recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder
|
||||
mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn,
|
||||
dazu "bodySite" und "laterality", wenn der Nutzer die Stelle angab) —
|
||||
lies einen abweichenden Vitalwert oder eine niedrige Erholung eher als
|
||||
krankheitsbedingt denn als alarmierend, dränge nicht auf eine engere
|
||||
Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Behandle die
|
||||
recentResolved[] }. "active" nennt jede gerade laufende Erkrankung (je
|
||||
mit Label, Typ, Lebenszyklus, Beginn, dazu "bodySite" und "laterality",
|
||||
wenn der Nutzer die Stelle angab), chronische eingeschlossen; ein
|
||||
abweichender Vitalwert kann mit jeder davon zusammenhängen. Ist
|
||||
"restMode" true, ist mindestens eine davon akut, wiederkehrend oder ein
|
||||
Schub — lies einen abweichenden Vitalwert oder eine niedrige Erholung
|
||||
eher als krankheitsbedingt denn als alarmierend, dränge nicht auf eine
|
||||
engere Mess-Kadenz und neige zu einem Ruhe-und-Erholung-Rahmen. Eine
|
||||
CHRONIC_ONGOING-Erkrankung allein lässt "restMode" false: der Nutzer
|
||||
lebt damit und ist ihretwegen heute nicht krank, stelle ihn also nicht
|
||||
als genesend dar. Behandle die
|
||||
Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
|
||||
"recentResolved" ist leichte Historie für "wie oft bin ich krank".
|
||||
Der Block KANN auch "symptoms" tragen: die EIGENEN benannten Symptome
|
||||
|
||||
@@ -439,6 +439,7 @@ export const USER_RESET = {
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
ouraScopeDenied: Prisma.DbNull,
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
stravaAccessTokenEncrypted: null,
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function loadLabResults(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,22 +44,25 @@ beforeEach(() => {
|
||||
|
||||
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
|
||||
// A pair the registry has no conversion for: ferritin in pmol/L against a
|
||||
// marker stored in ng/mL (protein molecular weights vary by source).
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
label: "Ferritin",
|
||||
value: 450,
|
||||
unit: "pmol/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
// Existing marker stored in mg/dL — the resolver returns it as-is.
|
||||
// Existing marker stored in ng/mL — the resolver returns it as-is.
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
name: "Ferritin",
|
||||
unit: "ng/mL",
|
||||
lowerBound: 30,
|
||||
upperBound: 400,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -71,6 +74,69 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
expect(prisma.labResult.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 6.1,
|
||||
unit: "mmol/L",
|
||||
referenceLow: 3.9,
|
||||
referenceHigh: 5.6,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
analyteKey: "fasting-glucose",
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-conv",
|
||||
} as never);
|
||||
|
||||
const ref = await commitApprovedFact("user-1", observationFact());
|
||||
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" });
|
||||
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
|
||||
expect(data.value).toBeCloseTo(6.1 * 18.0182, 9);
|
||||
expect(data.unit).toBe("mg/dL");
|
||||
expect(data.sourceValue).toBe(6.1);
|
||||
expect(data.sourceUnit).toBe("mmol/L");
|
||||
// The report's own window moved with the value.
|
||||
expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9);
|
||||
expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
|
||||
});
|
||||
|
||||
it("stores a reading in the marker's own unit without a conversion record", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2025-01-01",
|
||||
} as never);
|
||||
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: "fasting-glucose",
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-same",
|
||||
} as never);
|
||||
|
||||
await commitApprovedFact("user-1", observationFact());
|
||||
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
|
||||
expect(data.value).toBe(95);
|
||||
expect(data.sourceValue).toBeNull();
|
||||
expect(data.sourceUnit).toBeNull();
|
||||
});
|
||||
|
||||
it("commits when the stated unit is the marker's unit spelled another way", async () => {
|
||||
vi.mocked(decryptFactData).mockReturnValue({
|
||||
label: "Glucose",
|
||||
@@ -87,6 +153,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
lowerBound: 70,
|
||||
upperBound: 100,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-1",
|
||||
@@ -124,6 +191,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
|
||||
lowerBound: null,
|
||||
upperBound: null,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
vi.mocked(prisma.labResult.create).mockResolvedValue({
|
||||
id: "lr-2",
|
||||
|
||||
+37
-12
@@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
|
||||
import { decryptFactData } from "@/lib/documents/store";
|
||||
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
|
||||
import {
|
||||
isUnreadableRange,
|
||||
parseReferenceRange,
|
||||
@@ -128,22 +128,45 @@ async function commitObservation(
|
||||
referenceHigh: isQualitative ? null : sourceHigh,
|
||||
panel: null,
|
||||
});
|
||||
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
|
||||
// minted marker adopts the document's unit, so this only bites when the
|
||||
// stated unit disagrees with the catalog's authoritative one (e.g. a
|
||||
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
|
||||
// the stated number under the catalog's unit would corrupt the reading AND
|
||||
// break the "transcribe verbatim" contract — so reject the fact and let the
|
||||
// user reconcile on the review screen, exactly like the `unitRequired` gate.
|
||||
// A unit that differs from an EXISTING marker's is converted when the
|
||||
// marker's analyte is known and the registry has the pair (a document states
|
||||
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
|
||||
// stated beside the stored number. Otherwise it fails closed: writing the
|
||||
// stated number under the catalog's unit would corrupt the reading AND break
|
||||
// the "transcribe verbatim" contract — so reject the fact and let the user
|
||||
// reconcile on the review screen, exactly like the `unitRequired` gate. A
|
||||
// freshly minted marker adopts the document's unit, so it cannot disagree.
|
||||
// Spelling is compared through the shared normaliser, the same one the
|
||||
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
|
||||
let storedValue: number | null = isQualitative
|
||||
? null
|
||||
: (data.value as number);
|
||||
let storedSourceLow = isQualitative ? null : sourceLow;
|
||||
let storedSourceHigh = isQualitative ? null : sourceHigh;
|
||||
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
|
||||
if (!isQualitative) {
|
||||
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
|
||||
const reading = await readingInMarkerUnit(prisma, {
|
||||
marker: biomarker,
|
||||
value: data.value as number,
|
||||
unit: data.unit as string,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
});
|
||||
if (!reading.ok) {
|
||||
throw new FactCommitError(
|
||||
"observation.unitMismatch",
|
||||
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
|
||||
);
|
||||
}
|
||||
storedValue = reading.value;
|
||||
storedSourceLow = reading.sourceReferenceLow;
|
||||
storedSourceHigh = reading.sourceReferenceHigh;
|
||||
if (reading.converted) {
|
||||
convertedFrom = {
|
||||
sourceValue: reading.sourceValue,
|
||||
sourceUnit: reading.sourceUnit,
|
||||
};
|
||||
}
|
||||
}
|
||||
const created = await prisma.labResult.create({
|
||||
data: {
|
||||
@@ -151,15 +174,17 @@ async function commitObservation(
|
||||
biomarkerId: biomarker.id,
|
||||
panel: biomarker.panel,
|
||||
analyte: biomarker.name,
|
||||
value: isQualitative ? null : (data.value as number),
|
||||
value: storedValue,
|
||||
valueText: isQualitative ? data.valueText : null,
|
||||
unit: biomarker.unit,
|
||||
sourceValue: convertedFrom?.sourceValue ?? null,
|
||||
sourceUnit: convertedFrom?.sourceUnit ?? null,
|
||||
referenceLow: biomarker.lowerBound,
|
||||
referenceHigh: biomarker.upperBound,
|
||||
// The report's own window for THIS reading. A qualitative result has no
|
||||
// window to record.
|
||||
sourceReferenceLow: isQualitative ? null : sourceLow,
|
||||
sourceReferenceHigh: isQualitative ? null : sourceHigh,
|
||||
sourceReferenceLow: storedSourceLow,
|
||||
sourceReferenceHigh: storedSourceHigh,
|
||||
sourceReferenceText: isQualitative ? null : sourceText,
|
||||
takenAt: statedDateOrNow(data.effectiveDate),
|
||||
source: "DOCUMENT",
|
||||
|
||||
@@ -885,6 +885,9 @@ export const USER_COLUMN_BACKUP_CLASS = {
|
||||
ouraRefreshTokenEncrypted: "CREDENTIAL",
|
||||
ouraClientIdEncrypted: "CREDENTIAL",
|
||||
ouraClientSecretEncrypted: "CREDENTIAL",
|
||||
// What the Oura grant on this host does not cover; meaningless beside a
|
||||
// grant that does not travel, and found out again by the first sync.
|
||||
ouraScopeDenied: "CREDENTIAL",
|
||||
stravaClientIdEncrypted: "CREDENTIAL",
|
||||
stravaClientSecretEncrypted: "CREDENTIAL",
|
||||
stravaAccessTokenEncrypted: "CREDENTIAL",
|
||||
|
||||
@@ -95,9 +95,8 @@ export interface LabResultBackupEntry {
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
/**
|
||||
* @pending-consumer(#1095) The value and unit the source report printed,
|
||||
* kept when a write converted it. Nothing writes them yet; the backup
|
||||
* carries them so the conversion, when it lands, needs no backup change.
|
||||
* The value and unit the source report printed, kept when a write
|
||||
* converted the reading into its marker's unit.
|
||||
*/
|
||||
sourceValue?: number | null;
|
||||
sourceUnit?: string | null;
|
||||
@@ -125,7 +124,7 @@ export interface BiomarkerBackupEntry {
|
||||
panel: string | null;
|
||||
hidden: boolean;
|
||||
context: string | null;
|
||||
/** @pending-consumer(#1095) The catalogue key the name resolves to. */
|
||||
/** The catalogue key the marker's name and unit resolve to. */
|
||||
analyteKey?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -165,6 +165,47 @@ describe("lab-result FHIR Observations", () => {
|
||||
expect(crp?.referenceRange).toBeUndefined();
|
||||
});
|
||||
|
||||
it("states a converted reading's window only in the value's unit", () => {
|
||||
// A reading converted from mmol/L: the resolved entry carries the bounds
|
||||
// in mg/dL and no text (the printed text is in mmol/L and would disagree
|
||||
// with low and high), so the element carries the two bounds alone.
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
labResults: [
|
||||
{
|
||||
panel: null,
|
||||
analyte: "Glucose",
|
||||
value: 100.9,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: 70.27,
|
||||
referenceHigh: 99.1,
|
||||
catalogReferenceLow: 70,
|
||||
catalogReferenceHigh: 100,
|
||||
sourceReferenceText: null,
|
||||
referenceOrigin: "source" as const,
|
||||
referenceDivergesFromCatalog: true,
|
||||
takenAt: "2026-04-10T08:00:00.000Z",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const [obs] = observationsOf(bundle).filter((o) =>
|
||||
o.category?.some((c) =>
|
||||
c.coding?.some((code) => code.code === "laboratory"),
|
||||
),
|
||||
);
|
||||
expect(obs.referenceRange).toEqual([
|
||||
{
|
||||
low: expect.objectContaining({ value: 70.27, unit: "mg/dL" }),
|
||||
high: expect.objectContaining({ value: 99.1, unit: "mg/dL" }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits no lab Observations when labResults is null", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults: null }),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* stays OUT of the cached factory — see v1.18.0 B5 notes on the route).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { REST_MODE_EPISODE_WHERE } from "@/lib/illness/rest-mode";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import type { ModuleKey } from "@/lib/modules/gate";
|
||||
import {
|
||||
@@ -614,11 +615,16 @@ export async function buildAchievementsResult(
|
||||
// never freezes anything. Both ongoing and already-resolved episodes are
|
||||
// read: a past illness should not retroactively break a historical
|
||||
// streak, just as an active one should not break the current run.
|
||||
// A chronic ongoing condition never freezes anything (#1216): it has no
|
||||
// end date, so it would bridge every gap from its onset on and a streak
|
||||
// could no longer break. A flare under it carries its own lifecycle and
|
||||
// still does.
|
||||
moduleMap.illness !== false
|
||||
? prisma.illnessEpisode.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...REST_MODE_EPISODE_WHERE,
|
||||
onsetAt: { lte: now },
|
||||
OR: [{ resolvedAt: null }, { resolvedAt: { gte: startDate } }],
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
resolveRestMode,
|
||||
toRestModeContext,
|
||||
REST_MODE_INACTIVE,
|
||||
hasActiveIllnessEpisode,
|
||||
triggersRestMode,
|
||||
} from "@/lib/illness/rest-mode";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -115,3 +117,90 @@ describe("resolveRestMode", () => {
|
||||
await expect(resolveRestMode("u1")).resolves.toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1216 — a chronic ongoing condition has no end date by design, so it must
|
||||
* never put the account into Rest Mode on its own: Day N would count from an
|
||||
* onset years back, nudges would never resume and streaks could never break.
|
||||
* Only active episodes that are not CHRONIC_ONGOING trigger it, and Day N
|
||||
* counts from the earliest of those.
|
||||
*/
|
||||
describe("Rest Mode and chronic conditions (#1216)", () => {
|
||||
const chronic = {
|
||||
id: "c1",
|
||||
label: "Sleep apnoea",
|
||||
type: "CHRONIC" as const,
|
||||
lifecycle: "CHRONIC_ONGOING" as const,
|
||||
onsetAt: new Date("2015-08-01T00:00:00.000Z"),
|
||||
};
|
||||
const acute = {
|
||||
id: "a1",
|
||||
label: "Cold",
|
||||
type: "INFECTION" as const,
|
||||
lifecycle: "ACUTE" as const,
|
||||
onsetAt: new Date("2026-06-12T00:00:00.000Z"),
|
||||
};
|
||||
const flare = {
|
||||
id: "f1",
|
||||
label: "Flare",
|
||||
type: "AUTOIMMUNE" as const,
|
||||
lifecycle: "FLARE" as const,
|
||||
onsetAt: new Date("2026-06-14T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
it("a chronic condition alone is not Rest Mode", () => {
|
||||
expect(toRestModeContext([chronic])).toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
|
||||
it("chronic + acute is Rest Mode, anchored on the acute onset", () => {
|
||||
const ctx = toRestModeContext([chronic, acute]);
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-06-12T00:00:00.000Z");
|
||||
expect(ctx.episodeCount).toBe(1);
|
||||
expect(ctx.episodes.map((e) => e.id)).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("a flare threaded under a chronic condition is Rest Mode", () => {
|
||||
const ctx = toRestModeContext([chronic, flare]);
|
||||
expect(ctx.active).toBe(true);
|
||||
expect(ctx.since).toBe("2026-06-14T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("anchors on the earliest triggering onset whatever the input order", () => {
|
||||
expect(toRestModeContext([flare, chronic, acute]).since).toBe(
|
||||
"2026-06-12T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveRestMode is inactive for a chronic-only account", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
|
||||
await expect(
|
||||
resolveRestMode("u1", new Date("2026-06-16T00:00:00.000Z")),
|
||||
).resolves.toEqual(REST_MODE_INACTIVE);
|
||||
});
|
||||
|
||||
it("hasActiveIllnessEpisode still sees the chronic condition as context", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockResolvedValue([chronic]);
|
||||
await expect(
|
||||
hasActiveIllnessEpisode("u1", new Date("2026-06-16T00:00:00.000Z")),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("hasActiveIllnessEpisode is fail-soft", async () => {
|
||||
gate.isIllnessEnabled.mockResolvedValue(true);
|
||||
db.prisma.illnessEpisode.findMany.mockRejectedValue(new Error("db down"));
|
||||
await expect(
|
||||
hasActiveIllnessEpisode("u1", new Date("2026-06-17T00:00:00.000Z")),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("triggersRestMode exempts only CHRONIC_ONGOING", () => {
|
||||
expect(
|
||||
["ACUTE", "RECURRING", "FLARE", "CHRONIC_ONGOING"].map((lifecycle) =>
|
||||
triggersRestMode({ lifecycle }),
|
||||
),
|
||||
).toEqual([true, true, true, false]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Rest Mode — the server-authoritative "an episode is active" context (v1.18.1, P4).
|
||||
* Rest Mode — the server-authoritative "the account is unwell right now"
|
||||
* context (v1.18.1, P4).
|
||||
*
|
||||
* CORE PRINCIPLE: an active illness/condition episode ANNOTATES, it never
|
||||
* PENALISES. Rest Mode never changes a measured number — it only frames the
|
||||
* narrative around it: the health score keeps its value but carries a "you
|
||||
* were unwell" note, recovery keeps its number, achievement streaks freeze
|
||||
* (rather than break) across the days you were ill, cadence-nudges pause, and
|
||||
* a coincident vital deviation reads as illness-explained instead of an
|
||||
* unexplained anomaly.
|
||||
* CORE PRINCIPLE: an active illness episode ANNOTATES, it never PENALISES.
|
||||
* Rest Mode never changes a measured number — it only frames the narrative
|
||||
* around it: the health score keeps its value but carries a "you were unwell"
|
||||
* note, recovery keeps its number, achievement streaks freeze (rather than
|
||||
* break) across the days you were ill, cadence-nudges pause, and the Today
|
||||
* strip counts the days since onset.
|
||||
*
|
||||
* This module is the ONE place that resolves "is the account in Rest Mode
|
||||
* right now, and which episodes back it" — so every surface annotates from
|
||||
@@ -16,18 +16,55 @@
|
||||
* the suppression). The illness module gate is honoured: a disabled / opted-
|
||||
* out account is never in Rest Mode (the gate short-circuits before any read).
|
||||
*
|
||||
* "Active" = an `IllnessEpisode` with `resolvedAt = null` (still ongoing),
|
||||
* not soft-deleted, whose `onsetAt` is on or before the reference instant.
|
||||
* CHRONIC_ONGOING episodes are intentionally INCLUDED here — an ongoing
|
||||
* chronic condition is precisely the case where a measured dip should be
|
||||
* framed, not penalised. (They are excluded only from the recovery-GAP math,
|
||||
* which is P3 and lives elsewhere.)
|
||||
* Two different questions are answered here, and they must not be confused:
|
||||
*
|
||||
* - "Which episodes are active?" (`getActiveIllnessEpisodes`): every
|
||||
* `IllnessEpisode` with `resolvedAt = null`, not soft-deleted, whose
|
||||
* `onsetAt` is on or before the reference instant. CHRONIC_ONGOING
|
||||
* conditions are included. This is CONTEXT: a measured dip that coincides
|
||||
* with a known chronic condition is still framed as explained by it
|
||||
* (`hasActiveIllnessEpisode`), and the Coach still sees the condition.
|
||||
*
|
||||
* - "Is the account in Rest Mode?" (`resolveRestMode`): only the active
|
||||
* episodes that are NOT CHRONIC_ONGOING (acute, recurring, flare). A
|
||||
* chronic condition has no end date by design, so letting it trigger Rest
|
||||
* Mode would put the account into it permanently: the Day N counter would
|
||||
* count from an onset years back, nudges would never resume and streaks
|
||||
* could never break. A flare threaded under a chronic condition carries its
|
||||
* own lifecycle (FLARE), so a chronic condition getting worse still does.
|
||||
* `since` and Day N count from the earliest onset among the episodes that
|
||||
* trigger Rest Mode, never from a chronic onset.
|
||||
*
|
||||
* Every reader that freezes, pauses or counts on Rest Mode's behalf goes
|
||||
* through `triggersRestMode` / `REST_MODE_EPISODE_WHERE`, including the
|
||||
* achievements streak freeze, which runs its own windowed query.
|
||||
*/
|
||||
import type { PrismaClient, IllnessEpisode } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { isIllnessEnabled } from "@/lib/illness/gate";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
|
||||
/**
|
||||
* The lifecycle that never triggers Rest Mode on its own: a condition the
|
||||
* person lives with. See the module header.
|
||||
*/
|
||||
export const REST_MODE_EXEMPT_LIFECYCLE = "CHRONIC_ONGOING" as const;
|
||||
|
||||
/**
|
||||
* Prisma `where` fragment selecting the episodes that can trigger Rest Mode.
|
||||
* For readers that query episodes themselves (the achievements streak freeze
|
||||
* reads resolved ones too, over a window) instead of going through
|
||||
* `resolveRestMode`.
|
||||
*/
|
||||
export const REST_MODE_EPISODE_WHERE = {
|
||||
lifecycle: { not: REST_MODE_EXEMPT_LIFECYCLE },
|
||||
} as const;
|
||||
|
||||
/** Whether an episode, while active, puts the account into Rest Mode. */
|
||||
export function triggersRestMode(episode: { lifecycle: string }): boolean {
|
||||
return episode.lifecycle !== REST_MODE_EXEMPT_LIFECYCLE;
|
||||
}
|
||||
|
||||
/** Minimal shape the Rest Mode resolver needs from an episode row. */
|
||||
export type ActiveIllnessEpisode = Pick<
|
||||
IllnessEpisode,
|
||||
@@ -44,13 +81,19 @@ export type ActiveIllnessEpisode = Pick<
|
||||
* This is the server-authoritative shape iOS mirrors verbatim.
|
||||
*/
|
||||
export interface RestModeContext {
|
||||
/** True when ≥ 1 illness episode is active as of the reference instant. */
|
||||
/**
|
||||
* True when ≥ 1 active episode that is not CHRONIC_ONGOING exists as of the
|
||||
* reference instant.
|
||||
*/
|
||||
active: boolean;
|
||||
/** ISO onset of the earliest active episode, or null when none. */
|
||||
/** ISO onset of the earliest episode backing Rest Mode, or null when none. */
|
||||
since: string | null;
|
||||
/** Count of active episodes (usually 1; ≥ 2 when conditions overlap). */
|
||||
/** Count of episodes backing Rest Mode (usually 1; ≥ 2 when they overlap). */
|
||||
episodeCount: number;
|
||||
/** The active episodes, label + type + onset only — never a decrypted note. */
|
||||
/**
|
||||
* The episodes backing Rest Mode, label + type + onset only — never a
|
||||
* decrypted note. Chronic conditions are not among them.
|
||||
*/
|
||||
episodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -69,7 +112,9 @@ export const REST_MODE_INACTIVE: RestModeContext = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the active illness episodes for an account as of `asOf`. Gated through
|
||||
* Read the active illness episodes for an account as of `asOf`, chronic
|
||||
* conditions included — the context set, not the Rest Mode set (that is
|
||||
* `toRestModeContext` over this). Gated through
|
||||
* the illness module: a disabled / not-opted-in account returns `[]` without
|
||||
* touching the episode table. The reference instant is injectable so the
|
||||
* resolver is deterministic for tests and so a nightly job can resolve "as of
|
||||
@@ -118,10 +163,18 @@ async function readActiveIllnessEpisodes(
|
||||
});
|
||||
}
|
||||
|
||||
/** Fold a set of active episodes into the renderable Rest Mode context. Pure. */
|
||||
/**
|
||||
* Fold a set of active episodes into the renderable Rest Mode context. Pure.
|
||||
* Only the episodes that trigger Rest Mode count: a chronic condition alone
|
||||
* leaves the account out of it, and `since` is the earliest onset among the
|
||||
* episodes that do (the input is onset-ascending).
|
||||
*/
|
||||
export function toRestModeContext(
|
||||
episodes: ActiveIllnessEpisode[],
|
||||
activeEpisodes: ActiveIllnessEpisode[],
|
||||
): RestModeContext {
|
||||
const episodes = activeEpisodes
|
||||
.filter(triggersRestMode)
|
||||
.sort((a, b) => a.onsetAt.getTime() - b.onsetAt.getTime());
|
||||
if (episodes.length === 0) return REST_MODE_INACTIVE;
|
||||
return {
|
||||
active: true,
|
||||
@@ -155,3 +208,22 @@ export async function resolveRestMode(
|
||||
return REST_MODE_INACTIVE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any illness episode is active as of `asOf`, chronic conditions
|
||||
* included. For framing a measured deviation as explained by a known
|
||||
* condition, which a chronic one does as much as an acute one. Not Rest Mode:
|
||||
* nothing may freeze, pause or count on this. Fail-soft like
|
||||
* `resolveRestMode`.
|
||||
*/
|
||||
export async function hasActiveIllnessEpisode(
|
||||
userId: string,
|
||||
asOf: Date = new Date(),
|
||||
client: PrismaClient = defaultPrisma,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return (await getActiveIllnessEpisodes(userId, asOf, client)).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,31 @@ describe("buildBriefingIllnessCyclePrompt", () => {
|
||||
expect(out).toContain("SYSTEM CONTEXT");
|
||||
});
|
||||
|
||||
it("names a chronic-only condition as context, not Rest Mode (#1216)", () => {
|
||||
const out = buildBriefingIllnessCyclePrompt(
|
||||
{
|
||||
illness: {
|
||||
restMode: false,
|
||||
active: [
|
||||
{
|
||||
label: "Sleep apnoea",
|
||||
type: "CHRONIC",
|
||||
lifecycle: "CHRONIC_ONGOING",
|
||||
onsetAt: "x",
|
||||
},
|
||||
],
|
||||
recentResolved: [],
|
||||
},
|
||||
cycle: null,
|
||||
},
|
||||
"en",
|
||||
);
|
||||
expect(out).toContain("Sleep apnoea");
|
||||
expect(out).toContain("chronic condition");
|
||||
expect(out).not.toContain("The user is in Rest Mode");
|
||||
expect(out).not.toContain("measure more often");
|
||||
});
|
||||
|
||||
it("emits a cycle day/phase line", () => {
|
||||
const out = buildBriefingIllnessCyclePrompt(
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ function row(
|
||||
sourceReferenceLow: opts.sourceLow ?? null,
|
||||
sourceReferenceHigh: opts.sourceHigh ?? null,
|
||||
sourceReferenceText: opts.sourceText ?? null,
|
||||
sourceUnit: null,
|
||||
takenAt: new Date(`${day}T08:00:00.000Z`),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ export async function generateBiomarkerStatus(args: {
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
},
|
||||
});
|
||||
if (rows.length === 0) {
|
||||
|
||||
@@ -1072,6 +1072,7 @@ export async function fetchLabDraws(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
panel: true,
|
||||
value: true,
|
||||
takenAt: true,
|
||||
|
||||
@@ -17,14 +17,11 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active.
|
||||
// v1.18.1 P4 — the illness reframe. Default no active episode; the reframe
|
||||
// test flips it. Since #1216 the reframe reads active episodes directly
|
||||
// (chronic conditions included), not Rest Mode.
|
||||
const restModeMock = vi.hoisted(() => ({
|
||||
resolveRestMode: vi.fn(async () => ({
|
||||
active: false as boolean,
|
||||
since: null as string | null,
|
||||
episodeCount: 0,
|
||||
episodes: [] as unknown[],
|
||||
})),
|
||||
hasActiveIllnessEpisode: vi.fn(async () => false as boolean),
|
||||
}));
|
||||
vi.mock("@/lib/illness/rest-mode", () => restModeMock);
|
||||
|
||||
@@ -43,12 +40,7 @@ const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findMany.mockResolvedValue([]);
|
||||
restModeMock.resolveRestMode.mockResolvedValue({
|
||||
active: false,
|
||||
since: null,
|
||||
episodeCount: 0,
|
||||
episodes: [],
|
||||
});
|
||||
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** RHR + HRV each flat, then a final day that pushes both outside the band. */
|
||||
@@ -184,13 +176,8 @@ describe("computeCoincidentDeviation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => {
|
||||
restModeMock.resolveRestMode.mockResolvedValue({
|
||||
active: true,
|
||||
since: "2026-05-30T00:00:00.000Z",
|
||||
episodeCount: 1,
|
||||
episodes: [],
|
||||
});
|
||||
it("reframes a fired flag as illness-explained while an episode is active (vitals unchanged)", async () => {
|
||||
restModeMock.hasActiveIllnessEpisode.mockResolvedValue(true);
|
||||
findMany.mockImplementation(firingRows);
|
||||
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
||||
now: NOW,
|
||||
@@ -249,7 +236,7 @@ describe("computeCoincidentDeviation", () => {
|
||||
});
|
||||
expect(result.status === "ok" && result.value.fired).toBe(false);
|
||||
expect(result.status === "ok" && result.value.illnessExplained).toBe(false);
|
||||
expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
|
||||
expect(restModeMock.hasActiveIllnessEpisode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { VITALS_BASELINE_TYPES } from "./registry";
|
||||
import type { Derived, DerivedProvenanceSource } from "./types";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { hasActiveIllnessEpisode } from "@/lib/illness/rest-mode";
|
||||
import {
|
||||
isPlausibleMetricValue,
|
||||
plausibleMetricRange,
|
||||
@@ -463,8 +463,11 @@ export async function computeCoincidentDeviation(
|
||||
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
|
||||
// episode is active. Only resolved when the flag fired (no read on the
|
||||
// common quiet day). Annotation only — the vital deviations are unchanged.
|
||||
// Any active episode counts, a chronic condition included: it explains a
|
||||
// coincident dip as much as an acute one, even though it does not put the
|
||||
// account into Rest Mode (#1216).
|
||||
const illnessExplained = fired
|
||||
? (await resolveRestMode(userId, now)).active
|
||||
? await hasActiveIllnessEpisode(userId, now)
|
||||
: false;
|
||||
|
||||
const { coverage: cov, confidence } = deriveCoverage({
|
||||
|
||||
@@ -256,6 +256,7 @@ export async function readLabsBriefingBlock(
|
||||
sourceReferenceLow: true,
|
||||
sourceReferenceHigh: true,
|
||||
sourceReferenceText: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
biomarkerId: true,
|
||||
biomarker: {
|
||||
|
||||
@@ -82,6 +82,17 @@ export function buildBriefingIllnessCyclePrompt(
|
||||
? `- Aktive Erkrankung(en) gerade: ${labels}. Der Nutzer ist im Rest Mode — lies abweichende Vitalwerte oder eine niedrige Erholung als krankheitsbedingt, nicht alarmierend, und dränge NICHT auf häufigeres Messen.`
|
||||
: `- Active illness episode(s) right now: ${labels}. The user is in Rest Mode — read off-band vitals or low recovery as illness-explained rather than alarming, and do NOT push a "measure more often" cadence.`,
|
||||
);
|
||||
} else if (ctx.illness.active.length > 0) {
|
||||
// Chronic ongoing conditions only: context for a measured dip, but not
|
||||
// Rest Mode, so no cadence pause and no "unwell" framing (#1216).
|
||||
const labels = ctx.illness.active
|
||||
.map((e) => `${e.label} (${e.type}, ${e.lifecycle})`)
|
||||
.join("; ");
|
||||
lines.push(
|
||||
de
|
||||
? `- Bekannte chronische Erkrankung(en): ${labels}. Ein abweichender Vitalwert kann damit zusammenhängen; das ist Kontext, kein akutes Kranksein und kein Rest Mode.`
|
||||
: `- Known chronic condition(s): ${labels}. An off-band vital may relate to them; this is context, not an acute illness and not Rest Mode.`,
|
||||
);
|
||||
} else if (ctx.illness.recentResolved.length > 0) {
|
||||
lines.push(
|
||||
de
|
||||
|
||||
@@ -942,8 +942,10 @@ export async function runCoachNudgeTick(
|
||||
}
|
||||
|
||||
// Gate 7 — Rest Mode pause (v1.18.1 P4). A user with an active
|
||||
// illness/condition episode should not be told to measure more often;
|
||||
// the cadence-nudge pauses for the duration of the episode. Module-gated
|
||||
// illness episode should not be told to measure more often; the
|
||||
// cadence-nudge pauses for the duration of the episode. A chronic
|
||||
// ongoing condition alone does not pause it (#1216): it never ends, so
|
||||
// the nudge would never come back. Module-gated
|
||||
// (a non-illness account is never in Rest Mode) and only reached past
|
||||
// every cheaper gate, so the read fires for at most a nudge-eligible
|
||||
// user per tick.
|
||||
|
||||
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill";
|
||||
@@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser(
|
||||
lowerBound: group.referenceLow,
|
||||
upperBound: group.referenceHigh,
|
||||
panel: group.panel,
|
||||
analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }),
|
||||
},
|
||||
});
|
||||
markers += 1;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
syncUserMeasurements,
|
||||
} from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
import { releasePlanGatedWithingsParks } from "@/lib/withings/api-plan";
|
||||
import { syncUserSleep } from "@/lib/withings/sync-sleep";
|
||||
import { syncUserEcg } from "@/lib/withings/sync-ecg";
|
||||
import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue";
|
||||
@@ -74,6 +75,15 @@ export async function handleWithingsFallbackSync(
|
||||
"Withings subscription repair failed; continuing fallback sync",
|
||||
);
|
||||
}
|
||||
// Connections an earlier build parked at `error_reauth` over a Withings
|
||||
// API-plan refusal (277) are lifted first, so this pass and the activity,
|
||||
// sleep and ECG legs read again. Best-effort: a failure here leaves the
|
||||
// rows parked until the next tick, it never blocks the pass.
|
||||
try {
|
||||
await releasePlanGatedWithingsParks();
|
||||
} catch {
|
||||
evt.addWarning("Withings plan-park release failed; continuing");
|
||||
}
|
||||
const connections = await prisma.withingsConnection.findMany({
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
|
||||
import { matchAnalyteKey, analyteNameIndex } from "../analyte-match";
|
||||
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog";
|
||||
import { analyteAcceptsUnit } from "../unit-conversion/convert";
|
||||
import {
|
||||
allAnalyteConversions,
|
||||
analyteConversionFor,
|
||||
} from "../unit-conversion/registry";
|
||||
|
||||
const match = (name: string, unit: string) => matchAnalyteKey({ name, unit });
|
||||
|
||||
describe("the catalog's own names, in every language", () => {
|
||||
it.each([
|
||||
["Fasting glucose", "mg/dL", "fasting-glucose"],
|
||||
["Nüchternglukose", "mg/dL", "fasting-glucose"],
|
||||
["Glucosa en ayunas", "mg/dL", "fasting-glucose"],
|
||||
["Glycémie à jeun", "mmol/L", "fasting-glucose"],
|
||||
["Glicemia a digiuno", "mg/dL", "fasting-glucose"],
|
||||
["공복혈당", "mg/dL", "fasting-glucose"],
|
||||
["Glukoza na czczo", "mg/dL", "fasting-glucose"],
|
||||
["HbA1c", "%", "hba1c"],
|
||||
["Total cholesterol", "mg/dL", "total-cholesterol"],
|
||||
["Gesamtcholesterin", "mg/dL", "total-cholesterol"],
|
||||
["Cholestérol total", "mmol/L", "total-cholesterol"],
|
||||
["LDL cholesterol", "mg/dL", "ldl"],
|
||||
["LDL-Cholesterin", "mg/dL", "ldl"],
|
||||
["Colesterolo HDL", "mg/dL", "hdl"],
|
||||
["Vitamin D (25-OH)", "ng/mL", "vitamin-d"],
|
||||
["Créatinine", "mg/dL", "creatinine"],
|
||||
["Kreatynina", "µmol/L", "creatinine"],
|
||||
])("%s (%s) is %s", (name, unit, key) => {
|
||||
// The key is the catalog slug the name belongs to, so LDL and HDL keep
|
||||
// their own identity even though they convert with the cholesterol entry.
|
||||
expect(match(name, unit)).toBe(key);
|
||||
});
|
||||
|
||||
it("resolves an LDL or HDL key to the cholesterol entry for the conversion", () => {
|
||||
expect(match("LDL cholesterol", "mg/dL")).toBe("ldl");
|
||||
expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteAcceptsUnit("ldl", "mmol/L")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognises a seed typed without the app's gloss", () => {
|
||||
expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d");
|
||||
});
|
||||
});
|
||||
|
||||
describe("typed names", () => {
|
||||
it.each([
|
||||
["Glucose", "mg/dL", "fasting-glucose"],
|
||||
["Blutzucker", "mg/dL", "fasting-glucose"],
|
||||
["blood sugar", "mmol/L", "fasting-glucose"],
|
||||
["Cholesterin", "mg/dL", "total-cholesterol"],
|
||||
["Serum Creatinine", "mg/dL", "creatinine"],
|
||||
["Urea", "mmol/L", "urea"],
|
||||
["Harnstoff", "mg/dL", "urea"],
|
||||
["BUN", "mg/dL", "blood-urea-nitrogen"],
|
||||
["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"],
|
||||
["Harnsäure", "µmol/L", "uric-acid"],
|
||||
["Uric acid", "mg/dL", "uric-acid"],
|
||||
["Kalzium", "mmol/L", "calcium-total"],
|
||||
["Calcium", "mg/dL", "calcium-total"],
|
||||
["Gesamtbilirubin", "µmol/L", "bilirubin-total"],
|
||||
["Bilirubin total", "mg/dL", "bilirubin-total"],
|
||||
])("%s (%s) is %s", (name, unit, key) => {
|
||||
expect(match(name, unit)).toBe(key);
|
||||
});
|
||||
|
||||
it("folds case, accents, hyphens and spacing the same on both sides", () => {
|
||||
expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid");
|
||||
expect(match("ldl-c", "mg/dL")).toBe("ldl");
|
||||
expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a name that is not the whole analyte name stays unlinked", () => {
|
||||
it.each([
|
||||
["Calcium (ionisiert)", "mmol/L"],
|
||||
["Ionised calcium", "mmol/L"],
|
||||
["Calcium ionisiert", "mmol/L"],
|
||||
["Urea nitrogen ratio", "mg/dL"],
|
||||
["Glucose (urine)", "mg/dL"],
|
||||
["Glucose tolerance", "mg/dL"],
|
||||
["Non-HDL cholesterol", "mg/dL"],
|
||||
["Cholesterol ratio", "mg/dL"],
|
||||
["Direct bilirubin", "mg/dL"],
|
||||
["Free thyroxine", "pmol/L"],
|
||||
["", "mg/dL"],
|
||||
])("%s", (name, unit) => {
|
||||
expect(match(name, unit)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps urea and blood urea nitrogen apart", () => {
|
||||
expect(match("Urea", "mmol/L")).toBe("urea");
|
||||
expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the marker's unit must be one the analyte reaches", () => {
|
||||
it("links a unit the entry lists and one that differs by a power of ten", () => {
|
||||
expect(match("Glucose", "mg/dL")).toBe("fasting-glucose");
|
||||
expect(match("Glucose", "mmol/L")).toBe("fasting-glucose");
|
||||
expect(match("Glucose", "g/L")).toBe("fasting-glucose");
|
||||
expect(match("Creatinine", "mmol/L")).toBe("creatinine");
|
||||
});
|
||||
|
||||
it("leaves a unit the registry cannot reach unlinked", () => {
|
||||
expect(match("Glucose", "mEq/L")).toBeNull();
|
||||
expect(match("Glucose", "%")).toBeNull();
|
||||
expect(match("HbA1c", "mg/dL")).toBeNull();
|
||||
expect(match("Calcium", "furlongs")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a spelling of the unit through the normaliser", () => {
|
||||
expect(match("Glucose", "MG/DL")).toBe("fasting-glucose");
|
||||
expect(match("Creatinine", "umol/l")).toBe("creatinine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the alias list, as a guard", () => {
|
||||
it("names only analytes the registry knows", () => {
|
||||
for (const { key } of ANALYTE_ALIASES) {
|
||||
expect(analyteConversionFor(key), key).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes every name already folded, so what is read is what is matched", () => {
|
||||
for (const { key, names } of ANALYTE_ALIASES) {
|
||||
for (const name of names) {
|
||||
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has no name that two analytes claim", () => {
|
||||
expect(analyteNameIndex().ambiguous).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives every registry entry at least one name to be found by", () => {
|
||||
const { index } = analyteNameIndex();
|
||||
const reached = new Set(index.values());
|
||||
for (const entry of allAnalyteConversions()) {
|
||||
expect(reached.has(entry.key), entry.key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers every catalog seed the registry knows, in all seven languages", () => {
|
||||
const { index } = analyteNameIndex();
|
||||
const seeds = BIOMARKER_CATALOG.filter((seed) =>
|
||||
analyteConversionFor(seed.slug),
|
||||
);
|
||||
expect(seeds.length).toBeGreaterThanOrEqual(6);
|
||||
for (const seed of seeds) {
|
||||
const key = analyteConversionFor(seed.slug)!.key;
|
||||
const names = [...index.entries()].filter(([, k]) => k === key);
|
||||
expect(names.length, seed.slug).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("links each seed at its own catalog unit", () => {
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
const entry = analyteConversionFor(seed.slug);
|
||||
if (!entry) continue;
|
||||
expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const NO_SOURCE = {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: null,
|
||||
sourceUnit: null,
|
||||
};
|
||||
|
||||
describe("resolveEffectiveReferenceRange", () => {
|
||||
@@ -36,6 +37,7 @@ describe("resolveEffectiveReferenceRange", () => {
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: "3,9 - 5,4",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff).toMatchObject({
|
||||
low: 3.9,
|
||||
@@ -53,6 +55,7 @@ describe("resolveEffectiveReferenceRange", () => {
|
||||
sourceReferenceLow: 3.5,
|
||||
sourceReferenceHigh: 5,
|
||||
sourceReferenceText: "3,5 - 5,0",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff.origin).toBe("source");
|
||||
expect(eff.divergesFromCatalog).toBe(false);
|
||||
@@ -65,6 +68,7 @@ describe("resolveEffectiveReferenceRange", () => {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: 4.2,
|
||||
sourceReferenceText: "< 4,2",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff).toMatchObject({
|
||||
low: null,
|
||||
@@ -81,6 +85,7 @@ describe("resolveEffectiveReferenceRange", () => {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: "negativ",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff).toMatchObject({
|
||||
low: 3.5,
|
||||
@@ -96,12 +101,57 @@ describe("resolveEffectiveReferenceRange", () => {
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: "3,9 - 5,4",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff.origin).toBe("source");
|
||||
expect(eff.divergesFromCatalog).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a converted reading's printed text", () => {
|
||||
// 5.6 mmol/L stored as mg/dL, with the printed 3.9 to 5.5 mmol/L window
|
||||
// converted alongside; the text stays as the report printed it.
|
||||
const CONVERTED = {
|
||||
sourceReferenceLow: 3.9 * 18.0182,
|
||||
sourceReferenceHigh: 5.5 * 18.0182,
|
||||
sourceReferenceText: "3,9 - 5,5",
|
||||
sourceUnit: "mmol/L",
|
||||
};
|
||||
|
||||
it("is never the window's text, so no statement mixes two units", () => {
|
||||
const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED);
|
||||
expect(eff.origin).toBe("source");
|
||||
expect(eff.low).toBeCloseTo(70.27, 2);
|
||||
expect(eff.sourceText).toBeNull();
|
||||
});
|
||||
|
||||
it("is kept as evidence with the unit it is written in", () => {
|
||||
const eff = resolveEffectiveReferenceRange(70, 100, CONVERTED);
|
||||
expect(eff.printed).toEqual({ text: "3,9 - 5,5", unit: "mmol/L" });
|
||||
});
|
||||
|
||||
it("stays the window's text for a reading stored as stated", () => {
|
||||
const eff = resolveEffectiveReferenceRange(70, 100, {
|
||||
...CONVERTED,
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(eff.sourceText).toBe("3,9 - 5,5");
|
||||
expect(eff.printed).toBeNull();
|
||||
});
|
||||
|
||||
it("moves an unreadable printed text the same way", () => {
|
||||
const eff = resolveEffectiveReferenceRange(70, 100, {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: "siehe Befund",
|
||||
sourceUnit: "mmol/L",
|
||||
});
|
||||
expect(eff.origin).toBe("catalog");
|
||||
expect(eff.sourceText).toBeNull();
|
||||
expect(eff.printed).toEqual({ text: "siehe Befund", unit: "mmol/L" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyAgainstEffectiveRange", () => {
|
||||
it("judges a reading by the source window, not the catalog band", () => {
|
||||
// 5.2 sits inside the catalog band (3.5–5.0 would call it high) but the
|
||||
@@ -110,6 +160,7 @@ describe("classifyAgainstEffectiveRange", () => {
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.4,
|
||||
sourceReferenceText: "3,9 - 5,4",
|
||||
sourceUnit: null,
|
||||
});
|
||||
expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("in-range");
|
||||
expect(classifyAgainstEffectiveRange(3.7, eff)).toBe("below");
|
||||
|
||||
@@ -10,10 +10,17 @@ describe("lab value formatting", () => {
|
||||
it("trims to two decimals and prints the locale's separator", () => {
|
||||
expect(formatLabValue(1.8, de)).toBe("1,8");
|
||||
expect(formatLabValue(1.8, en)).toBe("1.8");
|
||||
expect(formatLabValue(0.123456, de)).toBe("0,12");
|
||||
expect(formatLabValue(0.123456, de)).toBe("0,123");
|
||||
expect(formatLabValue(12.3456, de)).toBe("12,35");
|
||||
expect(formatLabValue(5, de)).toBe("5");
|
||||
});
|
||||
|
||||
it("never shows a small nonzero reading as zero", () => {
|
||||
expect(formatLabValue(0.004, de)).toBe("0,004");
|
||||
expect(formatLabValue(0.012, en)).toBe("0.012");
|
||||
expect(formatLabValue(0.035, de)).toBe("0,035");
|
||||
});
|
||||
|
||||
it("formats a numeric reading with its unit and leaves a qualitative one verbatim", () => {
|
||||
expect(
|
||||
formatLabReading({ value: 61.3, valueText: null, unit: "kg" }, de),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readingInMarkerUnit, type ReadingMarker } from "../reading-conversion";
|
||||
|
||||
/** A client whose `updateMany` records what the fill wrote. */
|
||||
function client(current: string | null = null) {
|
||||
return {
|
||||
biomarker: {
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
findUnique: vi.fn(async () => ({ analyteKey: current })),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const glucose: ReadingMarker = {
|
||||
id: "bm-1",
|
||||
name: "Glucose",
|
||||
unit: "mg/dL",
|
||||
analyteKey: "fasting-glucose",
|
||||
};
|
||||
|
||||
const read = (
|
||||
marker: ReadingMarker,
|
||||
value: number,
|
||||
unit: string | undefined,
|
||||
low: number | null = null,
|
||||
high: number | null = null,
|
||||
c = client(),
|
||||
) =>
|
||||
readingInMarkerUnit(c, {
|
||||
marker,
|
||||
value,
|
||||
unit,
|
||||
sourceReferenceLow: low,
|
||||
sourceReferenceHigh: high,
|
||||
});
|
||||
|
||||
describe("a reading in the marker's own unit", () => {
|
||||
it("is stored as stated, with its window untouched", async () => {
|
||||
expect(await read(glucose, 95, "mg/dL", 70, 100)).toEqual({
|
||||
ok: true,
|
||||
converted: false,
|
||||
value: 95,
|
||||
sourceReferenceLow: 70,
|
||||
sourceReferenceHigh: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("is the same when the unit is spelled another way or not sent", async () => {
|
||||
expect(await read(glucose, 95, " mg/DL ")).toMatchObject({
|
||||
ok: true,
|
||||
converted: false,
|
||||
value: 95,
|
||||
});
|
||||
expect(await read(glucose, 95, undefined)).toMatchObject({
|
||||
ok: true,
|
||||
converted: false,
|
||||
value: 95,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not touch the database", async () => {
|
||||
const c = client();
|
||||
await read({ ...glucose, analyteKey: null }, 95, "mg/dL", null, null, c);
|
||||
expect(
|
||||
(c as never as { biomarker: { updateMany: unknown } }).biomarker
|
||||
.updateMany,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reading in another unit", () => {
|
||||
it("is converted into the marker's unit, keeping what the report printed", async () => {
|
||||
const result = await read(glucose, 5.6, "mmol/L");
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
converted: true,
|
||||
sourceValue: 5.6,
|
||||
sourceUnit: "mmol/L",
|
||||
});
|
||||
if (!result.ok) return;
|
||||
expect(result.value).toBeCloseTo(5.6 * 18.0182, 9);
|
||||
});
|
||||
|
||||
it("stores full precision and trims the printed unit", async () => {
|
||||
const result = await read(glucose, 5.6, " mmol/L ");
|
||||
if (!result.ok || !result.converted) throw new Error("not converted");
|
||||
expect(result.sourceUnit).toBe("mmol/L");
|
||||
expect(result.value).not.toBe(Math.round(result.value));
|
||||
});
|
||||
|
||||
it("moves the report's own window with the value", async () => {
|
||||
const result = await read(glucose, 5.6, "mmol/L", 3.9, 5.6);
|
||||
if (!result.ok) throw new Error("refused");
|
||||
expect(result.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9);
|
||||
expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
|
||||
expect(result.sourceReferenceLow!).toBeLessThan(
|
||||
result.sourceReferenceHigh!,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves an open bound open", async () => {
|
||||
const result = await read(glucose, 5, "mmol/L", null, 5.6);
|
||||
if (!result.ok) throw new Error("refused");
|
||||
expect(result.sourceReferenceLow).toBeNull();
|
||||
expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
|
||||
});
|
||||
|
||||
it("converts an affine pair: 47.5 mmol/mol is 6.5 % HbA1c", async () => {
|
||||
const hba1c: ReadingMarker = {
|
||||
id: "bm-2",
|
||||
name: "HbA1c",
|
||||
unit: "%",
|
||||
analyteKey: "hba1c",
|
||||
};
|
||||
const result = await read(hba1c, 47.5, "mmol/mol", 20, 42);
|
||||
if (!result.ok) throw new Error("refused");
|
||||
expect(result.value).toBeCloseTo(6.5, 1);
|
||||
expect(result.sourceReferenceLow!).toBeLessThan(
|
||||
result.sourceReferenceHigh!,
|
||||
);
|
||||
});
|
||||
|
||||
it("converts by scale alone, for a marker with no analyte", async () => {
|
||||
const unknown: ReadingMarker = {
|
||||
id: "bm-3",
|
||||
name: "Some marker",
|
||||
unit: "mg/dL",
|
||||
analyteKey: null,
|
||||
};
|
||||
const result = await read(unknown, 1, "g/L");
|
||||
expect(result).toMatchObject({ ok: true, converted: true, value: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the analyte is filled on first use", () => {
|
||||
it("links a marker that had no key yet, then converts", async () => {
|
||||
const c = client();
|
||||
const result = await read(
|
||||
{ id: "bm-1", name: "Blutzucker", unit: "mg/dL", analyteKey: null },
|
||||
5.6,
|
||||
"mmol/L",
|
||||
null,
|
||||
null,
|
||||
c,
|
||||
);
|
||||
expect(result).toMatchObject({ ok: true, converted: true });
|
||||
const updateMany = (
|
||||
c as never as {
|
||||
biomarker: { updateMany: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
).biomarker.updateMany;
|
||||
expect(updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "bm-1", analyteKey: null },
|
||||
data: { analyteKey: "fasting-glucose" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a refusal", () => {
|
||||
it("is returned for a marker with no analyte and a different kind of unit", async () => {
|
||||
const free: ReadingMarker = {
|
||||
id: "bm-4",
|
||||
name: "My own marker",
|
||||
unit: "mg/dL",
|
||||
analyteKey: null,
|
||||
};
|
||||
expect(await read(free, 5.6, "mmol/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
});
|
||||
|
||||
it("is returned for a pair the analyte's entry does not reach", async () => {
|
||||
expect(await read(glucose, 5.6, "%")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_unit",
|
||||
});
|
||||
});
|
||||
|
||||
it("is returned for a value that is not finite", async () => {
|
||||
expect(await read(glucose, Number.NaN, "mmol/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "not_finite",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ describe("resolveOrMintBiomarker", () => {
|
||||
lowerBound: null,
|
||||
upperBound: 100,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", {
|
||||
@@ -65,6 +66,7 @@ describe("resolveOrMintBiomarker", () => {
|
||||
lowerBound: null,
|
||||
upperBound: 116,
|
||||
panel: "lipids",
|
||||
analyteKey: null,
|
||||
});
|
||||
|
||||
const out = await resolveOrMintBiomarker("u1", baseInput);
|
||||
@@ -90,6 +92,7 @@ describe("resolveOrMintBiomarker", () => {
|
||||
lowerBound: 30,
|
||||
upperBound: 400,
|
||||
panel: null,
|
||||
analyteKey: null,
|
||||
});
|
||||
|
||||
await resolveOrMintBiomarker("u1", {
|
||||
|
||||
@@ -20,6 +20,8 @@ const baseRow: LabRow = {
|
||||
sourceReferenceLow: null,
|
||||
sourceReferenceHigh: null,
|
||||
sourceReferenceText: null,
|
||||
sourceValue: null,
|
||||
sourceUnit: null,
|
||||
takenAt: new Date("2026-01-01T08:00:00.000Z"),
|
||||
source: "MANUAL",
|
||||
biomarkerId: "bm_1",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveValueForUpdate, valueEditSeed } from "../value-update";
|
||||
|
||||
describe("valueEditSeed", () => {
|
||||
it("opens a converted reading at display precision, not its stored tail", () => {
|
||||
// 5.6 mmol/L of glucose, stored in mg/dL.
|
||||
expect(valueEditSeed(100.90191999999999)).toBe("100.9");
|
||||
});
|
||||
|
||||
it("keeps whole numbers bare and at most two decimals", () => {
|
||||
expect(valueEditSeed(110)).toBe("110");
|
||||
expect(valueEditSeed(4.567)).toBe("4.57");
|
||||
});
|
||||
|
||||
it("never opens a small nonzero value as zero", () => {
|
||||
expect(valueEditSeed(0.004)).toBe("0.004");
|
||||
expect(valueEditSeed(0.0123456)).toBe("0.0123");
|
||||
expect(valueEditSeed(0.87)).toBe("0.87");
|
||||
expect(valueEditSeed(0.123456)).toBe("0.123");
|
||||
expect(valueEditSeed(-0.004)).toBe("-0.004");
|
||||
expect(valueEditSeed(0.0000004)).toBe("0.0000004");
|
||||
for (const v of [0.004, 0.0004, 0.00004, 1e-9]) {
|
||||
expect(Number(valueEditSeed(v))).not.toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveValueForUpdate", () => {
|
||||
it("leaves an untouched value out of the update", () => {
|
||||
expect(
|
||||
resolveValueForUpdate({
|
||||
seed: "100.9",
|
||||
editorValue: "100.9",
|
||||
parsed: 100.9,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends a changed value", () => {
|
||||
expect(
|
||||
resolveValueForUpdate({
|
||||
seed: "100.9",
|
||||
editorValue: "101,5",
|
||||
parsed: 101.5,
|
||||
}),
|
||||
).toBe(101.5);
|
||||
});
|
||||
|
||||
it("sends a value typed back to the same number in another spelling", () => {
|
||||
// "100,9" is a deliberate re-entry; the stored tail goes, as for any edit.
|
||||
expect(
|
||||
resolveValueForUpdate({
|
||||
seed: "100.9",
|
||||
editorValue: "100,9",
|
||||
parsed: 100.9,
|
||||
}),
|
||||
).toBe(100.9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Link a marker to its analyte for the unit conversion.
|
||||
*
|
||||
* There is no manual choice of analyte: the key is a function of the marker's
|
||||
* name and unit (`matchAnalyteKey`). It is set when a marker is created and
|
||||
* re-derived whenever its name or unit changes, and this step brings a stored
|
||||
* key back in line with that function the first time a reading needs it. So a
|
||||
* marker that never had a key gets one, and a key that no longer agrees with
|
||||
* the name (written before a rename re-derived it, or in an older form) is
|
||||
* replaced, or cleared when the name now matches nothing. A key that agrees is
|
||||
* left alone, so the step is idempotent.
|
||||
*
|
||||
* The write is gated on the column still holding what the caller read, so two
|
||||
* concurrent writers converge, and a caller holding a stale copy re-reads the
|
||||
* row rather than writing over a newer value.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { matchAnalyteKey } from "./analyte-match";
|
||||
|
||||
interface MarkerKeyState {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
analyteKey?: string | null;
|
||||
}
|
||||
|
||||
export async function ensureAnalyteKey(
|
||||
client: Pick<PrismaClient, "biomarker">,
|
||||
marker: MarkerKeyState,
|
||||
): Promise<string | null> {
|
||||
let current: MarkerKeyState = marker;
|
||||
// Two attempts: the caller's copy, then the row as it stands if that copy was
|
||||
// stale. A third writer racing both is vanishingly rare; the derived key is
|
||||
// still the right answer for the name this call saw.
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const derived = matchAnalyteKey({ name: current.name, unit: current.unit });
|
||||
const stored = current.analyteKey ?? null;
|
||||
if (stored === derived) return derived;
|
||||
const { count } = await client.biomarker.updateMany({
|
||||
where: { id: current.id, analyteKey: stored },
|
||||
data: { analyteKey: derived },
|
||||
});
|
||||
if (count > 0) return derived;
|
||||
const row = await client.biomarker.findUnique({
|
||||
where: { id: current.id },
|
||||
select: { id: true, name: true, unit: true, analyteKey: true },
|
||||
});
|
||||
if (!row) return null;
|
||||
current = row;
|
||||
}
|
||||
return matchAnalyteKey({ name: current.name, unit: current.unit });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Which analyte a marker is, for the unit conversion.
|
||||
*
|
||||
* `matchAnalyteKey` answers from two places: the localised names of the
|
||||
* catalog seeds the registry knows (so a marker created from the "pick a common
|
||||
* marker" list is recognised in every language the app ships) and the alias
|
||||
* list in the catalog. The name must equal one of them as a WHOLE, folded the
|
||||
* same way on both sides; a part of a name never matches.
|
||||
*
|
||||
* Two safeguards keep a link from being wrong:
|
||||
* - a folded name that two analytes claim matches neither;
|
||||
* - the marker's own unit must be one the analyte reaches, so a marker called
|
||||
* "Glucose" in a unit the registry cannot convert is left unlinked.
|
||||
*
|
||||
* Returns the catalog slug the name was recognised as (`ldl`, `hdl`,
|
||||
* `fasting-glucose`), or the registry-only key for an analyte with no seed
|
||||
* (`urea`, `uric-acid`), or null. LDL stays `ldl` even though the registry
|
||||
* converts it with the cholesterol entry: the key says which marker this is,
|
||||
* and `analyteConversionFor` resolves it to its entry. Pure apart from reading
|
||||
* the message bundles once.
|
||||
*/
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { localisedValues } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog";
|
||||
import { analyteAcceptsUnit } from "./unit-conversion/convert";
|
||||
import { analyteConversionFor } from "./unit-conversion/registry";
|
||||
|
||||
/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */
|
||||
function withoutGloss(folded: string): string {
|
||||
return folded.replace(/\s*\([^)]*\)/gu, "").trim();
|
||||
}
|
||||
|
||||
let cache: ReadonlyMap<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* folded name -> analyte key, with every name two keys claim removed.
|
||||
* Exported for the guard test, which asserts nothing was dropped silently.
|
||||
*/
|
||||
export function analyteNameIndex(): {
|
||||
index: ReadonlyMap<string, string>;
|
||||
ambiguous: readonly string[];
|
||||
} {
|
||||
const claims = new Map<string, Set<string>>();
|
||||
const claim = (name: string, key: string) => {
|
||||
const folded = foldForMatch(name);
|
||||
if (!folded) return;
|
||||
const keys = claims.get(folded) ?? new Set<string>();
|
||||
keys.add(key);
|
||||
claims.set(folded, keys);
|
||||
};
|
||||
|
||||
for (const seed of BIOMARKER_CATALOG) {
|
||||
if (!analyteConversionFor(seed.slug)) continue;
|
||||
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
|
||||
claim(name, seed.slug);
|
||||
// The bare name too, for a marker typed without the app's gloss.
|
||||
claim(withoutGloss(foldForMatch(name)), seed.slug);
|
||||
}
|
||||
}
|
||||
for (const { key, names } of ANALYTE_ALIASES) {
|
||||
if (!analyteConversionFor(key)) continue;
|
||||
for (const name of names) claim(name, key);
|
||||
}
|
||||
|
||||
const index = new Map<string, string>();
|
||||
const ambiguous: string[] = [];
|
||||
for (const [folded, keys] of claims) {
|
||||
if (keys.size === 1) index.set(folded, [...keys][0]);
|
||||
else ambiguous.push(folded);
|
||||
}
|
||||
return { index, ambiguous };
|
||||
}
|
||||
|
||||
export function matchAnalyteKey(input: {
|
||||
name: string;
|
||||
unit: string;
|
||||
}): string | null {
|
||||
cache ??= analyteNameIndex().index;
|
||||
const key = cache.get(foldForMatch(input.name));
|
||||
if (!key) return null;
|
||||
return analyteAcceptsUnit(key, input.unit) ? key : null;
|
||||
}
|
||||
@@ -276,3 +276,152 @@ export const BIOMARKER_PANELS = [
|
||||
"electrolytes",
|
||||
"bloodCount",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Names a marker may carry for an analyte the unit-conversion registry knows
|
||||
* (`src/lib/labs/unit-conversion/registry.ts`), keyed by the analyte key a
|
||||
* marker stores: the catalog slug (`ldl`, `hdl`, `fasting-glucose`), or a
|
||||
* registry-only key for an analyte with no seed.
|
||||
*
|
||||
* A `Biomarker` row keeps only its name, not the seed it came from, so this is
|
||||
* how a marker is told which analyte it is: its folded name equals one of these,
|
||||
* or one of the localised names of a seed the registry knows (`ldl`, `hba1c`,
|
||||
* `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so
|
||||
* "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A
|
||||
* name that two analytes claim is left out of the match, and a marker named in
|
||||
* some other way stays unlinked, which is the safe default.
|
||||
*
|
||||
* Names are compared folded (lower case, no accents, hyphens and slashes as
|
||||
* spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose"
|
||||
* needs no entry of its own. The registry-only keys (urea, blood urea
|
||||
* nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their
|
||||
* names live only here.
|
||||
*/
|
||||
export interface AnalyteAliases {
|
||||
/** The analyte key: a catalog slug, or a registry-only key. */
|
||||
key: string;
|
||||
names: readonly string[];
|
||||
}
|
||||
|
||||
export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
|
||||
{
|
||||
key: "fasting-glucose",
|
||||
names: [
|
||||
"glucose",
|
||||
"blood glucose",
|
||||
"blood sugar",
|
||||
"plasma glucose",
|
||||
"fasting blood glucose",
|
||||
"fasting blood sugar",
|
||||
"fasting plasma glucose",
|
||||
"glukose",
|
||||
"blutglukose",
|
||||
"blutzucker",
|
||||
"nuchternblutzucker",
|
||||
"glucosa",
|
||||
"glicemia",
|
||||
"glycemie",
|
||||
"glucosio",
|
||||
"glukoza",
|
||||
"glikemia",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "hba1c",
|
||||
names: [
|
||||
"hb a1c",
|
||||
"a1c",
|
||||
"hemoglobin a1c",
|
||||
"glycated hemoglobin",
|
||||
"glycosylated hemoglobin",
|
||||
"glykohamoglobin",
|
||||
"hamoglobin a1c",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "total-cholesterol",
|
||||
names: [
|
||||
"cholesterol",
|
||||
"cholesterin",
|
||||
"cholesterol total",
|
||||
"cholesterin gesamt",
|
||||
"cholesterol gesamt",
|
||||
"colesterol",
|
||||
"colesterolo",
|
||||
"cholesterol calkowity",
|
||||
],
|
||||
},
|
||||
{ key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] },
|
||||
{ key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] },
|
||||
{
|
||||
key: "creatinine",
|
||||
names: [
|
||||
"serum creatinine",
|
||||
"serumkreatinin",
|
||||
"kreatinin im serum",
|
||||
"creatinine serique",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vitamin-d",
|
||||
names: [
|
||||
"25 oh vitamin d",
|
||||
"25 hydroxy vitamin d",
|
||||
"25 hydroxyvitamin d",
|
||||
"25 oh d",
|
||||
"calcidiol",
|
||||
"vitamin d 25 oh",
|
||||
],
|
||||
},
|
||||
{ key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] },
|
||||
{
|
||||
key: "blood-urea-nitrogen",
|
||||
names: [
|
||||
"bun",
|
||||
"blood urea nitrogen",
|
||||
"urea nitrogen",
|
||||
"harnstoff stickstoff",
|
||||
"stickstoff harnstoff",
|
||||
"azote ureique",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "uric-acid",
|
||||
names: [
|
||||
"uric acid",
|
||||
"urate",
|
||||
"harnsaure",
|
||||
"acide urique",
|
||||
"acido urico",
|
||||
"kwas moczowy",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "calcium-total",
|
||||
names: [
|
||||
"calcium",
|
||||
"total calcium",
|
||||
"calcium total",
|
||||
"calcium gesamt",
|
||||
"gesamtcalcium",
|
||||
"kalzium",
|
||||
"kalzium gesamt",
|
||||
"gesamtkalzium",
|
||||
"calcio",
|
||||
"wapn",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "bilirubin-total",
|
||||
names: [
|
||||
"bilirubin",
|
||||
"total bilirubin",
|
||||
"bilirubin total",
|
||||
"bilirubin gesamt",
|
||||
"gesamtbilirubin",
|
||||
"bilirrubina",
|
||||
"bilirubine",
|
||||
"bilirubina",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -13,6 +13,7 @@ import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
|
||||
@@ -64,6 +65,7 @@ export interface MintedBiomarker {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
analyteKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +103,7 @@ export async function resolveOrMintBiomarker(
|
||||
lowerBound: true,
|
||||
upperBound: true,
|
||||
panel: true,
|
||||
analyteKey: true,
|
||||
} as const;
|
||||
|
||||
// Case-insensitive match on the catalog identity. Postgres is the store;
|
||||
@@ -120,6 +123,7 @@ export async function resolveOrMintBiomarker(
|
||||
lowerBound: input.referenceLow,
|
||||
upperBound: input.referenceHigh,
|
||||
panel: input.panel,
|
||||
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
|
||||
},
|
||||
select,
|
||||
});
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*
|
||||
* The list card, the detail history rows, and the chart tooltip all trim a
|
||||
* lab reading the same way: whole numbers render bare, fractional values keep
|
||||
* up to two decimals (no trailing-zero noise). Centralised so the three
|
||||
* surfaces stay byte-identical.
|
||||
* up to two decimals (no trailing-zero noise), and a value below 1 keeps three
|
||||
* significant digits so a small reading (TSH 0.004, troponin 0.012) never
|
||||
* reads as zero. Centralised so the three surfaces stay byte-identical.
|
||||
*
|
||||
* The digits themselves come from the caller's locale number formatter
|
||||
* (`useFormatters().number`), so a German reader sees "1,8 mIU/L" where an
|
||||
@@ -16,9 +17,12 @@ export function formatLabValue(
|
||||
value: number,
|
||||
formatNumber: LabNumberFormat,
|
||||
): string {
|
||||
return formatNumber(
|
||||
Number.isInteger(value) ? value : Number(value.toFixed(2)),
|
||||
);
|
||||
if (Number.isInteger(value)) return formatNumber(value);
|
||||
const rounded =
|
||||
Math.abs(value) < 1
|
||||
? Number(value.toPrecision(3))
|
||||
: Number(value.toFixed(2));
|
||||
return formatNumber(rounded);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
isUnreadableRange,
|
||||
parseReferenceRange,
|
||||
} from "@/lib/labs/parse-reference-range";
|
||||
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
|
||||
import { sameLabUnit } from "@/lib/labs/unit-normalise";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
extractedLabsSchema,
|
||||
@@ -144,8 +146,19 @@ export async function annotateRow(
|
||||
// Biomarker match — case-insensitive on the catalog identity, presence-only.
|
||||
const existingBiomarker = await prisma.biomarker.findFirst({
|
||||
where: { userId, name: { equals: analyte, mode: "insensitive" } },
|
||||
select: { id: true, unit: true },
|
||||
select: { id: true, name: true, unit: true },
|
||||
});
|
||||
// The analyte the marker is, so the review can say whether a row in another
|
||||
// unit converts. Derived from the name and unit exactly as the commit derives
|
||||
// it (a stored key is only that derivation, written down), so the review and
|
||||
// the commit cannot disagree. Read only: the key is stored when a reading is
|
||||
// written.
|
||||
const markerAnalyteKey = existingBiomarker
|
||||
? matchAnalyteKey({
|
||||
name: existingBiomarker.name,
|
||||
unit: existingBiomarker.unit,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Duplicate check: a live lab_results row with the same analyte (fuzzy,
|
||||
// case-insensitive), the same calendar day, and the same value/valueText is
|
||||
@@ -161,7 +174,14 @@ export async function annotateRow(
|
||||
analyte: { equals: analyte, mode: "insensitive" },
|
||||
takenAt: labReadingDaySearchRange(takenAt),
|
||||
},
|
||||
select: { id: true, value: true, valueText: true, takenAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
valueText: true,
|
||||
sourceValue: true,
|
||||
sourceUnit: true,
|
||||
takenAt: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
const candidates = rows.filter(
|
||||
@@ -172,6 +192,20 @@ export async function annotateRow(
|
||||
duplicateOf = c.id;
|
||||
break;
|
||||
}
|
||||
// A reading stored converted holds the number in its marker's unit; what
|
||||
// the report printed is beside it. The same number in the same unit is
|
||||
// the same reading (the commit applies the same rule).
|
||||
if (
|
||||
row.value !== null &&
|
||||
row.unit !== null &&
|
||||
c.sourceValue !== null &&
|
||||
c.sourceValue === row.value &&
|
||||
c.sourceUnit !== null &&
|
||||
sameLabUnit(c.sourceUnit, row.unit)
|
||||
) {
|
||||
duplicateOf = c.id;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
row.valueText !== null &&
|
||||
c.valueText !== null &&
|
||||
@@ -199,6 +233,7 @@ export async function annotateRow(
|
||||
confidence: row.confidence,
|
||||
biomarkerMatch: existingBiomarker ? "existing" : "new",
|
||||
markerUnit: existingBiomarker?.unit ?? null,
|
||||
markerAnalyteKey,
|
||||
duplicateOf,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* A lab reading, written in its marker's unit.
|
||||
*
|
||||
* Every reading is stored under the unit of its marker. A reading that states
|
||||
* the same unit (spelling aside) goes in as it is. One that states another is
|
||||
* converted when the marker's analyte is known and the registry has the pair,
|
||||
* keeping what the report printed beside it (`sourceValue`, `sourceUnit`) so a
|
||||
* number nobody typed can always be traced back. Anything else is refused,
|
||||
* exactly as before: the number is never kept under a unit it is not in.
|
||||
*
|
||||
* The report's own reference window is in the unit the report printed, so it is
|
||||
* converted with the value; a verdict that compared a converted value with an
|
||||
* unconverted window would be wrong. Full precision is stored, rounding is for
|
||||
* display.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { ensureAnalyteKey } from "./analyte-key-store";
|
||||
import {
|
||||
convertLabRange,
|
||||
convertLabValue,
|
||||
type ConversionRefusal,
|
||||
} from "./unit-conversion/convert";
|
||||
import { sameLabUnit } from "./unit-normalise";
|
||||
|
||||
export interface ReadingMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
analyteKey?: string | null;
|
||||
}
|
||||
|
||||
export type ReadingInMarkerUnit =
|
||||
| {
|
||||
ok: true;
|
||||
converted: false;
|
||||
value: number;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
converted: true;
|
||||
value: number;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
/** The value as the report printed it. */
|
||||
sourceValue: number;
|
||||
/** The unit as the report printed it, trimmed. */
|
||||
sourceUnit: string;
|
||||
}
|
||||
| { ok: false; reason: ConversionRefusal };
|
||||
|
||||
export async function readingInMarkerUnit(
|
||||
client: Pick<PrismaClient, "biomarker">,
|
||||
input: {
|
||||
marker: ReadingMarker;
|
||||
value: number;
|
||||
/** The unit the reading states; undefined means the marker's own. */
|
||||
unit: string | undefined;
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
},
|
||||
): Promise<ReadingInMarkerUnit> {
|
||||
const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } =
|
||||
input;
|
||||
if (unit === undefined || sameLabUnit(unit, marker.unit)) {
|
||||
return {
|
||||
ok: true,
|
||||
converted: false,
|
||||
value,
|
||||
sourceReferenceLow,
|
||||
sourceReferenceHigh,
|
||||
};
|
||||
}
|
||||
|
||||
const analyteKey = await ensureAnalyteKey(client, marker);
|
||||
const converted = convertLabValue({
|
||||
analyteKey,
|
||||
value,
|
||||
from: unit,
|
||||
to: marker.unit,
|
||||
});
|
||||
if (!converted.ok) return { ok: false, reason: converted.reason };
|
||||
|
||||
// The printed window is in the printed unit; move it with the value.
|
||||
const range = convertLabRange({
|
||||
analyteKey,
|
||||
low: sourceReferenceLow,
|
||||
high: sourceReferenceHigh,
|
||||
from: unit,
|
||||
to: marker.unit,
|
||||
});
|
||||
if (!range.ok) return { ok: false, reason: range.reason };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
converted: true,
|
||||
value: converted.value,
|
||||
sourceReferenceLow: range.low,
|
||||
sourceReferenceHigh: range.high,
|
||||
sourceValue: value,
|
||||
sourceUnit: unit.trim(),
|
||||
};
|
||||
}
|
||||
@@ -65,11 +65,23 @@ export function formatReferenceRange(
|
||||
/**
|
||||
* The reference range a lab report printed beside ONE reading, as stored on
|
||||
* `LabResult.sourceReference*`.
|
||||
*
|
||||
* `sourceUnit` is set when the reading was converted into its marker's unit:
|
||||
* the bounds were converted with the value, but the text is the report's own
|
||||
* and stays in the unit the report printed. It is required, so a reader cannot
|
||||
* forget to select it and present that text beside numbers in another unit.
|
||||
*/
|
||||
export interface SourceReferenceRange {
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
sourceReferenceText: string | null;
|
||||
sourceUnit: string | null;
|
||||
}
|
||||
|
||||
/** A converted reading's printed range text, with the unit it is written in. */
|
||||
export interface PrintedReferenceRange {
|
||||
text: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** Where the bounds a reading is judged against came from. */
|
||||
@@ -84,8 +96,19 @@ export interface EffectiveReferenceRange {
|
||||
/** The catalog band, kept alongside so a surface can show both. */
|
||||
catalogLow: number | null;
|
||||
catalogHigh: number | null;
|
||||
/** The printed window, kept verbatim. Null when the report stated none. */
|
||||
/**
|
||||
* The printed window, kept verbatim, in the reading's unit. Null when the
|
||||
* report stated none, and null for a converted reading: its text is in the
|
||||
* unit the report printed, not the unit of the value and bounds beside it,
|
||||
* and moves to `printed`.
|
||||
*/
|
||||
sourceText: string | null;
|
||||
/**
|
||||
* For a converted reading only: the window as the report printed it and the
|
||||
* unit it is in. Evidence, never a statement about the stored numbers; a
|
||||
* surface that shows it names the unit with it.
|
||||
*/
|
||||
printed: PrintedReferenceRange | null;
|
||||
/**
|
||||
* True when the source window is in force AND the catalog states a window
|
||||
* that differs from it. This is the case a reader must be told about: the
|
||||
@@ -132,7 +155,15 @@ export function resolveEffectiveReferenceRange(
|
||||
const cHigh = catalogHigh ?? null;
|
||||
const sLow = source?.sourceReferenceLow ?? null;
|
||||
const sHigh = source?.sourceReferenceHigh ?? null;
|
||||
const sourceText = source?.sourceReferenceText ?? null;
|
||||
const verbatim = source?.sourceReferenceText ?? null;
|
||||
const sourceUnit = source?.sourceUnit ?? null;
|
||||
// A converted reading's text is in the printed unit; it is never presented as
|
||||
// the window of the converted value.
|
||||
const sourceText = sourceUnit === null ? verbatim : null;
|
||||
const printed: PrintedReferenceRange | null =
|
||||
sourceUnit !== null && verbatim !== null
|
||||
? { text: verbatim, unit: sourceUnit }
|
||||
: null;
|
||||
|
||||
if (sLow !== null || sHigh !== null) {
|
||||
return {
|
||||
@@ -142,6 +173,7 @@ export function resolveEffectiveReferenceRange(
|
||||
catalogLow: cLow,
|
||||
catalogHigh: cHigh,
|
||||
sourceText,
|
||||
printed,
|
||||
divergesFromCatalog:
|
||||
(cLow !== null || cHigh !== null) &&
|
||||
!sameWindow(sLow, sHigh, cLow, cHigh),
|
||||
@@ -155,6 +187,7 @@ export function resolveEffectiveReferenceRange(
|
||||
catalogLow: cLow,
|
||||
catalogHigh: cHigh,
|
||||
sourceText,
|
||||
printed,
|
||||
divergesFromCatalog: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
* - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a
|
||||
* surface can paint both and show where they part.
|
||||
* - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` —
|
||||
* what the report printed, the last verbatim.
|
||||
* what the report printed, the last verbatim. For a reading converted into
|
||||
* its marker's unit the bounds were converted with the value, but the text
|
||||
* is still in the unit the report printed, so it is never sent as
|
||||
* `sourceReferenceText` (that would put two units in one statement): it
|
||||
* travels as `printedReferenceText`, in `sourceUnit`.
|
||||
*
|
||||
* `referenceOrigin` names which window is in force and
|
||||
* `referenceDivergesFromCatalog` flags the case a reader must be told about.
|
||||
@@ -53,6 +57,8 @@ export interface ResolvedBiomarker {
|
||||
lowerBound: number | null;
|
||||
upperBound: number | null;
|
||||
panel: string | null;
|
||||
/** Which analyte the marker is (catalog slug or registry-only key), if known. */
|
||||
analyteKey?: string | null;
|
||||
}
|
||||
|
||||
/** The lab-result row shape the resolver reads (Prisma row subset). */
|
||||
@@ -74,6 +80,12 @@ export interface LabRow {
|
||||
takenAt: Date;
|
||||
source: string;
|
||||
biomarkerId: string | null;
|
||||
/**
|
||||
* When the reading was converted into the marker's unit: the value and unit
|
||||
* as the report printed them. Both null for a reading stored as stated.
|
||||
*/
|
||||
sourceValue: number | null;
|
||||
sourceUnit: string | null;
|
||||
noteEncrypted: Uint8Array | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -89,6 +101,7 @@ export type LabFieldRow = Pick<
|
||||
| "sourceReferenceLow"
|
||||
| "sourceReferenceHigh"
|
||||
| "sourceReferenceText"
|
||||
| "sourceUnit"
|
||||
| "panel"
|
||||
| "biomarkerId"
|
||||
>;
|
||||
@@ -107,7 +120,13 @@ export interface ResolvedLabFields {
|
||||
/** What the source report printed for this reading. */
|
||||
sourceReferenceLow: number | null;
|
||||
sourceReferenceHigh: number | null;
|
||||
/** Null for a converted reading; see `printedReferenceText`. */
|
||||
sourceReferenceText: string | null;
|
||||
/**
|
||||
* A converted reading's printed range text, verbatim, in `sourceUnit` (the
|
||||
* unit the report printed); null for every other reading.
|
||||
*/
|
||||
printedReferenceText: string | null;
|
||||
referenceOrigin: ReferenceRangeOrigin;
|
||||
referenceDivergesFromCatalog: boolean;
|
||||
/** The resolved window as one value, for callers that pass it on. */
|
||||
@@ -164,6 +183,7 @@ export function resolveLabFields(
|
||||
sourceReferenceLow: row.sourceReferenceLow,
|
||||
sourceReferenceHigh: row.sourceReferenceHigh,
|
||||
sourceReferenceText: effectiveRange.sourceText,
|
||||
printedReferenceText: effectiveRange.printed?.text ?? null,
|
||||
referenceOrigin: effectiveRange.origin,
|
||||
referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog,
|
||||
effectiveRange,
|
||||
@@ -179,6 +199,7 @@ export function serialiseLabResult(
|
||||
biomarker?: ResolvedBiomarker | null,
|
||||
) {
|
||||
const resolved = resolveLabFields(row, biomarker);
|
||||
const conversion = conversionFields(row);
|
||||
return {
|
||||
id: row.id,
|
||||
biomarkerId: row.biomarkerId,
|
||||
@@ -187,6 +208,9 @@ export function serialiseLabResult(
|
||||
value: row.value,
|
||||
valueText: row.valueText,
|
||||
unit: resolved.unit,
|
||||
converted: conversion.converted,
|
||||
sourceValue: conversion.sourceValue,
|
||||
sourceUnit: conversion.sourceUnit,
|
||||
referenceLow: resolved.referenceLow,
|
||||
referenceHigh: resolved.referenceHigh,
|
||||
catalogReferenceLow: resolved.catalogReferenceLow,
|
||||
@@ -194,6 +218,7 @@ export function serialiseLabResult(
|
||||
sourceReferenceLow: resolved.sourceReferenceLow,
|
||||
sourceReferenceHigh: resolved.sourceReferenceHigh,
|
||||
sourceReferenceText: resolved.sourceReferenceText,
|
||||
printedReferenceText: resolved.printedReferenceText,
|
||||
referenceOrigin: resolved.referenceOrigin,
|
||||
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
@@ -210,6 +235,20 @@ export function serialiseLabResult(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stored number is a conversion, and what the report printed. A
|
||||
* reading stored as stated reports `converted: false` and two nulls.
|
||||
*/
|
||||
function conversionFields(row: Pick<LabRow, "sourceValue" | "sourceUnit">) {
|
||||
const sourceValue = row.sourceValue ?? null;
|
||||
const sourceUnit = row.sourceUnit ?? null;
|
||||
return {
|
||||
converted: sourceValue !== null && sourceUnit !== null,
|
||||
sourceValue,
|
||||
sourceUnit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a lab row to the detail DTO — same as the list DTO but carries the
|
||||
* decrypted `note` instead of the `hasNote` flag.
|
||||
@@ -220,6 +259,7 @@ export function serialiseLabResultDetail(
|
||||
note: string | null,
|
||||
) {
|
||||
const resolved = resolveLabFields(row, biomarker);
|
||||
const conversion = conversionFields(row);
|
||||
return {
|
||||
id: row.id,
|
||||
biomarkerId: row.biomarkerId,
|
||||
@@ -228,6 +268,9 @@ export function serialiseLabResultDetail(
|
||||
value: row.value,
|
||||
valueText: row.valueText,
|
||||
unit: resolved.unit,
|
||||
converted: conversion.converted,
|
||||
sourceValue: conversion.sourceValue,
|
||||
sourceUnit: conversion.sourceUnit,
|
||||
referenceLow: resolved.referenceLow,
|
||||
referenceHigh: resolved.referenceHigh,
|
||||
catalogReferenceLow: resolved.catalogReferenceLow,
|
||||
@@ -235,6 +278,7 @@ export function serialiseLabResultDetail(
|
||||
sourceReferenceLow: resolved.sourceReferenceLow,
|
||||
sourceReferenceHigh: resolved.sourceReferenceHigh,
|
||||
sourceReferenceText: resolved.sourceReferenceText,
|
||||
printedReferenceText: resolved.printedReferenceText,
|
||||
referenceOrigin: resolved.referenceOrigin,
|
||||
referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { allAnalyteConversions } from "../registry";
|
||||
import {
|
||||
convertLabRange,
|
||||
convertLabValue,
|
||||
conversionsFor,
|
||||
type ConvertedValue,
|
||||
} from "../convert";
|
||||
|
||||
const ok = (result: ConvertedValue) => {
|
||||
if (!result.ok) throw new Error(`refused: ${result.reason}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const convert = (
|
||||
analyteKey: string | null | undefined,
|
||||
value: number,
|
||||
from: string,
|
||||
to: string,
|
||||
) => convertLabValue({ analyteKey, value, from, to });
|
||||
|
||||
describe("reference pairs, checked by hand", () => {
|
||||
it("5.6 mmol/L glucose is 100.9 mg/dL", () => {
|
||||
const r = ok(convert("fasting-glucose", 5.6, "mmol/L", "mg/dL"));
|
||||
expect(r.value).toBeCloseTo(100.9, 1);
|
||||
expect(r.via).toBe("analyte");
|
||||
});
|
||||
|
||||
it("1.0 mg/dL creatinine is 88.4 µmol/L", () => {
|
||||
expect(ok(convert("creatinine", 1, "mg/dL", "µmol/L")).value).toBeCloseTo(
|
||||
88.4,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("6.5 % HbA1c is 47.5 mmol/mol, and back", () => {
|
||||
const ifcc = ok(convert("hba1c", 6.5, "%", "mmol/mol")).value;
|
||||
expect(ifcc).toBeCloseTo(47.5, 1);
|
||||
expect(ok(convert("hba1c", ifcc, "mmol/mol", "%")).value).toBeCloseTo(
|
||||
6.5,
|
||||
9,
|
||||
);
|
||||
});
|
||||
|
||||
it("30 ng/mL vitamin D is 74.9 nmol/L", () => {
|
||||
expect(ok(convert("vitamin-d", 30, "ng/mL", "nmol/L")).value).toBeCloseTo(
|
||||
74.9,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("5.2 mmol/L total cholesterol is 201 mg/dL, and LDL goes the same way", () => {
|
||||
expect(
|
||||
ok(convert("total-cholesterol", 5.2, "mmol/L", "mg/dL")).value,
|
||||
).toBeCloseTo(201.1, 1);
|
||||
expect(ok(convert("ldl", 3, "mmol/L", "mg/dL")).value).toBeCloseTo(116, 0);
|
||||
});
|
||||
|
||||
it("5 mmol/L urea is 30.0 mg/dL, and 5 mmol/L is 14.0 mg/dL as BUN", () => {
|
||||
expect(ok(convert("urea", 5, "mmol/L", "mg/dL")).value).toBeCloseTo(
|
||||
30.03,
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
ok(convert("blood-urea-nitrogen", 5, "mmol/L", "mg/dL")).value,
|
||||
).toBeCloseTo(14.007, 2);
|
||||
});
|
||||
|
||||
it("2.4 mmol/L total calcium is 9.6 mg/dL", () => {
|
||||
expect(
|
||||
ok(convert("calcium-total", 2.4, "mmol/L", "mg/dL")).value,
|
||||
).toBeCloseTo(9.62, 1);
|
||||
});
|
||||
|
||||
it("300 µmol/L uric acid is 5.0 mg/dL and 17.1 µmol/L bilirubin is 1.0 mg/dL", () => {
|
||||
expect(ok(convert("uric-acid", 300, "µmol/L", "mg/dL")).value).toBeCloseTo(
|
||||
5.04,
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
ok(convert("bilirubin-total", 17.1, "µmol/L", "mg/dL")).value,
|
||||
).toBeCloseTo(1.0, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the same unit written two ways", () => {
|
||||
it("returns the value untouched", () => {
|
||||
expect(ok(convert(null, 5.6, "mmol/l", "mmol/L")).value).toBe(5.6);
|
||||
expect(ok(convert(null, 5.6, "MG/DL", "mg/dl")).value).toBe(5.6);
|
||||
expect(ok(convert("fasting-glucose", 5.6, "mg/dl", "mg/dL")).value).toBe(
|
||||
5.6,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a spelling of the unit through the normaliser", () => {
|
||||
const a = ok(convert("creatinine", 88.4, "umol/l", "mg/dl"));
|
||||
const b = ok(convert("creatinine", 88.4, "µmol/L", "mg/dL"));
|
||||
expect(a.value).toBe(b.value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a pair of scale units needs no analyte", () => {
|
||||
it.each([
|
||||
[1, "g/L", "mg/L", 1000],
|
||||
[1, "mg/dL", "mg/L", 10],
|
||||
[1, "g/dL", "g/L", 10],
|
||||
[1, "ng/mL", "µg/L", 1],
|
||||
[1, "ng/mL", "pg/mL", 1000],
|
||||
[1, "ng/dL", "ng/L", 10],
|
||||
[1, "mmol/L", "µmol/L", 1000],
|
||||
[1, "mol/L", "pmol/L", 1e12],
|
||||
[1, "mIU/L", "µIU/mL", 1],
|
||||
[1, "IU/L", "mIU/L", 1000],
|
||||
[4.5, "10³/µL", "10⁹/L", 4.5],
|
||||
[1, "10⁶/µL", "10¹²/L", 1],
|
||||
[1, "10⁶/µL", "10³/µL", 1000],
|
||||
[1, "G/L", "10³/µL", 1],
|
||||
])("%s %s is %s %s", (value, from, to, expected) => {
|
||||
const r = ok(convert(null, value, from, to));
|
||||
expect(r.via).toBe("scale");
|
||||
expect(r.value).toBeCloseTo(expected, 9);
|
||||
});
|
||||
|
||||
it("is exact where a power of ten is exactly representable", () => {
|
||||
expect(ok(convert(null, 5, "g/L", "mg/L")).value).toBe(5000);
|
||||
expect(ok(convert(null, 5, "mg/L", "g/L")).value).toBe(0.005);
|
||||
});
|
||||
|
||||
it("works for an analyte's own mass units too: glucose in g/L", () => {
|
||||
expect(ok(convert("fasting-glucose", 1, "g/L", "mg/dL")).value).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an analyte's units reach each other across a scale", () => {
|
||||
it("glucose in g/L to mmol/L", () => {
|
||||
// 1 g/L = 100 mg/dL = 100 / 18.0182 mmol/L
|
||||
expect(
|
||||
ok(convert("fasting-glucose", 1, "g/L", "mmol/L")).value,
|
||||
).toBeCloseTo(100 / 18.0182, 9);
|
||||
});
|
||||
|
||||
it("creatinine in mmol/L to mg/dL (the entry lists µmol/L)", () => {
|
||||
expect(
|
||||
ok(convert("creatinine", 0.0884, "mmol/L", "mg/dL")).value,
|
||||
).toBeCloseTo(1.0, 3);
|
||||
});
|
||||
|
||||
it("vitamin D in µg/L to nmol/L (the entry lists ng/mL)", () => {
|
||||
expect(ok(convert("vitamin-d", 30, "µg/L", "nmol/L")).value).toBeCloseTo(
|
||||
74.9,
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a round trip returns the start, for every unit of every analyte", () => {
|
||||
for (const entry of allAnalyteConversions()) {
|
||||
const units = Object.keys(entry.units);
|
||||
for (const from of units) {
|
||||
for (const to of units) {
|
||||
it(`${entry.key}: ${from} -> ${to} -> ${from}`, () => {
|
||||
for (const start of [0.5, 1, 7.3, 120.45]) {
|
||||
const there = ok(convert(entry.key, start, from, to)).value;
|
||||
const back = ok(convert(entry.key, there, to, from)).value;
|
||||
expect(Math.abs(back - start) / start).toBeLessThan(1e-12);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("holds for the scale pairs", () => {
|
||||
const units = ["g/L", "mg/dL", "µg/L", "ng/mL", "pg/mL", "ng/dL", "g/dL"];
|
||||
for (const from of units) {
|
||||
for (const to of units) {
|
||||
const there = ok(convert(null, 7.3, from, to)).value;
|
||||
const back = ok(convert(null, there, to, from)).value;
|
||||
expect(Math.abs(back - 7.3) / 7.3).toBeLessThan(1e-12);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a refusal, never a guess", () => {
|
||||
it("refuses a unit nobody knows", () => {
|
||||
expect(convert("fasting-glucose", 5, "mmol/L", "furlongs")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_unit",
|
||||
});
|
||||
expect(convert(null, 5, "foo", "bar")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_unit",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an analyte it has no entry for", () => {
|
||||
expect(convert("sodium", 140, "mmol/L", "mg/dL")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
expect(convert("triglycerides", 1.7, "mmol/L", "mg/dL")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses mass to amount when no analyte key is given", () => {
|
||||
expect(convert(null, 100, "mg/dL", "mmol/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
expect(convert(undefined, 100, "mg/dL", "mmol/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_analyte",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a unit the entry does not reach", () => {
|
||||
// HbA1c knows % and mmol/mol; g/L is neither, and not a scale of either.
|
||||
expect(convert("hba1c", 6.5, "%", "g/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "unknown_unit",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
|
||||
"refuses %s",
|
||||
(value) => {
|
||||
expect(convert("fasting-glucose", value, "mmol/L", "mg/dL")).toEqual({
|
||||
ok: false,
|
||||
reason: "not_finite",
|
||||
});
|
||||
expect(convert(null, value, "g/L", "mg/L")).toEqual({
|
||||
ok: false,
|
||||
reason: "not_finite",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("never turns the lookalike pairs into a conversion", () => {
|
||||
// Different measurements, not different units of one.
|
||||
expect(convert("urea", 5, "mmol/L", "mg/dL").ok).toBe(true);
|
||||
expect(convert("calcium-total", 2.4, "mmol/L", "mg/dL").ok).toBe(true);
|
||||
expect(convert("calcium-ionised", 1.2, "mmol/L", "mg/dL").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a reference range", () => {
|
||||
it("converts both bounds together and keeps them in order", () => {
|
||||
const r = convertLabRange({
|
||||
analyteKey: "fasting-glucose",
|
||||
low: 3.9,
|
||||
high: 5.6,
|
||||
from: "mmol/L",
|
||||
to: "mg/dL",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.low).toBeCloseTo(70.3, 1);
|
||||
expect(r.high).toBeCloseTo(100.9, 1);
|
||||
expect(r.low!).toBeLessThan(r.high!);
|
||||
expect(r.via).toBe("analyte");
|
||||
});
|
||||
|
||||
it("keeps the order for HbA1c, whose map has an offset", () => {
|
||||
const r = convertLabRange({
|
||||
analyteKey: "hba1c",
|
||||
low: 4,
|
||||
high: 5.6,
|
||||
from: "%",
|
||||
to: "mmol/mol",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.low!).toBeLessThan(r.high!);
|
||||
expect(r.low).toBeCloseTo(20.2, 1);
|
||||
expect(r.high).toBeCloseTo(37.7, 1);
|
||||
});
|
||||
|
||||
it("leaves an open bound open", () => {
|
||||
expect(
|
||||
convertLabRange({
|
||||
analyteKey: "fasting-glucose",
|
||||
low: null,
|
||||
high: 5.6,
|
||||
from: "mmol/L",
|
||||
to: "mg/dL",
|
||||
}),
|
||||
).toMatchObject({ ok: true, low: null });
|
||||
expect(
|
||||
convertLabRange({
|
||||
analyteKey: "fasting-glucose",
|
||||
low: 3.9,
|
||||
high: null,
|
||||
from: "mmol/L",
|
||||
to: "mg/dL",
|
||||
}),
|
||||
).toMatchObject({ ok: true, high: null });
|
||||
});
|
||||
|
||||
it("refuses the whole range when the units do not convert", () => {
|
||||
expect(
|
||||
convertLabRange({
|
||||
analyteKey: null,
|
||||
low: 3.9,
|
||||
high: 5.6,
|
||||
from: "mmol/L",
|
||||
to: "mg/dL",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "unknown_analyte" });
|
||||
});
|
||||
|
||||
it("refuses a range of two open bounds when the units do not convert either", () => {
|
||||
expect(
|
||||
convertLabRange({
|
||||
analyteKey: null,
|
||||
low: null,
|
||||
high: null,
|
||||
from: "mmol/L",
|
||||
to: "mg/dL",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "unknown_analyte" });
|
||||
});
|
||||
|
||||
it("is the unit spelled another way: no conversion, bounds as they are", () => {
|
||||
expect(
|
||||
convertLabRange({
|
||||
analyteKey: null,
|
||||
low: 3.9,
|
||||
high: 5.6,
|
||||
from: "mmol/l",
|
||||
to: "mmol/L",
|
||||
}),
|
||||
).toEqual({ ok: true, low: 3.9, high: 5.6, via: "scale" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversionsFor", () => {
|
||||
it("lists what glucose in mg/dL can arrive as, without mg/dL itself", () => {
|
||||
const units = conversionsFor({
|
||||
analyteKey: "fasting-glucose",
|
||||
to: "mg/dL",
|
||||
});
|
||||
expect(units).toContain("mmol/L");
|
||||
expect(units).toContain("g/L");
|
||||
expect(units).toContain("µmol/L"); // the amount family, scaled
|
||||
expect(units).not.toContain("mg/dL");
|
||||
expect(units).not.toContain("mg/dl");
|
||||
});
|
||||
|
||||
it("lists only the scale family for a marker with no entry", () => {
|
||||
const units = conversionsFor({ analyteKey: null, to: "mg/dL" });
|
||||
expect(units).toContain("g/L");
|
||||
expect(units).not.toContain("mmol/L");
|
||||
});
|
||||
|
||||
it("lists nothing for a unit that has no family and no entry", () => {
|
||||
expect(conversionsFor({ analyteKey: null, to: "%" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists HbA1c's two units", () => {
|
||||
expect(conversionsFor({ analyteKey: "hba1c", to: "%" })).toEqual([
|
||||
"mmol/mol",
|
||||
]);
|
||||
});
|
||||
|
||||
it("every listed unit really converts", () => {
|
||||
for (const entry of allAnalyteConversions()) {
|
||||
for (const to of Object.keys(entry.units)) {
|
||||
for (const from of conversionsFor({ analyteKey: entry.key, to })) {
|
||||
expect(
|
||||
convert(entry.key, 1, from, to).ok,
|
||||
`${entry.key}: ${from} -> ${to}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MGDL_PER_MMOL } from "@/lib/glucose";
|
||||
|
||||
import { molarMass } from "../atomic-weights";
|
||||
import {
|
||||
allAnalyteConversions,
|
||||
analyteConversionFor,
|
||||
defineAnalyteConversion,
|
||||
type AnalyteConversion,
|
||||
} from "../registry";
|
||||
|
||||
/**
|
||||
* The guard over the registry. Every entry is checked for what makes a
|
||||
* conversion safe to ship: a named source, a hub that is one of its units, a
|
||||
* finite positive slope, a key nobody else claims, and, for the molar-mass
|
||||
* entries, a factor that equals one recomputed here from the molar mass written
|
||||
* out by hand, not by the code that built the entry.
|
||||
*/
|
||||
|
||||
/** g/mol, summed by hand from H 1.008, C 12.011, N 14.007, O 15.999, Ca 40.078. */
|
||||
const MOLAR_MASS: Record<string, number> = {
|
||||
"fasting-glucose": 180.156, // C6H12O6
|
||||
"total-cholesterol": 386.664, // C27H46O
|
||||
creatinine: 113.12, // C4H7N3O
|
||||
urea: 60.056, // CH4N2O
|
||||
"blood-urea-nitrogen": 28.014, // N2
|
||||
"uric-acid": 168.112, // C5H4N4O3
|
||||
"calcium-total": 40.078, // Ca
|
||||
"bilirubin-total": 584.673, // C33H36N4O6
|
||||
"vitamin-d": 400.647, // C27H44O2, 25-hydroxyvitamin D3 (calcifediol)
|
||||
};
|
||||
|
||||
/** Of the entry's amount unit: how many of its mass unit one of it is, per g/mol. */
|
||||
const POWER_OF_TEN: Record<string, number> = {
|
||||
"fasting-glucose": 1 / 10, // mmol/L -> mg/dL
|
||||
"total-cholesterol": 1 / 10,
|
||||
creatinine: 1 / 10_000, // µmol/L -> mg/dL
|
||||
urea: 1 / 10,
|
||||
"blood-urea-nitrogen": 1 / 10,
|
||||
"uric-acid": 1 / 10_000,
|
||||
"calcium-total": 1 / 10,
|
||||
"bilirubin-total": 1 / 10_000,
|
||||
"vitamin-d": 1 / 1_000, // nmol/L -> ng/mL
|
||||
};
|
||||
|
||||
describe("the vitamin D entry", () => {
|
||||
const entry = analyteConversionFor("vitamin-d")!;
|
||||
|
||||
it("names the molecule its factor comes from: calcifediol, not vitamin D3", () => {
|
||||
// Cholecalciferol is C27H44O; the factor uses C27H44O2, which is the
|
||||
// 25-hydroxy form a lab reports. The note must say which one it is.
|
||||
expect(entry.source).toContain("calcifediol");
|
||||
expect(entry.source).toContain("C27H44O2");
|
||||
expect(entry.source).not.toMatch(/cholecalciferol \(C27H44O2\)/);
|
||||
});
|
||||
|
||||
it("converts 1 ng/mL to 2.496 nmol/L", () => {
|
||||
// ng/mL is the hub; nmol/L maps to it by 400.647 / 1000.
|
||||
expect(1 / entry.units["nmol/L"].a).toBeCloseTo(2.496, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the registry's entries", () => {
|
||||
const entries = allAnalyteConversions();
|
||||
|
||||
it("holds the starter set", () => {
|
||||
expect(entries.map((entry) => entry.key).sort()).toEqual(
|
||||
[
|
||||
"blood-urea-nitrogen",
|
||||
"bilirubin-total",
|
||||
"calcium-total",
|
||||
"creatinine",
|
||||
"fasting-glucose",
|
||||
"hba1c",
|
||||
"total-cholesterol",
|
||||
"uric-acid",
|
||||
"urea",
|
||||
"vitamin-d",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(entries.map((entry) => [entry.key, entry] as const))(
|
||||
"%s: names its source, has a hub among its units, and a finite positive slope",
|
||||
(_key, entry) => {
|
||||
expect(entry.source.trim().length).toBeGreaterThan(10);
|
||||
expect(Object.keys(entry.units)).toContain(entry.hub);
|
||||
for (const { a, b } of Object.values(entry.units)) {
|
||||
expect(Number.isFinite(a) && a > 0).toBe(true);
|
||||
expect(Number.isFinite(b)).toBe(true);
|
||||
}
|
||||
expect(entry.units[entry.hub]).toEqual({ a: 1, b: 0 });
|
||||
},
|
||||
);
|
||||
|
||||
it("never lists a key or an alias twice, across entries", () => {
|
||||
const keys = entries.flatMap((entry) => [entry.key, ...entry.aliases]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it("finds an analyte by its alias: LDL and HDL are cholesterol", () => {
|
||||
expect(analyteConversionFor("ldl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteConversionFor("hdl")?.key).toBe("total-cholesterol");
|
||||
expect(analyteConversionFor("nope")).toBeNull();
|
||||
expect(analyteConversionFor(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps urea and blood urea nitrogen, and the two calciums, apart", () => {
|
||||
expect(analyteConversionFor("urea")).not.toBe(
|
||||
analyteConversionFor("blood-urea-nitrogen"),
|
||||
);
|
||||
expect(analyteConversionFor("calcium-ionised")).toBeNull();
|
||||
});
|
||||
|
||||
describe("the molar-mass entries", () => {
|
||||
const molar = entries.filter((entry) => entry.kind === "molar-mass");
|
||||
|
||||
it("are exactly the ones this table knows", () => {
|
||||
expect(molar.map((entry) => entry.key).sort()).toEqual(
|
||||
Object.keys(MOLAR_MASS).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(molar.map((entry) => [entry.key, entry] as const))(
|
||||
"%s: the factor is the molar mass moved by its powers of ten",
|
||||
(key, entry) => {
|
||||
const [amountUnit] = Object.keys(entry.units).filter(
|
||||
(unit) => unit !== entry.hub,
|
||||
);
|
||||
const { a } = entry.units[amountUnit];
|
||||
const expected = MOLAR_MASS[key] * POWER_OF_TEN[key];
|
||||
if (key === "fasting-glucose") {
|
||||
// The constant the rest of the app uses.
|
||||
expect(a).toBe(MGDL_PER_MMOL);
|
||||
} else {
|
||||
expect(a).toBeCloseTo(expected, 9);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("has glucose agree with the derived factor to within 0.02 %", () => {
|
||||
const derived = molarMass({ C: 6, H: 12, O: 6 }) / 10;
|
||||
expect(derived).toBeCloseTo(18.0156, 4);
|
||||
expect(Math.abs(MGDL_PER_MMOL - derived) / derived).toBeLessThan(0.0002);
|
||||
});
|
||||
});
|
||||
|
||||
it("has HbA1c as the IFCC-NGSP master equation, affine and not a factor", () => {
|
||||
const hba1c = analyteConversionFor("hba1c")!;
|
||||
expect(hba1c.kind).toBe("standard");
|
||||
expect(hba1c.units["mmol/mol"]).toEqual({ a: 0.09148, b: 2.152 });
|
||||
expect(hba1c.source).toContain("Hoelzel");
|
||||
});
|
||||
|
||||
it("has no convention-based entry until its source is cited (triglycerides)", () => {
|
||||
expect(entries.filter((entry) => entry.kind === "convention")).toEqual([]);
|
||||
expect(analyteConversionFor("triglycerides")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("an entry that is not safe cannot be defined", () => {
|
||||
const valid: AnalyteConversion = {
|
||||
key: "x",
|
||||
aliases: [],
|
||||
hub: "mg/dL",
|
||||
units: { "mg/dL": { a: 1, b: 0 }, "mmol/L": { a: 10, b: 0 } },
|
||||
kind: "molar-mass",
|
||||
source: "A cited source.",
|
||||
};
|
||||
|
||||
it("accepts a valid entry", () => {
|
||||
expect(defineAnalyteConversion(valid).key).toBe("x");
|
||||
});
|
||||
|
||||
it("refuses an entry with no source", () => {
|
||||
expect(() => defineAnalyteConversion({ ...valid, source: " " })).toThrow(
|
||||
/no source/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a hub that is not one of its units", () => {
|
||||
expect(() => defineAnalyteConversion({ ...valid, hub: "g/L" })).toThrow(
|
||||
/hub/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"refuses a slope of %s",
|
||||
(a) => {
|
||||
expect(() =>
|
||||
defineAnalyteConversion({
|
||||
...valid,
|
||||
units: { ...valid.units, "mmol/L": { a, b: 0 } },
|
||||
}),
|
||||
).toThrow(/finite positive/);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses a unit that is not in its normalised spelling", () => {
|
||||
expect(() =>
|
||||
defineAnalyteConversion({
|
||||
...valid,
|
||||
units: { ...valid.units, "mmol/l": { a: 10, b: 0 } },
|
||||
}),
|
||||
).toThrow(/normalised/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { molarMass, formulaLabel } from "../atomic-weights";
|
||||
import {
|
||||
convertByScale,
|
||||
scaleUnitOf,
|
||||
scaleUnitsInFamilyOf,
|
||||
shiftPowerOfTen,
|
||||
} from "../scale";
|
||||
|
||||
describe("scale families", () => {
|
||||
it("puts each unit in the family of its quantity", () => {
|
||||
expect(scaleUnitOf("mg/dL")?.family).toBe("mass");
|
||||
expect(scaleUnitOf("pmol/L")?.family).toBe("amount");
|
||||
expect(scaleUnitOf("mIU/L")?.family).toBe("activity");
|
||||
expect(scaleUnitOf("10³/µL")?.family).toBe("count");
|
||||
});
|
||||
|
||||
it("derives the exponent from the prefix and the volume", () => {
|
||||
// Relative to g/L.
|
||||
expect(scaleUnitOf("g/L")?.exponent).toBe(0);
|
||||
expect(scaleUnitOf("g/dL")?.exponent).toBe(1);
|
||||
expect(scaleUnitOf("mg/dL")?.exponent).toBe(-2);
|
||||
expect(scaleUnitOf("ng/mL")?.exponent).toBe(-6);
|
||||
expect(scaleUnitOf("ng/dL")?.exponent).toBe(-8);
|
||||
expect(scaleUnitOf("pg/mL")?.exponent).toBe(-9);
|
||||
});
|
||||
|
||||
it("knows a unit by any spelling the normaliser resolves", () => {
|
||||
expect(scaleUnitOf("mg/dl")).toEqual(scaleUnitOf("mg/dL"));
|
||||
expect(scaleUnitOf("ug/L")).toEqual(scaleUnitOf("µg/L"));
|
||||
expect(scaleUnitOf("umol/l")).toEqual(scaleUnitOf("µmol/L"));
|
||||
});
|
||||
|
||||
it("knows no family for a unit that is not a plain scale", () => {
|
||||
expect(scaleUnitOf("%")).toBeNull();
|
||||
expect(scaleUnitOf("mmol/mol")).toBeNull();
|
||||
expect(scaleUnitOf("furlongs")).toBeNull();
|
||||
// A lone capital M is mega, not milli: not read as a scale unit at all.
|
||||
expect(scaleUnitOf("MIU/L")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not make mass to amount a scale pair", () => {
|
||||
expect(convertByScale(1, "mg/dL", "mmol/L")).toBeNull();
|
||||
expect(convertByScale(1, "g/L", "mol/L")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the 10^9/L of a blood count apart from g/L", () => {
|
||||
expect(scaleUnitOf("G/L")?.family).toBe("count");
|
||||
expect(scaleUnitOf("g/L")?.family).toBe("mass");
|
||||
expect(convertByScale(1, "G/L", "g/L")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists every unit of a family, in its own spelling", () => {
|
||||
const amount = scaleUnitsInFamilyOf("mmol/L");
|
||||
expect(amount).toEqual(
|
||||
expect.arrayContaining(["mol/L", "mmol/L", "µmol/L", "nmol/L", "pmol/L"]),
|
||||
);
|
||||
expect(amount).not.toContain("mg/dL");
|
||||
expect(scaleUnitsInFamilyOf("%")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shiftPowerOfTen", () => {
|
||||
it("multiplies for a positive power and divides for a negative one", () => {
|
||||
expect(shiftPowerOfTen(5, 3)).toBe(5000);
|
||||
expect(shiftPowerOfTen(5, -3)).toBe(0.005);
|
||||
expect(shiftPowerOfTen(5, 0)).toBe(5);
|
||||
});
|
||||
|
||||
it("is exact where multiplying by 10^-n is not", () => {
|
||||
// 3 * 1e-3 is 0.003 with a stray last digit; 3 / 1000 is exact.
|
||||
expect(shiftPowerOfTen(3, -3)).toBe(0.003);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomic weights", () => {
|
||||
it("sums a molar mass from the table, without float noise", () => {
|
||||
expect(molarMass({ C: 6, H: 12, O: 6 })).toBe(180.156);
|
||||
expect(molarMass({ Ca: 1 })).toBe(40.078);
|
||||
expect(molarMass({ N: 2 })).toBe(28.014);
|
||||
});
|
||||
|
||||
it("writes a formula in a stable order", () => {
|
||||
expect(formulaLabel({ O: 6, H: 12, C: 6 })).toBe("C6H12O6");
|
||||
expect(formulaLabel({ Ca: 1 })).toBe("Ca");
|
||||
expect(formulaLabel({ C: 1, H: 4, N: 2, O: 1 })).toBe("CH4N2O");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user