mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge(labs): structured lab results with reference ranges, doctor-report and FHIR
# Conflicts: # docs/api/openapi.yaml # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # src/lib/openapi/registry.ts # src/lib/openapi/routes/index.ts # src/lib/query-keys/index.ts
This commit is contained in:
commit
4f039626be
38 files changed
+2909
-24
No files matched your search
@@ -40,6 +40,7 @@ tags:
|
||||
- name: Sync
|
||||
- name: Cycle
|
||||
- name: MeasurementReminders
|
||||
- name: Labs
|
||||
- name: Admin
|
||||
- name: Meta
|
||||
paths:
|
||||
@@ -938,6 +939,8 @@ paths:
|
||||
type: boolean
|
||||
cycle:
|
||||
type: boolean
|
||||
labs:
|
||||
type: boolean
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
@@ -1066,6 +1069,8 @@ paths:
|
||||
type: boolean
|
||||
cycle:
|
||||
type: boolean
|
||||
labs:
|
||||
type: boolean
|
||||
resourceTypes:
|
||||
maxItems: 8
|
||||
type: array
|
||||
@@ -5393,6 +5398,249 @@ paths:
|
||||
"404": *a6
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs:
|
||||
get:
|
||||
tags:
|
||||
- Labs
|
||||
summary: List the caller's lab results
|
||||
description: Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel /
|
||||
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: analyte
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- in: query
|
||||
name: panel
|
||||
schema:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
- in: query
|
||||
name: to
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
default: 100
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 500
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
default: 0
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
- in: query
|
||||
name: sortDir
|
||||
schema:
|
||||
default: desc
|
||||
type: string
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
responses:
|
||||
"200":
|
||||
description: Lab-result list.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListLabResultsEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
tags:
|
||||
- Labs
|
||||
summary: Record a lab result
|
||||
description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is
|
||||
AES-256-GCM encrypted before write. Audits as `labResult.create`.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
panel:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- type: string
|
||||
const: ""
|
||||
analyte:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
referenceLow:
|
||||
type: number
|
||||
referenceHigh:
|
||||
type: number
|
||||
takenAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 2000
|
||||
- type: string
|
||||
const: ""
|
||||
required:
|
||||
- analyte
|
||||
- value
|
||||
- unit
|
||||
- takenAt
|
||||
responses:
|
||||
"201":
|
||||
description: Created lab result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateLabResultResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/labs/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Labs
|
||||
summary: Fetch a single lab result
|
||||
description: Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Lab-result detail.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetLabResultResponse"
|
||||
"401": *a1
|
||||
"404": &a7
|
||||
description: Lab result 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 lab result
|
||||
description: Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound.
|
||||
Audits as `labResult.update`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
panel:
|
||||
anyOf:
|
||||
- anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
- type: string
|
||||
const: ""
|
||||
- type: "null"
|
||||
analyte:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 40
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
referenceHigh:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
takenAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
note:
|
||||
anyOf:
|
||||
- anyOf:
|
||||
- type: string
|
||||
maxLength: 2000
|
||||
- type: string
|
||||
const: ""
|
||||
- type: "null"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated lab result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UpdateLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Labs
|
||||
summary: Delete a lab result
|
||||
description: Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: Deletion succeeded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeleteLabResultResponse"
|
||||
"401": *a1
|
||||
"404": *a7
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
PasskeyLoginVerifyRequest:
|
||||
@@ -16155,6 +16403,237 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ListLabResultsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/ListLabResultsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ListLabResultsResponse:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LabResult"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: number
|
||||
limit:
|
||||
type: number
|
||||
offset:
|
||||
type: number
|
||||
required:
|
||||
- total
|
||||
- limit
|
||||
- offset
|
||||
additionalProperties: false
|
||||
required:
|
||||
- results
|
||||
- meta
|
||||
additionalProperties: false
|
||||
LabResult:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
panel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
analyte:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
referenceHigh:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
takenAt:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
hasNote:
|
||||
type: boolean
|
||||
rangeStatus:
|
||||
$ref: "#/components/schemas/LabReferenceRangeStatus"
|
||||
createdAt:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- panel
|
||||
- analyte
|
||||
- value
|
||||
- unit
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- takenAt
|
||||
- source
|
||||
- hasNote
|
||||
- rangeStatus
|
||||
- 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`.
|
||||
LabReferenceRangeStatus:
|
||||
type: string
|
||||
enum:
|
||||
- in-range
|
||||
- below
|
||||
- above
|
||||
- unknown
|
||||
description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds.
|
||||
Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and
|
||||
informative — not an alarming red."
|
||||
CreateLabResultResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/LabResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetLabResultResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/LabResultDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
LabResultDetail:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
panel:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
analyte:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
type: string
|
||||
referenceLow:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
referenceHigh:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
takenAt:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
rangeStatus:
|
||||
$ref: "#/components/schemas/LabReferenceRangeStatus"
|
||||
createdAt:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- panel
|
||||
- analyte
|
||||
- value
|
||||
- unit
|
||||
- referenceLow
|
||||
- referenceHigh
|
||||
- takenAt
|
||||
- source
|
||||
- rangeStatus
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- note
|
||||
additionalProperties: false
|
||||
description: Single lab result including its decrypted free-text `note` (or null).
|
||||
UpdateLabResultResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/LabResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DeleteLabResultResponse:
|
||||
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:
|
||||
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Schlafdauer",
|
||||
"cycle": "Menstruationszyklus",
|
||||
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
|
||||
"unavailableHint": "Keine Daten in diesem Zeitraum"
|
||||
"unavailableHint": "Keine Daten in diesem Zeitraum",
|
||||
"labs": "Laborwerte"
|
||||
},
|
||||
"insurer": "Krankenkasse",
|
||||
"insuranceNumber": "Versichertennr.",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Follikelphase",
|
||||
"OVULATORY": "Ovulation",
|
||||
"LUTEAL": "Lutealphase"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Laborwerte",
|
||||
"labsColAnalyte": "Biomarker",
|
||||
"labsColValue": "Wert",
|
||||
"labsColReference": "Referenz",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Datum"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Stimmung",
|
||||
"moodDescription": "Stimmung und Tags erfassen"
|
||||
},
|
||||
"cycle": "Zyklus"
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
|
||||
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
|
||||
"generate": "Erstellen",
|
||||
"error": "Export fehlgeschlagen ({code})."
|
||||
"error": "Export fehlgeschlagen ({code}).",
|
||||
"labs": "Laborwerte"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Vorsorge-Erinnerung",
|
||||
"pushBody": "Fällig: {label}",
|
||||
"pushLocation": "(bei {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Laborwerte",
|
||||
"subtitle": "Blutwerte und Biomarker",
|
||||
"addResult": "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",
|
||||
"deleteLatest": "Letzten Wert löschen",
|
||||
"deletedToast": "Wert gelöscht",
|
||||
"deleteError": "Wert konnte nicht gelöscht werden",
|
||||
"loadError": "Laborwerte konnten nicht geladen werden",
|
||||
"range": {
|
||||
"inRange": "Im Bereich",
|
||||
"below": "Unter Bereich",
|
||||
"above": "Über Bereich"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarker",
|
||||
"analytePlaceholder": "z. B. HbA1c, LDL, Ferritin",
|
||||
"value": "Wert",
|
||||
"unit": "Einheit",
|
||||
"unitPlaceholder": "z. B. mg/dl, %, ng/ml",
|
||||
"takenAt": "Probendatum",
|
||||
"optionalGroup": "Optional",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "z. B. Großes Blutbild",
|
||||
"referenceLow": "Referenz unten",
|
||||
"referenceHigh": "Referenz oben",
|
||||
"referenceLowPlaceholder": "Untere Grenze",
|
||||
"referenceHighPlaceholder": "Obere Grenze",
|
||||
"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.",
|
||||
"rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.",
|
||||
"save": "Speichern",
|
||||
"savedToast": "Wert gespeichert",
|
||||
"saveError": "Wert konnte nicht gespeichert werden"
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Sleep duration",
|
||||
"cycle": "Menstrual cycle",
|
||||
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
|
||||
"unavailableHint": "No data in this period"
|
||||
"unavailableHint": "No data in this period",
|
||||
"labs": "Lab results"
|
||||
},
|
||||
"insurer": "Health insurer",
|
||||
"insuranceNumber": "Insurance no.",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Follicular",
|
||||
"OVULATORY": "Ovulatory",
|
||||
"LUTEAL": "Luteal"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Lab results",
|
||||
"labsColAnalyte": "Biomarker",
|
||||
"labsColValue": "Value",
|
||||
"labsColReference": "Reference",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Date"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Mood",
|
||||
"moodDescription": "Log your mood and tags"
|
||||
},
|
||||
"cycle": "Cycle"
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "Append AI summary (not clinically validated)",
|
||||
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
|
||||
"generate": "Generate",
|
||||
"error": "Export failed ({code})."
|
||||
"error": "Export failed ({code}).",
|
||||
"labs": "Lab results"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Preventive-care reminder",
|
||||
"pushBody": "Time for: {label}",
|
||||
"pushLocation": "(at {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Lab results",
|
||||
"subtitle": "Bloodwork and biomarker readings",
|
||||
"addResult": "Add",
|
||||
"addDescription": "Record a biomarker reading from a lab report.",
|
||||
"addFirst": "Add your first result",
|
||||
"emptyTitle": "No lab results yet",
|
||||
"emptyDescription": "Record bloodwork like HbA1c, LDL or ferritin to track it over time and share it with your doctor.",
|
||||
"referenceLabel": "Reference",
|
||||
"readingsCount": "{count} readings",
|
||||
"deleteLatest": "Delete latest reading",
|
||||
"deletedToast": "Result deleted",
|
||||
"deleteError": "Could not delete the result",
|
||||
"loadError": "Could not load lab results",
|
||||
"range": {
|
||||
"inRange": "In range",
|
||||
"below": "Below range",
|
||||
"above": "Above range"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarker",
|
||||
"analytePlaceholder": "e.g. HbA1c, LDL, Ferritin",
|
||||
"value": "Value",
|
||||
"unit": "Unit",
|
||||
"unitPlaceholder": "e.g. mg/dL, %, ng/mL",
|
||||
"takenAt": "Sample date",
|
||||
"optionalGroup": "Optional",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "e.g. Complete blood count",
|
||||
"referenceLow": "Reference low",
|
||||
"referenceHigh": "Reference high",
|
||||
"referenceLowPlaceholder": "Lower bound",
|
||||
"referenceHighPlaceholder": "Upper bound",
|
||||
"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.",
|
||||
"rangeOrderError": "The reference low must not exceed the reference high.",
|
||||
"save": "Save",
|
||||
"savedToast": "Result saved",
|
||||
"saveError": "Could not save the result"
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Duración del sueño",
|
||||
"cycle": "Ciclo menstrual",
|
||||
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
|
||||
"unavailableHint": "Sin datos en este periodo"
|
||||
"unavailableHint": "Sin datos en este periodo",
|
||||
"labs": "Resultados de análisis"
|
||||
},
|
||||
"insurer": "Seguro médico",
|
||||
"insuranceNumber": "N.º de asegurado",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Folicular",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Lútea"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Resultados de análisis",
|
||||
"labsColAnalyte": "Biomarcador",
|
||||
"labsColValue": "Valor",
|
||||
"labsColReference": "Referencia",
|
||||
"labsColStatus": "Estado",
|
||||
"labsColDate": "Fecha"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Estado de ánimo",
|
||||
"moodDescription": "Registrar estado de ánimo y etiquetas"
|
||||
},
|
||||
"cycle": "Ciclo"
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Iniciar sesión",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
|
||||
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
|
||||
"generate": "Generar",
|
||||
"error": "Error en la exportación ({code})."
|
||||
"error": "Error en la exportación ({code}).",
|
||||
"labs": "Resultados de análisis"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Recordatorio de prevención",
|
||||
"pushBody": "Toca: {label}",
|
||||
"pushLocation": "(en {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Resultados de análisis",
|
||||
"subtitle": "Analíticas de sangre y biomarcadores",
|
||||
"addResult": "Añadir",
|
||||
"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",
|
||||
"deleteLatest": "Eliminar el último valor",
|
||||
"deletedToast": "Resultado eliminado",
|
||||
"deleteError": "No se pudo eliminar el resultado",
|
||||
"loadError": "No se pudieron cargar los resultados",
|
||||
"range": {
|
||||
"inRange": "En rango",
|
||||
"below": "Por debajo",
|
||||
"above": "Por encima"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarcador",
|
||||
"analytePlaceholder": "p. ej. HbA1c, LDL, Ferritina",
|
||||
"value": "Valor",
|
||||
"unit": "Unidad",
|
||||
"unitPlaceholder": "p. ej. mg/dL, %, ng/mL",
|
||||
"takenAt": "Fecha de la muestra",
|
||||
"optionalGroup": "Opcional",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "p. ej. Hemograma completo",
|
||||
"referenceLow": "Referencia mínima",
|
||||
"referenceHigh": "Referencia máxima",
|
||||
"referenceLowPlaceholder": "Límite inferior",
|
||||
"referenceHighPlaceholder": "Límite superior",
|
||||
"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.",
|
||||
"rangeOrderError": "La referencia mínima no puede superar la máxima.",
|
||||
"save": "Guardar",
|
||||
"savedToast": "Resultado guardado",
|
||||
"saveError": "No se pudo guardar el resultado"
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Durée du sommeil",
|
||||
"cycle": "Cycle menstruel",
|
||||
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
|
||||
"unavailableHint": "Pas de données sur cette période"
|
||||
"unavailableHint": "Pas de données sur cette période",
|
||||
"labs": "Résultats d'analyses"
|
||||
},
|
||||
"insurer": "Assurance maladie",
|
||||
"insuranceNumber": "N° d’assuré",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Folliculaire",
|
||||
"OVULATORY": "Ovulatoire",
|
||||
"LUTEAL": "Lutéale"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Résultats d'analyses",
|
||||
"labsColAnalyte": "Biomarqueur",
|
||||
"labsColValue": "Valeur",
|
||||
"labsColReference": "Référence",
|
||||
"labsColStatus": "Statut",
|
||||
"labsColDate": "Date"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Humeur",
|
||||
"moodDescription": "Enregistrer l'humeur et les tags"
|
||||
},
|
||||
"cycle": "Cycle"
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Se connecter",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
|
||||
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
|
||||
"generate": "Générer",
|
||||
"error": "Échec de l’export ({code})."
|
||||
"error": "Échec de l’export ({code}).",
|
||||
"labs": "Résultats d'analyses"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
|
||||
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Rappel de prévention",
|
||||
"pushBody": "C'est l'heure : {label}",
|
||||
"pushLocation": "(chez {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Résultats d'analyses",
|
||||
"subtitle": "Analyses sanguines et biomarqueurs",
|
||||
"addResult": "Ajouter",
|
||||
"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",
|
||||
"deleteLatest": "Supprimer la dernière valeur",
|
||||
"deletedToast": "Résultat supprimé",
|
||||
"deleteError": "Impossible de supprimer le résultat",
|
||||
"loadError": "Impossible de charger les résultats",
|
||||
"range": {
|
||||
"inRange": "Dans la norme",
|
||||
"below": "Sous la norme",
|
||||
"above": "Au-dessus de la norme"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarqueur",
|
||||
"analytePlaceholder": "ex. HbA1c, LDL, Ferritine",
|
||||
"value": "Valeur",
|
||||
"unit": "Unité",
|
||||
"unitPlaceholder": "ex. mg/dL, %, ng/mL",
|
||||
"takenAt": "Date du prélèvement",
|
||||
"optionalGroup": "Facultatif",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "ex. Numération formule sanguine",
|
||||
"referenceLow": "Référence basse",
|
||||
"referenceHigh": "Référence haute",
|
||||
"referenceLowPlaceholder": "Borne inférieure",
|
||||
"referenceHighPlaceholder": "Borne supérieure",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Durata del sonno",
|
||||
"cycle": "Ciclo mestruale",
|
||||
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
|
||||
"unavailableHint": "Nessun dato in questo periodo"
|
||||
"unavailableHint": "Nessun dato in questo periodo",
|
||||
"labs": "Risultati degli esami"
|
||||
},
|
||||
"insurer": "Assicurazione sanitaria",
|
||||
"insuranceNumber": "N. assicurato",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Follicolare",
|
||||
"OVULATORY": "Ovulatoria",
|
||||
"LUTEAL": "Luteale"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Risultati degli esami",
|
||||
"labsColAnalyte": "Biomarcatore",
|
||||
"labsColValue": "Valore",
|
||||
"labsColReference": "Riferimento",
|
||||
"labsColStatus": "Stato",
|
||||
"labsColDate": "Data"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Umore",
|
||||
"moodDescription": "Registra umore e tag"
|
||||
},
|
||||
"cycle": "Ciclo"
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Accedi",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
|
||||
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
|
||||
"generate": "Genera",
|
||||
"error": "Esportazione non riuscita ({code})."
|
||||
"error": "Esportazione non riuscita ({code}).",
|
||||
"labs": "Risultati degli esami"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
|
||||
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Promemoria di prevenzione",
|
||||
"pushBody": "È ora di: {label}",
|
||||
"pushLocation": "(presso {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Risultati degli esami",
|
||||
"subtitle": "Analisi del sangue e biomarcatori",
|
||||
"addResult": "Aggiungi",
|
||||
"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",
|
||||
"deleteLatest": "Elimina l'ultimo valore",
|
||||
"deletedToast": "Risultato eliminato",
|
||||
"deleteError": "Impossibile eliminare il risultato",
|
||||
"loadError": "Impossibile caricare i risultati",
|
||||
"range": {
|
||||
"inRange": "Nella norma",
|
||||
"below": "Sotto la norma",
|
||||
"above": "Sopra la norma"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarcatore",
|
||||
"analytePlaceholder": "es. HbA1c, LDL, Ferritina",
|
||||
"value": "Valore",
|
||||
"unit": "Unità",
|
||||
"unitPlaceholder": "es. mg/dL, %, ng/mL",
|
||||
"takenAt": "Data del prelievo",
|
||||
"optionalGroup": "Facoltativo",
|
||||
"panel": "Pannello",
|
||||
"panelPlaceholder": "es. Emocromo completo",
|
||||
"referenceLow": "Riferimento minimo",
|
||||
"referenceHigh": "Riferimento massimo",
|
||||
"referenceLowPlaceholder": "Limite inferiore",
|
||||
"referenceHighPlaceholder": "Limite superiore",
|
||||
"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.",
|
||||
"rangeOrderError": "Il riferimento minimo non può superare quello massimo.",
|
||||
"save": "Salva",
|
||||
"savedToast": "Risultato salvato",
|
||||
"saveError": "Impossibile salvare il risultato"
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -151,7 +151,8 @@
|
||||
"sleep": "Czas snu",
|
||||
"cycle": "Cykl menstruacyjny",
|
||||
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
|
||||
"unavailableHint": "Brak danych w tym okresie"
|
||||
"unavailableHint": "Brak danych w tym okresie",
|
||||
"labs": "Wyniki badań"
|
||||
},
|
||||
"insurer": "Ubezpieczyciel zdrowotny",
|
||||
"insuranceNumber": "Nr ubezpieczonego",
|
||||
@@ -182,7 +183,13 @@
|
||||
"FOLLICULAR": "Folikularna",
|
||||
"OVULATORY": "Owulacyjna",
|
||||
"LUTEAL": "Lutealna"
|
||||
}
|
||||
},
|
||||
"labsTitle": "Wyniki badań",
|
||||
"labsColAnalyte": "Biomarker",
|
||||
"labsColValue": "Wartość",
|
||||
"labsColReference": "Norma",
|
||||
"labsColStatus": "Status",
|
||||
"labsColDate": "Data"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -240,7 +247,8 @@
|
||||
"mood": "Nastrój",
|
||||
"moodDescription": "Zapisz nastrój i tagi"
|
||||
},
|
||||
"cycle": "Cykl"
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Zaloguj się",
|
||||
@@ -4570,7 +4578,8 @@
|
||||
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
|
||||
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
|
||||
"generate": "Generuj",
|
||||
"error": "Eksport nie powiódł się ({code})."
|
||||
"error": "Eksport nie powiódł się ({code}).",
|
||||
"labs": "Wyniki badań"
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
|
||||
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
|
||||
@@ -6306,5 +6315,48 @@
|
||||
"pushTitle": "Przypomnienie profilaktyczne",
|
||||
"pushBody": "Czas na: {label}",
|
||||
"pushLocation": "(u {location})"
|
||||
},
|
||||
"labs": {
|
||||
"title": "Wyniki badań",
|
||||
"subtitle": "Badania krwi i biomarkery",
|
||||
"addResult": "Dodaj",
|
||||
"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",
|
||||
"deleteLatest": "Usuń ostatni wynik",
|
||||
"deletedToast": "Wynik usunięty",
|
||||
"deleteError": "Nie udało się usunąć wyniku",
|
||||
"loadError": "Nie udało się wczytać wyników",
|
||||
"range": {
|
||||
"inRange": "W normie",
|
||||
"below": "Poniżej normy",
|
||||
"above": "Powyżej normy"
|
||||
},
|
||||
"form": {
|
||||
"analyte": "Biomarker",
|
||||
"analytePlaceholder": "np. HbA1c, LDL, ferrytyna",
|
||||
"value": "Wartość",
|
||||
"unit": "Jednostka",
|
||||
"unitPlaceholder": "np. mg/dl, %, ng/ml",
|
||||
"takenAt": "Data pobrania",
|
||||
"optionalGroup": "Opcjonalne",
|
||||
"panel": "Panel",
|
||||
"panelPlaceholder": "np. Morfologia krwi",
|
||||
"referenceLow": "Norma dolna",
|
||||
"referenceHigh": "Norma górna",
|
||||
"referenceLowPlaceholder": "Dolna granica",
|
||||
"referenceHighPlaceholder": "Górna granica",
|
||||
"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.",
|
||||
"rangeOrderError": "Norma dolna nie może przekraczać górnej.",
|
||||
"save": "Zapisz",
|
||||
"savedToast": "Wynik zapisany",
|
||||
"saveError": "Nie udało się zapisać wyniku"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: true,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
@@ -71,6 +72,7 @@ function setEmptyDataMocks() {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "testuser",
|
||||
dateOfBirth: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
|
||||
medication: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
// First findUnique = aggregator profile select; second = route KVNR select.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "sample",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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 { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
updateLabResultSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
|
||||
*
|
||||
* GET returns the row including its decrypted note (the detail view is the
|
||||
* one place the plaintext note surfaces). PUT applies a partial edit
|
||||
* (`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).
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.labResult.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!row || row.userId !== user.id) {
|
||||
return apiError("Lab result not found", 404);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const PUT = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.labResult.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Lab result not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateLabResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "labs.update.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length, labResultId: id },
|
||||
});
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.update.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues, labResultId: id }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const d = parsed.data;
|
||||
|
||||
// Build `data` field-by-field. `undefined` → leave the column untouched;
|
||||
// an explicit `null` on `panel` / `note` / a reference bound clears it.
|
||||
const data: Record<string, unknown> = {};
|
||||
if (d.panel !== undefined) data.panel = d.panel;
|
||||
if (d.analyte !== undefined) data.analyte = d.analyte;
|
||||
if (d.value !== undefined) data.value = d.value;
|
||||
if (d.unit !== undefined) data.unit = d.unit;
|
||||
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
|
||||
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
|
||||
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
|
||||
if (d.note !== undefined) {
|
||||
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.labResult.update({ where: { id }, data });
|
||||
|
||||
await auditLog("labResult.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: id },
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.labResult.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Lab result not found", 404);
|
||||
}
|
||||
|
||||
// Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`,
|
||||
// so the row is invisible from here on. A re-delete is idempotent.
|
||||
await prisma.labResult.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await auditLog("labResult.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: id, analyte: existing.analyte },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
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 { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
createLabResultSchema,
|
||||
listLabResultsSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// 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(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listLabResultsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "labs.list.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(analyte && { analyte }),
|
||||
...(panel && { panel }),
|
||||
...(from || to
|
||||
? {
|
||||
takenAt: {
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.labResult.findMany({
|
||||
where,
|
||||
orderBy: { takenAt: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.labResult.count({ where }),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.list" },
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
results: rows.map(serialiseLabResult),
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult));
|
||||
|
||||
async function postLabResult(request: NextRequest) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createLabResultSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "labs.create.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
// Free-text `analyte` / `unit` / `note` could land verbatim in a Zod
|
||||
// issue message — strip values from the audit-ledger breadcrumb.
|
||||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||||
stripValuesFromMessage: true,
|
||||
});
|
||||
prisma.auditLog
|
||||
.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: "labs.create.validation-failed",
|
||||
details: JSON.stringify({ issues: auditIssues }),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallow — the 422 response is the contract */
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { 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.
|
||||
const created = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
panel: panel ?? null,
|
||||
analyte,
|
||||
value,
|
||||
unit,
|
||||
referenceLow: referenceLow ?? null,
|
||||
referenceHigh: referenceHigh ?? null,
|
||||
takenAt,
|
||||
source: "MANUAL",
|
||||
noteEncrypted: note ? encryptNoteToBytes(note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("labResult.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { labResultId: created.id, analyte: created.analyte },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "labs.create" },
|
||||
meta: { labResultId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseLabResult(created), 201);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
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">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("labs.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{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>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("labs.addResult")}
|
||||
description={t("labs.addDescription")}
|
||||
>
|
||||
<LabForm
|
||||
onSuccess={() => {
|
||||
setDialogOpen(false);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.labResults(),
|
||||
});
|
||||
}}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<LabList onAddFirst={() => setDialogOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,11 @@ interface SectionAvailability {
|
||||
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
|
||||
// typechecks. Optional — the availability endpoint may not provide it.
|
||||
cycle?: boolean;
|
||||
// v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog;
|
||||
// the flagship export panel surfaces the toggle. Optional — the
|
||||
// availability endpoint may not provide it. Present so the
|
||||
// `keyof DoctorReportPrefs` indexing typechecks.
|
||||
labs?: boolean;
|
||||
}
|
||||
|
||||
export interface DoctorReportSubmitPayload {
|
||||
@@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
|
||||
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
|
||||
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
|
||||
cycle: "doctorReport.sections.cycle",
|
||||
// v1.17.1 — labs section; the flagship export panel drives the toggle,
|
||||
// present here so the `keyof DoctorReportPrefs` record stays exhaustive.
|
||||
labs: "doctorReport.sections.labs",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
function defaultTakenAtValue() {
|
||||
const now = new Date();
|
||||
const offset = now.getTimezoneOffset();
|
||||
const local = new Date(now.getTime() - offset * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** 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 LabFormProps {
|
||||
onSuccess?: (created: LabResultDto) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.17.1 — manual 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.
|
||||
*/
|
||||
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [analyte, setAnalyte] = useState("");
|
||||
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);
|
||||
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
|
||||
const low = parseDecimal(refLow);
|
||||
const high = parseDecimal(refHigh);
|
||||
if (low !== null && high !== null && low > high) {
|
||||
setError(t("labs.form.rangeOrderError"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const created = await apiPost<LabResultDto>("/api/labs", {
|
||||
analyte: analyte.trim(),
|
||||
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"));
|
||||
onSuccess?.(created);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError ? err.message : t("labs.form.saveError");
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
<Input
|
||||
id="lab-refLow"
|
||||
inputMode="decimal"
|
||||
value={refLow}
|
||||
onChange={(e) => setRefLow(e.target.value)}
|
||||
placeholder={t("labs.form.referenceLowPlaceholder")}
|
||||
/>
|
||||
</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")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.form.referenceHint")}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
|
||||
<Textarea
|
||||
id="lab-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("labs.form.notePlaceholder")}
|
||||
maxLength={NOTE_MAX_LENGTH}
|
||||
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>
|
||||
) : null}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FlaskConical, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { LabTrendSparkline } from "./lab-trend-sparkline";
|
||||
import { ReferenceRangeBadge } from "./reference-range-badge";
|
||||
import type { LabResultDto, LabResultListResponse } from "./types";
|
||||
|
||||
interface AnalyteGroup {
|
||||
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[] {
|
||||
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);
|
||||
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 ordered = [...readings].sort(
|
||||
(a, b) =>
|
||||
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
);
|
||||
const latest = ordered[ordered.length - 1];
|
||||
groups.push({
|
||||
analyte: latest.analyte,
|
||||
panel: latest.panel,
|
||||
unit: latest.unit,
|
||||
readings: ordered,
|
||||
latest,
|
||||
});
|
||||
}
|
||||
// Group order: most-recently-updated analyte first.
|
||||
return groups.sort(
|
||||
(a, b) =>
|
||||
new Date(b.latest.takenAt).getTime() -
|
||||
new Date(a.latest.takenAt).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
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")),
|
||||
});
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupByAnalyte(data?.results ?? []),
|
||||
[data?.results],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("labs.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FlaskConical className="size-6" />}
|
||||
title={t("labs.emptyTitle")}
|
||||
description={t("labs.emptyDescription")}
|
||||
action={
|
||||
onAddFirst ? (
|
||||
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.analyte.toLowerCase()}>
|
||||
<CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="font-medium">{group.analyte}</span>
|
||||
{group.panel ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{group.panel}
|
||||
</span>
|
||||
) : null}
|
||||
<ReferenceRangeBadge status={group.latest.rangeStatus} />
|
||||
</div>
|
||||
<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")}{" "}
|
||||
{group.latest.referenceLow !== null &&
|
||||
group.latest.referenceHigh !== null
|
||||
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
|
||||
: group.latest.referenceHigh !== null
|
||||
? `≤ ${formatValue(group.latest.referenceHigh)}`
|
||||
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
|
||||
</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>
|
||||
<div className="flex items-center gap-3 self-end sm:self-center">
|
||||
<LabTrendSparkline
|
||||
values={group.readings.map((r) => r.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={t("labs.deleteLatest")}
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(group.latest.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.17.1 — minimal inline trend sparkline for an analyte's readings.
|
||||
*
|
||||
* A dependency-free SVG polyline (the project defers Recharts for tiny
|
||||
* inline trends; this matches the doctor-report PDF's vector-polyline
|
||||
* approach). Rendered only when an analyte has ≥ 2 readings. Stroke uses
|
||||
* the neutral `currentColor` so it inherits the calm muted-foreground tone
|
||||
* of its row — no status colour, in keeping with the no-alarming-colour
|
||||
* ethos.
|
||||
*
|
||||
* `values` are passed oldest → newest.
|
||||
*/
|
||||
export function LabTrendSparkline({
|
||||
values,
|
||||
width = 72,
|
||||
height = 20,
|
||||
}: {
|
||||
values: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}) {
|
||||
if (values.length < 2) return null;
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min || 1;
|
||||
const stepX = width / (values.length - 1);
|
||||
const pad = 2;
|
||||
const usableH = height - pad * 2;
|
||||
|
||||
const points = values
|
||||
.map((v, i) => {
|
||||
const x = i * stepX;
|
||||
// Invert Y so a higher value sits higher on screen.
|
||||
const y = pad + usableH - ((v - min) / span) * usableH;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="text-muted-foreground/70 overflow-visible"
|
||||
aria-hidden
|
||||
role="presentation"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.25}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
|
||||
|
||||
/**
|
||||
* v1.17.1 — reference-range badge.
|
||||
*
|
||||
* Renders the server-computed `rangeStatus` as a calm, informative marker.
|
||||
* The project's no-alarming-colour ethos is a hard rule here: an
|
||||
* out-of-range value is NOT painted red. It reads as a neutral `secondary`
|
||||
* badge with a quiet direction arrow (↓ below / ↑ above), exactly the same
|
||||
* weight as the in-range and unknown states. The number leaving the
|
||||
* reference window is information a clinician scans — not an alarm to the
|
||||
* user, who may be perfectly aware of (and managing) the value.
|
||||
*
|
||||
* `unknown` (the lab reported no usable bounds) renders nothing — there is
|
||||
* no verdict to show, and an empty badge would be noise.
|
||||
*/
|
||||
export function ReferenceRangeBadge({
|
||||
status,
|
||||
}: {
|
||||
status: ReferenceRangeStatus;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (status === "unknown") return null;
|
||||
|
||||
if (status === "in-range") {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<Minus aria-hidden />
|
||||
{t("labs.range.inRange")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// Below / above — both neutral `secondary`, distinguished only by the
|
||||
// arrow direction and label. No red, no amber.
|
||||
const Icon = status === "below" ? ArrowDown : ArrowUp;
|
||||
const label =
|
||||
status === "below" ? t("labs.range.below") : t("labs.range.above");
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<Icon aria-hidden />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
|
||||
|
||||
/** A lab-result row as the list / create endpoints serialise it. */
|
||||
export interface LabResultDto {
|
||||
id: string;
|
||||
panel: string | null;
|
||||
analyte: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
takenAt: string;
|
||||
source: string;
|
||||
hasNote: boolean;
|
||||
rangeStatus: ReferenceRangeStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LabResultListResponse {
|
||||
results: LabResultDto[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Bug,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
FlaskConical,
|
||||
Home,
|
||||
Lightbulb,
|
||||
MoreHorizontal,
|
||||
@@ -70,6 +71,7 @@ const PRIMARY_RIGHT: ReadonlyArray<NavLink> = [
|
||||
const MORE_HUB: ReadonlyArray<NavLink> = [
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/labs", tKey: "nav.labs", icon: FlaskConical },
|
||||
{ href: "/insights/workouts", tKey: "nav.workouts", icon: Dumbbell },
|
||||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||||
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Droplets,
|
||||
FlaskConical,
|
||||
Home,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
@@ -66,6 +67,9 @@ const navItems = [
|
||||
icon: Pill,
|
||||
tourId: "nav-medications",
|
||||
},
|
||||
// v1.17.1 — structured lab-result store. Pairs with the Vorsorge
|
||||
// annual-blood-panel reminder (which records its result here).
|
||||
{ href: "/labs", tKey: "nav.labs", icon: FlaskConical, tourId: "nav-labs" },
|
||||
// v1.4.15 Phase B5: `tourId` values match `data-tour-id` lookups
|
||||
// performed by the onboarding tour. Keep these stable — renaming
|
||||
// them silently breaks the spotlight cutout for that step.
|
||||
|
||||
@@ -56,6 +56,7 @@ interface SectionState {
|
||||
compliance: boolean;
|
||||
mood: boolean;
|
||||
bmi: boolean;
|
||||
labs: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SECTIONS: SectionState = {
|
||||
@@ -76,6 +77,7 @@ const DEFAULT_SECTIONS: SectionState = {
|
||||
compliance: true,
|
||||
mood: false, // privacy default
|
||||
bmi: true,
|
||||
labs: true,
|
||||
};
|
||||
|
||||
function buildSelectionSections(s: SectionState) {
|
||||
@@ -102,6 +104,7 @@ function buildSelectionSections(s: SectionState) {
|
||||
medications: { list: s.medList, compliance: s.compliance },
|
||||
mood: s.mood,
|
||||
bmi: s.bmi,
|
||||
labs: s.labs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -411,6 +414,11 @@ export function HealthRecordExportPanel() {
|
||||
checked={sections.mood}
|
||||
onToggle={() => toggle("mood")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.labs")}
|
||||
checked={sections.labs}
|
||||
onToggle={() => toggle("labs")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPdfLike && (
|
||||
|
||||
@@ -230,6 +230,31 @@ export interface DoctorReportData {
|
||||
* still typecheck.
|
||||
*/
|
||||
cycle?: import("@/lib/cycle/export-data").CycleExportSummary | null;
|
||||
/**
|
||||
* v1.17.1 — structured lab results recorded over the report window.
|
||||
* Populated when the `labs` section toggle is ON (default) and the user
|
||||
* recorded at least one result in the period. Statistics + the single
|
||||
* latest reading per analyte; free-text notes never reach this surface.
|
||||
* Each entry carries the lab's reference bounds so the PDF can print an
|
||||
* in/out-of-range marker and the FHIR exporter can emit `referenceRange`.
|
||||
* Null/absent otherwise; both the PDF and FHIR builders skip the section.
|
||||
* Optional so pre-v1.17.1 fixtures still typecheck.
|
||||
*/
|
||||
labResults?: Array<{
|
||||
/** Optional grouping label (e.g. "Großes Blutbild"); null = standalone. */
|
||||
panel: string | null;
|
||||
/** Biomarker name as the user recorded it. */
|
||||
analyte: string;
|
||||
/** Latest recorded value for this analyte over the window. */
|
||||
value: number;
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
/** ISO timestamp of the latest reading. */
|
||||
takenAt: string;
|
||||
/** Count of readings for this analyte in the window (≥ 1). */
|
||||
count: number;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/** The three persisted score types surfaced in the wellness summary. */
|
||||
@@ -1057,6 +1082,50 @@ export async function collectDoctorReportData(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.17.1 — structured lab results over the window. ON by default; the
|
||||
// user recorded these specifically to share with a clinician, so the
|
||||
// privacy stance matches BP / weight, not mood / cycle. We reduce to the
|
||||
// latest reading per analyte (with a count) so the report is a concise
|
||||
// panel, not a raw dump; notes are never read here.
|
||||
let labResults: DoctorReportData["labResults"] = null;
|
||||
if (sections.labs) {
|
||||
const labRows = await prisma.labResult.findMany({
|
||||
where: { userId, takenAt: { gte: start, lte: end }, deletedAt: null },
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: {
|
||||
panel: true,
|
||||
analyte: true,
|
||||
value: true,
|
||||
unit: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
takenAt: true,
|
||||
},
|
||||
});
|
||||
// Latest-per-analyte, keyed case-insensitively so "LDL" / "ldl" fold
|
||||
// together; rows are ascending so the last seen wins as the latest.
|
||||
const byAnalyte = new Map<
|
||||
string,
|
||||
NonNullable<DoctorReportData["labResults"]>[number]
|
||||
>();
|
||||
for (const r of labRows) {
|
||||
const key = r.analyte.toLowerCase();
|
||||
const prev = byAnalyte.get(key);
|
||||
byAnalyte.set(key, {
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
unit: r.unit,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt.toISOString(),
|
||||
count: (prev?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
const collapsed = Array.from(byAnalyte.values());
|
||||
labResults = collapsed.length > 0 ? collapsed : null;
|
||||
}
|
||||
|
||||
return {
|
||||
period: {
|
||||
days,
|
||||
@@ -1109,6 +1178,7 @@ export async function collectDoctorReportData(
|
||||
glp1,
|
||||
wellnessScores,
|
||||
cycle,
|
||||
labResults,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,6 +1319,89 @@ export function buildDoctorReportPdfDocument(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.17.1 — structured lab-results section. Populated when the `labs`
|
||||
// toggle is ON (default) and the user recorded at least one result in the
|
||||
// window. One row per analyte (latest reading), with the lab's reference
|
||||
// range and a NEUTRAL in/out-of-range marker — informative, never an
|
||||
// alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
|
||||
// the clinical PDF stays calm.
|
||||
if (data.labResults && data.labResults.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.labsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const rangeText = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): string => {
|
||||
if (low !== null && high !== null) return `${num(low)}–${num(high)}`;
|
||||
if (high !== null) return `≤ ${num(high)}`;
|
||||
if (low !== null) return `≥ ${num(low)}`;
|
||||
return "—";
|
||||
};
|
||||
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
|
||||
// the table is not a field of warning marks; out-of-range reads as a
|
||||
// direction arrow the clinician can scan, with no alarm tint.
|
||||
const statusGlyph = (
|
||||
value: number,
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
): string => {
|
||||
if (low === null && high === null) return "";
|
||||
if (low !== null && value < low) return "↓";
|
||||
if (high !== null && value > high) return "↑";
|
||||
return "—";
|
||||
};
|
||||
|
||||
const labRows = data.labResults.map((lr) => [
|
||||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||||
`${num(lr.value)} ${lr.unit}`.trim(),
|
||||
rangeText(lr.referenceLow, lr.referenceHigh),
|
||||
statusGlyph(lr.value, lr.referenceLow, lr.referenceHigh),
|
||||
fmtDate(lr.takenAt),
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.labsColAnalyte"),
|
||||
t("doctorReport.labsColValue"),
|
||||
t("doctorReport.labsColReference"),
|
||||
t("doctorReport.labsColStatus"),
|
||||
t("doctorReport.labsColDate"),
|
||||
],
|
||||
],
|
||||
body: labRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
|
||||
// rendered ONLY when the user explicitly opted in. Clearly labelled and
|
||||
// flagged as not clinically validated so a physician never mistakes it
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
|
||||
import { buildFhirDocumentBundle } from "../build-bundle";
|
||||
import type { FhirObservation } from "../types";
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
|
||||
|
||||
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
return {
|
||||
period: {
|
||||
days: 90,
|
||||
since: "2026-02-02T00:00:00.000Z",
|
||||
start: "2026-02-02T00:00:00.000Z",
|
||||
end: "2026-05-03T12:00:00.000Z",
|
||||
},
|
||||
patient: {
|
||||
username: "sample-user",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 182,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
|
||||
glucoseUnit: "mg/dL",
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
glp1: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
|
||||
return bundle.entry
|
||||
.map((e) => e.resource)
|
||||
.filter((r): r is FhirObservation => r.resourceType === "Observation");
|
||||
}
|
||||
|
||||
describe("lab-result FHIR Observations", () => {
|
||||
const labResults: NonNullable<DoctorReportData["labResults"]> = [
|
||||
{
|
||||
panel: "Lipid panel",
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: 116,
|
||||
takenAt: "2026-04-20T08:00:00.000Z",
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
panel: null,
|
||||
analyte: "HbA1c",
|
||||
value: 5.4,
|
||||
unit: "%",
|
||||
referenceLow: 4,
|
||||
referenceHigh: 5.6,
|
||||
takenAt: "2026-04-21T08:00:00.000Z",
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
it("emits one laboratory Observation per lab result", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const labObs = observationsOf(bundle).filter((o) =>
|
||||
o.category?.some((c) =>
|
||||
c.coding?.some((code) => code.code === "laboratory"),
|
||||
),
|
||||
);
|
||||
// Two lab observations (no glucose in this fixture).
|
||||
expect(labObs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("maps the value, unit, and effective date onto the Observation", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
|
||||
expect(ldl).toBeDefined();
|
||||
expect(ldl?.valueQuantity?.value).toBe(110);
|
||||
expect(ldl?.valueQuantity?.unit).toBe("mg/dL");
|
||||
expect(ldl?.effectiveDateTime).toBe("2026-04-20T08:00:00.000Z");
|
||||
});
|
||||
|
||||
it("emits a referenceRange with only the high bound when low is null", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const ldl = observationsOf(bundle).find((o) => o.code.text === "LDL (Lipid panel)");
|
||||
expect(ldl?.referenceRange).toHaveLength(1);
|
||||
expect(ldl?.referenceRange?.[0].high?.value).toBe(116);
|
||||
expect(ldl?.referenceRange?.[0].low).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits a referenceRange with both bounds", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const a1c = observationsOf(bundle).find((o) => o.code.text === "HbA1c");
|
||||
expect(a1c?.referenceRange?.[0].low?.value).toBe(4);
|
||||
expect(a1c?.referenceRange?.[0].high?.value).toBe(5.6);
|
||||
});
|
||||
|
||||
it("omits referenceRange entirely when no bounds are present", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({
|
||||
labResults: [
|
||||
{
|
||||
panel: null,
|
||||
analyte: "CRP",
|
||||
value: 2.1,
|
||||
unit: "mg/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
takenAt: "2026-04-22T08:00:00.000Z",
|
||||
count: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const crp = observationsOf(bundle).find((o) => o.code.text === "CRP");
|
||||
expect(crp).toBeDefined();
|
||||
expect(crp?.referenceRange).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits no lab Observations when labResults is null", () => {
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
makeData({ labResults: null }),
|
||||
{ insuranceNumber: "A123456780" },
|
||||
FIXED_NOW,
|
||||
);
|
||||
const labObs = observationsOf(bundle).filter((o) =>
|
||||
o.category?.some((c) =>
|
||||
c.coding?.some((code) => code.code === "laboratory"),
|
||||
),
|
||||
);
|
||||
expect(labObs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -511,6 +511,47 @@ export function observationsFromReportData(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Lab-result Observations (v1.17.1) ---------------------------------
|
||||
// One `laboratory` Observation per recorded analyte. `analyte` + `unit`
|
||||
// are user-recorded free text (no closed LOINC enum), so the code is a
|
||||
// text-only `CodeableConcept` — honest about what we have rather than
|
||||
// asserting a LOINC mapping we can't validate. The lab's reference bounds
|
||||
// map onto R4 `referenceRange`. UCUM `code` is omitted (the free-text
|
||||
// unit can't be asserted as a UCUM symbol); `unit` carries the display.
|
||||
for (const lab of data.labResults ?? []) {
|
||||
obsSeq += 1;
|
||||
const referenceRange =
|
||||
lab.referenceLow !== null || lab.referenceHigh !== null
|
||||
? [
|
||||
{
|
||||
...(lab.referenceLow !== null
|
||||
? { low: { value: lab.referenceLow, unit: lab.unit } }
|
||||
: {}),
|
||||
...(lab.referenceHigh !== null
|
||||
? { high: { value: lab.referenceHigh, unit: lab.unit } }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
push({
|
||||
resourceType: "Observation",
|
||||
id: `obs-${obsSeq}`,
|
||||
status: "final",
|
||||
category: [categoryConcept("laboratory")],
|
||||
code: {
|
||||
text: lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte,
|
||||
},
|
||||
subject: patientRef,
|
||||
effectiveDateTime: lab.takenAt,
|
||||
valueQuantity: {
|
||||
value: lab.value,
|
||||
unit: lab.unit,
|
||||
system: UCUM_SYSTEM,
|
||||
},
|
||||
...(referenceRange ? { referenceRange } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Medication-adherence Observations (one per medication) ------------
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
|
||||
@@ -85,6 +85,18 @@ export interface FhirObservation {
|
||||
component?: FhirObservationComponent[];
|
||||
/** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */
|
||||
note?: FhirAnnotation[];
|
||||
/**
|
||||
* v1.17.1 — R4 `Observation.referenceRange`. Carries the lab's reported
|
||||
* normal bounds for a structured lab result; either bound may be absent
|
||||
* (a lab may report only an upper or lower limit).
|
||||
*/
|
||||
referenceRange?: FhirObservationReferenceRange[];
|
||||
}
|
||||
|
||||
/** R4 `Observation.referenceRange` element. */
|
||||
export interface FhirObservationReferenceRange {
|
||||
low?: FhirQuantity;
|
||||
high?: FhirQuantity;
|
||||
}
|
||||
|
||||
export interface FhirDosage {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.17.1 — server-side helpers for the structured lab-result store.
|
||||
*
|
||||
* Two concerns live here:
|
||||
*
|
||||
* 1. The AES-256-GCM ↔ `Bytes` codec for `LabResult.noteEncrypted`. The
|
||||
* free-text note 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.
|
||||
*
|
||||
* 2. `findLatestLabResultForAnalytes` — the read point the Vorsorge
|
||||
* annual-blood-panel reminder consults to decide whether a panel was
|
||||
* recorded inside its lead-time window. Exposed here, NOT coupled to
|
||||
* the reminder's code, so the reminder can mark itself satisfied
|
||||
* without this module taking a dependency on it.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
|
||||
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
|
||||
const ciphertext = encrypt(plaintext);
|
||||
const encoded = Buffer.from(ciphertext, "utf8");
|
||||
// Prisma `Bytes` maps to `Uint8Array<ArrayBuffer>`; allocate a fresh
|
||||
// ArrayBuffer-backed view so the structural type stays stable.
|
||||
const out = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
out.set(encoded);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decrypt a stored `Bytes` note back to plaintext. Throws on a bad key id. */
|
||||
export function decryptNoteFromBytes(buf: Uint8Array): string {
|
||||
return decrypt(Buffer.from(buf).toString("utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest live (non-tombstoned) lab result for any of the given analytes,
|
||||
* recorded at or after `since`. Returns the single most-recent matching
|
||||
* row by `takenAt`, or `null` when no matching result exists in the window.
|
||||
*
|
||||
* Case-insensitive analyte match so "HbA1c" / "hba1c" resolve the same — a
|
||||
* lab report's casing is not the user's concern. The Vorsorge reminder
|
||||
* passes the analytes that satisfy an annual blood panel (e.g. an HbA1c or
|
||||
* a lipid marker) plus the reminder's window start; a non-null return means
|
||||
* the panel was recorded and the reminder can mark itself satisfied.
|
||||
*/
|
||||
export async function findLatestLabResultForAnalytes(
|
||||
userId: string,
|
||||
analytes: string[],
|
||||
since: Date,
|
||||
): Promise<{ id: string; analyte: string; takenAt: Date } | null> {
|
||||
if (analytes.length === 0) return null;
|
||||
const candidates = await prisma.labResult.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
takenAt: { gte: since },
|
||||
// Prisma has no case-insensitive `in`; match each analyte
|
||||
// case-insensitively and let the DB OR them together.
|
||||
OR: analytes.map((a) => ({
|
||||
analyte: { equals: a, mode: "insensitive" as const },
|
||||
})),
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 1,
|
||||
select: { id: true, analyte: true, takenAt: true },
|
||||
});
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
@@ -73,6 +73,7 @@ const openApiBase: Pick<
|
||||
{ name: "Sync" },
|
||||
{ name: "Cycle" },
|
||||
{ name: "MeasurementReminders" },
|
||||
{ name: "Labs" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -30,6 +30,7 @@ import { devicePaths } from "./devices";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { importPaths } from "./import";
|
||||
import { insightsPaths } from "./insights";
|
||||
import { labsPaths } from "./labs";
|
||||
import { measurementPaths } from "./measurements";
|
||||
import { measurementReminderPaths } from "./measurement-reminders";
|
||||
import { medicationPaths, medicationResource } from "./medications";
|
||||
@@ -61,6 +62,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...consentPaths,
|
||||
...importPaths,
|
||||
...measurementReminderPaths,
|
||||
...labsPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* OpenAPI route table for the structured lab-result store (`/api/labs`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + query reuse the runtime Zod schemas from `@/lib/validations/labs`
|
||||
* so the wire contract stays single-source. Response shapes are declared
|
||||
* here (the route serialises a derived `rangeStatus` + `hasNote` the input
|
||||
* schema doesn't carry).
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
createLabResultSchema,
|
||||
listLabResultsSchema,
|
||||
updateLabResultSchema,
|
||||
} from "@/lib/validations/labs";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
createLabResultSchema.meta({
|
||||
id: "CreateLabResultRequest",
|
||||
description:
|
||||
"Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). `analyte` + `unit` are free-form (a lab prints its own naming). Reference bounds are independently optional; when both are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest.",
|
||||
});
|
||||
|
||||
updateLabResultSchema.meta({
|
||||
id: "UpdateLabResultRequest",
|
||||
description:
|
||||
"Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it.",
|
||||
});
|
||||
|
||||
listLabResultsSchema.meta({
|
||||
id: "ListLabResultsQuery",
|
||||
description:
|
||||
"Query params for the lab-result list: optional `analyte` (exact) + `panel` filters, an inclusive `from`/`to` date range, and `limit` (≤ 500) / `offset` pagination. Defaults to `takenAt` DESC.",
|
||||
});
|
||||
|
||||
const rangeStatusEnum = z
|
||||
.enum(["in-range", "below", "above", "unknown"])
|
||||
.meta({
|
||||
id: "LabReferenceRangeStatus",
|
||||
description:
|
||||
"Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
|
||||
});
|
||||
|
||||
const labResultRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
panel: z.string().nullable(),
|
||||
analyte: z.string(),
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
referenceLow: z.number().nullable(),
|
||||
referenceHigh: z.number().nullable(),
|
||||
takenAt: z.string(),
|
||||
source: z.string(),
|
||||
hasNote: z.boolean(),
|
||||
rangeStatus: rangeStatusEnum,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.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`.",
|
||||
});
|
||||
|
||||
const labResultDetail = labResultRow
|
||||
.omit({ hasNote: true })
|
||||
.extend({ note: z.string().nullable() })
|
||||
.meta({
|
||||
id: "LabResultDetail",
|
||||
description:
|
||||
"Single lab result including its decrypted free-text `note` (or null).",
|
||||
});
|
||||
|
||||
const listResponse = z
|
||||
.object({
|
||||
results: z.array(labResultRow),
|
||||
meta: z.object({
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
}),
|
||||
})
|
||||
.meta({ id: "ListLabResultsResponse" });
|
||||
|
||||
const notFound = {
|
||||
"404": {
|
||||
description: "Lab result not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
|
||||
export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/labs": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
summary: "List the caller's lab results",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).",
|
||||
requestParams: { query: listLabResultsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Lab-result list.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(listResponse, "ListLabResultsEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Labs"],
|
||||
summary: "Record a lab result",
|
||||
description:
|
||||
"Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createLabResultSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Created lab result.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultRow, "CreateLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/labs/{id}": {
|
||||
get: {
|
||||
tags: ["Labs"],
|
||||
summary: "Fetch a single lab result",
|
||||
description:
|
||||
"Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Lab-result detail.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Labs"],
|
||||
summary: "Edit a lab result",
|
||||
description:
|
||||
"Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. Audits as `labResult.update`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: updateLabResultSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated lab result.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(labResultRow, "UpdateLabResultResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Labs"],
|
||||
summary: "Delete a lab result",
|
||||
description:
|
||||
"Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Deletion succeeded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteLabResultResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...notFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { cycleKeys } from "./cycle";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { insightsKeys } from "./insights";
|
||||
import { integrationKeys } from "./integrations";
|
||||
import { labKeys } from "./labs";
|
||||
import { measurementKeys } from "./measurements";
|
||||
import { measurementReminderKeys } from "./measurement-reminders";
|
||||
import { medicationKeys } from "./medications";
|
||||
@@ -42,6 +43,7 @@ export const queryKeys = {
|
||||
...settingsKeys,
|
||||
...cycleKeys,
|
||||
...measurementReminderKeys,
|
||||
...labKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* v1.17.1 — TanStack Query keys for the structured lab-result store.
|
||||
*
|
||||
* `labResultsList` bakes the filter + pagination shape into the key so a
|
||||
* filter change refetches without a same-key cache collision (the project's
|
||||
* queryKey-factory invariant).
|
||||
*/
|
||||
export const labKeys = {
|
||||
labResults: () => ["lab-results"] as const,
|
||||
|
||||
labResultsList: (params: {
|
||||
analyte: string | undefined;
|
||||
panel: string | undefined;
|
||||
from: string | undefined;
|
||||
to: string | undefined;
|
||||
page: number;
|
||||
sortDir: string;
|
||||
}) =>
|
||||
[
|
||||
"lab-results",
|
||||
"list",
|
||||
params.analyte ?? null,
|
||||
params.panel ?? null,
|
||||
params.from ?? null,
|
||||
params.to ?? null,
|
||||
params.page,
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
/** Per-analyte trend series (≥2 values) shown inline on a grouped card. */
|
||||
labAnalyteTrend: (analyte: string) =>
|
||||
["lab-results", "trend", analyte] as const,
|
||||
};
|
||||
@@ -57,6 +57,11 @@ describe("parseDoctorReportPrefs", () => {
|
||||
expect(out.mood).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults labs to ON (recorded to share with a clinician)", () => {
|
||||
const out = parseDoctorReportPrefs(null);
|
||||
expect(out.labs).toBe(true);
|
||||
});
|
||||
|
||||
it("fills missing keys from defaults", () => {
|
||||
const out = parseDoctorReportPrefs({ bp: false });
|
||||
expect(out).toEqual({
|
||||
@@ -86,6 +91,7 @@ describe("resolveDoctorReportPrefs", () => {
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
cycle: false,
|
||||
labs: false,
|
||||
};
|
||||
const out = resolveDoctorReportPrefs(current, { mood: false });
|
||||
expect(out).toEqual({ ...current, mood: false });
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
createLabResultSchema,
|
||||
listLabResultsSchema,
|
||||
updateLabResultSchema,
|
||||
} from "../labs";
|
||||
|
||||
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const RECENT = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
describe("createLabResultSchema", () => {
|
||||
it("accepts a minimal valid reading", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "HbA1c",
|
||||
value: 5.4,
|
||||
unit: "%",
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("normalises a backdatable takenAt to a Date", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.takenAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("rejects a future takenAt", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
takenAt: FUTURE,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty analyte", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: " ",
|
||||
value: 1,
|
||||
unit: "%",
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a transposed reference range (low > high)", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "Ferritin",
|
||||
value: 50,
|
||||
unit: "ng/mL",
|
||||
referenceLow: 300,
|
||||
referenceHigh: 30,
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a single (upper-only) reference bound", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "LDL",
|
||||
value: 110,
|
||||
unit: "mg/dL",
|
||||
referenceHigh: 116,
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("normalises an empty-string panel to undefined", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "TSH",
|
||||
value: 2,
|
||||
unit: "mIU/L",
|
||||
panel: "",
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) expect(r.data.panel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a non-finite value", () => {
|
||||
const r = createLabResultSchema.safeParse({
|
||||
analyte: "LDL",
|
||||
value: Number.POSITIVE_INFINITY,
|
||||
unit: "mg/dL",
|
||||
takenAt: RECENT,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLabResultSchema", () => {
|
||||
it("accepts an explicit null to clear panel + note", () => {
|
||||
const r = updateLabResultSchema.safeParse({ panel: null, note: null });
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.panel).toBeNull();
|
||||
expect(r.data.note).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a partial value-only edit", () => {
|
||||
const r = updateLabResultSchema.safeParse({ value: 5.6 });
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still enforces the range order when both bounds are supplied", () => {
|
||||
const r = updateLabResultSchema.safeParse({
|
||||
referenceLow: 10,
|
||||
referenceHigh: 1,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLabResultsSchema", () => {
|
||||
it("defaults limit/offset/sortDir", () => {
|
||||
const r = listLabResultsSchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
if (r.success) {
|
||||
expect(r.data.limit).toBe(100);
|
||||
expect(r.data.offset).toBe(0);
|
||||
expect(r.data.sortDir).toBe("desc");
|
||||
}
|
||||
});
|
||||
|
||||
it("caps limit at 500", () => {
|
||||
const r = listLabResultsSchema.safeParse({ limit: "9999" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyReferenceRange", () => {
|
||||
it("returns unknown when no bounds are given", () => {
|
||||
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
|
||||
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns in-range inside both bounds", () => {
|
||||
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
|
||||
});
|
||||
|
||||
it("treats the bounds as inclusive", () => {
|
||||
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
|
||||
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
|
||||
});
|
||||
|
||||
it("returns below when under the low bound", () => {
|
||||
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
|
||||
});
|
||||
|
||||
it("returns above when over the high bound", () => {
|
||||
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
|
||||
});
|
||||
|
||||
it("classifies against a single upper bound", () => {
|
||||
expect(classifyReferenceRange(120, null, 116)).toBe("above");
|
||||
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
|
||||
});
|
||||
|
||||
it("classifies against a single lower bound", () => {
|
||||
expect(classifyReferenceRange(8, 10, null)).toBe("below");
|
||||
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export const doctorReportPrefsSchema = z
|
||||
compliance: z.boolean(),
|
||||
sleep: z.boolean(),
|
||||
cycle: z.boolean(),
|
||||
labs: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -51,6 +52,7 @@ export interface DoctorReportPrefs {
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
cycle: boolean;
|
||||
labs: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +72,9 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = {
|
||||
// Cycle data is opt-in: a user sharing a BP report with a cardiologist
|
||||
// should not auto-leak reproductive data. Same privacy stance as mood.
|
||||
cycle: false,
|
||||
// Lab results the user recorded for exactly this purpose — sharing
|
||||
// bloodwork with a clinician. ON by default, like BP / weight.
|
||||
labs: true,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,6 +74,9 @@ export const exportSectionsSchema = z
|
||||
bmi: z.boolean(),
|
||||
// Cycle / reproductive health — opt-in (privacy default OFF, like mood).
|
||||
cycle: z.boolean(),
|
||||
// Structured lab results — ON by default (recorded to share with a
|
||||
// clinician, same stance as BP / weight).
|
||||
labs: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
@@ -135,5 +138,6 @@ export function toDoctorReportPrefs(
|
||||
sleep: a.sleep ?? fallback.sleep,
|
||||
// Cycle is opt-in: only true when explicitly requested (privacy).
|
||||
cycle: s.cycle === true,
|
||||
labs: s.labs ?? fallback.labs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.17.1 — structured lab-result store.
|
||||
*
|
||||
* A minimal, generic `LabResult` capture path: a self-hoster records a
|
||||
* biomarker reading (HbA1c, LDL, ferritin, TSH, …) with its value, unit,
|
||||
* the lab's reference range, and the sample date. Pairs with the v1.17.1
|
||||
* Vorsorge annual-blood-panel reminder, which otherwise has nowhere to
|
||||
* record the result it reminds the user to obtain.
|
||||
*
|
||||
* The model is intentionally free-form on `analyte` + `unit` (no closed
|
||||
* LOINC enum): a lab report prints whatever name and unit it likes, and a
|
||||
* forced taxonomy would reject real-world values. The optional `panel`
|
||||
* groups analytes that arrived together (e.g. "Großes Blutbild").
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/**
|
||||
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
|
||||
* old paper report, so the window is generous (50 years) but still rejects
|
||||
* epoch-zero / corrupted-import instants via the shared 1900 floor.
|
||||
*/
|
||||
const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
|
||||
const takenAtField = validateEntryInstant(
|
||||
z
|
||||
.string()
|
||||
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
|
||||
.transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
|
||||
pastMessage: "Sample date is too far in the past",
|
||||
},
|
||||
);
|
||||
|
||||
/** Trimmed, bounded free-text field with a min-length guard. */
|
||||
const requiredText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(max);
|
||||
|
||||
const optionalPanel = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
// An empty-string panel is normalised to "no panel" so the grouping
|
||||
// stays NULL-distinct rather than carrying a blank label.
|
||||
.or(z.literal("").transform(() => undefined));
|
||||
|
||||
const optionalNote = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(2000)
|
||||
.optional()
|
||||
.or(z.literal("").transform(() => undefined));
|
||||
|
||||
/**
|
||||
* Reference bounds are independently optional (a lab may report only an
|
||||
* upper bound, e.g. LDL < 116). When BOTH are present the low bound must
|
||||
* not exceed the high bound — a transposed range is a client error.
|
||||
*/
|
||||
const referenceLow = z.number().finite().optional();
|
||||
const referenceHigh = z.number().finite().optional();
|
||||
|
||||
export const createLabResultSchema = z
|
||||
.object({
|
||||
panel: optionalPanel,
|
||||
analyte: requiredText(120),
|
||||
value: z.number().finite(),
|
||||
unit: requiredText(40),
|
||||
referenceLow,
|
||||
referenceHigh,
|
||||
takenAt: takenAtField,
|
||||
note: optionalNote,
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.referenceLow === undefined ||
|
||||
d.referenceHigh === undefined ||
|
||||
d.referenceLow <= d.referenceHigh,
|
||||
{
|
||||
message: "referenceLow must not exceed referenceHigh",
|
||||
path: ["referenceLow"],
|
||||
},
|
||||
);
|
||||
|
||||
export type CreateLabResultInput = z.infer<typeof createLabResultSchema>;
|
||||
|
||||
/**
|
||||
* Update schema: every field optional (partial edit). `panel` and `note`
|
||||
* accept an explicit `null` to clear the stored value — distinct from an
|
||||
* omitted key, which leaves the column untouched.
|
||||
*/
|
||||
export const updateLabResultSchema = z
|
||||
.object({
|
||||
panel: optionalPanel.or(z.null()),
|
||||
analyte: requiredText(120).optional(),
|
||||
value: z.number().finite().optional(),
|
||||
unit: requiredText(40).optional(),
|
||||
referenceLow: referenceLow.or(z.null()),
|
||||
referenceHigh: referenceHigh.or(z.null()),
|
||||
takenAt: takenAtField.optional(),
|
||||
note: optionalNote.or(z.null()),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.referenceLow === undefined ||
|
||||
d.referenceLow === null ||
|
||||
d.referenceHigh === undefined ||
|
||||
d.referenceHigh === null ||
|
||||
d.referenceLow <= d.referenceHigh,
|
||||
{
|
||||
message: "referenceLow must not exceed referenceHigh",
|
||||
path: ["referenceLow"],
|
||||
},
|
||||
);
|
||||
|
||||
export type UpdateLabResultInput = z.infer<typeof updateLabResultSchema>;
|
||||
|
||||
/** List query: filter by analyte (exact) + date range, paginate. */
|
||||
export const listLabResultsSchema = z.object({
|
||||
analyte: z.string().trim().min(1).max(120).optional(),
|
||||
panel: z.string().trim().min(1).max(120).optional(),
|
||||
from: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
sortDir: z.enum(["asc", "desc"]).default("desc"),
|
||||
});
|
||||
|
||||
export type ListLabResultsInput = z.infer<typeof listLabResultsSchema>;
|
||||
|
||||
/**
|
||||
* Reference-range classification. Deliberately a three-state, NEUTRAL
|
||||
* verdict — the badge that renders it must stay calm and informative, NOT
|
||||
* alarming (no red "out of range" tint). `"unknown"` when the lab reported
|
||||
* no usable bounds.
|
||||
*
|
||||
* Bounds are treated as inclusive: a value exactly on the reference limit
|
||||
* reads as in-range, matching how labs print "≤" / "≥" reference notation.
|
||||
*/
|
||||
export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown";
|
||||
|
||||
export function classifyReferenceRange(
|
||||
value: number,
|
||||
referenceLow: number | null | undefined,
|
||||
referenceHigh: number | null | undefined,
|
||||
): ReferenceRangeStatus {
|
||||
const hasLow = referenceLow !== null && referenceLow !== undefined;
|
||||
const hasHigh = referenceHigh !== null && referenceHigh !== undefined;
|
||||
if (!hasLow && !hasHigh) return "unknown";
|
||||
if (hasLow && value < (referenceLow as number)) return "below";
|
||||
if (hasHigh && value > (referenceHigh as number)) return "above";
|
||||
return "in-range";
|
||||
}
|
||||
Reference in new issue
Block a user