merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill

# Conflicts:
#	src/app/api/labs/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:17:09 +02:00
commit cc32e2adf7
40 files changed
+4403 -360

No files matched your search

+410 -4
View File
@@ -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:
+120 -4
View File
@@ -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": {
+119 -3
View File
@@ -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": {
+120 -4
View File
@@ -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": {
+120 -4
View File
@@ -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": {
+120 -4
View File
@@ -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": {
+120 -4
View File
@@ -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": {
@@ -74,6 +74,8 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"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<string> = [
"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",
+190
View File
@@ -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<string, unknown> = {};
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 });
},
);
+151
View File
@@ -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);
}
+50 -45
View File
@@ -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<string, unknown> = {};
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 } });
+73
View File
@@ -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<Response> {
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 });
}
+93 -56
View File
@@ -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);
}
+51
View File
@@ -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
* `<LabBiomarkerDetail>`, 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 (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<div className="space-y-6">
<BackLink href="/labs" label={t("labs.backToLabs")} />
<LabBiomarkerDetail biomarkerId={biomarkerId} />
</div>
);
}
+32 -9
View File
@@ -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")}
</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
onClick={() => setManageOpen(true)}
className="min-h-11 sm:min-h-9"
>
<FlaskConical className="h-4 w-4" />
<span className="hidden sm:inline">
{t("labs.biomarker.manage")}
</span>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
</div>
<ResponsiveSheet
@@ -85,6 +99,15 @@ export default function LabsPage() {
/>
</ResponsiveSheet>
<ResponsiveSheet
open={manageOpen}
onOpenChange={setManageOpen}
title={t("labs.biomarker.manageTitle")}
description={t("labs.biomarker.manageSheetDescription")}
>
<BiomarkerManager />
</ResponsiveSheet>
<LabList onAddFirst={() => setDialogOpen(true)} />
</div>
);
+281
View File
@@ -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 `<Select>` grouped by panel) or fill them by hand.
* Either bound is optional (a marker may report only an upper bound, e.g.
* LDL < 116).
*/
export function BiomarkerForm({
existing,
onSuccess,
onCancel,
}: BiomarkerFormProps) {
const { t } = useTranslations();
const [name, setName] = useState(existing?.name ?? "");
const [unit, setUnit] = useState(existing?.unit ?? "");
const [lower, setLower] = useState(
existing?.lowerBound != null ? String(existing.lowerBound) : "",
);
const [upper, setUpper] = useState(
existing?.upperBound != null ? String(existing.upperBound) : "",
);
const [panel, setPanel] = useState(existing?.panel ?? "");
const [context, setContext] = useState(existing?.context ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
function applySeed(slug: string) {
const seed = BIOMARKER_CATALOG.find((s) => s.slug === slug);
if (!seed) return;
setName(t(`labs.catalog.${seed.slug}`));
setUnit(seed.unit);
setLower(seed.lowerBound != null ? String(seed.lowerBound) : "");
setUpper(seed.upperBound != null ? String(seed.upperBound) : "");
setPanel(t(`labs.catalog.panel.${seed.panel}`));
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || !unit.trim()) {
setError(t("labs.biomarker.form.requiredError"));
return;
}
const low = parseDecimal(lower);
const high = parseDecimal(upper);
if (low !== null && high !== null && low > high) {
setError(t("labs.biomarker.form.rangeOrderError"));
return;
}
const body = {
name: name.trim(),
unit: unit.trim(),
...(low !== null ? { lowerBound: low } : {}),
...(high !== null ? { upperBound: high } : {}),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(context.trim() ? { context: context.trim() } : {}),
};
setSubmitting(true);
try {
const saved = existing
? await apiPut<BiomarkerDto>(`/api/biomarkers/${existing.id}`, body)
: await apiPost<BiomarkerDto>("/api/biomarkers", body);
toast.success(
existing
? t("labs.biomarker.form.updatedToast")
: t("labs.biomarker.form.savedToast"),
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.biomarker.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
{!existing ? (
<div className="space-y-1.5">
<Label htmlFor="biomarker-seed">
{t("labs.biomarker.form.seedLabel")}
</Label>
<Select onValueChange={applySeed}>
<SelectTrigger id="biomarker-seed" className="w-full">
<SelectValue
placeholder={t("labs.biomarker.form.seedPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{BIOMARKER_PANELS.map((panelKey) => {
const items = BIOMARKER_CATALOG.filter(
(s) => s.panel === panelKey,
);
if (items.length === 0) return null;
return (
<SelectGroup key={panelKey}>
<SelectLabel>
{t(`labs.catalog.panel.${panelKey}`)}
</SelectLabel>
{items.map((s) => (
<SelectItem key={s.slug} value={s.slug}>
{t(`labs.catalog.${s.slug}`)}
</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
{t("labs.biomarker.form.seedHint")}
</p>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="biomarker-name">
{t("labs.biomarker.form.name")}
</Label>
<Input
id="biomarker-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("labs.biomarker.form.namePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-unit">
{t("labs.biomarker.form.unit")}
</Label>
<Input
id="biomarker-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("labs.biomarker.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-panel">
{t("labs.biomarker.form.panel")}
</Label>
<Input
id="biomarker-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.biomarker.form.panelPlaceholder")}
maxLength={120}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-lower">
{t("labs.biomarker.form.lowerBound")}
</Label>
<Input
id="biomarker-lower"
inputMode="decimal"
value={lower}
onChange={(e) => setLower(e.target.value)}
placeholder={t("labs.biomarker.form.lowerPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="biomarker-upper">
{t("labs.biomarker.form.upperBound")}
</Label>
<Input
id="biomarker-upper"
inputMode="decimal"
value={upper}
onChange={(e) => setUpper(e.target.value)}
placeholder={t("labs.biomarker.form.upperPlaceholder")}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="biomarker-context">
{t("labs.biomarker.form.context")}
</Label>
<Textarea
id="biomarker-context"
value={context}
onChange={(e) => setContext(e.target.value)}
placeholder={t("labs.biomarker.form.contextPlaceholder")}
maxLength={CONTEXT_MAX_LENGTH}
rows={2}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.biomarker.form.rangeHint")}
</p>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.biomarker.form.save")}
</Button>
</div>
</form>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { BiomarkerForm } from "./biomarker-form";
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
/**
* v1.18.1 — the Biomarker catalog manager.
*
* Lives on the Labs page (not Settings) because the catalog IS the Labs
* feature's primary object. Lists every defined marker with its unit +
* reference range, opens a define-new / edit sheet, and wires delete (the
* `onDelete: SetNull` FK keeps existing readings, just unlinks them).
*/
export function BiomarkerManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<BiomarkerDto | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`),
onSuccess: () => {
toast.success(t("labs.biomarker.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.biomarker.deleteError")),
});
function openNew() {
setEditing(null);
setFormOpen(true);
}
function openEdit(marker: BiomarkerDto) {
setEditing(marker);
setFormOpen(true);
}
function afterSave() {
setFormOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
const markers = data?.biomarkers ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("labs.biomarker.managerDescription")}
</p>
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : isError ? (
<p className="text-destructive py-6 text-center text-sm">
{t("labs.biomarker.loadError")}
</p>
) : markers.length === 0 ? (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.biomarker.emptyTitle")}
description={t("labs.biomarker.emptyDescription")}
action={
<Button onClick={openNew}>{t("labs.biomarker.defineFirst")}</Button>
}
/>
) : (
<ul className="space-y-2">
{markers.map((marker) => {
const range = formatReferenceRange(
marker.lowerBound,
marker.upperBound,
formatLabValue,
);
return (
<li key={marker.id}>
<Card>
<CardContent className="flex items-center justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">
{marker.name}
</span>
<span className="text-muted-foreground text-xs">
{marker.unit}
</span>
</div>
<p className="text-muted-foreground text-xs">
{range
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
: t("labs.biomarker.noRange")}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-9"
onClick={() => openEdit(marker)}
aria-label={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(marker.id)}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t(
"labs.biomarker.deleteConfirmDescription",
)}
confirmLabel={t("labs.biomarker.delete")}
className="size-9"
iconClassName="h-4 w-4"
/>
</div>
</CardContent>
</Card>
</li>
);
})}
</ul>
)}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
title={
editing
? t("labs.biomarker.editTitle")
: t("labs.biomarker.defineTitle")
}
description={t("labs.biomarker.defineDescription")}
>
<BiomarkerForm
existing={editing ?? undefined}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { useMemo, useState } from "react";
import {
CartesianGrid,
ComposedChart,
Line,
ReferenceArea,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
import type { LabResultDto } from "./types";
/**
* v1.18.1 — per-biomarker trend chart, in the dashboard's visual language.
*
* Replaces the hand-rolled 72×20 SVG sparkline with a proper Recharts
* `ComposedChart`: a single line over time, the catalog reference window
* painted as a shaded `ReferenceArea` band with dashed bound lines, range
* tabs (30 / 90 / 365 / all), and the shared rich tooltip. The band uses a
* 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.
*/
const RANGE_DAYS = [
{ key: "30", days: 30 },
{ key: "90", days: 90 },
{ key: "365", days: 365 },
{ key: "all", days: 0 },
] as const;
type RangeKey = (typeof RANGE_DAYS)[number]["key"];
interface ChartPoint {
timestamp: number;
value: number;
}
export function LabBiomarkerChart({
readings,
unit,
lowerBound,
upperBound,
}: {
/** Readings for this biomarker, any order (sorted internally). */
readings: LabResultDto[];
unit: string;
lowerBound: number | null;
upperBound: number | null;
}) {
const { t } = useTranslations();
const [range, setRange] = useState<RangeKey>("365");
// Capture "now" once at mount so the recency filter stays pure across
// re-renders (an inline `Date.now()` in render is impure).
const [nowMs] = useState(() => Date.now());
const points = useMemo<ChartPoint[]>(() => {
const sorted = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const days = RANGE_DAYS.find((r) => r.key === range)?.days ?? 0;
const filtered =
days > 0
? sorted.filter((r) => {
const ageMs = nowMs - new Date(r.takenAt).getTime();
return ageMs <= days * 24 * 60 * 60 * 1000;
})
: sorted;
return filtered.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.value,
}));
}, [readings, range, nowMs]);
// 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]>(() => {
const values = points.map((p) => p.value);
const candidates = [...values];
if (lowerBound != null) candidates.push(lowerBound);
if (upperBound != null) candidates.push(upperBound);
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]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
return (
<div className="space-y-3">
<div className="flex justify-end gap-1">
{RANGE_DAYS.map((r) => (
<Button
key={r.key}
size="sm"
variant={range === r.key ? "secondary" : "ghost"}
className="h-7 px-2 text-xs"
onClick={() => setRange(r.key)}
>
{t(`labs.chart.range.${r.key}`)}
</Button>
))}
</div>
{points.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">
{t("labs.chart.empty")}
</p>
) : (
<div style={{ height: CHART_HEIGHT_PX }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={points}
margin={{ top: 8, right: 12, bottom: 4, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={["dataMin", "dataMax"]}
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
minTickGap={32}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--border)"
width={44}
tickFormatter={(v: number) => formatLabValue(v)}
/>
{/* Reference window — muted band + dashed bounds. No alarm
colour: the in/out verdict is shown by the neutral badge,
not by painting the chart red. */}
{lowerBound != null && upperBound != null ? (
<ReferenceArea
y1={lowerBound}
y2={upperBound}
fill={primary}
fillOpacity={0.1}
stroke="none"
/>
) : null}
{lowerBound != null ? (
<ReferenceLine
y={lowerBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
{upperBound != null ? (
<ReferenceLine
y={upperBound}
stroke={primary}
strokeOpacity={0.4}
strokeDasharray="4 4"
/>
) : null}
<Tooltip
content={(props) => {
const active = props.active ?? false;
const payload = props.payload as
| ReadonlyArray<{ payload?: ChartPoint }>
| undefined;
const point = payload?.[0]?.payload;
if (!active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows: RichTooltipRow[] = [
{
name: t("labs.chart.valueLabel"),
value: `${formatLabValue(point.value)} ${unit}`,
color: primary,
},
];
return (
<RichChartTooltip
active
label={formatDateShort(new Date(point.timestamp))}
rows={rows}
/>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={primary}
strokeWidth={2}
dot={{ r: 3, fill: primary }}
activeDot={{ r: 5 }}
isAnimationActive={animate}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { LabBiomarkerChart } from "./lab-biomarker-chart";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type {
BiomarkerDto,
LabResultDto,
LabResultListResponse,
} from "./types";
/**
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
* dashboard-style chart with the reference band, and the editable reading
* history. "Add value" pre-selects this biomarker so the user never re-picks
* it. Mirrors the measurement-detail layout.
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const { data: marker, isError: markerError } = useQuery({
queryKey: queryKeys.biomarkerDetail(biomarkerId),
queryFn: () => apiGet<BiomarkerDto>(`/api/biomarkers/${biomarkerId}`),
});
const {
data: list,
isLoading,
isError: listError,
} = useQuery({
queryKey: queryKeys.labResultsList({
biomarkerId,
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
queryFn: () =>
apiGet<LabResultListResponse>(
`/api/labs?biomarkerId=${encodeURIComponent(biomarkerId)}&limit=500&sortDir=desc`,
),
});
const readings: LabResultDto[] = list?.results ?? [];
const latest =
readings.length > 0
? [...readings].sort(
(a, b) =>
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
)[0]
: null;
function afterAdd() {
setAddOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
if (markerError || listError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
}
const latestStatus = latest
? classifyReferenceRange(
latest.value,
marker?.lowerBound ?? null,
marker?.upperBound ?? null,
)
: "unknown";
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{marker?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
</div>
<Button onClick={() => setAddOpen(true)} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
{latest ? (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-2xl font-bold tabular-nums">
{formatLabValue(latest.value)}{" "}
<span className="text-muted-foreground text-base font-normal">
{marker?.unit ?? latest.unit}
</span>
</span>
<ReferenceRangeBadge status={latestStatus} />
</div>
) : null}
{marker?.context ? (
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
<Card>
<CardContent className="pt-6">
{isLoading ? (
<Skeleton className="h-60 w-full" />
) : readings.length === 0 ? (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
}
/>
) : (
<LabBiomarkerChart
readings={readings}
unit={marker?.unit ?? latest?.unit ?? ""}
lowerBound={marker?.lowerBound ?? null}
upperBound={marker?.upperBound ?? null}
/>
)}
</CardContent>
</Card>
{readings.length > 0 ? (
<div className="space-y-2">
<h2 className="text-sm font-semibold">{t("labs.detail.history")}</h2>
<Card>
<CardContent className="py-0">
<LabHistoryList readings={readings} />
</CardContent>
</Card>
</div>
) : null}
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
>
<LabForm
lockedBiomarkerId={biomarkerId}
onSuccess={afterAdd}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
+171 -124
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -8,13 +9,30 @@ import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { LabResultDto } from "./types";
import { BiomarkerForm } from "./biomarker-form";
import type {
BiomarkerDto,
BiomarkerListResponse,
LabResultDto,
} from "./types";
const NOTE_MAX_LENGTH = 2000;
const DEFINE_NEW = "__define_new__";
function defaultTakenAtValue() {
const now = new Date();
@@ -32,59 +50,83 @@ function parseDecimal(raw: string): number | null {
}
interface LabFormProps {
/** When set, the biomarker is pre-selected and locked (add-from-detail). */
lockedBiomarkerId?: string;
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
}
/**
* v1.17.1 — manual lab-result entry.
* v1.18.1 — structured lab-result entry.
*
* Progressive disclosure: the four essentials (analyte, value, unit, date)
* are always visible; the optional reference range, panel grouping, and
* note sit below under a clearly-labelled "optional" heading so a quick
* single-value entry stays a four-field affair.
* The error-prone free-text path is gone: the user PICKS a biomarker from the
* catalog, then enters only the value against its known unit + reference range
* (resolved server-side). A "+ define new" row opens the marker-definition
* sheet inline and returns with it selected. Panel / unit / range are NOT
* re-entered per reading — they live on the biomarker. Only the per-reading
* note stays optional here.
*/
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
export function LabForm({
lockedBiomarkerId,
onSuccess,
onCancel,
}: LabFormProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [analyte, setAnalyte] = useState("");
const { data: catalog, isLoading: catalogLoading } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
const [biomarkerId, setBiomarkerId] = useState<string>(
lockedBiomarkerId ?? "",
);
const [value, setValue] = useState("");
const [unit, setUnit] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [panel, setPanel] = useState("");
const [refLow, setRefLow] = useState("");
const [refHigh, setRefHigh] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const markers = catalog?.biomarkers ?? [];
const selected = markers.find((m) => m.id === biomarkerId);
function handleSelect(next: string) {
if (next === DEFINE_NEW) {
setDefineOpen(true);
return;
}
setBiomarkerId(next);
setError(null);
}
function afterDefine(created: BiomarkerDto) {
setDefineOpen(false);
queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() });
setBiomarkerId(created.id);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const numericValue = parseDecimal(value);
if (!analyte.trim() || numericValue === null || !unit.trim()) {
setError(t("labs.form.requiredError"));
if (!biomarkerId) {
setError(t("labs.form.pickBiomarkerError"));
return;
}
const low = parseDecimal(refLow);
const high = parseDecimal(refHigh);
if (low !== null && high !== null && low > high) {
setError(t("labs.form.rangeOrderError"));
if (numericValue === null) {
setError(t("labs.form.requiredError"));
return;
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
analyte: analyte.trim(),
biomarkerId,
value: numericValue,
unit: unit.trim(),
takenAt: new Date(takenAt).toISOString(),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(low !== null ? { referenceLow: low } : {}),
...(high !== null ? { referenceHigh: high } : {}),
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
@@ -98,94 +140,87 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-analyte">{t("labs.form.analyte")}</Label>
<Input
id="lab-analyte"
value={analyte}
onChange={(e) => setAnalyte(e.target.value)}
placeholder={t("labs.form.analytePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-value">{t("labs.form.value")}</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-unit">{t("labs.form.unit")}</Label>
<Input
id="lab-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("labs.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
const referenceText = selected
? formatReferenceRange(
selected.lowerBound,
selected.upperBound,
formatLabValue,
)
: "";
<fieldset className="space-y-4 rounded-lg border border-dashed p-4">
<legend className="text-muted-foreground px-1 text-xs font-medium">
{t("labs.form.optionalGroup")}
</legend>
return (
<>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<Label htmlFor="lab-panel">{t("labs.form.panel")}</Label>
<Input
id="lab-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.form.panelPlaceholder")}
maxLength={120}
/>
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
value={biomarkerId || undefined}
onValueChange={handleSelect}
disabled={!!lockedBiomarkerId || catalogLoading}
>
<SelectTrigger id="lab-biomarker" className="w-full">
<SelectValue
placeholder={t("labs.form.biomarkerPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{markers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} · {m.unit}
</SelectItem>
))}
{!lockedBiomarkerId ? (
<SelectItem value={DEFINE_NEW}>
{t("labs.form.defineNew")}
</SelectItem>
) : null}
</SelectContent>
</Select>
{markers.length === 0 && !catalogLoading ? (
<p className="text-muted-foreground text-xs">
{t("labs.form.noBiomarkersHint")}
</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-refLow">{t("labs.form.referenceLow")}</Label>
<Label htmlFor="lab-value">
{t("labs.form.value")}
{selected ? (
<span className="text-muted-foreground font-normal">
{" "}
({selected.unit})
</span>
) : null}
</Label>
<Input
id="lab-refLow"
id="lab-value"
inputMode="decimal"
value={refLow}
onChange={(e) => setRefLow(e.target.value)}
placeholder={t("labs.form.referenceLowPlaceholder")}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-refHigh">{t("labs.form.referenceHigh")}</Label>
<Input
id="lab-refHigh"
inputMode="decimal"
value={refHigh}
onChange={(e) => setRefHigh(e.target.value)}
placeholder={t("labs.form.referenceHighPlaceholder")}
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.form.referenceHint")}
</p>
{selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
@@ -197,32 +232,44 @@ export function LabForm({ onSuccess, onCancel }: LabFormProps) {
rows={2}
/>
</div>
</fieldset>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
{t("labs.form.save")}
</Button>
</div>
</form>
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</div>
</form>
<ResponsiveSheet
open={defineOpen}
onOpenChange={setDefineOpen}
title={t("labs.biomarker.defineTitle")}
description={t("labs.biomarker.defineDescription")}
>
<BiomarkerForm
onSuccess={afterDefine}
onCancel={() => setDefineOpen(false)}
/>
</ResponsiveSheet>
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { formatDateShort } from "@/lib/format";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDetailDto, LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
function toDateTimeLocal(iso: string): string {
const d = new Date(iso);
const offset = d.getTimezoneOffset();
const local = new Date(d.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
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;
}
/**
* v1.18.1 — reverse-chronological reading history for one biomarker, with
* full edit / amend / delete (L5). Edit opens a sheet that loads the
* decrypted note from the single-resource GET; delete soft-deletes with an
* Undo toast that restores via `/api/labs/restore` (the measurement pattern).
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [editTakenAt, setEditTakenAt] = useState("");
const [editNote, setEditNote] = useState("");
const [editLoading, setEditLoading] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
function invalidate() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
async function restore(id: string) {
try {
await apiPost("/api/labs/restore", { ids: [id] });
invalidate();
toast.success(t("labs.restoredToast"));
} catch {
toast.error(t("labs.restoreError"));
}
}
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
onSuccess: (_data, id) => {
invalidate();
toast.success(t("labs.deletedToast"), {
action: {
label: t("common.undo"),
onClick: () => void restore(id),
},
});
},
onError: () => toast.error(t("labs.deleteError")),
});
const updateMutation = useMutation({
mutationFn: (body: {
id: string;
value: number;
takenAt: string;
note: string | null;
}) =>
apiPut<LabResultDto>(`/api/labs/${body.id}`, {
value: body.value,
takenAt: body.takenAt,
note: body.note,
}),
onSuccess: () => {
invalidate();
toast.success(t("labs.editedToast"));
closeEdit();
},
onError: (err) => {
setEditError(
err instanceof ApiError ? err.message : t("labs.editError"),
);
},
});
async function openEdit(reading: LabResultDto) {
setEditingId(reading.id);
setEditValue(String(reading.value));
setEditTakenAt(toDateTimeLocal(reading.takenAt));
setEditNote("");
setEditError(null);
// Load the decrypted note (list rows withhold it).
setEditLoading(true);
try {
const detail = await apiGet<LabResultDetailDto>(
`/api/labs/${reading.id}`,
);
setEditNote(detail.note ?? "");
} catch {
/* leave note blank on a load miss */
} finally {
setEditLoading(false);
}
}
function closeEdit() {
if (updateMutation.isPending) return;
setEditingId(null);
setEditError(null);
}
function submitEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
const numericValue = parseDecimal(editValue);
if (numericValue === null) {
setEditError(t("labs.form.requiredError"));
return;
}
const takenDate = new Date(editTakenAt);
if (Number.isNaN(takenDate.getTime())) {
setEditError(t("labs.editTimestampError"));
return;
}
updateMutation.mutate({
id: editingId,
value: numericValue,
takenAt: takenDate.toISOString(),
note: editNote.trim() ? editNote.trim() : null,
});
}
const ordered = [...readings].sort(
(a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
);
return (
<>
<ul className="divide-border divide-y">
{ordered.map((r) => (
<li
key={r.id}
className="flex items-center justify-between gap-3 py-3"
>
<div className="min-w-0">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(r.value)} {r.unit}
</span>
<ReferenceRangeBadge status={r.rangeStatus} />
</div>
<p className="text-muted-foreground text-xs">
{formatDateShort(r.takenAt, true)}
{r.hasNote ? ` · ${t("labs.hasNote")}` : ""}
</p>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteReading")}
className="size-9"
iconClassName="h-4 w-4"
/>
</div>
</li>
))}
</ul>
<ResponsiveSheet
open={editingId !== null}
onOpenChange={(open) => {
if (!open) closeEdit();
}}
title={t("labs.editTitle")}
description={t("labs.editDescription")}
>
<form onSubmit={submitEdit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-edit-value">{t("labs.form.value")}</Label>
<Input
id="lab-edit-value"
inputMode="decimal"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-takenAt">
{t("labs.form.takenAt")}
</Label>
<DateTimeInput
id="lab-edit-takenAt"
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-note">{t("labs.form.note")}</Label>
<Textarea
id="lab-edit-note"
value={editNote}
onChange={(e) => setEditNote(e.target.value)}
maxLength={NOTE_MAX_LENGTH}
rows={2}
disabled={editLoading}
/>
</div>
{editError ? (
<p className="text-destructive text-sm" role="alert">
{editError}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeEdit}
disabled={updateMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</div>
</form>
</ResponsiveSheet>
</>
);
}
+101 -95
View File
@@ -1,24 +1,23 @@
"use client";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, FlaskConical } from "lucide-react";
import { DeleteButton } from "@/components/data-list";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -26,34 +25,44 @@ import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDto, LabResultListResponse } from "./types";
interface AnalyteGroup {
interface MarkerGroup {
/** Stable group key: the biomarker id, or `analyte:<lower>` for legacy rows. */
key: string;
/** Link target when the group is catalog-linked; null for legacy rows. */
biomarkerId: string | null;
analyte: string;
panel: string | null;
unit: string;
/** All readings for this analyte, oldest → newest. */
readings: LabResultDto[];
latest: LabResultDto;
}
/** Reduce the flat result list into per-analyte groups (case-insensitive). */
function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
/**
* Group readings by their linked biomarker (or, for legacy un-linked rows,
* case-insensitively by analyte). A catalog-linked group deep-links to its
* detail chart; legacy groups render inert until the backfill links them.
*/
function groupReadings(results: LabResultDto[]): MarkerGroup[] {
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.analyte.toLowerCase();
const existing = byKey.get(key);
if (existing) existing.push(r);
const key = r.biomarkerId
? `bm:${r.biomarkerId}`
: `analyte:${r.analyte.toLowerCase()}`;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: AnalyteGroup[] = [];
for (const readings of byKey.values()) {
// Ascending by takenAt so the sparkline reads left→right in time and
// the last element is the latest.
const groups: MarkerGroup[] = [];
for (const [key, readings] of byKey.entries()) {
const ordered = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
key,
biomarkerId: latest.biomarkerId,
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
@@ -61,7 +70,6 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
latest,
});
}
// Group order: most-recently-updated analyte first.
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
@@ -69,16 +77,11 @@ function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
);
}
function formatValue(value: number): string {
// Trim trailing zeros for whole numbers; keep up to 2 decimals otherwise.
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
}
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
analyte: undefined,
panel: undefined,
from: undefined,
@@ -89,25 +92,12 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { data, isLoading, isError } = useQuery({
queryKey: listKey,
queryFn: async () => {
const res = await apiGet<LabResultListResponse>(
"/api/labs?limit=500&sortDir=desc",
);
return res;
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
onSuccess: () => {
toast.success(t("labs.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.deleteError")),
queryFn: () =>
apiGet<LabResultListResponse>("/api/labs?limit=500&sortDir=desc"),
});
const groups = useMemo(
() => groupByAnalyte(data?.results ?? []),
() => groupReadings(data?.results ?? []),
[data?.results],
);
@@ -154,60 +144,76 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
return (
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<DeleteButton
onConfirm={() => deleteMutation.mutate(group.latest.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
confirmLabel={t("labs.deleteLatest")}
className="size-9"
iconClassName="h-4 w-4"
/>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatValue,
)}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</CardContent>
</Card>
))}
{groups.map((group) => {
const body = (
<>
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-3">
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatLabValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{formatReferenceRange(
group.latest.referenceLow,
group.latest.referenceHigh,
formatLabValue,
)}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<LabTrendSparkline
values={group.readings.map((r) => r.value)}
/>
{group.biomarkerId ? (
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" />
) : null}
</div>
</div>
</CardContent>
</>
);
// Catalog-linked groups deep-link to the per-biomarker detail chart.
// Legacy un-linked groups render as a plain card until the backfill
// links them (or the user re-adds via the structured path).
return group.biomarkerId ? (
<Link
key={group.key}
href={`/labs/${group.biomarkerId}`}
className="block"
>
<Card className="hover:bg-muted/40 transition-colors">{body}</Card>
</Link>
) : (
<Card key={group.key}>{body}</Card>
);
})}
</div>
);
}
+25
View File
@@ -3,6 +3,8 @@ import type { ReferenceRangeStatus } from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
id: string;
/** v1.18.1 — link to the catalog marker (null for legacy free-text rows). */
biomarkerId: string | null;
panel: string | null;
analyte: string;
value: number;
@@ -17,7 +19,30 @@ export interface LabResultDto {
updatedAt: string;
}
/** The single-resource detail DTO — carries the decrypted note. */
export interface LabResultDetailDto extends Omit<LabResultDto, "hasNote"> {
note: string | null;
}
export interface LabResultListResponse {
results: LabResultDto[];
meta: { total: number; limit: number; offset: number };
}
/** A user-scoped Biomarker catalog entry as the API serialises it. */
export interface BiomarkerDto {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
hasContext: boolean;
context: string | null;
createdAt: string;
updatedAt: string;
}
export interface BiomarkerListResponse {
biomarkers: BiomarkerDto[];
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { groupRowsByAnalyte } from "../lab-biomarker-backfill";
function row(
id: string,
analyte: string,
unit: string,
takenAt: string,
opts: { low?: number | null; high?: number | null; panel?: string | null } = {},
) {
return {
id,
analyte,
unit,
referenceLow: opts.low ?? null,
referenceHigh: opts.high ?? null,
panel: opts.panel ?? null,
takenAt: new Date(takenAt),
};
}
describe("groupRowsByAnalyte", () => {
it("collapses case/spacing variants of the same analyte into one group", () => {
const groups = groupRowsByAnalyte([
row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "ldl", "mg/dl", "2026-02-01T00:00:00Z"),
row("c", " LDL ", "mg/dL", "2026-03-01T00:00:00Z"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].ids.sort()).toEqual(["a", "b", "c"]);
});
it("uses the most-recently-taken reading for the canonical name/unit/range", () => {
// Same analyte key ("ldl"), case + unit + range drift across reports —
// the newest report's spelling/unit/range wins.
const groups = groupRowsByAnalyte([
row("old", "ldl", "mg/dl", "2026-01-01T00:00:00Z", { high: 100 }),
row("new", "LDL", "mg/dL", "2026-06-01T00:00:00Z", { high: 116 }),
]);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("LDL");
expect(groups[0].unit).toBe("mg/dL");
expect(groups[0].referenceHigh).toBe(116);
});
it("keeps distinct analytes in separate groups", () => {
const groups = groupRowsByAnalyte([
row("a", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "HDL", "mg/dL", "2026-01-01T00:00:00Z"),
]);
expect(groups).toHaveLength(2);
});
it("skips a row with a blank analyte", () => {
const groups = groupRowsByAnalyte([
row("a", " ", "mg/dL", "2026-01-01T00:00:00Z"),
row("b", "LDL", "mg/dL", "2026-01-01T00:00:00Z"),
]);
expect(groups).toHaveLength(1);
expect(groups[0].name).toBe("LDL");
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* v1.18.1 — one-shot backfill that links legacy `LabResult` rows to a
* user-scoped `Biomarker` catalog entry.
*
* Before the catalog existed, every lab reading carried its own free-text
* `analyte` / `unit` / `reference*`. Recording "LDL" three times forked the
* "same" marker into "LDL" / "ldl" / "LDL-C" with possibly diverging units
* and ranges. This pass heals that at the source: for each user, it groups
* every un-linked live reading by `lower(analyte)`, creates ONE `Biomarker`
* per group (using the most-recently-taken reading's spelling / unit /
* reference bounds — "last lab report wins"), and stamps `biomarkerId` on
* every row in the group.
*
* Idempotent across reboots: the discovery predicate is "the user has at
* least one live reading with `biomarkerId IS NULL`". A completed pass leaves
* every reading linked, so the user drops out of the discovery set. A reboot
* mid-pass re-runs from scratch — `Biomarker` creation upserts on
* `(userId, name)` and the row-link `updateMany` is `biomarkerId: null`-gated,
* so the result converges. A reading whose marker already exists (e.g. the
* user defined it by hand) re-uses that row rather than minting a duplicate.
*
* Modelled on `sleep-timeline-backfill.ts` / `whoop-backfill.ts`. The queue
* name MUST be registered in `allQueues` in `src/lib/jobs/reminder-worker.ts`
* or pg-boss never provisions it and the boot enqueue silently never drains
* (the v1.4.37 dead-queue class).
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill";
/**
* Serial concurrency — the per-user pass is a short burst of grouped writes;
* concurrency-1 keeps it off the request pool, matching the other backfill
* queues.
*/
export const LAB_BIOMARKER_BACKFILL_CONCURRENCY = 1;
export interface LabBiomarkerBackfillPayload {
userId: string;
enqueuedAt: string;
}
/** A single legacy reading the grouping reads. */
interface LegacyRow {
id: string;
analyte: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
takenAt: Date;
}
interface AnalyteGroup {
/** Canonical spelling = the most-recently-taken reading's `analyte`. */
name: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
ids: string[];
}
/**
* Reduce the user's un-linked rows into one group per `lower(analyte)`. The
* canonical name / unit / range come from the most-recently-TAKEN reading in
* each group ("last lab report wins"); every row id rides into the group so a
* single `updateMany` can link them. Exported for unit testing.
*/
export function groupRowsByAnalyte(rows: LegacyRow[]): AnalyteGroup[] {
const byKey = new Map<string, LegacyRow[]>();
for (const r of rows) {
const key = r.analyte.trim().toLowerCase();
if (!key) continue;
const bucket = byKey.get(key);
if (bucket) bucket.push(r);
else byKey.set(key, [r]);
}
const groups: AnalyteGroup[] = [];
for (const bucket of byKey.values()) {
// Newest reading defines the canonical name / unit / range.
const newest = bucket.reduce((a, b) =>
b.takenAt.getTime() >= a.takenAt.getTime() ? b : a,
);
groups.push({
name: newest.analyte.trim(),
unit: newest.unit,
referenceLow: newest.referenceLow,
referenceHigh: newest.referenceHigh,
panel: newest.panel,
ids: bucket.map((r) => r.id),
});
}
return groups;
}
/**
* Backfill one user: group un-linked live readings, create/reuse a Biomarker
* per group, link the rows. Returns the count of markers touched + rows
* linked.
*/
export async function runLabBiomarkerBackfillForUser(
userId: string,
): Promise<{ markers: number; linked: number }> {
const rows = await prisma.labResult.findMany({
where: { userId, deletedAt: null, biomarkerId: null },
select: {
id: true,
analyte: true,
unit: true,
referenceLow: true,
referenceHigh: true,
panel: true,
takenAt: true,
},
});
if (rows.length === 0) {
return { markers: 0, linked: 0 };
}
const groups = groupRowsByAnalyte(rows);
let markers = 0;
let linked = 0;
for (const group of groups) {
// Re-use a hand-defined marker of the same name if one exists; otherwise
// mint it. `(userId, name)` is unique, so a concurrent re-run converges.
const biomarker = await prisma.biomarker.upsert({
where: { userId_name: { userId, name: group.name } },
// An existing marker keeps its own (possibly user-tuned) unit / range.
update: {},
create: {
userId,
name: group.name,
unit: group.unit,
lowerBound: group.referenceLow,
upperBound: group.referenceHigh,
panel: group.panel,
},
});
markers += 1;
const { count } = await prisma.labResult.updateMany({
where: { id: { in: group.ids }, userId, biomarkerId: null },
data: { biomarkerId: biomarker.id },
});
linked += count;
}
annotate({
action: {
name: "labs.biomarker.backfill.complete",
details: { markers, linked },
},
});
return { markers, linked };
}
/**
* Boot-time discovery. Finds every user holding at least one un-linked live
* lab reading and enqueues one job each. Idempotent across reboots: a
* completed pass links every reading, dropping the user from the discovery
* set. `singletonKey` coalesces duplicate sends.
*
* Best-effort: errors come back through the result value so worker boot never
* fails because of a backfill miss.
*/
export async function enqueueBootTimeLabBiomarkerBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const rows = await prisma.labResult.findMany({
where: { deletedAt: null, biomarkerId: null },
select: { userId: true },
distinct: ["userId"],
});
if (rows.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of rows) {
const payload: LabBiomarkerBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(LAB_BIOMARKER_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `lab-biomarker-backfill|${userId}`,
});
if (jobId) enqueued += 1;
else skipped += 1;
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
+58
View File
@@ -28,6 +28,13 @@ import {
enqueueBootTimeSleepTimelineBackfill,
type SleepTimelineBackfillPayload,
} from "@/lib/jobs/sleep-timeline-backfill";
import {
LAB_BIOMARKER_BACKFILL_QUEUE,
LAB_BIOMARKER_BACKFILL_CONCURRENCY,
runLabBiomarkerBackfillForUser,
enqueueBootTimeLabBiomarkerBackfill,
type LabBiomarkerBackfillPayload,
} from "@/lib/jobs/lab-biomarker-backfill";
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
@@ -662,6 +669,11 @@ export async function startReminderWorker() {
// pg-boss never provisions it and the boot enqueue silently never drains
// (the v1.4.37 dead-queue class).
SLEEP_TIMELINE_BACKFILL_QUEUE,
// v1.18.1 — one-shot backfill that links legacy free-text lab readings to
// a user-scoped Biomarker catalog entry (group by `lower(analyte)`). The
// queue MUST be registered here or pg-boss never provisions it and the
// boot enqueue silently never drains (the v1.4.37 dead-queue class).
LAB_BIOMARKER_BACKFILL_QUEUE,
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
// backfill queue — the hourly window walks the recent SGV set). The queue
// MUST be registered here or pg-boss never provisions it and the schedule
@@ -1125,6 +1137,25 @@ export async function startReminderWorker() {
}
},
);
// v1.18.1 — one-shot lab-biomarker backfill. The boot enqueue below sends
// one job per user holding un-linked free-text lab readings; this handler
// groups them by `lower(analyte)`, mints/reuses a Biomarker per group, and
// links the rows. Idempotent — a re-run links only what is still un-linked.
await boss.work<LabBiomarkerBackfillPayload>(
LAB_BIOMARKER_BACKFILL_QUEUE,
{ localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const { markers, linked } =
await runLabBiomarkerBackfillForUser(userId);
workerLog(
"info",
`[lab-biomarker-backfill] user=${userId} markers=${markers} linked=${linked}`,
);
}
},
);
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
// `userId`) walks every configured instance; one user's unreachable host is
// warned, not fatal.
@@ -1996,6 +2027,33 @@ export async function startReminderWorker() {
);
}
// v1.18.1 — fire-and-forget boot discovery for the one-shot lab-biomarker
// backfill. Finds every user holding an un-linked live lab reading and
// enqueues one job each. Idempotent across reboots: a completed pass links
// every reading, dropping the user from the discovery set. Errors come back
// through the helper's result value — worker boot never fails on a miss.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeLabBiomarkerBackfill();
if (error) {
workerLog(
"error",
`[lab-biomarker-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[lab-biomarker-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.7.0 — fire-and-forget boot discovery for the daily-mean
// consolidation pass. Finds every user holding live per-sample
// high-frequency mean-type rows and enqueues one job per account.
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
decryptContextFromBytes,
encryptContextToBytes,
} from "../biomarker-store";
const KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
describe("biomarker context codec", () => {
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY);
_resetCryptoCacheForTests();
});
it("round-trips a UTF-8 context note through Bytes", () => {
const plain = "Nüchtern · morgens · Lipidpanel";
const bytes = encryptContextToBytes(plain);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(decryptContextFromBytes(bytes)).toBe(plain);
});
it("does not store the plaintext verbatim in the bytes", () => {
const plain = "secret context";
const bytes = encryptContextToBytes(plain);
const asString = Buffer.from(bytes).toString("utf8");
expect(asString).not.toContain(plain);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import {
resolveLabFields,
serialiseLabResult,
serialiseLabResultDetail,
type LabRow,
type ResolvedBiomarker,
} from "../serialise";
const baseRow: LabRow = {
id: "lr_1",
panel: "Legacy panel",
analyte: "ldl", // lowercase legacy spelling
value: 130,
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
noteEncrypted: null,
createdAt: new Date("2026-01-01T08:00:00.000Z"),
updatedAt: new Date("2026-01-01T08:00:00.000Z"),
};
const biomarker: ResolvedBiomarker = {
id: "bm_1",
name: "LDL Cholesterol",
unit: "mg/dL",
lowerBound: null,
upperBound: 116,
panel: "Lipids",
};
describe("resolveLabFields", () => {
it("prefers the linked biomarker over the legacy row fields", () => {
const r = resolveLabFields(baseRow, biomarker);
expect(r.analyte).toBe("LDL Cholesterol");
expect(r.unit).toBe("mg/dL");
expect(r.referenceHigh).toBe(116);
expect(r.panel).toBe("Lipids");
});
it("falls back to the row fields when unlinked", () => {
const r = resolveLabFields({ ...baseRow, biomarkerId: null }, null);
expect(r.analyte).toBe("ldl");
expect(r.unit).toBe("mg/dl");
expect(r.referenceHigh).toBe(100);
expect(r.panel).toBe("Legacy panel");
});
});
describe("serialiseLabResult", () => {
it("computes the verdict from the RESOLVED catalog bounds, not the stale row", () => {
// value 130 vs row's stale high 100 → would be 'above';
// vs catalog high 116 → still 'above', but the resolved bound is what counts.
const dto = serialiseLabResult(baseRow, biomarker);
expect(dto.unit).toBe("mg/dL");
expect(dto.referenceHigh).toBe(116);
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBe("bm_1");
expect(dto.hasNote).toBe(false);
});
it("in-range when the resolved catalog band contains the value", () => {
const within = serialiseLabResult({ ...baseRow, value: 90 }, biomarker);
expect(within.rangeStatus).toBe("in-range");
});
it("uses legacy fields and verdict when unlinked", () => {
const dto = serialiseLabResult({ ...baseRow, biomarkerId: null }, null);
expect(dto.unit).toBe("mg/dl");
// 130 > row high 100 → above
expect(dto.rangeStatus).toBe("above");
expect(dto.biomarkerId).toBeNull();
});
it("flags an encrypted note without echoing it", () => {
const dto = serialiseLabResult(
{ ...baseRow, noteEncrypted: new Uint8Array([1, 2, 3]) },
biomarker,
);
expect(dto.hasNote).toBe(true);
expect(dto).not.toHaveProperty("noteEncrypted");
});
});
describe("serialiseLabResultDetail", () => {
it("carries the decrypted note in place of hasNote", () => {
const dto = serialiseLabResultDetail(baseRow, biomarker, "fasting sample");
expect(dto.note).toBe("fasting sample");
expect(dto).not.toHaveProperty("hasNote");
expect(dto.unit).toBe("mg/dL");
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* v1.18.1 — static seed catalog of common blood/lab biomarkers.
*
* This is NOT a DB table. It is a curated list offered at the add step
* ("pick a common marker, or create your own") so a self-hoster does not
* type a marker definition from scratch. Selecting a seed pre-fills the
* inline definition form (canonical name, unit, suggested reference bounds)
* which the user then accepts or overrides before it becomes a real,
* user-scoped `Biomarker` row.
*
* The suggested ranges are EDITABLE DEFAULTS, never gospel — reference
* windows vary by lab, sex, and age. The display name is localised via the
* `labs.catalog.<slug>` i18n key; the EN slug is the stable identity (mirrors
* the Insights slug/alias pattern). Units stay literal (mg/dL etc. are not
* translated).
*
* Source ranges: common adult fasting reference windows (US conventional
* units where the metric/SI split matters). Presented as starting points.
*/
export interface BiomarkerSeed {
/** Stable EN identity; the `labs.catalog.<slug>` i18n key resolves the name. */
slug: string;
/** Panel grouping key; the `labs.catalog.panel.<panel>` i18n key labels it. */
panel: string;
/** Canonical unit for every reading against this marker. */
unit: string;
/** Suggested lower bound (editable default), or null when open-ended. */
lowerBound: number | null;
/** Suggested upper bound (editable default), or null when open-ended. */
upperBound: number | null;
}
export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
// Lipids
{ slug: "total-cholesterol", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 200 },
{ slug: "ldl", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 116 },
{ slug: "hdl", panel: "lipids", unit: "mg/dL", lowerBound: 40, upperBound: null },
{ slug: "triglycerides", panel: "lipids", unit: "mg/dL", lowerBound: null, upperBound: 150 },
// Glucose metabolism
{ slug: "fasting-glucose", panel: "glucose", unit: "mg/dL", lowerBound: 70, upperBound: 100 },
{ slug: "hba1c", panel: "glucose", unit: "%", lowerBound: null, upperBound: 5.7 },
// Thyroid
{ slug: "tsh", panel: "thyroid", unit: "mIU/L", lowerBound: 0.4, upperBound: 4 },
{ slug: "ft3", panel: "thyroid", unit: "pg/mL", lowerBound: 2.3, upperBound: 4.2 },
{ slug: "ft4", panel: "thyroid", unit: "ng/dL", lowerBound: 0.8, upperBound: 1.8 },
// Iron
{ slug: "ferritin", panel: "iron", unit: "ng/mL", lowerBound: 30, upperBound: 400 },
{ slug: "transferrin-saturation", panel: "iron", unit: "%", lowerBound: 20, upperBound: 50 },
// Vitamins
{ slug: "vitamin-d", panel: "vitamins", unit: "ng/mL", lowerBound: 30, upperBound: 100 },
{ slug: "vitamin-b12", panel: "vitamins", unit: "pg/mL", lowerBound: 200, upperBound: 900 },
{ slug: "folate", panel: "vitamins", unit: "ng/mL", lowerBound: 3, upperBound: 20 },
// Inflammation
{ slug: "hs-crp", panel: "inflammation", unit: "mg/L", lowerBound: null, upperBound: 3 },
// Renal
{ slug: "creatinine", panel: "renal", unit: "mg/dL", lowerBound: 0.6, upperBound: 1.3 },
{ slug: "egfr", panel: "renal", unit: "mL/min/1.73m²", lowerBound: 90, upperBound: null },
// Liver
{ slug: "alt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ast", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 40 },
{ slug: "ggt", panel: "liver", unit: "U/L", lowerBound: null, upperBound: 55 },
// Electrolytes
{ slug: "sodium", panel: "electrolytes", unit: "mmol/L", lowerBound: 135, upperBound: 145 },
{ slug: "potassium", panel: "electrolytes", unit: "mmol/L", lowerBound: 3.5, upperBound: 5.1 },
// Blood count
{ slug: "hemoglobin", panel: "bloodCount", unit: "g/dL", lowerBound: 12, upperBound: 17.5 },
{ slug: "hematocrit", panel: "bloodCount", unit: "%", lowerBound: 36, upperBound: 50 },
{ slug: "wbc", panel: "bloodCount", unit: "10³/µL", lowerBound: 4, upperBound: 11 },
{ slug: "platelets", panel: "bloodCount", unit: "10³/µL", lowerBound: 150, upperBound: 400 },
] as const;
/** The set of panel keys present in the catalog, in display order. */
export const BIOMARKER_PANELS = [
"lipids",
"glucose",
"thyroid",
"iron",
"vitamins",
"inflammation",
"renal",
"liver",
"electrolytes",
"bloodCount",
] as const;
export type BiomarkerPanelKey = (typeof BIOMARKER_PANELS)[number];
+30
View File
@@ -0,0 +1,30 @@
/**
* v1.18.1 — server-side helpers for the user-scoped Biomarker catalog.
*
* The AES-256-GCM ↔ `Bytes` codec for `Biomarker.contextEncrypted`. The
* per-marker context note ("what this means") is the only sensitive column on
* the model; it shares the `encrypt()` string format (`"<keyId>.<base64>"`)
* every other `*Encrypted` column uses, encoded as UTF-8 bytes.
*
* Mirrors the `LabResult.noteEncrypted` codec in `./store.ts` so the two
* encrypted columns on the Labs feature share one byte layout.
*/
import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
export function encryptContextToBytes(
plaintext: string,
): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(plaintext);
const encoded = Buffer.from(ciphertext, "utf8");
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
out.set(encoded);
return out;
}
/** Decrypt a stored `Bytes` context note back to plaintext. */
export function decryptContextFromBytes(buf: Uint8Array): string {
return decrypt(Buffer.from(buf).toString("utf8"));
}
+13
View File
@@ -0,0 +1,13 @@
/**
* v1.18.1 — shared lab-value number formatting.
*
* The list card, the detail history rows, and the chart tooltip all trim a
* lab reading the same way: whole numbers render bare, fractional values keep
* up to two decimals (no trailing-zero noise). Centralised so the three
* surfaces stay byte-identical.
*/
export function formatLabValue(value: number): string {
return Number.isInteger(value)
? String(value)
: String(Number(value.toFixed(2)));
}
+150
View File
@@ -0,0 +1,150 @@
/**
* v1.18.1 — server-authoritative lab-result DTO resolution.
*
* A lab row resolves its display name + unit + reference bounds in ONE place:
*
* - If the row links a `Biomarker` (`biomarkerId` set), the canonical name,
* unit, and reference bounds come FROM THE BIOMARKER. The per-row legacy
* `analyte` / `unit` / `reference*` columns are ignored — they are stale
* historical truth at that point.
* - If the row is unlinked (legacy / pre-backfill), it falls back to its own
* free-text `analyte` / `unit` / `reference*` columns.
*
* Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds
* via `classifyReferenceRange`. The DTO carries the resolved values so the web
* client AND the iOS client render the same numbers — neither recomputes the
* range or guesses the unit. This is the server-authoritative-parity rule for
* Labs.
*/
import { classifyReferenceRange } from "@/lib/labs/reference-range";
/** The minimal biomarker shape the resolver needs (no encrypted context). */
export interface ResolvedBiomarker {
id: string;
name: string;
unit: string;
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
}
/** The lab-result row shape the resolver reads (Prisma row subset). */
export interface LabRow {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
source: string;
biomarkerId: string | null;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Resolve the canonical name / unit / bounds / panel for a lab row, preferring
* the linked biomarker over the legacy per-row fields. Returns a plain shape
* the list + detail serialisers both build on.
*/
export function resolveLabFields(
row: Pick<
LabRow,
| "analyte"
| "unit"
| "referenceLow"
| "referenceHigh"
| "panel"
| "biomarkerId"
>,
biomarker: ResolvedBiomarker | null | undefined,
): {
analyte: string;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
panel: string | null;
} {
if (biomarker) {
return {
analyte: biomarker.name,
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
panel: biomarker.panel,
};
}
return {
analyte: row.analyte,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
panel: row.panel,
};
}
/**
* Serialise a lab row to the list DTO — never echoes the encrypted note bytes
* (only the `hasNote` flag) and resolves name/unit/range server-side.
*/
export function serialiseLabResult(
row: LabRow,
biomarker?: ResolvedBiomarker | null,
) {
const resolved = resolveLabFields(row, biomarker);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Serialise a lab row to the detail DTO — same as the list DTO but carries the
* decrypted `note` instead of the `hasNote` flag.
*/
export function serialiseLabResultDetail(
row: LabRow,
biomarker: ResolvedBiomarker | null | undefined,
note: string | null,
) {
const resolved = resolveLabFields(row, biomarker);
return {
id: row.id,
biomarkerId: row.biomarkerId,
panel: resolved.panel,
analyte: resolved.analyte,
value: row.value,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
rangeStatus: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
+182
View File
@@ -0,0 +1,182 @@
/**
* OpenAPI route table for the user-scoped Biomarker catalog (`/api/biomarkers`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* bodies reuse the runtime Zod schemas from `@/lib/validations/biomarkers`
* so the wire contract stays single-source. The response shape is declared
* here (the route serialises a derived `hasContext` flag the input schema
* doesn't carry, and returns the decrypted `context`).
*
* The iOS client consumes the biomarker DTO + the `biomarkerId` field on
* `LabResult` to render the resolved unit + reference range without
* recomputing — server-authoritative parity for Labs.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "@/lib/validations/biomarkers";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
createBiomarkerSchema.meta({
id: "CreateBiomarkerRequest",
description:
"Define a user-scoped biomarker ONCE: canonical `name`, `unit`, optional reference bounds (`lowerBound` / `upperBound`; when both present `lowerBound` must not exceed `upperBound`), an optional encrypted `context` note, and an optional `panel` grouping. The name is unique per user (no second 'LDL'). Recording a value later just picks this marker — its unit + range are never re-entered.",
});
updateBiomarkerSchema.meta({
id: "UpdateBiomarkerRequest",
description:
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.",
});
const biomarkerRow = z
.object({
id: z.string(),
name: z.string(),
unit: z.string(),
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "Biomarker",
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.",
});
const listResponse = z
.object({ biomarkers: z.array(biomarkerRow) })
.meta({ id: "ListBiomarkersResponse" });
const conflict = {
"409": {
description: "A biomarker with this name already exists for the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const notFound = {
"404": {
description: "Biomarker not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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: dataEnvelope(listResponse, "ListBiomarkersEnvelope"),
},
},
},
...stdResponses,
},
},
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: createBiomarkerSchema } },
},
responses: {
"201": {
description: "Created biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "CreateBiomarkerResponse"),
},
},
},
...conflict,
...stdResponses,
},
},
},
"/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.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Biomarker detail.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"),
},
},
},
...notFound,
...stdResponses,
},
},
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`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateBiomarkerSchema } },
},
responses: {
"200": {
description: "Updated biomarker.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "UpdateBiomarkerResponse"),
},
},
},
...conflict,
...notFound,
...stdResponses,
},
},
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`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deletion succeeded.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteBiomarkerResponse",
),
},
},
},
...notFound,
...stdResponses,
},
},
},
};
+2
View File
@@ -26,6 +26,7 @@ import { authPaths } from "./auth";
import { coachFeedbackPaths, coachPaths } from "./coach";
import { consentPaths } from "./consent";
import { cyclePaths } from "./cycle";
import { biomarkerPaths } from "./biomarkers";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { importPaths } from "./import";
@@ -63,6 +64,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...importPaths,
...measurementReminderPaths,
...labsPaths,
...biomarkerPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+34 -1
View File
@@ -47,6 +47,7 @@ const rangeStatusEnum = z
const labResultRow = z
.object({
id: z.string(),
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
value: z.number(),
@@ -63,7 +64,7 @@ const labResultRow = z
.meta({
id: "LabResult",
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`.",
"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`.",
});
const labResultDetail = labResultRow
@@ -201,4 +202,36 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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: z
.object({ ids: z.array(z.string()).min(1).max(200) })
.meta({ id: "RestoreLabResultsRequest" }),
},
},
},
responses: {
"200": {
description: "Restore count.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ restored: z.number() }),
"RestoreLabResultsResponse",
),
},
},
},
...stdResponses,
},
},
},
};
+7
View File
@@ -9,6 +9,7 @@ export const labKeys = {
labResults: () => ["lab-results"] as const,
labResultsList: (params: {
biomarkerId?: string | undefined;
analyte: string | undefined;
panel: string | undefined;
from: string | undefined;
@@ -19,6 +20,7 @@ export const labKeys = {
[
"lab-results",
"list",
params.biomarkerId ?? null,
params.analyte ?? null,
params.panel ?? null,
params.from ?? null,
@@ -26,4 +28,9 @@ export const labKeys = {
params.page,
params.sortDir,
] as const,
// v1.18.1 — user-scoped Biomarker catalog. The list feeds the picker and
// the manager; both invalidate `biomarkers()` after a mutation.
biomarkers: () => ["biomarkers"] as const,
biomarkerDetail: (id: string) => ["biomarkers", "detail", id] as const,
};
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "../biomarkers";
describe("createBiomarkerSchema", () => {
it("accepts a minimal marker (name + unit only)", () => {
const r = createBiomarkerSchema.safeParse({ name: "LDL", unit: "mg/dL" });
expect(r.success).toBe(true);
});
it("accepts both bounds when low <= high", () => {
const r = createBiomarkerSchema.safeParse({
name: "Fasting glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
});
expect(r.success).toBe(true);
});
it("accepts an open-ended upper bound (lower omitted)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
upperBound: 116,
});
expect(r.success).toBe(true);
});
it("rejects a transposed range (low > high)", () => {
const r = createBiomarkerSchema.safeParse({
name: "LDL",
unit: "mg/dL",
lowerBound: 200,
upperBound: 100,
});
expect(r.success).toBe(false);
});
it("rejects an empty name", () => {
const r = createBiomarkerSchema.safeParse({ name: " ", unit: "mg/dL" });
expect(r.success).toBe(false);
});
it("normalises an empty context to undefined", () => {
const r = createBiomarkerSchema.safeParse({
name: "TSH",
unit: "mIU/L",
context: "",
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.context).toBeUndefined();
});
});
describe("updateBiomarkerSchema", () => {
it("accepts a partial edit", () => {
const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" });
expect(r.success).toBe(true);
});
it("accepts an explicit null to clear a bound", () => {
const r = updateBiomarkerSchema.safeParse({ lowerBound: null });
expect(r.success).toBe(true);
if (r.success) expect(r.data.lowerBound).toBeNull();
});
it("still rejects a transposed range on update", () => {
const r = updateBiomarkerSchema.safeParse({
lowerBound: 10,
upperBound: 5,
});
expect(r.success).toBe(false);
});
});
@@ -75,6 +75,32 @@ describe("createLabResultSchema", () => {
expect(r.success).toBe(true);
});
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects an entry with neither biomarkerId nor analyte", () => {
const r = createLabResultSchema.safeParse({
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a free-text entry missing the unit", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
+89
View File
@@ -0,0 +1,89 @@
/**
* v1.18.1 — user-scoped Biomarker catalog validation.
*
* A `Biomarker` defines a lab marker ONCE: canonical name, unit, reference
* bounds, and an optional context note. Recording a value later is just
* picking the biomarker — the unit and reference range are resolved
* server-side from the catalog row, never re-entered (ending the per-row
* free-text divergence: "LDL" vs "ldl", `mg/dL` vs `mg/dl`).
*
* The reference window is the lab/sex/age-dependent "normal" range; the
* server's neutral range verdict (`classifyReferenceRange`) is computed
* against these bounds. Both are independently optional (a marker may report
* only an upper bound, e.g. LDL < 116); when both are present the lower must
* not exceed the upper.
*/
import { z } from "zod/v4";
const requiredText = (max: number) => z.string().trim().min(1).max(max);
const lowerBound = z.number().finite().optional();
const upperBound = z.number().finite().optional();
const optionalContext = z
.string()
.trim()
.max(2000)
.optional()
// An empty / whitespace-only context normalises to "no context" so a
// blank field clears rather than stores an empty string.
.transform((v) => (v && v.length > 0 ? v : undefined));
const optionalPanel = z
.string()
.trim()
.min(1)
.max(120)
.optional()
.or(z.literal("").transform(() => undefined));
export const createBiomarkerSchema = z
.object({
name: requiredText(120),
unit: requiredText(40),
lowerBound,
upperBound,
context: optionalContext,
panel: optionalPanel,
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.upperBound === undefined ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type CreateBiomarkerInput = z.infer<typeof createBiomarkerSchema>;
/**
* Update schema: every field optional (partial edit). `context` / `panel` /
* a bound accept an explicit `null` to clear the stored value — distinct from
* an omitted key, which leaves the column untouched.
*/
export const updateBiomarkerSchema = z
.object({
name: requiredText(120).optional(),
unit: requiredText(40).optional(),
lowerBound: lowerBound.or(z.null()),
upperBound: upperBound.or(z.null()),
context: optionalContext.or(z.null()),
panel: optionalPanel.or(z.null()),
})
.refine(
(d) =>
d.lowerBound === undefined ||
d.lowerBound === null ||
d.upperBound === undefined ||
d.upperBound === null ||
d.lowerBound <= d.upperBound,
{
message: "lowerBound must not exceed upperBound",
path: ["lowerBound"],
},
);
export type UpdateBiomarkerInput = z.infer<typeof updateBiomarkerSchema>;
+26 -3
View File
@@ -68,17 +68,38 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
* from the biomarker server-side, so `analyte` / `unit` may be omitted (the
* route fills them from the catalog row). A free-text reading without a
* catalog link stays valid (quick-capture / legacy path).
*/
const biomarkerId = z.string().trim().min(1).max(64).optional();
export const createLabResultSchema = z
.object({
biomarkerId,
panel: optionalPanel,
analyte: requiredText(120),
analyte: requiredText(120).optional(),
value: z.number().finite(),
unit: requiredText(40),
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
takenAt: takenAtField,
note: optionalNote,
})
// Either a catalog link OR a free-text analyte must be present.
.refine((d) => d.biomarkerId !== undefined || d.analyte !== undefined, {
message: "Either biomarkerId or analyte is required",
path: ["analyte"],
})
// Without a catalog link the unit is required (the catalog otherwise
// supplies it server-side).
.refine((d) => d.biomarkerId !== undefined || d.unit !== undefined, {
message: "unit is required when no biomarkerId is given",
path: ["unit"],
})
.refine(
(d) =>
d.referenceLow === undefined ||
@@ -123,8 +144,10 @@ export const updateLabResultSchema = z
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
/** List query: filter by analyte (exact) + date range, paginate. */
/** List query: filter by biomarker / analyte (exact) + date range, paginate. */
export const listLabResultsSchema = z.object({
// v1.18.1 — filter to one catalog marker's readings (the detail chart feed).
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
from: z