From 72fb51c2576bd5a88a654cd4df895d8f9e2e099d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:39:52 +0200 Subject: [PATCH] feat(labs): add the Laborwerte page, navigation, and OpenAPI contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 479 ++++++++++++++++++ messages/de.json | 60 ++- messages/en.json | 60 ++- messages/es.json | 60 ++- messages/fr.json | 60 ++- messages/it.json | 60 ++- messages/pl.json | 60 ++- src/app/labs/page.tsx | 91 ++++ src/components/labs/lab-form.tsx | 228 +++++++++ src/components/labs/lab-list.tsx | 199 ++++++++ src/components/labs/lab-trend-sparkline.tsx | 61 +++ src/components/labs/reference-range-badge.tsx | 52 ++ src/components/labs/types.ts | 23 + src/components/layout/bottom-nav.tsx | 2 + src/components/layout/sidebar-nav.tsx | 4 + .../settings/health-record-export-panel.tsx | 8 + src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/index.ts | 2 + src/lib/openapi/routes/labs.ts | 204 ++++++++ 19 files changed, 1690 insertions(+), 24 deletions(-) create mode 100644 src/app/labs/page.tsx create mode 100644 src/components/labs/lab-form.tsx create mode 100644 src/components/labs/lab-list.tsx create mode 100644 src/components/labs/lab-trend-sparkline.tsx create mode 100644 src/components/labs/reference-range-badge.tsx create mode 100644 src/components/labs/types.ts create mode 100644 src/lib/openapi/routes/labs.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bf628854e..9db2458e2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 01784ec8b..169d69e54 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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" + } } } diff --git a/messages/en.json b/messages/en.json index a78cc1a24..88ef6142c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } } diff --git a/messages/es.json b/messages/es.json index 07ef722cb..a586a4020 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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" + } } } diff --git a/messages/fr.json b/messages/fr.json index acec48c77..a9314d126 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" + } } } diff --git a/messages/it.json b/messages/it.json index e825a1770..bf62de8c0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" + } } } diff --git a/messages/pl.json b/messages/pl.json index b551d7b0b..12c2ba0e7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" + } } } diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx new file mode 100644 index 000000000..524e903e2 --- /dev/null +++ b/src/app/labs/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ +
+
+

+ {t("labs.title")} +

+

+ {t("labs.subtitle")} +

+
+ +
+ + + { + setDialogOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.labResults(), + }); + }} + onCancel={() => setDialogOpen(false)} + /> + + + setDialogOpen(true)} /> +
+ ); +} diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx new file mode 100644 index 000000000..e49a52d66 --- /dev/null +++ b/src/components/labs/lab-form.tsx @@ -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(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("/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 ( +
+
+
+ + setAnalyte(e.target.value)} + placeholder={t("labs.form.analytePlaceholder")} + maxLength={120} + autoFocus + required + /> +
+
+ + setValue(e.target.value)} + placeholder="0.0" + required + /> +
+
+ + setUnit(e.target.value)} + placeholder={t("labs.form.unitPlaceholder")} + maxLength={40} + required + /> +
+
+ + setTakenAt(e.target.value)} + max={defaultTakenAtValue()} + required + /> +
+
+ +
+ + {t("labs.form.optionalGroup")} + +
+ + setPanel(e.target.value)} + placeholder={t("labs.form.panelPlaceholder")} + maxLength={120} + /> +
+
+
+ + setRefLow(e.target.value)} + placeholder={t("labs.form.referenceLowPlaceholder")} + /> +
+
+ + setRefHigh(e.target.value)} + placeholder={t("labs.form.referenceHighPlaceholder")} + /> +
+
+

+ {t("labs.form.referenceHint")} +

+
+ +