mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
commit
cc32e2adf7
40 files changed
+4403
-360
No files matched your search
+410
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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"]> = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user