diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eddfd2b8c..6ee4395b9 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 @@ -32970,6 +33007,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: @@ -32992,6 +33053,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -32999,12 +33067,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: @@ -33012,9 +33099,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: @@ -33082,6 +33169,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: @@ -33106,6 +33217,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - rangeStatus @@ -33288,6 +33406,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 @@ -33325,6 +33451,7 @@ components: - unit - referenceLow - referenceHigh + - referenceText - takenAt - confidence - biomarkerMatch @@ -33422,6 +33549,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: @@ -33449,6 +33604,13 @@ components: - unit - referenceLow - referenceHigh + - catalogReferenceLow + - catalogReferenceHigh + - sourceReferenceLow + - sourceReferenceHigh + - sourceReferenceText + - referenceOrigin + - referenceDivergesFromCatalog - takenAt - source - hasNote @@ -33456,6 +33618,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 bd178a38a..dfdfd8e3e 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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 62a606b7c..5b7f7c10d 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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 95921a55c..ba654e6b7 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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 60ceddd0d..88e3e18fa 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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 fac161835..a29739d32 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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 8e8b9e4bd..a56973762 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", @@ -8037,7 +8039,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", @@ -8068,7 +8075,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", @@ -8243,11 +8253,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/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 fa1aeaa28..7adb641be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5005,9 +5005,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/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 3c22417f3..d57c7fc0f 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -894,6 +894,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/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} +