diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e349dc30a..7b0d150d4 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -5690,6 +5690,12 @@ paths:
date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted
note itself is not echoed).
parameters:
+ - in: query
+ name: biomarkerId
+ schema:
+ type: string
+ minLength: 1
+ maxLength: 64
- in: query
name: analyte
schema:
@@ -5759,6 +5765,10 @@ paths:
schema:
type: object
properties:
+ biomarkerId:
+ type: string
+ minLength: 1
+ maxLength: 64
panel:
anyOf:
- type: string
@@ -5791,9 +5801,7 @@ paths:
- type: string
const: ""
required:
- - analyte
- value
- - unit
- takenAt
responses:
"201":
@@ -5924,6 +5932,212 @@ paths:
"404": *a8
"422": *a3
"429": *a4
+ /api/labs/restore:
+ post:
+ tags:
+ - Labs
+ summary: Restore soft-deleted lab results
+ description: Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A
+ foreign / live id is a silent no-op. Audits via the wide-event ledger.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestoreLabResultsRequest"
+ responses:
+ "200":
+ description: Restore count.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/RestoreLabResultsResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/biomarkers:
+ get:
+ tags:
+ - Labs
+ summary: List the caller's biomarker catalog
+ description: Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and
+ decrypted context.
+ responses:
+ "200":
+ description: Biomarker catalog.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ListBiomarkersEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ post:
+ tags:
+ - Labs
+ summary: Define a biomarker
+ description: Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as
+ `biomarker.create`.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 120
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 40
+ lowerBound:
+ type: number
+ upperBound:
+ type: number
+ context:
+ type: string
+ maxLength: 2000
+ panel:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 120
+ - type: string
+ const: ""
+ required:
+ - name
+ - unit
+ responses:
+ "201":
+ description: Created biomarker.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateBiomarkerResponse"
+ "401": *a1
+ "409": &a10
+ description: A biomarker with this name already exists for the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ /api/biomarkers/{id}:
+ get:
+ tags:
+ - Labs
+ summary: Fetch a single biomarker
+ description: Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Biomarker detail.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetBiomarkerResponse"
+ "401": *a1
+ "404": &a9
+ description: Biomarker not found (or owned by another user).
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a3
+ "429": *a4
+ put:
+ tags:
+ - Labs
+ summary: Edit a biomarker
+ description: Partial edit; omitted fields are untouched, an explicit null clears `context` / `panel` / a bound. Audits
+ as `biomarker.update`.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 120
+ unit:
+ type: string
+ minLength: 1
+ maxLength: 40
+ lowerBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ upperBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ context:
+ anyOf:
+ - type: string
+ maxLength: 2000
+ - type: "null"
+ panel:
+ anyOf:
+ - anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 120
+ - type: string
+ const: ""
+ - type: "null"
+ responses:
+ "200":
+ description: Updated biomarker.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateBiomarkerResponse"
+ "401": *a1
+ "404": *a9
+ "409": *a10
+ "422": *a3
+ "429": *a4
+ delete:
+ tags:
+ - Labs
+ summary: Delete a biomarker
+ description: "Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their
+ legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ responses:
+ "200":
+ description: Deletion succeeded.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteBiomarkerResponse"
+ "401": *a1
+ "404": *a9
+ "422": *a3
+ "429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -7239,6 +7453,17 @@ components:
type: boolean
description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side
after the patch applies.
+ RestoreLabResultsRequest:
+ type: object
+ properties:
+ ids:
+ minItems: 1
+ maxItems: 200
+ type: array
+ items:
+ type: string
+ required:
+ - ids
MedicationTreatmentClass:
type: string
enum:
@@ -17126,6 +17351,10 @@ components:
properties:
id:
type: string
+ biomarkerId:
+ anyOf:
+ - type: string
+ - type: "null"
panel:
anyOf:
- type: string
@@ -17158,6 +17387,7 @@ components:
type: string
required:
- id
+ - biomarkerId
- panel
- analyte
- value
@@ -17171,8 +17401,10 @@ components:
- createdAt
- updatedAt
additionalProperties: false
- description: A stored lab result. 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. `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`.
LabReferenceRangeStatus:
type: string
enum:
@@ -17222,6 +17454,10 @@ components:
properties:
id:
type: string
+ biomarkerId:
+ anyOf:
+ - type: string
+ - type: "null"
panel:
anyOf:
- type: string
@@ -17256,6 +17492,7 @@ components:
- type: "null"
required:
- id
+ - biomarkerId
- panel
- analyte
- value
@@ -17310,6 +17547,175 @@ components:
- data
- error
additionalProperties: false
+ RestoreLabResultsResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ restored:
+ type: number
+ required:
+ - restored
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ListBiomarkersEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/ListBiomarkersResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ ListBiomarkersResponse:
+ type: object
+ properties:
+ biomarkers:
+ type: array
+ items:
+ $ref: "#/components/schemas/Biomarker"
+ required:
+ - biomarkers
+ additionalProperties: false
+ Biomarker:
+ type: object
+ properties:
+ id:
+ type: string
+ name:
+ type: string
+ unit:
+ type: string
+ lowerBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ upperBound:
+ anyOf:
+ - type: number
+ - type: "null"
+ panel:
+ anyOf:
+ - type: string
+ - type: "null"
+ hasContext:
+ type: boolean
+ context:
+ anyOf:
+ - type: string
+ - type: "null"
+ createdAt:
+ type: string
+ updatedAt:
+ type: string
+ required:
+ - id
+ - name
+ - unit
+ - lowerBound
+ - upperBound
+ - panel
+ - hasContext
+ - context
+ - createdAt
+ - updatedAt
+ additionalProperties: false
+ description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
+ presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.
+ CreateBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ GetBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ UpdateBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/Biomarker"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DeleteBiomarkerResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ deleted:
+ type: boolean
+ required:
+ - deleted
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
MedicationScheduleOutput:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index c8f36560c..c1a6e3490 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6516,13 +6516,13 @@
"labs": {
"title": "Laborwerte",
"subtitle": "Blutwerte und Biomarker",
- "addResult": "Hinzufügen",
+ "addResult": "Wert hinzufügen",
"addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.",
"addFirst": "Ersten Wert erfassen",
"emptyTitle": "Noch keine Laborwerte",
"emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.",
"referenceLabel": "Referenz",
- "readingsCount": "{count} Werte",
+ "readingsCount": "{count} Messungen",
"deleteLatest": "Letzten Wert löschen",
"deleteConfirmTitle": "Laborwert löschen?",
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
@@ -6551,11 +6551,127 @@
"referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.",
"note": "Notiz",
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
- "requiredError": "Biomarker, Wert und Einheit sind erforderlich.",
+ "requiredError": "Bitte einen Wert eingeben.",
"rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.",
"save": "Speichern",
"savedToast": "Wert gespeichert",
- "saveError": "Wert konnte nicht gespeichert werden"
+ "saveError": "Wert konnte nicht gespeichert werden",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Biomarker auswählen",
+ "defineNew": "+ Neuen Biomarker anlegen",
+ "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.",
+ "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen."
+ },
+ "backToLabs": "Zurück zu den Laborwerten",
+ "hasNote": "Notiz",
+ "editTitle": "Wert bearbeiten",
+ "editDescription": "Wert, Datum oder Notiz dieser Messung korrigieren.",
+ "editedToast": "Wert aktualisiert",
+ "editError": "Wert konnte nicht aktualisiert werden",
+ "editTimestampError": "Bitte ein gültiges Datum eingeben.",
+ "editReading": "Wert bearbeiten",
+ "deleteReading": "Wert löschen",
+ "restoredToast": "Wert wiederhergestellt",
+ "restoreError": "Wert konnte nicht wiederhergestellt werden",
+ "detail": {
+ "history": "Verlauf",
+ "emptyTitle": "Noch keine Werte",
+ "emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen."
+ },
+ "chart": {
+ "empty": "Keine Werte in diesem Zeitraum.",
+ "valueLabel": "Wert",
+ "range": {
+ "30": "30 T",
+ "90": "90 T",
+ "365": "1 J",
+ "all": "Alle"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarker",
+ "manageTitle": "Biomarker",
+ "manageSheetDescription": "Lege die Marker an, die du verfolgst. Jeder wird einmal mit Einheit und Referenzbereich eingerichtet.",
+ "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.",
+ "define": "Anlegen",
+ "defineFirst": "Ersten Biomarker anlegen",
+ "defineTitle": "Biomarker anlegen",
+ "defineDescription": "Name, Einheit und Referenzbereich einmalig festlegen. Beim Erfassen wählst du nur den Marker.",
+ "editTitle": "Biomarker bearbeiten",
+ "edit": "Bearbeiten",
+ "delete": "Löschen",
+ "deletedToast": "Biomarker gelöscht",
+ "deleteError": "Biomarker konnte nicht gelöscht werden",
+ "deleteConfirmTitle": "Biomarker löschen?",
+ "deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.",
+ "emptyTitle": "Noch keine Biomarker",
+ "emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.",
+ "loadError": "Biomarker konnten nicht geladen werden",
+ "noRange": "Kein Referenzbereich",
+ "form": {
+ "seedLabel": "Von einem gängigen Marker starten",
+ "seedPlaceholder": "Marker wählen (optional)",
+ "seedHint": "Eine Auswahl füllt die Felder unten — passe den Bereich an dein Labor an.",
+ "name": "Name",
+ "namePlaceholder": "z. B. LDL-Cholesterin",
+ "unit": "Einheit",
+ "unitPlaceholder": "z. B. mg/dl",
+ "panel": "Panel",
+ "panelPlaceholder": "z. B. Lipide",
+ "lowerBound": "Referenz unten",
+ "upperBound": "Referenz oben",
+ "lowerPlaceholder": "Untere Grenze",
+ "upperPlaceholder": "Obere Grenze",
+ "context": "Kontext",
+ "contextPlaceholder": "Optionale Notiz zu diesem Marker",
+ "rangeHint": "Beide Grenzen sind optional — eine leer lassen für einen offenen Bereich (z. B. LDL < 116).",
+ "requiredError": "Name und Einheit sind erforderlich.",
+ "rangeOrderError": "Die untere Grenze darf die obere nicht überschreiten.",
+ "save": "Biomarker speichern",
+ "savedToast": "Biomarker gespeichert",
+ "updatedToast": "Biomarker aktualisiert",
+ "saveError": "Biomarker konnte nicht gespeichert werden"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Gesamtcholesterin",
+ "ldl": "LDL-Cholesterin",
+ "hdl": "HDL-Cholesterin",
+ "triglycerides": "Triglyceride",
+ "fasting-glucose": "Nüchternglukose",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Freies T3",
+ "ft4": "Freies T4",
+ "ferritin": "Ferritin",
+ "transferrin-saturation": "Transferrinsättigung",
+ "vitamin-d": "Vitamin D (25-OH)",
+ "vitamin-b12": "Vitamin B12",
+ "folate": "Folsäure",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Kreatinin",
+ "egfr": "eGFR",
+ "alt": "ALT (GPT)",
+ "ast": "AST (GOT)",
+ "ggt": "GGT",
+ "sodium": "Natrium",
+ "potassium": "Kalium",
+ "hemoglobin": "Hämoglobin",
+ "hematocrit": "Hämatokrit",
+ "wbc": "Leukozyten",
+ "platelets": "Thrombozyten",
+ "panel": {
+ "lipids": "Lipide",
+ "glucose": "Glukosestoffwechsel",
+ "thyroid": "Schilddrüse",
+ "iron": "Eisen",
+ "vitamins": "Vitamine",
+ "inflammation": "Entzündung",
+ "renal": "Niere",
+ "liver": "Leber",
+ "electrolytes": "Elektrolyte",
+ "bloodCount": "Blutbild"
+ }
}
},
"modules": {
diff --git a/messages/en.json b/messages/en.json
index 7b5bbdced..93ad4921f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6516,7 +6516,7 @@
"labs": {
"title": "Lab results",
"subtitle": "Bloodwork and biomarker readings",
- "addResult": "Add",
+ "addResult": "Add value",
"addDescription": "Record a biomarker reading from a lab report.",
"addFirst": "Add your first result",
"emptyTitle": "No lab results yet",
@@ -6551,11 +6551,127 @@
"referenceHint": "Copy the reference range from your lab report. Either bound is optional.",
"note": "Note",
"notePlaceholder": "Optional context for this result",
- "requiredError": "Biomarker, value and unit are required.",
+ "requiredError": "Enter a value.",
"rangeOrderError": "The reference low must not exceed the reference high.",
"save": "Save",
"savedToast": "Result saved",
- "saveError": "Could not save the result"
+ "saveError": "Could not save the result",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Pick a biomarker",
+ "defineNew": "+ Define new biomarker",
+ "noBiomarkersHint": "Define a biomarker first to record a value against it.",
+ "pickBiomarkerError": "Pick a biomarker first."
+ },
+ "backToLabs": "Back to lab results",
+ "hasNote": "Note",
+ "editTitle": "Edit reading",
+ "editDescription": "Correct the value, date or note of this reading.",
+ "editedToast": "Reading updated",
+ "editError": "Could not update the reading",
+ "editTimestampError": "Enter a valid date.",
+ "editReading": "Edit reading",
+ "deleteReading": "Delete reading",
+ "restoredToast": "Reading restored",
+ "restoreError": "Could not restore the reading",
+ "detail": {
+ "history": "History",
+ "emptyTitle": "No readings yet",
+ "emptyDescription": "Add a value to start tracking this biomarker over time."
+ },
+ "chart": {
+ "empty": "No readings in this period.",
+ "valueLabel": "Value",
+ "range": {
+ "30": "30d",
+ "90": "90d",
+ "365": "1y",
+ "all": "All"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarkers",
+ "manageTitle": "Biomarkers",
+ "manageSheetDescription": "Define the markers you track. Each is set up once with its unit and reference range.",
+ "managerDescription": "Define a marker once — its unit and range are reused for every reading.",
+ "define": "Define",
+ "defineFirst": "Define your first biomarker",
+ "defineTitle": "Define biomarker",
+ "defineDescription": "Set the name, unit and reference range once. You pick the marker when adding a value.",
+ "editTitle": "Edit biomarker",
+ "edit": "Edit",
+ "delete": "Delete",
+ "deletedToast": "Biomarker deleted",
+ "deleteError": "Could not delete the biomarker",
+ "deleteConfirmTitle": "Delete biomarker?",
+ "deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.",
+ "emptyTitle": "No biomarkers yet",
+ "emptyDescription": "Define markers like LDL or HbA1c to record values against them.",
+ "loadError": "Could not load biomarkers",
+ "noRange": "No reference range",
+ "form": {
+ "seedLabel": "Start from a common marker",
+ "seedPlaceholder": "Choose a marker (optional)",
+ "seedHint": "Picking one fills the fields below — adjust the range to match your lab.",
+ "name": "Name",
+ "namePlaceholder": "e.g. LDL cholesterol",
+ "unit": "Unit",
+ "unitPlaceholder": "e.g. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "e.g. Lipids",
+ "lowerBound": "Reference low",
+ "upperBound": "Reference high",
+ "lowerPlaceholder": "Lower bound",
+ "upperPlaceholder": "Upper bound",
+ "context": "Context",
+ "contextPlaceholder": "Optional note about this marker",
+ "rangeHint": "Either bound is optional — leave one blank for an open-ended range (e.g. LDL < 116).",
+ "requiredError": "Name and unit are required.",
+ "rangeOrderError": "The lower bound must not exceed the upper bound.",
+ "save": "Save biomarker",
+ "savedToast": "Biomarker saved",
+ "updatedToast": "Biomarker updated",
+ "saveError": "Could not save the biomarker"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Total cholesterol",
+ "ldl": "LDL cholesterol",
+ "hdl": "HDL cholesterol",
+ "triglycerides": "Triglycerides",
+ "fasting-glucose": "Fasting glucose",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Free T3",
+ "ft4": "Free T4",
+ "ferritin": "Ferritin",
+ "transferrin-saturation": "Transferrin saturation",
+ "vitamin-d": "Vitamin D (25-OH)",
+ "vitamin-b12": "Vitamin B12",
+ "folate": "Folate",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Creatinine",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodium",
+ "potassium": "Potassium",
+ "hemoglobin": "Hemoglobin",
+ "hematocrit": "Hematocrit",
+ "wbc": "White blood cells",
+ "platelets": "Platelets",
+ "panel": {
+ "lipids": "Lipids",
+ "glucose": "Glucose metabolism",
+ "thyroid": "Thyroid",
+ "iron": "Iron",
+ "vitamins": "Vitamins",
+ "inflammation": "Inflammation",
+ "renal": "Kidney",
+ "liver": "Liver",
+ "electrolytes": "Electrolytes",
+ "bloodCount": "Blood count"
+ }
}
},
"modules": {
diff --git a/messages/es.json b/messages/es.json
index 27d84b2a8..3599840b7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6516,13 +6516,13 @@
"labs": {
"title": "Resultados de análisis",
"subtitle": "Analíticas de sangre y biomarcadores",
- "addResult": "Añadir",
+ "addResult": "Añadir valor",
"addDescription": "Registra un biomarcador a partir de un informe de laboratorio.",
"addFirst": "Añade tu primer resultado",
"emptyTitle": "Aún no hay resultados",
"emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.",
"referenceLabel": "Referencia",
- "readingsCount": "{count} valores",
+ "readingsCount": "{count} mediciones",
"deleteLatest": "Eliminar el último valor",
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
@@ -6551,11 +6551,127 @@
"referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.",
"note": "Nota",
"notePlaceholder": "Contexto opcional para este resultado",
- "requiredError": "El biomarcador, el valor y la unidad son obligatorios.",
+ "requiredError": "Introduce un valor.",
"rangeOrderError": "La referencia mínima no puede superar la máxima.",
"save": "Guardar",
"savedToast": "Resultado guardado",
- "saveError": "No se pudo guardar el resultado"
+ "saveError": "No se pudo guardar el resultado",
+ "biomarker": "Biomarcador",
+ "biomarkerPlaceholder": "Elige un biomarcador",
+ "defineNew": "+ Definir nuevo biomarcador",
+ "noBiomarkersHint": "Define primero un biomarcador para registrar un valor.",
+ "pickBiomarkerError": "Elige primero un biomarcador."
+ },
+ "backToLabs": "Volver a los resultados",
+ "hasNote": "Nota",
+ "editTitle": "Editar medición",
+ "editDescription": "Corrige el valor, la fecha o la nota de esta medición.",
+ "editedToast": "Medición actualizada",
+ "editError": "No se pudo actualizar la medición",
+ "editTimestampError": "Introduce una fecha válida.",
+ "editReading": "Editar medición",
+ "deleteReading": "Eliminar medición",
+ "restoredToast": "Medición restaurada",
+ "restoreError": "No se pudo restaurar la medición",
+ "detail": {
+ "history": "Historial",
+ "emptyTitle": "Aún no hay mediciones",
+ "emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo."
+ },
+ "chart": {
+ "empty": "Sin mediciones en este periodo.",
+ "valueLabel": "Valor",
+ "range": {
+ "30": "30 d",
+ "90": "90 d",
+ "365": "1 a",
+ "all": "Todo"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarcadores",
+ "manageTitle": "Biomarcadores",
+ "manageSheetDescription": "Define los marcadores que sigues. Cada uno se configura una vez con su unidad y rango de referencia.",
+ "managerDescription": "Define un marcador una vez: su unidad y rango se reutilizan en cada medición.",
+ "define": "Definir",
+ "defineFirst": "Define tu primer biomarcador",
+ "defineTitle": "Definir biomarcador",
+ "defineDescription": "Configura el nombre, la unidad y el rango una vez. Eliges el marcador al añadir un valor.",
+ "editTitle": "Editar biomarcador",
+ "edit": "Editar",
+ "delete": "Eliminar",
+ "deletedToast": "Biomarcador eliminado",
+ "deleteError": "No se pudo eliminar el biomarcador",
+ "deleteConfirmTitle": "¿Eliminar biomarcador?",
+ "deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.",
+ "emptyTitle": "Aún no hay biomarcadores",
+ "emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.",
+ "loadError": "No se pudieron cargar los biomarcadores",
+ "noRange": "Sin rango de referencia",
+ "form": {
+ "seedLabel": "Partir de un marcador común",
+ "seedPlaceholder": "Elige un marcador (opcional)",
+ "seedHint": "Al elegir uno se rellenan los campos: ajusta el rango a tu laboratorio.",
+ "name": "Nombre",
+ "namePlaceholder": "p. ej. Colesterol LDL",
+ "unit": "Unidad",
+ "unitPlaceholder": "p. ej. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "p. ej. Lípidos",
+ "lowerBound": "Referencia mínima",
+ "upperBound": "Referencia máxima",
+ "lowerPlaceholder": "Límite inferior",
+ "upperPlaceholder": "Límite superior",
+ "context": "Contexto",
+ "contextPlaceholder": "Nota opcional sobre este marcador",
+ "rangeHint": "Ambos límites son opcionales: deja uno en blanco para un rango abierto (p. ej. LDL < 116).",
+ "requiredError": "El nombre y la unidad son obligatorios.",
+ "rangeOrderError": "El límite inferior no debe superar al superior.",
+ "save": "Guardar biomarcador",
+ "savedToast": "Biomarcador guardado",
+ "updatedToast": "Biomarcador actualizado",
+ "saveError": "No se pudo guardar el biomarcador"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Colesterol total",
+ "ldl": "Colesterol LDL",
+ "hdl": "Colesterol HDL",
+ "triglycerides": "Triglicéridos",
+ "fasting-glucose": "Glucosa en ayunas",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libre",
+ "ft4": "T4 libre",
+ "ferritin": "Ferritina",
+ "transferrin-saturation": "Saturación de transferrina",
+ "vitamin-d": "Vitamina D (25-OH)",
+ "vitamin-b12": "Vitamina B12",
+ "folate": "Folato",
+ "hs-crp": "PCR ultrasensible",
+ "creatinine": "Creatinina",
+ "egfr": "TFGe",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodio",
+ "potassium": "Potasio",
+ "hemoglobin": "Hemoglobina",
+ "hematocrit": "Hematocrito",
+ "wbc": "Leucocitos",
+ "platelets": "Plaquetas",
+ "panel": {
+ "lipids": "Lípidos",
+ "glucose": "Metabolismo de la glucosa",
+ "thyroid": "Tiroides",
+ "iron": "Hierro",
+ "vitamins": "Vitaminas",
+ "inflammation": "Inflamación",
+ "renal": "Riñón",
+ "liver": "Hígado",
+ "electrolytes": "Electrolitos",
+ "bloodCount": "Hemograma"
+ }
}
},
"modules": {
diff --git a/messages/fr.json b/messages/fr.json
index 25c877947..c8822ab63 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6516,13 +6516,13 @@
"labs": {
"title": "Résultats d'analyses",
"subtitle": "Analyses sanguines et biomarqueurs",
- "addResult": "Ajouter",
+ "addResult": "Ajouter une valeur",
"addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.",
"addFirst": "Ajouter votre premier résultat",
"emptyTitle": "Aucun résultat pour l'instant",
"emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.",
"referenceLabel": "Référence",
- "readingsCount": "{count} valeurs",
+ "readingsCount": "{count} mesures",
"deleteLatest": "Supprimer la dernière valeur",
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
@@ -6551,11 +6551,127 @@
"referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.",
"note": "Note",
"notePlaceholder": "Contexte facultatif pour ce résultat",
- "requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.",
+ "requiredError": "Saisissez une valeur.",
"rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.",
"save": "Enregistrer",
"savedToast": "Résultat enregistré",
- "saveError": "Impossible d'enregistrer le résultat"
+ "saveError": "Impossible d'enregistrer le résultat",
+ "biomarker": "Biomarqueur",
+ "biomarkerPlaceholder": "Choisir un biomarqueur",
+ "defineNew": "+ Définir un nouveau biomarqueur",
+ "noBiomarkersHint": "Définissez d'abord un biomarqueur pour enregistrer une valeur.",
+ "pickBiomarkerError": "Choisissez d'abord un biomarqueur."
+ },
+ "backToLabs": "Retour aux résultats",
+ "hasNote": "Note",
+ "editTitle": "Modifier la mesure",
+ "editDescription": "Corrigez la valeur, la date ou la note de cette mesure.",
+ "editedToast": "Mesure mise à jour",
+ "editError": "Impossible de mettre à jour la mesure",
+ "editTimestampError": "Saisissez une date valide.",
+ "editReading": "Modifier la mesure",
+ "deleteReading": "Supprimer la mesure",
+ "restoredToast": "Mesure restaurée",
+ "restoreError": "Impossible de restaurer la mesure",
+ "detail": {
+ "history": "Historique",
+ "emptyTitle": "Aucune mesure pour l'instant",
+ "emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps."
+ },
+ "chart": {
+ "empty": "Aucune mesure sur cette période.",
+ "valueLabel": "Valeur",
+ "range": {
+ "30": "30 j",
+ "90": "90 j",
+ "365": "1 a",
+ "all": "Tout"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarqueurs",
+ "manageTitle": "Biomarqueurs",
+ "manageSheetDescription": "Définissez les marqueurs que vous suivez. Chacun est configuré une fois avec son unité et sa plage de référence.",
+ "managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.",
+ "define": "Définir",
+ "defineFirst": "Définir votre premier biomarqueur",
+ "defineTitle": "Définir un biomarqueur",
+ "defineDescription": "Réglez le nom, l'unité et la plage une fois. Vous choisissez le marqueur à l'ajout d'une valeur.",
+ "editTitle": "Modifier le biomarqueur",
+ "edit": "Modifier",
+ "delete": "Supprimer",
+ "deletedToast": "Biomarqueur supprimé",
+ "deleteError": "Impossible de supprimer le biomarqueur",
+ "deleteConfirmTitle": "Supprimer le biomarqueur ?",
+ "deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.",
+ "emptyTitle": "Aucun biomarqueur pour l'instant",
+ "emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.",
+ "loadError": "Impossible de charger les biomarqueurs",
+ "noRange": "Pas de plage de référence",
+ "form": {
+ "seedLabel": "Partir d'un marqueur courant",
+ "seedPlaceholder": "Choisir un marqueur (facultatif)",
+ "seedHint": "En choisir un remplit les champs — ajustez la plage à votre laboratoire.",
+ "name": "Nom",
+ "namePlaceholder": "ex. Cholestérol LDL",
+ "unit": "Unité",
+ "unitPlaceholder": "ex. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "ex. Lipides",
+ "lowerBound": "Référence basse",
+ "upperBound": "Référence haute",
+ "lowerPlaceholder": "Borne inférieure",
+ "upperPlaceholder": "Borne supérieure",
+ "context": "Contexte",
+ "contextPlaceholder": "Note facultative sur ce marqueur",
+ "rangeHint": "Les deux bornes sont facultatives — laissez-en une vide pour une plage ouverte (ex. LDL < 116).",
+ "requiredError": "Le nom et l'unité sont requis.",
+ "rangeOrderError": "La borne inférieure ne doit pas dépasser la borne supérieure.",
+ "save": "Enregistrer le biomarqueur",
+ "savedToast": "Biomarqueur enregistré",
+ "updatedToast": "Biomarqueur mis à jour",
+ "saveError": "Impossible d'enregistrer le biomarqueur"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Cholestérol total",
+ "ldl": "Cholestérol LDL",
+ "hdl": "Cholestérol HDL",
+ "triglycerides": "Triglycérides",
+ "fasting-glucose": "Glycémie à jeun",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libre",
+ "ft4": "T4 libre",
+ "ferritin": "Ferritine",
+ "transferrin-saturation": "Saturation de la transferrine",
+ "vitamin-d": "Vitamine D (25-OH)",
+ "vitamin-b12": "Vitamine B12",
+ "folate": "Folate",
+ "hs-crp": "CRP us",
+ "creatinine": "Créatinine",
+ "egfr": "DFGe",
+ "alt": "ALAT",
+ "ast": "ASAT",
+ "ggt": "GGT",
+ "sodium": "Sodium",
+ "potassium": "Potassium",
+ "hemoglobin": "Hémoglobine",
+ "hematocrit": "Hématocrite",
+ "wbc": "Leucocytes",
+ "platelets": "Plaquettes",
+ "panel": {
+ "lipids": "Lipides",
+ "glucose": "Métabolisme du glucose",
+ "thyroid": "Thyroïde",
+ "iron": "Fer",
+ "vitamins": "Vitamines",
+ "inflammation": "Inflammation",
+ "renal": "Rein",
+ "liver": "Foie",
+ "electrolytes": "Électrolytes",
+ "bloodCount": "Numération sanguine"
+ }
}
},
"modules": {
diff --git a/messages/it.json b/messages/it.json
index 2f27bd97c..4c93a1d0b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6516,13 +6516,13 @@
"labs": {
"title": "Risultati degli esami",
"subtitle": "Analisi del sangue e biomarcatori",
- "addResult": "Aggiungi",
+ "addResult": "Aggiungi valore",
"addDescription": "Registra un biomarcatore da un referto di laboratorio.",
"addFirst": "Aggiungi il primo risultato",
"emptyTitle": "Ancora nessun risultato",
"emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.",
"referenceLabel": "Riferimento",
- "readingsCount": "{count} valori",
+ "readingsCount": "{count} misurazioni",
"deleteLatest": "Elimina l'ultimo valore",
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
@@ -6551,11 +6551,127 @@
"referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.",
"note": "Nota",
"notePlaceholder": "Contesto facoltativo per questo risultato",
- "requiredError": "Biomarcatore, valore e unità sono obbligatori.",
+ "requiredError": "Inserisci un valore.",
"rangeOrderError": "Il riferimento minimo non può superare quello massimo.",
"save": "Salva",
"savedToast": "Risultato salvato",
- "saveError": "Impossibile salvare il risultato"
+ "saveError": "Impossibile salvare il risultato",
+ "biomarker": "Biomarcatore",
+ "biomarkerPlaceholder": "Scegli un biomarcatore",
+ "defineNew": "+ Definisci nuovo biomarcatore",
+ "noBiomarkersHint": "Definisci prima un biomarcatore per registrare un valore.",
+ "pickBiomarkerError": "Scegli prima un biomarcatore."
+ },
+ "backToLabs": "Torna ai risultati",
+ "hasNote": "Nota",
+ "editTitle": "Modifica misurazione",
+ "editDescription": "Correggi valore, data o nota di questa misurazione.",
+ "editedToast": "Misurazione aggiornata",
+ "editError": "Impossibile aggiornare la misurazione",
+ "editTimestampError": "Inserisci una data valida.",
+ "editReading": "Modifica misurazione",
+ "deleteReading": "Elimina misurazione",
+ "restoredToast": "Misurazione ripristinata",
+ "restoreError": "Impossibile ripristinare la misurazione",
+ "detail": {
+ "history": "Cronologia",
+ "emptyTitle": "Ancora nessuna misurazione",
+ "emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo."
+ },
+ "chart": {
+ "empty": "Nessuna misurazione in questo periodo.",
+ "valueLabel": "Valore",
+ "range": {
+ "30": "30 g",
+ "90": "90 g",
+ "365": "1 a",
+ "all": "Tutto"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarcatori",
+ "manageTitle": "Biomarcatori",
+ "manageSheetDescription": "Definisci i marcatori che segui. Ognuno si imposta una volta con la sua unità e l'intervallo di riferimento.",
+ "managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.",
+ "define": "Definisci",
+ "defineFirst": "Definisci il tuo primo biomarcatore",
+ "defineTitle": "Definisci biomarcatore",
+ "defineDescription": "Imposta nome, unità e intervallo una volta. Scegli il marcatore quando aggiungi un valore.",
+ "editTitle": "Modifica biomarcatore",
+ "edit": "Modifica",
+ "delete": "Elimina",
+ "deletedToast": "Biomarcatore eliminato",
+ "deleteError": "Impossibile eliminare il biomarcatore",
+ "deleteConfirmTitle": "Eliminare il biomarcatore?",
+ "deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.",
+ "emptyTitle": "Ancora nessun biomarcatore",
+ "emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.",
+ "loadError": "Impossibile caricare i biomarcatori",
+ "noRange": "Nessun intervallo di riferimento",
+ "form": {
+ "seedLabel": "Parti da un marcatore comune",
+ "seedPlaceholder": "Scegli un marcatore (facoltativo)",
+ "seedHint": "Sceglierne uno compila i campi — adatta l'intervallo al tuo laboratorio.",
+ "name": "Nome",
+ "namePlaceholder": "es. Colesterolo LDL",
+ "unit": "Unità",
+ "unitPlaceholder": "es. mg/dL",
+ "panel": "Panel",
+ "panelPlaceholder": "es. Lipidi",
+ "lowerBound": "Riferimento minimo",
+ "upperBound": "Riferimento massimo",
+ "lowerPlaceholder": "Limite inferiore",
+ "upperPlaceholder": "Limite superiore",
+ "context": "Contesto",
+ "contextPlaceholder": "Nota facoltativa su questo marcatore",
+ "rangeHint": "Entrambi i limiti sono facoltativi — lasciane uno vuoto per un intervallo aperto (es. LDL < 116).",
+ "requiredError": "Nome e unità sono obbligatori.",
+ "rangeOrderError": "Il limite inferiore non deve superare quello superiore.",
+ "save": "Salva biomarcatore",
+ "savedToast": "Biomarcatore salvato",
+ "updatedToast": "Biomarcatore aggiornato",
+ "saveError": "Impossibile salvare il biomarcatore"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Colesterolo totale",
+ "ldl": "Colesterolo LDL",
+ "hdl": "Colesterolo HDL",
+ "triglycerides": "Trigliceridi",
+ "fasting-glucose": "Glicemia a digiuno",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "T3 libero",
+ "ft4": "T4 libero",
+ "ferritin": "Ferritina",
+ "transferrin-saturation": "Saturazione della transferrina",
+ "vitamin-d": "Vitamina D (25-OH)",
+ "vitamin-b12": "Vitamina B12",
+ "folate": "Folati",
+ "hs-crp": "PCR ad alta sensibilità",
+ "creatinine": "Creatinina",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGT",
+ "sodium": "Sodio",
+ "potassium": "Potassio",
+ "hemoglobin": "Emoglobina",
+ "hematocrit": "Ematocrito",
+ "wbc": "Leucociti",
+ "platelets": "Piastrine",
+ "panel": {
+ "lipids": "Lipidi",
+ "glucose": "Metabolismo del glucosio",
+ "thyroid": "Tiroide",
+ "iron": "Ferro",
+ "vitamins": "Vitamine",
+ "inflammation": "Infiammazione",
+ "renal": "Reni",
+ "liver": "Fegato",
+ "electrolytes": "Elettroliti",
+ "bloodCount": "Emocromo"
+ }
}
},
"modules": {
diff --git a/messages/pl.json b/messages/pl.json
index 2a5354dbc..6c4294f56 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6516,13 +6516,13 @@
"labs": {
"title": "Wyniki badań",
"subtitle": "Badania krwi i biomarkery",
- "addResult": "Dodaj",
+ "addResult": "Dodaj wynik",
"addDescription": "Zapisz biomarker z wyniku laboratoryjnego.",
"addFirst": "Dodaj pierwszy wynik",
"emptyTitle": "Brak wyników badań",
"emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.",
"referenceLabel": "Norma",
- "readingsCount": "{count} wyników",
+ "readingsCount": "{count} pomiarów",
"deleteLatest": "Usuń ostatni wynik",
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
@@ -6551,11 +6551,127 @@
"referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.",
"note": "Notatka",
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
- "requiredError": "Biomarker, wartość i jednostka są wymagane.",
+ "requiredError": "Podaj wartość.",
"rangeOrderError": "Norma dolna nie może przekraczać górnej.",
"save": "Zapisz",
"savedToast": "Wynik zapisany",
- "saveError": "Nie udało się zapisać wyniku"
+ "saveError": "Nie udało się zapisać wyniku",
+ "biomarker": "Biomarker",
+ "biomarkerPlaceholder": "Wybierz biomarker",
+ "defineNew": "+ Zdefiniuj nowy biomarker",
+ "noBiomarkersHint": "Najpierw zdefiniuj biomarker, aby zapisać wartość.",
+ "pickBiomarkerError": "Najpierw wybierz biomarker."
+ },
+ "backToLabs": "Wróć do wyników",
+ "hasNote": "Notatka",
+ "editTitle": "Edytuj pomiar",
+ "editDescription": "Popraw wartość, datę lub notatkę tego pomiaru.",
+ "editedToast": "Pomiar zaktualizowany",
+ "editError": "Nie udało się zaktualizować pomiaru",
+ "editTimestampError": "Podaj prawidłową datę.",
+ "editReading": "Edytuj pomiar",
+ "deleteReading": "Usuń pomiar",
+ "restoredToast": "Pomiar przywrócony",
+ "restoreError": "Nie udało się przywrócić pomiaru",
+ "detail": {
+ "history": "Historia",
+ "emptyTitle": "Brak pomiarów",
+ "emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie."
+ },
+ "chart": {
+ "empty": "Brak pomiarów w tym okresie.",
+ "valueLabel": "Wartość",
+ "range": {
+ "30": "30 d",
+ "90": "90 d",
+ "365": "1 r",
+ "all": "Wszystko"
+ }
+ },
+ "biomarker": {
+ "manage": "Biomarkery",
+ "manageTitle": "Biomarkery",
+ "manageSheetDescription": "Zdefiniuj śledzone markery. Każdy ustawiasz raz wraz z jednostką i zakresem referencyjnym.",
+ "managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.",
+ "define": "Zdefiniuj",
+ "defineFirst": "Zdefiniuj pierwszy biomarker",
+ "defineTitle": "Zdefiniuj biomarker",
+ "defineDescription": "Ustaw nazwę, jednostkę i zakres raz. Marker wybierasz przy dodawaniu wartości.",
+ "editTitle": "Edytuj biomarker",
+ "edit": "Edytuj",
+ "delete": "Usuń",
+ "deletedToast": "Biomarker usunięty",
+ "deleteError": "Nie udało się usunąć biomarkera",
+ "deleteConfirmTitle": "Usunąć biomarker?",
+ "deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.",
+ "emptyTitle": "Brak biomarkerów",
+ "emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.",
+ "loadError": "Nie udało się wczytać biomarkerów",
+ "noRange": "Brak zakresu referencyjnego",
+ "form": {
+ "seedLabel": "Zacznij od popularnego markera",
+ "seedPlaceholder": "Wybierz marker (opcjonalnie)",
+ "seedHint": "Wybór wypełnia pola poniżej — dopasuj zakres do swojego laboratorium.",
+ "name": "Nazwa",
+ "namePlaceholder": "np. Cholesterol LDL",
+ "unit": "Jednostka",
+ "unitPlaceholder": "np. mg/dl",
+ "panel": "Panel",
+ "panelPlaceholder": "np. Lipidy",
+ "lowerBound": "Dolna granica",
+ "upperBound": "Górna granica",
+ "lowerPlaceholder": "Dolna granica",
+ "upperPlaceholder": "Górna granica",
+ "context": "Kontekst",
+ "contextPlaceholder": "Opcjonalna notatka o tym markerze",
+ "rangeHint": "Obie granice są opcjonalne — zostaw jedną pustą dla zakresu otwartego (np. LDL < 116).",
+ "requiredError": "Nazwa i jednostka są wymagane.",
+ "rangeOrderError": "Dolna granica nie może przekraczać górnej.",
+ "save": "Zapisz biomarker",
+ "savedToast": "Biomarker zapisany",
+ "updatedToast": "Biomarker zaktualizowany",
+ "saveError": "Nie udało się zapisać biomarkera"
+ }
+ },
+ "catalog": {
+ "total-cholesterol": "Cholesterol całkowity",
+ "ldl": "Cholesterol LDL",
+ "hdl": "Cholesterol HDL",
+ "triglycerides": "Trójglicerydy",
+ "fasting-glucose": "Glukoza na czczo",
+ "hba1c": "HbA1c",
+ "tsh": "TSH",
+ "ft3": "Wolne T3",
+ "ft4": "Wolne T4",
+ "ferritin": "Ferrytyna",
+ "transferrin-saturation": "Wysycenie transferyny",
+ "vitamin-d": "Witamina D (25-OH)",
+ "vitamin-b12": "Witamina B12",
+ "folate": "Kwas foliowy",
+ "hs-crp": "hs-CRP",
+ "creatinine": "Kreatynina",
+ "egfr": "eGFR",
+ "alt": "ALT",
+ "ast": "AST",
+ "ggt": "GGTP",
+ "sodium": "Sód",
+ "potassium": "Potas",
+ "hemoglobin": "Hemoglobina",
+ "hematocrit": "Hematokryt",
+ "wbc": "Leukocyty",
+ "platelets": "Płytki krwi",
+ "panel": {
+ "lipids": "Lipidy",
+ "glucose": "Metabolizm glukozy",
+ "thyroid": "Tarczyca",
+ "iron": "Żelazo",
+ "vitamins": "Witaminy",
+ "inflammation": "Stan zapalny",
+ "renal": "Nerki",
+ "liver": "Wątroba",
+ "electrolytes": "Elektrolity",
+ "bloodCount": "Morfologia krwi"
+ }
}
},
"modules": {
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 91447908d..0b78b7f6e 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -74,6 +74,8 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [
"src/app/api/sleep",
"src/app/api/workouts",
"src/app/api/labs",
+ // v1.18.1 — the user-scoped Biomarker catalog backs the Labs feature.
+ "src/app/api/biomarkers",
"src/app/api/cycle",
"src/app/api/gamification",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
@@ -129,6 +131,14 @@ const EXEMPT_ROUTES: ReadonlyArray = [
"src/app/api/workouts/batch/route.ts",
"src/app/api/labs/route.ts",
"src/app/api/labs/[id]/route.ts",
+ // v1.18.1 — the lab-result delete-Undo restore endpoint and the
+ // user-scoped Biomarker catalog CRUD share the LabResult data-layer
+ // reasoning: the labs module gates the SURFACES (the Labs page), not the
+ // row store. A synced / pre-existing reading and its catalog definition
+ // must survive a disabled module so re-enabling reveals a complete history.
+ "src/app/api/labs/restore/route.ts",
+ "src/app/api/biomarkers/route.ts",
+ "src/app/api/biomarkers/[id]/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts
new file mode 100644
index 000000000..48be5a921
--- /dev/null
+++ b/src/app/api/biomarkers/[id]/route.ts
@@ -0,0 +1,190 @@
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import {
+ decryptContextFromBytes,
+ encryptContextToBytes,
+} from "@/lib/labs/biomarker-store";
+import { annotate } from "@/lib/logging/context";
+import { updateBiomarkerSchema } from "@/lib/validations/biomarkers";
+
+/**
+ * v1.18.1 — single Biomarker resource (`/api/biomarkers/{id}`).
+ *
+ * PUT applies a partial edit (`data` built field-by-field; an explicit `null`
+ * clears `context` / `panel` / a bound, an omitted key leaves it untouched).
+ * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on
+ * `LabResult.biomarkerId` unlinks every reading without losing it (the row
+ * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows
+ * surface as 404 (existence sealed).
+ */
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+function serialiseBiomarker(row: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ contextEncrypted: Uint8Array | null;
+ createdAt: Date;
+ updatedAt: Date;
+}) {
+ return {
+ id: row.id,
+ name: row.name,
+ unit: row.unit,
+ lowerBound: row.lowerBound,
+ upperBound: row.upperBound,
+ panel: row.panel,
+ hasContext: row.contextEncrypted !== null,
+ context: row.contextEncrypted
+ ? decryptContextFromBytes(row.contextEncrypted)
+ : null,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+}
+
+export const GET = apiHandler(
+ async (_request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const row = await prisma.biomarker.findFirst({ where: { id } });
+ if (!row || row.userId !== user.id) {
+ return apiError("Biomarker not found", 404);
+ }
+
+ annotate({
+ action: { name: "labs.biomarker.get" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess(serialiseBiomarker(row));
+ },
+);
+
+export const PUT = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.biomarker.findFirst({ where: { id } });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Biomarker not found", 404);
+ }
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = updateBiomarkerSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "labs.biomarker.update.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length, biomarkerId: id },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "labs.biomarker.update.validation-failed",
+ details: JSON.stringify({ issues: auditIssues, biomarkerId: id }),
+ },
+ })
+ .catch(() => {
+ /* swallow — the 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const d = parsed.data;
+
+ // A rename must not collide with another of the caller's markers.
+ if (d.name !== undefined && d.name !== existing.name) {
+ const clash = await prisma.biomarker.findFirst({
+ where: { userId: user.id, name: d.name },
+ select: { id: true },
+ });
+ if (clash) {
+ return apiError("A biomarker with this name already exists", 409);
+ }
+ }
+
+ // Build `data` field-by-field. `undefined` → leave the column untouched;
+ // an explicit `null` on `context` / `panel` / a bound clears it.
+ const data: Record = {};
+ if (d.name !== undefined) data.name = d.name;
+ if (d.unit !== undefined) data.unit = d.unit;
+ if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound;
+ if (d.upperBound !== undefined) data.upperBound = d.upperBound;
+ if (d.panel !== undefined) data.panel = d.panel;
+ if (d.context !== undefined) {
+ data.contextEncrypted = d.context
+ ? encryptContextToBytes(d.context)
+ : null;
+ }
+
+ const updated = await prisma.biomarker.update({ where: { id }, data });
+
+ await auditLog("biomarker.update", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.update" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess(serialiseBiomarker(updated));
+ },
+);
+
+export const DELETE = apiHandler(
+ async (request: NextRequest, { params }: RouteParams) => {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ const existing = await prisma.biomarker.findFirst({ where: { id } });
+ if (!existing || existing.userId !== user.id) {
+ return apiError("Biomarker not found", 404);
+ }
+
+ // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather
+ // than cascading it away. The readings keep their legacy `analyte` /
+ // `unit` so the history survives the catalog edit.
+ await prisma.biomarker.delete({ where: { id } });
+
+ await auditLog("biomarker.delete", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.delete" },
+ meta: { biomarkerId: id },
+ });
+
+ return apiSuccess({ deleted: true });
+ },
+);
diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts
new file mode 100644
index 000000000..61a5b2f14
--- /dev/null
+++ b/src/app/api/biomarkers/route.ts
@@ -0,0 +1,151 @@
+import { NextRequest } from "next/server";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+ safeJson,
+ sanitiseZodIssues,
+} from "@/lib/api-response";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import { withIdempotency } from "@/lib/idempotency";
+import {
+ decryptContextFromBytes,
+ encryptContextToBytes,
+} from "@/lib/labs/biomarker-store";
+import { annotate } from "@/lib/logging/context";
+import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
+
+/**
+ * v1.18.1 — user-scoped Biomarker catalog (`/api/biomarkers`).
+ *
+ * The catalog is the Labs feature's primary object: a marker is defined ONCE
+ * (name, unit, reference bounds, optional context) and every later reading
+ * just picks it. GET lists the caller's markers; POST defines a new one.
+ * `userId` is always narrowed from the session — never a body field — and the
+ * write `data` object is built field-by-field (no mass assignment). The
+ * optional context note is AES-256-GCM encrypted into the `contextEncrypted`
+ * Bytes column before write. The `@@unique([userId, name])` index means there
+ * is never a second "LDL" definition for one user.
+ */
+
+// The encrypted context bytes are never echoed back; `hasContext` flags
+// presence and the single-resource GET returns the decrypted text.
+function serialiseBiomarker(row: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ contextEncrypted: Uint8Array | null;
+ createdAt: Date;
+ updatedAt: Date;
+}) {
+ return {
+ id: row.id,
+ name: row.name,
+ unit: row.unit,
+ lowerBound: row.lowerBound,
+ upperBound: row.upperBound,
+ panel: row.panel,
+ hasContext: row.contextEncrypted !== null,
+ context: row.contextEncrypted
+ ? decryptContextFromBytes(row.contextEncrypted)
+ : null,
+ createdAt: row.createdAt.toISOString(),
+ updatedAt: row.updatedAt.toISOString(),
+ };
+}
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+
+ const rows = await prisma.biomarker.findMany({
+ where: { userId: user.id },
+ orderBy: [{ name: "asc" }],
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.list" },
+ meta: { total: rows.length },
+ });
+
+ return apiSuccess({ biomarkers: rows.map(serialiseBiomarker) });
+});
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
+
+async function postBiomarker(request: NextRequest) {
+ const { user } = await requireAuth();
+
+ const { data: body, error: jsonError } = await safeJson(request, {
+ maxBytes: 16 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = createBiomarkerSchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({
+ action: { name: "labs.biomarker.create.validation-failed" },
+ meta: { issue_count: parsed.error.issues.length },
+ });
+ const auditIssues = sanitiseZodIssues(parsed.error.issues, {
+ stripValuesFromMessage: true,
+ });
+ prisma.auditLog
+ .create({
+ data: {
+ userId: user.id,
+ action: "labs.biomarker.create.validation-failed",
+ details: JSON.stringify({ issues: auditIssues }),
+ },
+ })
+ .catch(() => {
+ /* swallow — the 422 response is the contract */
+ });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
+
+ // Reject a duplicate name up front so the client sees a clean 409 rather
+ // than a Prisma unique-constraint 500. The `@@unique([userId, name])` index
+ // is the structural backstop.
+ const existing = await prisma.biomarker.findFirst({
+ where: { userId: user.id, name },
+ select: { id: true },
+ });
+ if (existing) {
+ return apiError("A biomarker with this name already exists", 409);
+ }
+
+ // Field-by-field assignment — never spread `parsed.data`.
+ const created = await prisma.biomarker.create({
+ data: {
+ userId: user.id,
+ name,
+ unit,
+ lowerBound: lowerBound ?? null,
+ upperBound: upperBound ?? null,
+ panel: panel ?? null,
+ contextEncrypted: context ? encryptContextToBytes(context) : null,
+ },
+ });
+
+ await auditLog("biomarker.create", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { biomarkerId: created.id },
+ });
+
+ annotate({
+ action: { name: "labs.biomarker.create" },
+ meta: { biomarkerId: created.id },
+ });
+
+ return apiSuccess(serialiseBiomarker(created), 201);
+}
diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts
index 829f88498..9463efa45 100644
--- a/src/app/api/labs/[id]/route.ts
+++ b/src/app/api/labs/[id]/route.ts
@@ -11,12 +11,14 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
+import {
+ type ResolvedBiomarker,
+ serialiseLabResult,
+ serialiseLabResultDetail,
+} from "@/lib/labs/serialise";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
-import {
- classifyReferenceRange,
- updateLabResultSchema,
-} from "@/lib/validations/labs";
+import { updateLabResultSchema } from "@/lib/validations/labs";
/**
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
@@ -26,10 +28,36 @@ import {
* (`data` built field-by-field; an explicit `null` clears `panel` / `note` /
* a reference bound, an omitted key leaves it untouched). DELETE soft-deletes
* by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed).
+ *
+ * v1.18.1 — when the row links a `Biomarker`, the response resolves the
+ * canonical name / unit / range from the catalog (server-authoritative). The
+ * legacy per-row free-text fields stay editable for an unlinked row.
*/
type RouteParams = { params: Promise<{ id: string }> };
+const biomarkerSelect = {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+} as const;
+
+function toResolved(
+ bm: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ } | null,
+): ResolvedBiomarker | null {
+ return bm ?? null;
+}
+
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -37,6 +65,7 @@ export const GET = apiHandler(
const row = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
+ include: { biomarker: { select: biomarkerSelect } },
});
if (!row || row.userId !== user.id) {
return apiError("Lab result not found", 404);
@@ -44,27 +73,13 @@ export const GET = apiHandler(
annotate({ action: { name: "labs.get" }, meta: { labResultId: id } });
- return apiSuccess({
- id: row.id,
- panel: row.panel,
- analyte: row.analyte,
- value: row.value,
- unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
- takenAt: row.takenAt.toISOString(),
- source: row.source,
- note: row.noteEncrypted
- ? decryptNoteFromBytes(row.noteEncrypted)
- : null,
- rangeStatus: classifyReferenceRange(
- row.value,
- row.referenceLow,
- row.referenceHigh,
+ return apiSuccess(
+ serialiseLabResultDetail(
+ row,
+ toResolved(row.biomarker),
+ row.noteEncrypted ? decryptNoteFromBytes(row.noteEncrypted) : null,
),
- createdAt: row.createdAt.toISOString(),
- updatedAt: row.updatedAt.toISOString(),
- });
+ );
},
);
@@ -112,6 +127,8 @@ export const PUT = apiHandler(
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
+ // A reading linked to a catalog marker is edited via VALUE / takenAt /
+ // note here; its name / unit / range stay resolved from the biomarker.
const data: Record = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
@@ -124,7 +141,11 @@ export const PUT = apiHandler(
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
- const updated = await prisma.labResult.update({ where: { id }, data });
+ const updated = await prisma.labResult.update({
+ where: { id },
+ data,
+ include: { biomarker: { select: biomarkerSelect } },
+ });
await auditLog("labResult.update", {
userId: user.id,
@@ -134,25 +155,9 @@ export const PUT = apiHandler(
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
- return apiSuccess({
- id: updated.id,
- panel: updated.panel,
- analyte: updated.analyte,
- value: updated.value,
- unit: updated.unit,
- referenceLow: updated.referenceLow,
- referenceHigh: updated.referenceHigh,
- takenAt: updated.takenAt.toISOString(),
- source: updated.source,
- hasNote: updated.noteEncrypted !== null,
- rangeStatus: classifyReferenceRange(
- updated.value,
- updated.referenceLow,
- updated.referenceHigh,
- ),
- createdAt: updated.createdAt.toISOString(),
- updatedAt: updated.updatedAt.toISOString(),
- });
+ return apiSuccess(
+ serialiseLabResult(updated, toResolved(updated.biomarker)),
+ );
},
);
@@ -178,7 +183,7 @@ export const DELETE = apiHandler(
await auditLog("labResult.delete", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { labResultId: id, analyte: existing.analyte },
+ details: { labResultId: id },
});
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts
new file mode 100644
index 000000000..07d0bc409
--- /dev/null
+++ b/src/app/api/labs/restore/route.ts
@@ -0,0 +1,73 @@
+/**
+ * `POST /api/labs/restore` — un-tombstone for the lab-history delete-Undo
+ * affordance (v1.18.1).
+ *
+ * Body: `{ ids: string[] }` — 1..200 lab-result ids, scoped to the caller.
+ *
+ * Clears `deletedAt` on every owned, currently-tombstoned row in one
+ * `updateMany`, so the reading re-surfaces in normal reads. A forged /
+ * foreign / not-deleted id is a silent no-op (never a 404 existence leak):
+ * the mutation `where` pins `userId` so it only ever touches the caller's
+ * rows. Mirrors `/api/measurements/restore`.
+ */
+import { NextRequest } from "next/server";
+import { z } from "zod/v4";
+
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ returnAllZodIssues,
+ safeJson,
+} from "@/lib/api-response";
+import { prisma } from "@/lib/db";
+import { withIdempotency } from "@/lib/idempotency";
+import { annotate } from "@/lib/logging/context";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const MAX_IDS_PER_BATCH = 200;
+const RATE_LIMIT_MAX = 60;
+const RATE_LIMIT_WINDOW_MS = 60 * 1000;
+
+const restoreSchema = z.object({
+ ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
+});
+
+export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
+
+async function postRestore(request: NextRequest): Promise {
+ const { user } = await requireAuth();
+
+ const rl = await checkRateLimit(
+ `labs:restore:${user.id}`,
+ RATE_LIMIT_MAX,
+ RATE_LIMIT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many restore requests, try again later", 429);
+ }
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 64 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = restoreSchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return returnAllZodIssues(parsed.error, 422, {
+ errorCode: "labs.restore.invalid",
+ });
+ }
+
+ const { ids } = parsed.data;
+
+ // `userId`-scoped + `deletedAt != null`: a foreign / live id is skipped.
+ const { count } = await prisma.labResult.updateMany({
+ where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
+ data: { deletedAt: null },
+ });
+
+ annotate({ action: { name: "labs.restore" }, meta: { restored: count } });
+
+ return apiSuccess({ restored: count });
+}
diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts
index 2a8ec3902..485c63c0a 100644
--- a/src/app/api/labs/route.ts
+++ b/src/app/api/labs/route.ts
@@ -2,6 +2,7 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
+ apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
@@ -12,10 +13,13 @@ import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
+import {
+ type ResolvedBiomarker,
+ serialiseLabResult,
+} from "@/lib/labs/serialise";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
- classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
} from "@/lib/validations/labs";
@@ -23,49 +27,31 @@ import {
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
- * GET lists the caller's live results with optional analyte / panel / date
- * filters. POST records a single reading. `userId` is always narrowed from
- * the session — never a body field — and the write `data` object is built
- * field-by-field (no mass assignment). The free-text note, when present, is
- * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write.
+ * GET lists the caller's live results with optional biomarker / analyte /
+ * panel / date filters. POST records a single reading. `userId` is always
+ * narrowed from the session — never a body field — and the write `data`
+ * object is built field-by-field (no mass assignment). The free-text note,
+ * when present, is AES-256-GCM encrypted into the `noteEncrypted` Bytes
+ * column before write.
+ *
+ * v1.18.1 — structured entry: when the body carries a `biomarkerId`, the row
+ * links the user-scoped catalog marker and the response resolves its unit +
+ * reference range FROM the biomarker (server-authoritative). The web + iOS
+ * clients render the resolved DTO and never recompute.
*/
-// The serialised row never echoes the encrypted note bytes back; the badge
-// status is computed server-side so the client renders a coherent, neutral
-// in/out-of-range verdict without re-deriving the rule.
-function serialiseLabResult(row: {
- id: string;
- panel: string | null;
- analyte: string;
- value: number;
- unit: string;
- referenceLow: number | null;
- referenceHigh: number | null;
- takenAt: Date;
- source: string;
- noteEncrypted: Uint8Array | null;
- createdAt: Date;
- updatedAt: Date;
-}) {
- return {
- id: row.id,
- panel: row.panel,
- analyte: row.analyte,
- value: row.value,
- unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
- takenAt: row.takenAt.toISOString(),
- source: row.source,
- hasNote: row.noteEncrypted !== null,
- rangeStatus: classifyReferenceRange(
- row.value,
- row.referenceLow,
- row.referenceHigh,
- ),
- createdAt: row.createdAt.toISOString(),
- updatedAt: row.updatedAt.toISOString(),
- };
+/** Map the joined biomarker (or null) into the resolver's shape. */
+function toResolved(
+ bm: {
+ id: string;
+ name: string;
+ unit: string;
+ lowerBound: number | null;
+ upperBound: number | null;
+ panel: string | null;
+ } | null,
+): ResolvedBiomarker | null {
+ return bm ?? null;
}
export const GET = apiHandler(async (request: NextRequest) => {
@@ -81,11 +67,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
- const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
+ const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
+ parsed.data;
const where = {
userId: user.id,
deletedAt: null,
+ ...(biomarkerId && { biomarkerId }),
...(analyte && { analyte }),
...(panel && { panel }),
...(from || to
@@ -104,6 +92,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
orderBy: { takenAt: sortDir },
take: limit,
skip: offset,
+ include: {
+ biomarker: {
+ select: {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+ },
+ },
+ },
}),
prisma.labResult.count({ where }),
]);
@@ -114,7 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
return apiSuccess({
- results: rows.map(serialiseLabResult),
+ results: rows.map((row) =>
+ serialiseLabResult(row, toResolved(row.biomarker)),
+ ),
meta: { total, limit, offset },
});
});
@@ -154,20 +156,55 @@ async function postLabResult(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
- const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
- parsed.data;
+ const {
+ biomarkerId,
+ panel,
+ analyte,
+ value,
+ unit,
+ referenceLow,
+ referenceHigh,
+ takenAt,
+ note,
+ } = parsed.data;
- // Field-by-field assignment — never spread `parsed.data`. `source` is
- // hardcoded "MANUAL" for this user-facing path; import paths set their own.
+ // Structured-entry path: resolve the catalog marker (and verify ownership)
+ // so the row's name + unit derive from it. A forged / foreign id is a 404.
+ let biomarker: ResolvedBiomarker | null = null;
+ if (biomarkerId) {
+ const found = await prisma.biomarker.findFirst({
+ where: { id: biomarkerId, userId: user.id },
+ select: {
+ id: true,
+ name: true,
+ unit: true,
+ lowerBound: true,
+ upperBound: true,
+ panel: true,
+ },
+ });
+ if (!found) {
+ return apiError("Biomarker not found", 404);
+ }
+ biomarker = found;
+ }
+
+ // Field-by-field assignment — never spread `parsed.data`. With a catalog
+ // link the row stamps the resolved name/unit/range as historical truth
+ // (so a later catalog edit does not silently rewrite a past reading) AND
+ // keeps the FK; reads resolve the CURRENT catalog values via `serialise`.
const created = await prisma.labResult.create({
data: {
userId: user.id,
- panel: panel ?? null,
- analyte,
+ biomarkerId: biomarker?.id ?? null,
+ panel: biomarker ? biomarker.panel : (panel ?? null),
+ analyte: biomarker ? biomarker.name : (analyte as string),
value,
- unit,
- referenceLow: referenceLow ?? null,
- referenceHigh: referenceHigh ?? null,
+ unit: biomarker ? biomarker.unit : (unit as string),
+ referenceLow: biomarker ? biomarker.lowerBound : (referenceLow ?? null),
+ referenceHigh: biomarker
+ ? biomarker.upperBound
+ : (referenceHigh ?? null),
takenAt,
source: "MANUAL",
noteEncrypted: note ? encryptNoteToBytes(note) : null,
@@ -177,12 +214,12 @@ async function postLabResult(request: NextRequest) {
await auditLog("labResult.create", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { labResultId: created.id, analyte: created.analyte },
+ details: { labResultId: created.id },
});
annotate({
action: { name: "labs.create" },
- meta: { labResultId: created.id },
+ meta: { labResultId: created.id, structured: biomarker !== null },
});
// v1.18.1 (D2) — eventful Lab↔Vorsorge satisfaction. A lab panel just
@@ -190,5 +227,5 @@ async function postLabResult(request: NextRequest) {
// now rather than waiting on the 15-min cron. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
- return apiSuccess(serialiseLabResult(created), 201);
+ return apiSuccess(serialiseLabResult(created, biomarker), 201);
}
diff --git a/src/app/labs/[biomarkerId]/page.tsx b/src/app/labs/[biomarkerId]/page.tsx
new file mode 100644
index 000000000..29189ca39
--- /dev/null
+++ b/src/app/labs/[biomarkerId]/page.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+/**
+ * v1.18.1 — per-biomarker detail page (`/labs/[biomarkerId]`).
+ *
+ * Owns auth + the back link and hands the marker id to
+ * ``, which carries the heading, the dashboard-style
+ * chart with the reference band, and the editable reading history.
+ */
+import { use, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+
+import { LabBiomarkerDetail } from "@/components/labs/lab-biomarker-detail";
+import { BackLink } from "@/components/ui/back-link";
+import { useAuth } from "@/hooks/use-auth";
+import { useMounted } from "@/hooks/use-mounted";
+import { useTranslations } from "@/lib/i18n/context";
+
+export default function LabBiomarkerDetailPage({
+ params,
+}: {
+ params: Promise<{ biomarkerId: string }>;
+}) {
+ const { biomarkerId } = use(params);
+ const { isAuthenticated, isLoading } = useAuth();
+ const mounted = useMounted();
+ const router = useRouter();
+ const { t } = useTranslations();
+
+ useEffect(() => {
+ if (!isLoading && !isAuthenticated) {
+ router.push("/auth/login");
+ }
+ }, [isLoading, isAuthenticated, router]);
+
+ if (!mounted || isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 2030d0510..d0b685f1c 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -3,8 +3,9 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
-import { Loader2, Plus } from "lucide-react";
+import { FlaskConical, Loader2, Plus } from "lucide-react";
+import { BiomarkerManager } from "@/components/labs/biomarker-manager";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { Button } from "@/components/ui/button";
@@ -23,6 +24,7 @@ export default function LabsPage() {
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
+ const [manageOpen, setManageOpen] = useState(false);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
@@ -30,7 +32,7 @@ export default function LabsPage() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
- disabled: dialogOpen,
+ disabled: dialogOpen || manageOpen,
});
useEffect(() => {
@@ -59,13 +61,25 @@ export default function LabsPage() {
{t("labs.subtitle")}
-
+
+
+
+
+
+
+
+
setDialogOpen(true)} />
);
diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx
new file mode 100644
index 000000000..e814ef717
--- /dev/null
+++ b/src/components/labs/biomarker-form.tsx
@@ -0,0 +1,281 @@
+"use client";
+
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
+import {
+ BIOMARKER_CATALOG,
+ BIOMARKER_PANELS,
+} from "@/lib/labs/biomarker-catalog";
+import { useTranslations } from "@/lib/i18n/context";
+
+import type { BiomarkerDto } from "./types";
+
+const CONTEXT_MAX_LENGTH = 2000;
+
+/** Parse a free-text decimal that may use a comma separator. */
+function parseDecimal(raw: string): number | null {
+ const trimmed = raw.trim().replace(",", ".");
+ if (trimmed === "") return null;
+ const n = Number(trimmed);
+ return Number.isFinite(n) ? n : null;
+}
+
+interface BiomarkerFormProps {
+ /** When set the form edits this marker; otherwise it defines a new one. */
+ existing?: BiomarkerDto;
+ onSuccess?: (saved: BiomarkerDto) => void;
+ onCancel?: () => void;
+}
+
+/**
+ * v1.18.1 — define or edit a Biomarker catalog entry.
+ *
+ * A marker is the Labs feature's reusable definition: name, unit, reference
+ * bounds (set ONCE, never re-entered per reading), and an optional context
+ * note. When defining a new marker the user can seed the fields from the
+ * common-panel catalog (a `