feat(labs): add the Laborwerte page, navigation, and OpenAPI contract

A dedicated /labs page hosts manual entry and a per-analyte view: the
four essentials (biomarker, value, unit, date) stay visible, the
reference range, panel grouping and note sit under an optional section.
Results group by analyte with a small inline trend once an analyte has
two or more readings. The reference-range badge is deliberately neutral
and calm — an out-of-range value reads as a quiet directional marker, not
an alarming red.

Adds the labs section toggle to the health-record export panel, the /labs
nav entries, the /api/labs OpenAPI routes, and the labs copy across all
six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:39:52 +02:00
1 parent fbecb6b5c8
commit 72fb51c257
19 files changed
+1690 -24

No files matched your search

+479
View File
@@ -39,6 +39,7 @@ tags:
- name: Export
- name: Sync
- name: Cycle
- name: Labs
- name: Admin
- name: Meta
paths:
@@ -937,6 +938,8 @@ paths:
type: boolean
cycle:
type: boolean
labs:
type: boolean
locale:
type: string
enum:
@@ -1065,6 +1068,8 @@ paths:
type: boolean
cycle:
type: boolean
labs:
type: boolean
resourceTypes:
maxItems: 8
type: array
@@ -5202,6 +5207,249 @@ paths:
"401": *a1
"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": &a6
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": *a6
"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": *a6
"422": *a3
"429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -15570,6 +15818,237 @@ components:
- kind
additionalProperties: false
description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.
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
View File
@@ -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",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Löschen ({count})",
"clearSelection": "Auswahl aufheben",
"selectionBarLabel": "Auswahlaktionen"
},
"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
View File
@@ -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",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Delete ({count})",
"clearSelection": "Clear selection",
"selectionBarLabel": "Selection actions"
},
"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
View File
@@ -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",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Eliminar ({count})",
"clearSelection": "Borrar selección",
"selectionBarLabel": "Acciones de selección"
},
"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
View File
@@ -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",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Supprimer ({count})",
"clearSelection": "Effacer la sélection",
"selectionBarLabel": "Actions de sélection"
},
"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
View File
@@ -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",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Elimina ({count})",
"clearSelection": "Cancella selezione",
"selectionBarLabel": "Azioni di selezione"
},
"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
View File
@@ -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ę",
@@ -4537,7 +4545,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.",
@@ -6208,5 +6217,48 @@
"deleteN": "Usuń ({count})",
"clearSelection": "Wyczyść zaznaczenie",
"selectionBarLabel": "Akcje zaznaczenia"
},
"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
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}
+199
View File
@@ -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>
);
}
+23
View File
@@ -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 };
}
+2
View File
@@ -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 },
+4
View File
@@ -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 && (
+1
View File
@@ -72,6 +72,7 @@ const openApiBase: Pick<
{ name: "Export" },
{ name: "Sync" },
{ name: "Cycle" },
{ name: "Labs" },
{ name: "Admin" },
{ name: "Meta" },
],
+2
View File
@@ -29,6 +29,7 @@ import { cyclePaths } from "./cycle";
import { devicePaths } from "./devices";
import { healthRecordPaths } from "./health-record";
import { insightsPaths } from "./insights";
import { labsPaths } from "./labs";
import { measurementPaths } from "./measurements";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
@@ -57,6 +58,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...moodPaths,
...settingsPaths,
...consentPaths,
...labsPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+204
View File
@@ -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,
},
},
},
};