diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..286f9d079 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,73 @@ +name: CodeQL + +# Static analysis for the TypeScript tree. This is GitHub's default setup +# expressed as a workflow file rather than enabled from the repository +# settings, so the query suite, the excluded paths and the schedule are +# reviewable in the diff like everything else here. +# +# Deliberately NOT on `push: main`. The weekly run already analyses the +# default branch, which is what keeps the Security tab current, and every +# change reaches `main` through a pull request that is analysed on the way +# in. Adding a push trigger would analyse the same tree a second time +# minutes later for no additional signal. +on: + pull_request: + branches: [main] + schedule: + # Mondays, 04:27 UTC. Off the hour so the run does not queue behind + # everyone else's midnight cron. + - cron: "27 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyse javascript-typescript + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # Required to write the results into the repository's code-scanning + # alerts. Free for public repositories. + security-events: write + # Required by the CodeQL action to read workflow run metadata. + actions: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # `build-mode: none` is the correct mode for JavaScript/TypeScript — + # CodeQL extracts from source and needs neither a compile step nor + # installed dependencies. + # + # The excluded paths are all generated or vendored output. Chief + # among them is `src/generated`, the ~9 MB Prisma client with a + # 224 KB inline schema literal in it: extracting that costs real + # minutes and can produce nothing actionable, because nobody edits + # it and the fix for anything found there is `pnpm prisma generate`. + - name: Initialise CodeQL + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + languages: javascript-typescript + build-mode: none + config: | + paths-ignore: + - src/generated + - .next + - dist + - coverage + - playwright-report + - test-results + - public + + - name: Analyse + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + category: "/language:javascript-typescript" diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index ceb9557f5..a4add73d3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,21 +10,21 @@ permissions: contents: read jobs: - # The suite outgrew a single runner: 128 files took ~13 minutes when the - # 25-minute guard was written, and the v1.37 sharing work took it to 173 - # files and past 25 minutes of wall clock, so every run died at the timeout - # with every test to that point green. The guard's own rule applies — split, - # don't raise — so the files run as two vitest shards. Each shard boots its - # own Postgres testcontainer; the timeout stays a hang detector, sized so a - # healthy half-suite clears it on a slow runner. + # The suite outgrew a single runner, and then a half-suite outgrew a slow + # one: two shards cleared in ~14 minutes on a quiet day, but with six pull + # requests running side by side the larger shard hit the same 25-minute + # guard the split was meant to escape. Three shards keep the biggest slice + # around 60 files, which clears the guard with real headroom even on the + # slowest runner observed. Each shard boots its own Postgres testcontainer; + # the timeout stays a hang detector, not a performance budget. shard: runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false matrix: - shard: [1, 2] - name: integration shard ${{ matrix.shard }}/2 + shard: [1, 2, 3] + name: integration shard ${{ matrix.shard }}/3 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,7 +44,7 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2 + - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3 env: # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..0fb4bcd82 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,60 @@ +name: OpenSSF Scorecard + +# Automated checks against the OpenSSF's supply-chain criteria: are Actions +# pinned by SHA, is there a security policy, are dependencies updated +# automatically, is the release pipeline signed, is there a fuzzing or a +# static-analysis story. Most of what it looks for this repository already +# does; the value is that it keeps looking after the person who set it up +# has moved on to something else. +# +# `publish_results: true` sends the result to the OpenSSF's public API, +# which is what makes the score citable by anyone evaluating the project +# without asking for CI access. Nothing about the code or its contents +# leaves; the checks read repository metadata and the workflow files. +on: + # Re-score when a branch-protection rule changes, since several checks + # read exactly that. + branch_protection_rule: + schedule: + # Tuesdays, 07:20 UTC. + - cron: "20 7 * * 2" + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Score supply-chain practices + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # Write the findings into the repository's code-scanning alerts. + security-events: write + # Publish the result to the public API. This is an OIDC token + # exchange, the same mechanism the release workflow uses for keyless + # image signing; no long-lived credential is involved. + id-token: write + contents: read + actions: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Scorecard reads the repository through the API, not through the + # checkout, and leaving the token in `.git/config` would hand it + # to anything else running in the job. + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: scorecard-results.sarif + results_format: sarif + publish_results: true + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: scorecard-results.sarif + category: scorecard diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 000000000..235c8ea63 --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,164 @@ +name: Trivy Scheduled Scan + +# Two scans that the per-change gates cannot do, on a daily cadence. +# +# What already exists and is NOT repeated here: +# - `security.yml` → `dependency-audit` runs `pnpm audit --prod` on every +# push and pull request, blocking on HIGH/CRITICAL. +# - `security.yml` → `docker-security` builds the image on a push to `main` +# and runs `trivy image` against it, blocking on HIGH/CRITICAL. +# +# Both are change-triggered, and both answer "is this change safe". Neither +# answers "is what we shipped last week still safe", because an advisory +# published today lands against an image nobody is about to rebuild. That is +# what the schedule is for. There is deliberately no push or pull_request +# trigger: adding one would run the same scan the `docker-security` job +# already ran, minutes later, on the same tree. +on: + schedule: + # Daily, 05:17 UTC. + - cron: "17 5 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: trivy-scheduled + cancel-in-progress: true + +jobs: + filesystem: + name: Lockfile (including dev dependencies) + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # `pnpm audit --prod` is the blocking gate and it is scoped to + # production dependencies on purpose: a dev-only advisory should not + # stop a release. The gap that scoping leaves is that "dev dependency" + # and "not in the image" are not the same statement. + # + # `pdfjs-dist` is the worked example. It sits in `devDependencies`, + # and `src/lib/documents/rasterize-pdf.ts` imports it at runtime, so + # Next's standalone output tracing copies it into the production + # image. `pnpm audit --prod` cannot see it. This scan can: without + # the flag Trivy reads 627 packages out of `pnpm-lock.yaml`, with it + # 1455, and `pdfjs-dist` is only in the second set. + # + # Trivy's own `--help` lists the flag as supported for npm, yarn and + # gradle. It works on `pnpm-lock.yaml` too; the help text is behind + # the parser. Verified against this repository before this workflow + # was written, and worth re-checking if the package count ever + # collapses back to the production figure. + # + # Report-only, and that is a deliberate choice rather than a soft + # one. The findings this widening surfaces are overwhelmingly + # transitive dev-tool advisories with fixes available upstream; + # failing the run on them from day one produces a red badge that gets + # muted within a week, and a muted signal is worse than none. The + # SARIF upload puts every finding in the repository's code-scanning + # alerts, which persist, notify, and can be triaged one at a time. + # The blocking answer to "does this ship" stays where it is, in + # `security.yml`. + - name: Trivy filesystem scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + # No action input maps to `--include-dev-deps`; Trivy reads every + # flag from a `TRIVY_`-prefixed environment variable, so this is + # the documented way to reach it through the action. + TRIVY_INCLUDE_DEV_DEPS: "true" + with: + scan-type: fs + scan-ref: . + scanners: vuln + format: sarif + output: trivy-filesystem.sarif + severity: "CRITICAL,HIGH,MEDIUM" + limit-severities-for-sarif: true + exit-code: "0" + # Generated Prisma client. Nobody edits it, the fix for anything + # found in it is `pnpm prisma generate`, and it is ~9 MB. + skip-dirs: src/generated + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: trivy-filesystem.sarif + category: trivy-filesystem + + published-image: + name: Published image + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + packages: read + security-events: write + + steps: + # The `docker-security` job scans an image it just built from the + # current `main`. This one scans the image operators actually pull, + # which is the one built from the last release tag and unchanged + # since. The two answer different questions and the gap between them + # widens every day a release stays out. + # + # Trivy resolves the multi-arch manifest to the runner's platform, so + # this covers `linux/amd64`. The arm64 manifest shares every + # application layer and differs only in native binaries. + - name: Log in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `.trivyignore` lives at the repository root and Trivy reads it + # automatically, so the suppressions the blocking gate already + # carries apply here too and the two scans cannot disagree about + # what is accepted. + - name: Checkout for .trivyignore + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Fails on CRITICAL only. A critical advisory against the image + # self-hosters are running right now is worth a red badge and a + # notification; HIGH goes to code scanning with everything else, + # because the release pipeline already refuses to publish an image + # carrying one and anything that appears here appeared after the + # fact. + - name: Trivy published-image scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ghcr.io/mbombeck/healthlog:latest + scanners: vuln + format: sarif + output: trivy-published-image.sarif + severity: "CRITICAL,HIGH,MEDIUM" + limit-severities-for-sarif: true + exit-code: "0" + + - name: Upload results + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + with: + sarif_file: trivy-published-image.sarif + category: trivy-published-image + + # Separate table pass so the SARIF upload above always runs, even + # when this one fails the job. A scan whose failure suppresses its + # own report is a scan you have to re-run by hand to read. + - name: Fail on a critical finding in the published image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ghcr.io/mbombeck/healthlog:latest + scanners: vuln + format: table + severity: "CRITICAL" + exit-code: "1" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 92583d744..357b9cfae 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9147,6 +9147,16 @@ paths: type: number referenceHigh: type: number + sourceReferenceLow: + type: number + sourceReferenceHigh: + type: number + sourceReferenceText: + anyOf: + - type: string + maxLength: 120 + - type: string + const: "" takenAt: type: string format: date-time @@ -9254,6 +9264,22 @@ paths: anyOf: - type: number - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - anyOf: + - type: string + maxLength: 120 + - type: string + const: "" + - type: "null" takenAt: type: string format: date-time @@ -9430,6 +9456,12 @@ paths: type: number referenceHigh: type: number + referenceText: + anyOf: + - type: string + maxLength: 120 + - type: string + const: "" takenAt: type: string format: date-time @@ -11616,6 +11648,11 @@ paths: anyOf: - type: number - type: "null" + referenceText: + anyOf: + - type: string + maxLength: 120 + - type: "null" effectiveDate: anyOf: - type: string @@ -32974,6 +33011,30 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + $ref: "#/components/schemas/LabReferenceRangeOrigin" + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -32996,6 +33057,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -33003,12 +33071,31 @@ components: - createdAt - updatedAt additionalProperties: false - description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is - non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links - the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / - `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them 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`. + description: >- + A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; + `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the + user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED + server-side from the Biomarker. + + + Three reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` + are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are + correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / + `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no + 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`. + LabReferenceRangeOrigin: + type: string + enum: + - source + - catalog + - none + description: Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own + report, which wins for that reading because it is the window the physician evaluated the value against. + `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file. LabReferenceRangeStatus: type: string enum: @@ -33016,9 +33103,9 @@ components: - below - above - unknown - description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. - Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and - informative — not an alarming red." + description: "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the + window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the + limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red." CreateLabResultResponse: type: object properties: @@ -33086,6 +33173,30 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + $ref: "#/components/schemas/LabReferenceRangeOrigin" + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -33110,6 +33221,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - rangeStatus @@ -33292,6 +33410,14 @@ components: anyOf: - type: number - type: "null" + referenceText: + anyOf: + - type: string + - type: "null" + description: The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0", + "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to + numbers; this field carries it either way, so a window stated in words is not lost between the report and + the reading. takenAt: anyOf: - type: string @@ -33329,6 +33455,7 @@ components: - unit - referenceLow - referenceHigh + - referenceText - takenAt - confidence - biomarkerMatch @@ -33426,6 +33553,34 @@ components: anyOf: - type: number - type: "null" + catalogReferenceLow: + anyOf: + - type: number + - type: "null" + catalogReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceLow: + anyOf: + - type: number + - type: "null" + sourceReferenceHigh: + anyOf: + - type: number + - type: "null" + sourceReferenceText: + anyOf: + - type: string + - type: "null" + referenceOrigin: + type: string + enum: + - source + - catalog + - none + referenceDivergesFromCatalog: + type: boolean takenAt: type: string source: @@ -33453,6 +33608,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -33460,6 +33622,7 @@ components: - createdAt - updatedAt additionalProperties: false + description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. ListBiomarkersEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 69655e2de..6565d4a05 100644 --- a/messages/de.json +++ b/messages/de.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Familienanamnese", "familyHistoryColRelationship": "Verwandtschaft", "familyHistoryColCondition": "Erkrankung", - "familyHistoryColAgeAtOnset": "Alter bei Beginn" + "familyHistoryColAgeAtOnset": "Alter bei Beginn", + "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:", + "labsCatalogRangeLabel": "gespeicherter Bereich" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "z. B. negativ", "qualNegative": "negativ", "qualPositive": "positiv", - "qualBorderline": "grenzwertig" + "qualBorderline": "grenzwertig", + "sourceRange": "Bereich laut Befund", + "sourceRangePlaceholder": "z. B. 3,5 - 5,0 oder < 5", + "sourceRangeAdd": "+ Bereich aus dem Befund ergänzen", + "sourceRangeHint": "Optional. Gilt nur für diesen Wert; dein gespeicherter Bereich bleibt unverändert.", + "sourceRangeUnparsed": "Wird so übernommen — daraus ließ sich kein Zahlenbereich lesen." }, "backToLabs": "Zurück zu den Laborwerten", "hasNote": "Notiz", @@ -8117,7 +8124,10 @@ "90": "90 T", "365": "1 J", "all": "Alle" - } + }, + "sourceRangeLabel": "Befundbereich", + "sourceBandLegend": "Zweites Band: der Bereich aus dem Befund vom {date}.", + "sourceBandMixed": "Zweites Band: der Bereich aus dem neuesten Befund. Frühere Befunde im Zeitraum nennen andere Bereiche." }, "biomarker": { "manageTitle": "Biomarker", @@ -8292,11 +8302,17 @@ "fileTooLarge": "Datei ist zu groß (max. 12 MB).", "fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.", "extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.", - "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch." + "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.", + "refTextLabel": "Bereich wie gedruckt", + "refTextPlaceholder": "z. B. 3,5 - 5,0" }, "loadMoreReadings": "Weitere Werte laden", "documentsLink": "Befund-Dokumente", - "notLinkedYet": "Noch keinem Biomarker zugeordnet" + "notLinkedYet": "Noch keinem Biomarker zugeordnet", + "sourceRange": { + "fromReport": "Befundbereich {range}", + "catalogDiffers": "dein Bereich {range}" + } }, "modules": { "cycle": { diff --git a/messages/en.json b/messages/en.json index 412589882..68d644404 100644 --- a/messages/en.json +++ b/messages/en.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Family history", "familyHistoryColRelationship": "Relative", "familyHistoryColCondition": "Condition", - "familyHistoryColAgeAtOnset": "Age at onset" + "familyHistoryColAgeAtOnset": "Age at onset", + "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:", + "labsCatalogRangeLabel": "saved range" }, "errorBoundary": { "title": "Something went wrong", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "e.g. negative", "qualNegative": "negative", "qualPositive": "positive", - "qualBorderline": "borderline" + "qualBorderline": "borderline", + "sourceRange": "Range printed on the report", + "sourceRangePlaceholder": "e.g. 3.5 - 5.0 or < 5", + "sourceRangeAdd": "+ Add the range from the report", + "sourceRangeHint": "Optional. Applies to this reading only; your saved range stays as it is.", + "sourceRangeUnparsed": "Kept as written — no numeric range read from it." }, "backToLabs": "Back to lab results", "hasNote": "Note", @@ -8117,7 +8124,10 @@ "90": "90d", "365": "1y", "all": "All" - } + }, + "sourceRangeLabel": "Report range", + "sourceBandLegend": "Second band: the range printed on the report of {date}.", + "sourceBandMixed": "Second band: the range printed on the most recent report. Earlier reports in view state different ranges." }, "biomarker": { "manageTitle": "Biomarkers", @@ -8292,11 +8302,17 @@ "fileTooLarge": "File is too large (max 12 MB).", "fileType": "Upload a JPEG, PNG, WebP, or PDF.", "extractFailed": "Couldn't read the report. Try a clearer photo.", - "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead." + "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.", + "refTextLabel": "Range as printed", + "refTextPlaceholder": "e.g. 3.5 - 5.0" }, "loadMoreReadings": "Load more readings", "documentsLink": "Lab report documents", - "notLinkedYet": "Not linked to a biomarker yet" + "notLinkedYet": "Not linked to a biomarker yet", + "sourceRange": { + "fromReport": "Report range {range}", + "catalogDiffers": "your range {range}" + } }, "modules": { "cycle": { diff --git a/messages/es.json b/messages/es.json index 5cc45adb6..f0ce9484e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Antecedentes familiares", "familyHistoryColRelationship": "Parentesco", "familyHistoryColCondition": "Afección", - "familyHistoryColAgeAtOnset": "Edad de inicio" + "familyHistoryColAgeAtOnset": "Edad de inicio", + "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:", + "labsCatalogRangeLabel": "rango guardado" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "p. ej. negativo", "qualNegative": "negativo", "qualPositive": "positivo", - "qualBorderline": "límite" + "qualBorderline": "límite", + "sourceRange": "Rango impreso en el informe", + "sourceRangePlaceholder": "p. ej. 3,5 - 5,0 o < 5", + "sourceRangeAdd": "+ Añadir el rango del informe", + "sourceRangeHint": "Opcional. Se aplica solo a esta lectura; tu rango guardado no cambia.", + "sourceRangeUnparsed": "Se guarda tal cual: no se pudo leer un rango numérico." }, "backToLabs": "Volver a los resultados", "hasNote": "Nota", @@ -8117,7 +8124,10 @@ "90": "90 d", "365": "1 a", "all": "Todo" - } + }, + "sourceRangeLabel": "Rango del informe", + "sourceBandLegend": "Segunda banda: el rango impreso en el informe del {date}.", + "sourceBandMixed": "Segunda banda: el rango impreso en el informe más reciente. Informes anteriores del periodo indican rangos distintos." }, "biomarker": { "manageTitle": "Biomarcadores", @@ -8292,11 +8302,17 @@ "fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).", "fileType": "Sube un JPEG, PNG, WebP o PDF.", "extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.", - "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar." + "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.", + "refTextLabel": "Rango tal como aparece", + "refTextPlaceholder": "p. ej. 3,5 - 5,0" }, "loadMoreReadings": "Cargar más mediciones", "documentsLink": "Documentos de resultados", - "notLinkedYet": "Aún no vinculado a un biomarcador" + "notLinkedYet": "Aún no vinculado a un biomarcador", + "sourceRange": { + "fromReport": "Rango del informe {range}", + "catalogDiffers": "tu rango {range}" + } }, "modules": { "cycle": { diff --git a/messages/fr.json b/messages/fr.json index 246e2aee0..6ce20efa6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Antécédents familiaux", "familyHistoryColRelationship": "Lien de parenté", "familyHistoryColCondition": "Affection", - "familyHistoryColAgeAtOnset": "Âge au début" + "familyHistoryColAgeAtOnset": "Âge au début", + "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :", + "labsCatalogRangeLabel": "plage enregistrée" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "p. ex. négatif", "qualNegative": "négatif", "qualPositive": "positif", - "qualBorderline": "limite" + "qualBorderline": "limite", + "sourceRange": "Plage imprimée sur le compte rendu", + "sourceRangePlaceholder": "p. ex. 3,5 - 5,0 ou < 5", + "sourceRangeAdd": "+ Ajouter la plage du compte rendu", + "sourceRangeHint": "Facultatif. Ne vaut que pour cette mesure ; votre plage enregistrée reste inchangée.", + "sourceRangeUnparsed": "Conservé tel quel : aucune plage numérique n'a pu en être lue." }, "backToLabs": "Retour aux résultats", "hasNote": "Note", @@ -8117,7 +8124,10 @@ "90": "90 j", "365": "1 a", "all": "Tout" - } + }, + "sourceRangeLabel": "Plage du compte rendu", + "sourceBandLegend": "Deuxième bande : la plage imprimée sur le compte rendu du {date}.", + "sourceBandMixed": "Deuxième bande : la plage imprimée sur le compte rendu le plus récent. Des comptes rendus antérieurs de la période indiquent d'autres plages." }, "biomarker": { "manageTitle": "Biomarqueurs", @@ -8292,11 +8302,17 @@ "fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", "fileType": "Importez un JPEG, PNG, WebP ou PDF.", "extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.", - "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo." + "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.", + "refTextLabel": "Plage telle qu'imprimée", + "refTextPlaceholder": "p. ex. 3,5 - 5,0" }, "loadMoreReadings": "Charger plus de mesures", "documentsLink": "Documents de résultats", - "notLinkedYet": "Pas encore lié à un biomarqueur" + "notLinkedYet": "Pas encore lié à un biomarqueur", + "sourceRange": { + "fromReport": "Plage du compte rendu {range}", + "catalogDiffers": "votre plage {range}" + } }, "modules": { "cycle": { diff --git a/messages/it.json b/messages/it.json index 6120167d7..f7e60cd97 100644 --- a/messages/it.json +++ b/messages/it.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Anamnesi familiare", "familyHistoryColRelationship": "Parentela", "familyHistoryColCondition": "Condizione", - "familyHistoryColAgeAtOnset": "Età di esordio" + "familyHistoryColAgeAtOnset": "Età di esordio", + "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:", + "labsCatalogRangeLabel": "intervallo salvato" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "es. negativo", "qualNegative": "negativo", "qualPositive": "positivo", - "qualBorderline": "borderline" + "qualBorderline": "borderline", + "sourceRange": "Intervallo stampato sul referto", + "sourceRangePlaceholder": "es. 3,5 - 5,0 oppure < 5", + "sourceRangeAdd": "+ Aggiungi l'intervallo dal referto", + "sourceRangeHint": "Facoltativo. Vale solo per questa misura; l'intervallo salvato resta invariato.", + "sourceRangeUnparsed": "Conservato così com'è: non è stato letto alcun intervallo numerico." }, "backToLabs": "Torna ai risultati", "hasNote": "Nota", @@ -8117,7 +8124,10 @@ "90": "90 g", "365": "1 a", "all": "Tutto" - } + }, + "sourceRangeLabel": "Intervallo del referto", + "sourceBandLegend": "Seconda banda: l'intervallo stampato sul referto del {date}.", + "sourceBandMixed": "Seconda banda: l'intervallo stampato sul referto più recente. Referti precedenti nel periodo indicano intervalli diversi." }, "biomarker": { "manageTitle": "Biomarcatori", @@ -8292,11 +8302,17 @@ "fileTooLarge": "Il file è troppo grande (max 12 MB).", "fileType": "Carica un JPEG, PNG, WebP o PDF.", "extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.", - "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto." + "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.", + "refTextLabel": "Intervallo come stampato", + "refTextPlaceholder": "es. 3,5 - 5,0" }, "loadMoreReadings": "Carica altre misurazioni", "documentsLink": "Documenti dei referti", - "notLinkedYet": "Non ancora collegato a un biomarcatore" + "notLinkedYet": "Non ancora collegato a un biomarcatore", + "sourceRange": { + "fromReport": "Intervallo del referto {range}", + "catalogDiffers": "il tuo intervallo {range}" + } }, "modules": { "cycle": { diff --git a/messages/pl.json b/messages/pl.json index 007254a17..ba8680481 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -281,7 +281,9 @@ "familyHistoryTitle": "Wywiad rodzinny", "familyHistoryColRelationship": "Pokrewieństwo", "familyHistoryColCondition": "Schorzenie", - "familyHistoryColAgeAtOnset": "Wiek zachorowania" + "familyHistoryColAgeAtOnset": "Wiek zachorowania", + "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:", + "labsCatalogRangeLabel": "zapisany zakres" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -8086,7 +8088,12 @@ "qualitativePlaceholder": "np. ujemny", "qualNegative": "ujemny", "qualPositive": "dodatni", - "qualBorderline": "graniczny" + "qualBorderline": "graniczny", + "sourceRange": "Zakres wydrukowany na wyniku", + "sourceRangePlaceholder": "np. 3,5 - 5,0 lub < 5", + "sourceRangeAdd": "+ Dodaj zakres z wyniku", + "sourceRangeHint": "Opcjonalne. Dotyczy tylko tego pomiaru; zapisany zakres pozostaje bez zmian.", + "sourceRangeUnparsed": "Zapisane bez zmian — nie odczytano z tego zakresu liczbowego." }, "backToLabs": "Wróć do wyników", "hasNote": "Notatka", @@ -8117,7 +8124,10 @@ "90": "90 d", "365": "1 r", "all": "Wszystko" - } + }, + "sourceRangeLabel": "Zakres z wyniku", + "sourceBandLegend": "Drugie pasmo: zakres wydrukowany na wyniku z {date}.", + "sourceBandMixed": "Drugie pasmo: zakres z najnowszego wyniku. Wcześniejsze wyniki w tym okresie podają inne zakresy." }, "biomarker": { "manageTitle": "Biomarkery", @@ -8292,11 +8302,17 @@ "fileTooLarge": "Plik jest za duży (maks. 12 MB).", "fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", "extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.", - "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie." + "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.", + "refTextLabel": "Zakres tak jak wydrukowano", + "refTextPlaceholder": "np. 3,5 - 5,0" }, "loadMoreReadings": "Wczytaj więcej pomiarów", "documentsLink": "Dokumenty wyników badań", - "notLinkedYet": "Jeszcze niepowiązane z biomarkerem" + "notLinkedYet": "Jeszcze niepowiązane z biomarkerem", + "sourceRange": { + "fromReport": "Zakres z wyniku {range}", + "catalogDiffers": "twój zakres {range}" + } }, "modules": { "cycle": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92dc14bec..4f5efaea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: dompurify@<=3.4.10: ^3.4.11 postcss@<8.5.10: ^8.5.10 + nanoid@<3.3.17: ^3.3.17 '@hono/node-server@<1.19.13': ^1.19.13 hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 @@ -5556,13 +5557,8 @@ packages: nan@2.27.0: resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -12636,9 +12632,7 @@ snapshots: nan@2.27.0: optional: true - nanoid@3.3.12: {} - - nanoid@3.3.16: {} + nanoid@3.3.17: {} napi-postinstall@0.3.4: {} @@ -13045,19 +13039,19 @@ snapshots: postcss@8.5.14: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.20: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.25: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7fabca7c..3fa8ec2d3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ overrides: "dompurify@<=3.4.10": "^3.4.11" "postcss@<8.5.10": "^8.5.10" + "nanoid@<3.3.17": "^3.3.17" "@hono/node-server@<1.19.13": "^1.19.13" "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" diff --git a/prisma/migrations/0303_lab_source_reference_range/migration.sql b/prisma/migrations/0303_lab_source_reference_range/migration.sql new file mode 100644 index 000000000..d4f6644c1 --- /dev/null +++ b/prisma/migrations/0303_lab_source_reference_range/migration.sql @@ -0,0 +1,14 @@ +-- Per-reading source reference range on a lab result. +-- +-- A lab report prints the window its own method and device produce, right +-- beside the value, and that window is what a physician evaluates against. +-- Until now the document reading pulled the value and dropped the range. +-- These three columns carry it: the derived numeric bounds where the printed +-- string yields them, plus the report's verbatim string, which is stored +-- whether or not it parsed. Nothing is backfilled — an existing row genuinely +-- has no source range on file and stays NULL. + +ALTER TABLE "lab_results" + ADD COLUMN "source_reference_low" DOUBLE PRECISION, + ADD COLUMN "source_reference_high" DOUBLE PRECISION, + ADD COLUMN "source_reference_text" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ffc9a4a14..affc5e70c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5057,9 +5057,41 @@ model LabResult { /// reference bounds + range verdict are meaningless for a qualitative row. valueText String? @map("value_text") unit String - /// Optional reference-range bounds as reported by the lab. + /// Reference-range bounds stamped onto the row at write time. Despite the + /// name these are NOT the lab's printed range: since v1.18.1 every write + /// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as + /// historical truth, so a later catalog edit does not silently rewrite a + /// past reading. The range the LAB printed lives in `sourceReference*`. referenceLow Float? @map("reference_low") referenceHigh Float? @map("reference_high") + + /// The reference range as PRINTED ON THE SOURCE REPORT for this one reading. + /// + /// Labs run different methods on different devices, so the window a + /// physician evaluates against is the one printed beside the value on that + /// report — not a catalog band that averages every lab. These three columns + /// carry it: the parsed numeric bounds where a bound could be derived, and + /// `sourceReferenceText` as the report's own verbatim string ("< 5", + /// "bis 5,0", "negativ", "siehe Befund"). The text is kept whenever the + /// source printed one, parseable or not — absence reads as absence, and a + /// window that could not be derived is never invented. + /// + /// Precedence: for a single reading the source bounds win over the catalog + /// band; the catalog stays the net for readings that carry no source range. + /// `resolveEffectiveReferenceRange` in `src/lib/labs/reference-range.ts` is + /// the one place that rule is expressed, and every surface that judges a + /// reading routes through it. + /// + /// Plaintext, not `*Encrypted`: that suffix marks user prose (`noteEncrypted` + /// here, `Biomarker.contextEncrypted` next door). This is a structured + /// lab-report field of exactly the class of the plaintext `analyte` / `unit` + /// / `value` / `valueText` columns beside it — `valueText` already carries + /// the actual clinical RESULT ("positiv") in the clear, so encrypting the + /// window that result is judged against would protect strictly less while + /// making the column unreadable to the SQL the read paths run. + sourceReferenceLow Float? @map("source_reference_low") + sourceReferenceHigh Float? @map("source_reference_high") + sourceReferenceText String? @map("source_reference_text") /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") diff --git a/scripts/diagnose-implausible-measurements.ts b/scripts/diagnose-implausible-measurements.ts new file mode 100644 index 000000000..0be518a26 --- /dev/null +++ b/scripts/diagnose-implausible-measurements.ts @@ -0,0 +1,150 @@ +/** + * Read-only diagnostic for stored measurement rows outside the app's own + * declared plausibility domain. + * + * `VALUE_RANGES` in `@/lib/validations/measurement` is the band the + * application says a metric can occupy. Every path a person can drive has + * always enforced it; the provider sync writers did not, so rows the + * application itself calls impossible reached the table — and every mean, + * median and personal band built from them inherited the impossibility. The + * writers enforce it now, which stops new rows landing. It says nothing about + * the rows already there. + * + * This reports them and repairs nothing. What to do with a stored impossible + * row is a judgement the data cannot make: a pulse of 55 million is plainly a + * decode slip and a candidate for deletion, while a weight of 0.9 kg might be + * a neonatal record on a shared scale, or a unit mix-up, or a real reading the + * band is simply too narrow for. Deleting somebody's health record on a + * heuristic is not a thing a script gets to decide. + * + * Output is aggregate: one line per (metric, source) bucket with the row + * count, the number of distinct records affected, the observed extremes, and + * the window they fall in. Individual readings are not printed — the bucket + * plus the metric's own declared bound is what a decision needs, and a log + * file full of somebody's vitals is not. + * + * Run (inside the app container, which has DATABASE_URL): + * pnpm dlx tsx scripts/diagnose-implausible-measurements.ts + */ +import "dotenv/config"; + +import { prisma } from "@/lib/db"; +import { VALUE_RANGES } from "@/lib/validations/measurement"; + +interface BucketRow { + type: string; + source: string; + row_count: bigint; + record_count: bigint; + min_value: number; + max_value: number; + first_at: Date; + last_at: Date; + tombstoned: bigint; +} + +function ymd(date: Date): string { + return date.toISOString().slice(0, 10); +} + +async function main(): Promise { + const types = Object.keys(VALUE_RANGES).sort(); + const mins = types.map((t) => VALUE_RANGES[t]!.min); + const maxes = types.map((t) => VALUE_RANGES[t]!.max); + + // The band table rides in as three parallel bound arrays rather than a + // spliced VALUES list, so the query carries no interpolated text at all. + // `value <> value` is the NaN probe: a double-precision column can hold one, + // and it compares false against every bound including itself. + const buckets = await prisma.$queryRaw` + WITH bands(type, min_value, max_value) AS ( + SELECT * FROM unnest( + ${types}::text[], + ${mins}::double precision[], + ${maxes}::double precision[] + ) + ) + SELECT + m."type"::text AS type, + m."source"::text AS source, + COUNT(*)::bigint AS row_count, + COUNT(DISTINCT m."user_id")::bigint AS record_count, + MIN(m."value") AS min_value, + MAX(m."value") AS max_value, + MIN(m."measured_at") AS first_at, + MAX(m."measured_at") AS last_at, + COUNT(*) FILTER (WHERE m."deleted_at" IS NOT NULL)::bigint AS tombstoned + FROM "measurements" m + JOIN bands b ON b."type" = m."type"::text + WHERE m."value" < b.min_value + OR m."value" > b.max_value + OR m."value" <> m."value" + GROUP BY m."type", m."source" + ORDER BY COUNT(*) DESC, m."type"::text ASC, m."source"::text ASC + `; + + if (buckets.length === 0) { + console.log( + `Every stored measurement sits inside its metric's declared range ` + + `(${types.length} metric bands checked). Nothing to review.`, + ); + return; + } + + const totalRows = buckets.reduce((sum, b) => sum + Number(b.row_count), 0); + const affectedTypes = new Set(buckets.map((b) => b.type)); + const affectedSources = new Set(buckets.map((b) => b.source)); + + console.log( + `${totalRows} stored row(s) sit outside the declared plausibility band, ` + + `across ${affectedTypes.size} metric(s) and ${affectedSources.size} source(s).\n` + + `Nothing below is modified. Each line is a (metric, source) bucket; the ` + + `band is the metric's own declared domain.\n`, + ); + + // Group by metric so the declared band is stated once per heading, and rank + // the metrics by how much of the problem each one is. The query's own + // ordering already ranks the sources inside a metric. + const byType = new Map(); + for (const bucket of buckets) { + const bucketsForType = byType.get(bucket.type); + if (bucketsForType) bucketsForType.push(bucket); + else byType.set(bucket.type, [bucket]); + } + const ranked = [...byType.entries()].sort( + (a, b) => + b[1].reduce((sum, x) => sum + Number(x.row_count), 0) - + a[1].reduce((sum, x) => sum + Number(x.row_count), 0), + ); + + for (const [type, rows] of ranked) { + const band = VALUE_RANGES[type]!; + console.log(`${type} — declared band ${band.min}…${band.max}`); + for (const bucket of rows) { + const live = Number(bucket.row_count) - Number(bucket.tombstoned); + console.log( + ` ${bucket.source.padEnd(14)} ` + + `${String(bucket.row_count).padStart(7)} row(s) ` + + `(${live} live, ${bucket.tombstoned} tombstoned) ` + + `across ${bucket.record_count} record(s) — ` + + `observed ${bucket.min_value}…${bucket.max_value}, ` + + `${ymd(bucket.first_at)} → ${ymd(bucket.last_at)}`, + ); + } + } + + console.log( + `\nThe sync writers refuse these values now, so the population above is ` + + `closed unless a writer without the gate is still in play. Removing a ` + + `row is a maintainer decision, per bucket.`, + ); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts new file mode 100644 index 000000000..88fbafee2 --- /dev/null +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -0,0 +1,200 @@ +/** + * Structural guard on the measurement ingest boundary. + * + * `VALUE_RANGES` is the band the application declares a metric can occupy. + * Every path a person can drive enforced it from the start; the provider sync + * writers never did, and nothing noticed, because each writer is correct on + * its own terms — it wrote what the remote sent. The gap was only visible from + * above, by listing every writer at once and asking which of them had ever + * been told about the band. + * + * So the list lives here. Every file that writes a `Measurement` row is + * enumerated with the reason it is allowed to, and the guard fails when the + * discovered set stops matching. A provider added tomorrow lands a file that + * is not in the map and has to answer the question before it can ship. + * + * This is a tripwire, not a proof. It cannot show a disposition is the right + * one — only that nobody added a writer without writing down which gate covers + * it. A reviewer who waves through a bogus `derived` label defeats it, and no + * test substitutes for that review. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** The gate module every provider-fed writer has to reach. */ +const GATE_MODULE = "@/lib/measurements/plausibility-gate"; +/** The predicate every person-driven write path has always used. */ +const VALIDATOR = "validateMeasurementRange"; + +/** + * How a writer satisfies the plausibility domain. + * + * - `gate` — provider-fed; must reach {@link GATE_MODULE} itself. + * - `validator` — person-driven; must call {@link VALIDATOR} itself. + * - `upstream` — validated in a named module before the row is built; the + * named module is checked, so the label cannot be a shrug. + * - `derived` — the value is arithmetic over rows already in the table, not a + * number from outside. Gating here would drop a consolidation of data that + * IS present, which is a different failure from admitting one that is not. + * - `literal` — the written value is a constant in the file, so no external + * number can reach the column. + * - `restore` — replays the record's own backup verbatim. Fidelity is the + * contract; a restore that silently dropped rows would be the worse bug, + * and the diagnostic script is how a stored impossible row gets found. + */ +type Disposition = + | { kind: "gate" } + | { kind: "validator" } + | { kind: "upstream"; validatedIn: string } + | { kind: "derived"; why: string } + | { kind: "literal"; why: string } + | { kind: "restore"; why: string }; + +const WRITERS: Record = { + // ── Provider sync writers, gated ────────────────────────────────────── + "lib/withings/sync.ts": { kind: "gate" }, + "lib/withings/sync-activity.ts": { kind: "gate" }, + "lib/fitbit/sync-core.ts": { kind: "gate" }, + "lib/google-health/sync-core.ts": { kind: "gate" }, + "lib/nightscout/sync.ts": { kind: "gate" }, + // The shared identity seam. Polar, Oura, WHOOP, the Withings sleep leg, + // the Apple export importer and the batch endpoint all write through it, + // so the gate here is what makes a new provider fail closed. + "lib/measurements/reconcile-external-measurement.ts": { kind: "gate" }, + + // ── Person-driven write paths, validated ────────────────────────────── + "app/api/measurements/route.ts": { + kind: "upstream", + validatedIn: "lib/validations/measurement.ts", + }, + "app/api/import/route.ts": { kind: "validator" }, + "lib/mcp/writes.ts": { kind: "validator" }, + "lib/measurements/create-from-telegram.ts": { kind: "validator" }, + "lib/measurements/import-apple-health-export.ts": { kind: "validator" }, + "app/api/import/csv/route.ts": { + kind: "upstream", + validatedIn: "lib/import/csv-measurements.ts", + }, + + // ── Fixed values ────────────────────────────────────────────────────── + "lib/withings/sync-ecg.ts": { + kind: "literal", + why: "an ECG row carries value 1 — the file's own constant, never a provider number", + }, + "app/api/mental-health/assessments/route.ts": { + kind: "literal", + why: "the questionnaire score is summed from Zod-bounded item answers", + }, + "lib/insights/score-row.ts": { + kind: "literal", + why: "the health score is computed in-app on a closed 0-100 scale", + }, + + // ── Derived from rows already stored ────────────────────────────────── + "lib/measurements/consolidate-daily-mean.ts": { + kind: "derived", + why: "writes the mean of the day's own stored rows", + }, + "lib/measurements/consolidate-legacy-steps.ts": { + kind: "derived", + why: "folds stored per-sample step rows into the day's canonical row", + }, + "lib/measurements/dense-intraday-retention.ts": { + kind: "derived", + why: "rebuilds hourly buckets from stored per-sample rows", + }, + "lib/measurements/drain-per-sample-cumulative.ts": { + kind: "derived", + why: "reduces stored cumulative per-sample rows to one daily total", + }, + + // ── Restore ─────────────────────────────────────────────────────────── + "app/api/admin/backups/[id]/restore/route.ts": { + kind: "restore", + why: "replays the record's own export; dropping rows would break the restore contract", + }, +}; + +/** + * Any Prisma client handle followed by a measurement write. Whitespace- and + * handle-tolerant on purpose: the earlier generation of this repo's guards + * demanded a literal `prisma.x.findUnique(` and went green because it matched + * nothing, not because nothing was there. The transaction handles in use are + * `tx` and `pc`; a future one is caught by the `[A-Za-z_$][\w$]*` head. + */ +const MEASUREMENT_WRITE = + /[A-Za-z_$][\w$]*\s*\.\s*measurement\s*\.\s*(create|createMany|createManyAndReturn|upsert)\s*\(/; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function discoveredWriters(): string[] { + return sourceFiles().filter((rel) => MEASUREMENT_WRITE.test(read(rel))); +} + +describe("every measurement writer declares how it meets the plausibility domain", () => { + it("finds the writers at all — an empty sweep is a broken guard, not a clean repo", () => { + // The map is the claim; this asserts the matcher can still see the + // population it describes. A regex that silently stops matching would + // otherwise turn every check below into a tautology. + expect(discoveredWriters().length).toBeGreaterThanOrEqual(15); + }); + + it("the discovered writer set matches the declared map exactly", () => { + expect(discoveredWriters()).toEqual(Object.keys(WRITERS).sort()); + }); + + it("every provider-fed writer reaches the gate module", () => { + const gated = Object.entries(WRITERS) + .filter(([, d]) => d.kind === "gate") + .map(([rel]) => rel); + expect(gated.length).toBeGreaterThanOrEqual(6); + for (const rel of gated) { + expect(read(rel), `${rel} must import ${GATE_MODULE}`).toContain( + GATE_MODULE, + ); + } + }); + + it("every person-driven writer calls the range validator", () => { + for (const [rel, disposition] of Object.entries(WRITERS)) { + if (disposition.kind === "validator") { + expect(read(rel), `${rel} must call ${VALIDATOR}`).toContain(VALIDATOR); + } + if (disposition.kind === "upstream") { + expect( + read(disposition.validatedIn), + `${disposition.validatedIn} must call ${VALIDATOR} on behalf of ${rel}`, + ).toContain(VALIDATOR); + } + } + }); + + it("the exempt writers carry a written reason", () => { + for (const [rel, disposition] of Object.entries(WRITERS)) { + if ( + disposition.kind === "derived" || + disposition.kind === "literal" || + disposition.kind === "restore" + ) { + expect(disposition.why.length, `${rel} needs a reason`).toBeGreaterThan( + 20, + ); + } + } + }); +}); diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index 904107571..335b152f5 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -724,6 +724,13 @@ const OCR_EXCEPTIONS: Readonly> = { "inserted.unit": OCR_ROW_EXCEPTION, "inserted.referenceLow": OCR_ROW_EXCEPTION, "inserted.referenceHigh": OCR_ROW_EXCEPTION, + "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION, + "inserted.catalogReferenceHigh": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceLow": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION, + "inserted.sourceReferenceText": OCR_ROW_EXCEPTION, + "inserted.referenceOrigin": OCR_ROW_EXCEPTION, + "inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION, "inserted.takenAt": OCR_ROW_EXCEPTION, "inserted.source": OCR_ROW_EXCEPTION, "inserted.hasNote": OCR_ROW_EXCEPTION, diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 7a3956b06..a6196cf0c 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -980,6 +980,9 @@ const handler = apiHandler( unit: lab.unit, referenceLow: lab.referenceLow ?? null, referenceHigh: lab.referenceHigh ?? null, + sourceReferenceLow: lab.sourceReferenceLow ?? null, + sourceReferenceHigh: lab.sourceReferenceHigh ?? null, + sourceReferenceText: lab.sourceReferenceText ?? null, takenAt: new Date(lab.takenAt), source: lab.source, noteEncrypted: diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts index 042b1387e..498a7ce25 100644 --- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts +++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts @@ -57,6 +57,7 @@ function editToFactData(edit: InboundFactEdit): FactData { unit: edit.unit ?? null, referenceLow: edit.referenceLow ?? null, referenceHigh: edit.referenceHigh ?? null, + referenceText: edit.referenceText ?? null, effectiveDate: edit.effectiveDate ?? null, }; return data; diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 452af57da..ddfde9972 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -46,6 +46,9 @@ export const GET = apiHandler(async () => { value: true, referenceLow: true, referenceHigh: true, + sourceReferenceLow: true, + sourceReferenceHigh: true, + sourceReferenceText: true, takenAt: true, }, orderBy: { takenAt: "desc" }, @@ -58,6 +61,9 @@ export const GET = apiHandler(async () => { value: r.value as number, referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, + sourceReferenceLow: r.sourceReferenceLow, + sourceReferenceHigh: r.sourceReferenceHigh, + sourceReferenceText: r.sourceReferenceText, takenAt: r.takenAt, })), ); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index d143259fb..34afa72a7 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -143,6 +143,12 @@ export const PUT = apiHandler( // historical columns the serialiser ignores. Reject the attempt with a // 422 rather than silently no-op, so the client never believes an edit // landed. Edit such fields on the Biomarker itself instead. + // + // `sourceReference*` is deliberately NOT on that list. The catalog band is + // a property of the marker; the source window is a property of THIS + // reading — it is what one report printed beside one value — so it is + // editable on a linked row and there is nothing on the biomarker to edit + // it on instead. const isLinked = existing.biomarkerId !== null; if ( isLinked && @@ -194,6 +200,15 @@ export const PUT = apiHandler( if (d.unit !== undefined) data.unit = d.unit; if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow; if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh; + if (d.sourceReferenceLow !== undefined) { + data.sourceReferenceLow = d.sourceReferenceLow; + } + if (d.sourceReferenceHigh !== undefined) { + data.sourceReferenceHigh = d.sourceReferenceHigh; + } + if (d.sourceReferenceText !== undefined) { + data.sourceReferenceText = d.sourceReferenceText; + } if (d.takenAt !== undefined) data.takenAt = d.takenAt; // Inverted-range guard for a PARTIAL bound update. The schema-level @@ -212,6 +227,20 @@ export const PUT = apiHandler( errorCode: "labs.update.referenceRangeInvalid", }); } + // The same partial-update hole exists on the source window, and closes the + // same way. + if ( + isInvertedRange( + effectiveBound(d.sourceReferenceLow, existing.sourceReferenceLow), + effectiveBound(d.sourceReferenceHigh, existing.sourceReferenceHigh), + ) + ) { + return apiError( + "sourceReferenceLow must not exceed sourceReferenceHigh", + 422, + { errorCode: "labs.update.sourceReferenceRangeInvalid" }, + ); + } if (d.note !== undefined) { data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; } @@ -239,6 +268,9 @@ export const PUT = apiHandler( panel: existing.panel, referenceLow: existing.referenceLow, referenceHigh: existing.referenceHigh, + sourceReferenceLow: existing.sourceReferenceLow, + sourceReferenceHigh: existing.sourceReferenceHigh, + sourceReferenceText: existing.sourceReferenceText, takenAt: existing.takenAt, }, after: { @@ -249,6 +281,9 @@ export const PUT = apiHandler( panel: updated.panel, referenceLow: updated.referenceLow, referenceHigh: updated.referenceHigh, + sourceReferenceLow: updated.sourceReferenceLow, + sourceReferenceHigh: updated.sourceReferenceHigh, + sourceReferenceText: updated.sourceReferenceText, takenAt: updated.takenAt, }, redacted: d.note !== undefined ? ["note"] : [], diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index d6bd0b431..b8700562e 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -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 { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { serialiseLabResult } from "@/lib/labs/serialise"; import { linkOcrLabsToVaultDocument, @@ -151,12 +152,19 @@ async function commitOcrRows(request: NextRequest) { } const isQualitative = row.valueText !== undefined; + // The window the scan read off the report. The confirmed numeric bounds + // win over a re-parse of the string (the human may have corrected them on + // the review screen); the string rides along either way. + const printed = parseReferenceRange(row.referenceText, row.unit); + const sourceLow = row.referenceLow ?? printed?.low ?? null; + const sourceHigh = row.referenceHigh ?? printed?.high ?? null; + const sourceText = printed?.text ?? null; const biomarker = await resolveOrMintBiomarker(user.id, { analyte: row.analyte, // A qualitative reading has no numeric unit / range. unit: isQualitative ? (row.unit ?? "") : (row.unit as string), - referenceLow: isQualitative ? null : (row.referenceLow ?? null), - referenceHigh: isQualitative ? null : (row.referenceHigh ?? null), + referenceLow: isQualitative ? null : sourceLow, + referenceHigh: isQualitative ? null : sourceHigh, panel: row.panel ?? null, }); @@ -173,6 +181,9 @@ async function commitOcrRows(request: NextRequest) { unit: biomarker.unit, referenceLow: biomarker.lowerBound, referenceHigh: biomarker.upperBound, + sourceReferenceLow: isQualitative ? null : sourceLow, + sourceReferenceHigh: isQualitative ? null : sourceHigh, + sourceReferenceText: isQualitative ? null : sourceText, takenAt: row.takenAt, source: "OCR", noteEncrypted: null, diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 1ab53c06c..60e325499 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -184,6 +184,9 @@ async function postLabResult(request: NextRequest) { unit, referenceLow, referenceHigh, + sourceReferenceLow, + sourceReferenceHigh, + sourceReferenceText, takenAt, note, source, @@ -248,6 +251,13 @@ async function postLabResult(request: NextRequest) { unit: biomarker.unit, 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), + sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null), takenAt, // v1.25 (iOS #36) — provenance: the on-device-OCR path posts // `source: "OCR"`; an omitted field stays "MANUAL" so the legacy @@ -258,6 +268,7 @@ async function postLabResult(request: NextRequest) { }, }); invalidateUserHealthScore(user.id); + const dto = serialiseLabResult(created, biomarker); await auditLog("labResult.create", { userId: user.id, @@ -275,6 +286,9 @@ async function postLabResult(request: NextRequest) { structured: biomarkerId !== undefined, // v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR"). source: source ?? "MANUAL", + // Which window governs this reading's verdict, so a dashboard can see + // how often a report's own range displaces the catalog band. + referenceOrigin: dto.referenceOrigin, }, }); @@ -298,5 +312,5 @@ async function postLabResult(request: NextRequest) { source: "manual", }).catch(() => {}); - return apiSuccess(serialiseLabResult(created, biomarker), 201); + return apiSuccess(dto, 201); } diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index df8ffcd59..1199e2354 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -620,6 +620,17 @@ async function postBatch(request: NextRequest): Promise { }; failedCount++; continue; + case "rejected_range": + // The reconciler carries the same plausibility gate this route + // already applied above, so reaching here means the two disagreed — + // report it exactly as the earlier guard would have, and let the + // wide-event tally record which end refused. + results[p.index] = { + index: p.index, + status: "skipped", + reason: "value_out_of_range", + }; + continue; } if (isMergeableSource(p.row.source as string)) { diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx index bec4b97b6..a58b764ac 100644 --- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -64,6 +64,13 @@ function reading(id: string, value: number, takenAt: string): LabResultDto { unit: "mg/dL", referenceLow: 0, referenceHigh: 100, + catalogReferenceLow: 0, + catalogReferenceHigh: 100, + sourceReferenceLow: null, + sourceReferenceHigh: null, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, takenAt, source: "MANUAL", hasNote: false, diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index 96a48fad9..db6612d54 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { formatLabValue } from "@/lib/labs/format-value"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip"; @@ -32,6 +33,15 @@ import type { LabResultDto } from "./types"; * MUTED primary fill — never a danger colour — honouring the no-alarming- * colour rule. Every reading carries the same y-axis unit because a chart is * one biomarker only. + * + * A SECOND band draws the window a source report printed, when the readings in + * view carry one. It is visually distinct from the catalog band — an outlined + * band in the chart's secondary hue against the catalog's filled primary — and + * both stay muted; the in/out verdict is the badge's job, never the chart's + * colour. The source window is a per-reading fact, so the band is drawn from + * the NEWEST reading in view that carries one and the legend dates it. When + * the readings in view carry more than one distinct printed window the legend + * says so rather than implying one window covered the whole series. */ const RANGE_DAYS = [ @@ -46,6 +56,26 @@ type RangeKey = (typeof RANGE_DAYS)[number]["key"]; interface ChartPoint { timestamp: number; value: number; + /** The window this reading was judged against, for the tooltip. */ + referenceLow: number | null; + referenceHigh: number | null; + referenceOrigin: LabResultDto["referenceOrigin"]; + sourceReferenceText: string | null; +} + +/** The source window drawn as the second band, resolved from the readings in view. */ +interface SourceBand { + low: number | null; + high: number | null; + /** The reading the band was taken from. */ + takenAt: number; + /** True when the readings in view printed more than one distinct window. */ + mixed: boolean; +} + +/** Identity of a printed window, for counting distinct ones. */ +function bandKey(low: number | null, high: number | null): string { + return `${low ?? ""}|${high ?? ""}`; } export function LabBiomarkerChart({ @@ -87,9 +117,37 @@ export function LabBiomarkerChart({ return filtered.map((r) => ({ timestamp: new Date(r.takenAt).getTime(), value: r.value, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + referenceOrigin: r.referenceOrigin, + sourceReferenceText: r.sourceReferenceText, })); }, [readings, range, nowMs]); + // The source band: the newest in-view reading whose report printed a window + // with usable bounds. Readings without one contribute nothing — an absent + // window is absent, not a reason to widen or narrow the band. + const sourceBand = useMemo(() => { + const withWindow = points.filter( + (p) => + p.referenceOrigin === "source" && + (p.referenceLow !== null || p.referenceHigh !== null), + ); + if (withWindow.length === 0) return null; + const newest = withWindow.reduce((a, b) => + b.timestamp > a.timestamp ? b : a, + ); + const distinct = new Set( + withWindow.map((p) => bandKey(p.referenceLow, p.referenceHigh)), + ); + return { + low: newest.referenceLow, + high: newest.referenceHigh, + takenAt: newest.timestamp, + mixed: distinct.size > 1, + }; + }, [points]); + // Y domain wraps both the data and the reference window so the band is // always visible even when every reading sits inside it. const yDomain = useMemo<[number, number]>(() => { @@ -97,15 +155,23 @@ export function LabBiomarkerChart({ const candidates = [...values]; if (lowerBound != null) candidates.push(lowerBound); if (upperBound != null) candidates.push(upperBound); + // The source band has to fit too, or the second band is drawn off-canvas + // and the chart silently shows one window while claiming two. + if (sourceBand?.low != null) candidates.push(sourceBand.low); + if (sourceBand?.high != null) candidates.push(sourceBand.high); if (candidates.length === 0) return [0, 1]; const min = Math.min(...candidates); const max = Math.max(...candidates); const pad = (max - min || Math.abs(max) || 1) * 0.1; return [min - pad, max + pad]; - }, [points, lowerBound, upperBound]); + }, [points, lowerBound, upperBound, sourceBand]); const animate = !prefersReducedMotion(); const primary = "var(--primary)"; + // A second muted hue from the chart palette. Distinct from the primary band + // by hue AND by treatment (outline vs fill), so the two are told apart + // without relying on colour alone. + const secondary = "var(--chart-2)"; return (
@@ -185,6 +251,42 @@ export function LabBiomarkerChart({ strokeDasharray="4 4" /> ) : null} + {/* The source window — outlined, not filled, so it reads as a + second band over the catalog one rather than a second fill + competing with it. Same no-alarm-colour rule. */} + {sourceBand && + sourceBand.low != null && + sourceBand.high != null ? ( + + ) : null} + {sourceBand && + sourceBand.low != null && + sourceBand.high == null ? ( + + ) : null} + {sourceBand && + sourceBand.high != null && + sourceBand.low == null ? ( + + ) : null} { const active = props.active ?? false; @@ -201,6 +303,22 @@ export function LabBiomarkerChart({ color: primary, }, ]; + // Name the window THIS reading was judged against. Two + // readings on one chart can carry different printed windows, + // so the band alone cannot answer it per point. + if (point.referenceOrigin === "source") { + rows.push({ + name: t("labs.chart.sourceRangeLabel"), + value: + point.sourceReferenceText ?? + `${formatReferenceRange( + point.referenceLow, + point.referenceHigh, + formatLabValue, + )} ${unit}`.trim(), + color: secondary, + }); + } return ( + {sourceBand ? ( +

+ {sourceBand.mixed + ? t("labs.chart.sourceBandMixed") + : t("labs.chart.sourceBandLegend", { + date: fmt.dateShortSmart(new Date(sourceBand.takenAt)), + })} +

+ ) : null}
)} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 476c5da8a..4e865e9dd 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -22,6 +22,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; +import { parseReferenceRange } from "@/lib/labs/parse-reference-range"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -118,6 +119,13 @@ export function LabForm({ const [valueText, setValueText] = useState(""); const [takenAt, setTakenAt] = useState(defaultTakenAtValue); const [note, setNote] = useState(""); + // The window the paper report printed for THIS reading. Optional and folded + // away by default: most entries are typed against a catalog marker and never + // need it, and a field nobody fills is a field in the way. One text input, + // because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0". + // The bounds are derived from what is typed; what is typed is kept verbatim. + const [sourceRangeOpen, setSourceRangeOpen] = useState(false); + const [sourceRange, setSourceRange] = useState(""); // v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't // also show a spinner. `!== null` replaces the old plain `submitting` // boolean for every disabled check below. @@ -193,11 +201,27 @@ export function LabForm({ setPendingAction(keepOpen ? "saveAndAddAnother" : "save"); try { + // Parse client-side so the user sees the derived window before saving, + // and send BOTH: the bounds where they could be read, the text always. + const printed = isQualitative + ? null + : parseReferenceRange(sourceRange, selected?.unit); const created = await apiPost("/api/labs", { biomarkerId, ...(isQualitative ? { valueText: qualitativeValue } : { value: numericValue }), + ...(printed + ? { + ...(printed.low !== null + ? { sourceReferenceLow: printed.low } + : {}), + ...(printed.high !== null + ? { sourceReferenceHigh: printed.high } + : {}), + sourceReferenceText: printed.text, + } + : {}), takenAt: new Date(takenAt).toISOString(), ...(note.trim() ? { note: note.trim() } : {}), }); @@ -221,6 +245,8 @@ export function LabForm({ setValue(""); setValueText(""); setNote(""); + // The next analyte off the same report has its own printed window. + setSourceRange(""); setError(null); onSavedKeepOpen?.(created); // Return focus to wherever the next entry starts so the flow @@ -252,6 +278,20 @@ export function LabForm({ ) : ""; + // What the typed string resolved to, shown back so the user can see whether + // a window was read out of it before the reading is saved. When none was, + // the hint says the text is kept as written rather than staying silent. + const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit); + const sourceRangeHint = parsedSourceRange + ? parsedSourceRange.low === null && parsedSourceRange.high === null + ? t("labs.form.sourceRangeUnparsed") + : `${formatReferenceRange( + parsedSourceRange.low, + parsedSourceRange.high, + formatLabValue, + )} ${selected?.unit ?? ""}`.trim() + : ""; + const footerNode = ( <> {onCancel ? ( @@ -415,6 +455,39 @@ export function LabForm({

) : null} + {resultType === "numeric" ? ( +
+ {sourceRangeOpen ? ( + <> + + setSourceRange(e.target.value)} + placeholder={t("labs.form.sourceRangePlaceholder")} + maxLength={120} + autoFocus + /> +

+ {sourceRangeHint || t("labs.form.sourceRangeHint")} +

+ + ) : ( + + )} +
+ ) : null} +