diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bb945ee6b..f06755f3a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -40,6 +40,7 @@ tags: - name: Sync - name: Cycle - name: MeasurementReminders + - name: Labs - name: Admin - name: Meta paths: @@ -938,6 +939,8 @@ paths: type: boolean cycle: type: boolean + labs: + type: boolean locale: type: string enum: @@ -1066,6 +1069,8 @@ paths: type: boolean cycle: type: boolean + labs: + type: boolean resourceTypes: maxItems: 8 type: array @@ -5393,6 +5398,249 @@ paths: "404": *a6 "422": *a3 "429": *a4 + /api/labs: + get: + tags: + - Labs + summary: List the caller's lab results + description: Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / + date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted + note itself is not echoed). + parameters: + - in: query + name: analyte + schema: + type: string + minLength: 1 + maxLength: 120 + - in: query + name: panel + schema: + type: string + minLength: 1 + maxLength: 120 + - in: query + name: from + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - in: query + name: to + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - in: query + name: limit + schema: + default: 100 + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + responses: + "200": + description: Lab-result list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListLabResultsEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Labs + summary: Record a lab result + description: Creates a single lab result for the caller. `source` is hardcoded MANUAL on this path. The optional note is + AES-256-GCM encrypted before write. Audits as `labResult.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + panel: + anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: string + const: "" + analyte: + type: string + minLength: 1 + maxLength: 120 + value: + type: number + unit: + type: string + minLength: 1 + maxLength: 40 + referenceLow: + type: number + referenceHigh: + type: number + takenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + required: + - analyte + - value + - unit + - takenAt + responses: + "201": + description: Created lab result. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLabResultResponse" + "401": *a1 + "422": *a3 + "429": *a4 + /api/labs/{id}: + get: + tags: + - Labs + summary: Fetch a single lab result + description: Returns the lab result including its decrypted `note`. Cross-user rows surface as 404. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Lab-result detail. + content: + application/json: + schema: + $ref: "#/components/schemas/GetLabResultResponse" + "401": *a1 + "404": &a7 + description: Lab result not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + put: + tags: + - Labs + summary: Edit a lab result + description: Partial edit; omitted fields are untouched, an explicit null clears `panel` / `note` / a reference bound. + Audits as `labResult.update`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + panel: + anyOf: + - anyOf: + - type: string + minLength: 1 + maxLength: 120 + - type: string + const: "" + - type: "null" + analyte: + type: string + minLength: 1 + maxLength: 120 + value: + type: number + unit: + type: string + minLength: 1 + maxLength: 40 + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + note: + anyOf: + - anyOf: + - type: string + maxLength: 2000 + - type: string + const: "" + - type: "null" + responses: + "200": + description: Updated lab result. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLabResultResponse" + "401": *a1 + "404": *a7 + "422": *a3 + "429": *a4 + delete: + tags: + - Labs + summary: Delete a lab result + description: Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteLabResultResponse" + "401": *a1 + "404": *a7 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -16155,6 +16403,237 @@ components: - data - error additionalProperties: false + ListLabResultsEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ListLabResultsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListLabResultsResponse: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/LabResult" + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + required: + - total + - limit + - offset + additionalProperties: false + required: + - results + - meta + additionalProperties: false + LabResult: + type: object + properties: + id: + type: string + panel: + anyOf: + - type: string + - type: "null" + analyte: + type: string + value: + type: number + unit: + type: string + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + source: + type: string + hasNote: + type: boolean + rangeStatus: + $ref: "#/components/schemas/LabReferenceRangeStatus" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - panel + - analyte + - value + - unit + - referenceLow + - referenceHigh + - takenAt + - source + - hasNote + - rangeStatus + - createdAt + - updatedAt + additionalProperties: false + description: A stored lab result. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the + single-resource GET returns the decrypted `note`. + LabReferenceRangeStatus: + type: string + enum: + - in-range + - below + - above + - unknown + description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. + Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and + informative — not an alarming red." + CreateLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResultDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LabResultDetail: + type: object + properties: + id: + type: string + panel: + anyOf: + - type: string + - type: "null" + analyte: + type: string + value: + type: number + unit: + type: string + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + takenAt: + type: string + source: + type: string + rangeStatus: + $ref: "#/components/schemas/LabReferenceRangeStatus" + createdAt: + type: string + updatedAt: + type: string + note: + anyOf: + - type: string + - type: "null" + required: + - id + - panel + - analyte + - value + - unit + - referenceLow + - referenceHigh + - takenAt + - source + - rangeStatus + - createdAt + - updatedAt + - note + additionalProperties: false + description: Single lab result including its decrypted free-text `note` (or null). + UpdateLabResultResponse: + type: object + properties: + data: + $ref: "#/components/schemas/LabResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteLabResultResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index 7600005a2..a00c1da74 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", @@ -4570,7 +4578,8 @@ "includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)", "fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).", "generate": "Erstellen", - "error": "Export fehlgeschlagen ({code})." + "error": "Export fehlgeschlagen ({code}).", + "labs": "Laborwerte" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -6306,5 +6315,48 @@ "pushTitle": "Vorsorge-Erinnerung", "pushBody": "Fällig: {label}", "pushLocation": "(bei {location})" + }, + "labs": { + "title": "Laborwerte", + "subtitle": "Blutwerte und Biomarker", + "addResult": "Hinzufügen", + "addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.", + "addFirst": "Ersten Wert erfassen", + "emptyTitle": "Noch keine Laborwerte", + "emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.", + "referenceLabel": "Referenz", + "readingsCount": "{count} Werte", + "deleteLatest": "Letzten Wert löschen", + "deletedToast": "Wert gelöscht", + "deleteError": "Wert konnte nicht gelöscht werden", + "loadError": "Laborwerte konnten nicht geladen werden", + "range": { + "inRange": "Im Bereich", + "below": "Unter Bereich", + "above": "Über Bereich" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "z. B. HbA1c, LDL, Ferritin", + "value": "Wert", + "unit": "Einheit", + "unitPlaceholder": "z. B. mg/dl, %, ng/ml", + "takenAt": "Probendatum", + "optionalGroup": "Optional", + "panel": "Panel", + "panelPlaceholder": "z. B. Großes Blutbild", + "referenceLow": "Referenz unten", + "referenceHigh": "Referenz oben", + "referenceLowPlaceholder": "Untere Grenze", + "referenceHighPlaceholder": "Obere Grenze", + "referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.", + "note": "Notiz", + "notePlaceholder": "Optionaler Kontext zu diesem Wert", + "requiredError": "Biomarker, Wert und Einheit sind erforderlich.", + "rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.", + "save": "Speichern", + "savedToast": "Wert gespeichert", + "saveError": "Wert konnte nicht gespeichert werden" + } } } diff --git a/messages/en.json b/messages/en.json index ad96d881d..982cde664 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", @@ -4570,7 +4578,8 @@ "includeAiSummary": "Append AI summary (not clinically validated)", "fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).", "generate": "Generate", - "error": "Export failed ({code})." + "error": "Export failed ({code}).", + "labs": "Lab results" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -6306,5 +6315,48 @@ "pushTitle": "Preventive-care reminder", "pushBody": "Time for: {label}", "pushLocation": "(at {location})" + }, + "labs": { + "title": "Lab results", + "subtitle": "Bloodwork and biomarker readings", + "addResult": "Add", + "addDescription": "Record a biomarker reading from a lab report.", + "addFirst": "Add your first result", + "emptyTitle": "No lab results yet", + "emptyDescription": "Record bloodwork like HbA1c, LDL or ferritin to track it over time and share it with your doctor.", + "referenceLabel": "Reference", + "readingsCount": "{count} readings", + "deleteLatest": "Delete latest reading", + "deletedToast": "Result deleted", + "deleteError": "Could not delete the result", + "loadError": "Could not load lab results", + "range": { + "inRange": "In range", + "below": "Below range", + "above": "Above range" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "e.g. HbA1c, LDL, Ferritin", + "value": "Value", + "unit": "Unit", + "unitPlaceholder": "e.g. mg/dL, %, ng/mL", + "takenAt": "Sample date", + "optionalGroup": "Optional", + "panel": "Panel", + "panelPlaceholder": "e.g. Complete blood count", + "referenceLow": "Reference low", + "referenceHigh": "Reference high", + "referenceLowPlaceholder": "Lower bound", + "referenceHighPlaceholder": "Upper bound", + "referenceHint": "Copy the reference range from your lab report. Either bound is optional.", + "note": "Note", + "notePlaceholder": "Optional context for this result", + "requiredError": "Biomarker, value and unit are required.", + "rangeOrderError": "The reference low must not exceed the reference high.", + "save": "Save", + "savedToast": "Result saved", + "saveError": "Could not save the result" + } } } diff --git a/messages/es.json b/messages/es.json index 7d92cd4c7..d8a572cc4 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", @@ -4570,7 +4578,8 @@ "includeAiSummary": "Añadir resumen de IA (no validado clínicamente)", "fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).", "generate": "Generar", - "error": "Error en la exportación ({code})." + "error": "Error en la exportación ({code}).", + "labs": "Resultados de análisis" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -6306,5 +6315,48 @@ "pushTitle": "Recordatorio de prevención", "pushBody": "Toca: {label}", "pushLocation": "(en {location})" + }, + "labs": { + "title": "Resultados de análisis", + "subtitle": "Analíticas de sangre y biomarcadores", + "addResult": "Añadir", + "addDescription": "Registra un biomarcador a partir de un informe de laboratorio.", + "addFirst": "Añade tu primer resultado", + "emptyTitle": "Aún no hay resultados", + "emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.", + "referenceLabel": "Referencia", + "readingsCount": "{count} valores", + "deleteLatest": "Eliminar el último valor", + "deletedToast": "Resultado eliminado", + "deleteError": "No se pudo eliminar el resultado", + "loadError": "No se pudieron cargar los resultados", + "range": { + "inRange": "En rango", + "below": "Por debajo", + "above": "Por encima" + }, + "form": { + "analyte": "Biomarcador", + "analytePlaceholder": "p. ej. HbA1c, LDL, Ferritina", + "value": "Valor", + "unit": "Unidad", + "unitPlaceholder": "p. ej. mg/dL, %, ng/mL", + "takenAt": "Fecha de la muestra", + "optionalGroup": "Opcional", + "panel": "Panel", + "panelPlaceholder": "p. ej. Hemograma completo", + "referenceLow": "Referencia mínima", + "referenceHigh": "Referencia máxima", + "referenceLowPlaceholder": "Límite inferior", + "referenceHighPlaceholder": "Límite superior", + "referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.", + "note": "Nota", + "notePlaceholder": "Contexto opcional para este resultado", + "requiredError": "El biomarcador, el valor y la unidad son obligatorios.", + "rangeOrderError": "La referencia mínima no puede superar la máxima.", + "save": "Guardar", + "savedToast": "Resultado guardado", + "saveError": "No se pudo guardar el resultado" + } } } diff --git a/messages/fr.json b/messages/fr.json index b52d3d2ac..52f5b9d53 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", @@ -4570,7 +4578,8 @@ "includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)", "fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).", "generate": "Générer", - "error": "Échec de l’export ({code})." + "error": "Échec de l’export ({code}).", + "labs": "Résultats d'analyses" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -6306,5 +6315,48 @@ "pushTitle": "Rappel de prévention", "pushBody": "C'est l'heure : {label}", "pushLocation": "(chez {location})" + }, + "labs": { + "title": "Résultats d'analyses", + "subtitle": "Analyses sanguines et biomarqueurs", + "addResult": "Ajouter", + "addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.", + "addFirst": "Ajouter votre premier résultat", + "emptyTitle": "Aucun résultat pour l'instant", + "emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.", + "referenceLabel": "Référence", + "readingsCount": "{count} valeurs", + "deleteLatest": "Supprimer la dernière valeur", + "deletedToast": "Résultat supprimé", + "deleteError": "Impossible de supprimer le résultat", + "loadError": "Impossible de charger les résultats", + "range": { + "inRange": "Dans la norme", + "below": "Sous la norme", + "above": "Au-dessus de la norme" + }, + "form": { + "analyte": "Biomarqueur", + "analytePlaceholder": "ex. HbA1c, LDL, Ferritine", + "value": "Valeur", + "unit": "Unité", + "unitPlaceholder": "ex. mg/dL, %, ng/mL", + "takenAt": "Date du prélèvement", + "optionalGroup": "Facultatif", + "panel": "Panel", + "panelPlaceholder": "ex. Numération formule sanguine", + "referenceLow": "Référence basse", + "referenceHigh": "Référence haute", + "referenceLowPlaceholder": "Borne inférieure", + "referenceHighPlaceholder": "Borne supérieure", + "referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.", + "note": "Note", + "notePlaceholder": "Contexte facultatif pour ce résultat", + "requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.", + "rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.", + "save": "Enregistrer", + "savedToast": "Résultat enregistré", + "saveError": "Impossible d'enregistrer le résultat" + } } } diff --git a/messages/it.json b/messages/it.json index 80b09d762..7c50a8d29 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", @@ -4570,7 +4578,8 @@ "includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)", "fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).", "generate": "Genera", - "error": "Esportazione non riuscita ({code})." + "error": "Esportazione non riuscita ({code}).", + "labs": "Risultati degli esami" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -6306,5 +6315,48 @@ "pushTitle": "Promemoria di prevenzione", "pushBody": "È ora di: {label}", "pushLocation": "(presso {location})" + }, + "labs": { + "title": "Risultati degli esami", + "subtitle": "Analisi del sangue e biomarcatori", + "addResult": "Aggiungi", + "addDescription": "Registra un biomarcatore da un referto di laboratorio.", + "addFirst": "Aggiungi il primo risultato", + "emptyTitle": "Ancora nessun risultato", + "emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.", + "referenceLabel": "Riferimento", + "readingsCount": "{count} valori", + "deleteLatest": "Elimina l'ultimo valore", + "deletedToast": "Risultato eliminato", + "deleteError": "Impossibile eliminare il risultato", + "loadError": "Impossibile caricare i risultati", + "range": { + "inRange": "Nella norma", + "below": "Sotto la norma", + "above": "Sopra la norma" + }, + "form": { + "analyte": "Biomarcatore", + "analytePlaceholder": "es. HbA1c, LDL, Ferritina", + "value": "Valore", + "unit": "Unità", + "unitPlaceholder": "es. mg/dL, %, ng/mL", + "takenAt": "Data del prelievo", + "optionalGroup": "Facoltativo", + "panel": "Pannello", + "panelPlaceholder": "es. Emocromo completo", + "referenceLow": "Riferimento minimo", + "referenceHigh": "Riferimento massimo", + "referenceLowPlaceholder": "Limite inferiore", + "referenceHighPlaceholder": "Limite superiore", + "referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.", + "note": "Nota", + "notePlaceholder": "Contesto facoltativo per questo risultato", + "requiredError": "Biomarcatore, valore e unità sono obbligatori.", + "rangeOrderError": "Il riferimento minimo non può superare quello massimo.", + "save": "Salva", + "savedToast": "Risultato salvato", + "saveError": "Impossibile salvare il risultato" + } } } diff --git a/messages/pl.json b/messages/pl.json index 5871f223f..f96c44bb7 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ę", @@ -4570,7 +4578,8 @@ "includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)", "fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).", "generate": "Generuj", - "error": "Eksport nie powiódł się ({code})." + "error": "Eksport nie powiódł się ({code}).", + "labs": "Wyniki badań" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -6306,5 +6315,48 @@ "pushTitle": "Przypomnienie profilaktyczne", "pushBody": "Czas na: {label}", "pushLocation": "(u {location})" + }, + "labs": { + "title": "Wyniki badań", + "subtitle": "Badania krwi i biomarkery", + "addResult": "Dodaj", + "addDescription": "Zapisz biomarker z wyniku laboratoryjnego.", + "addFirst": "Dodaj pierwszy wynik", + "emptyTitle": "Brak wyników badań", + "emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.", + "referenceLabel": "Norma", + "readingsCount": "{count} wyników", + "deleteLatest": "Usuń ostatni wynik", + "deletedToast": "Wynik usunięty", + "deleteError": "Nie udało się usunąć wyniku", + "loadError": "Nie udało się wczytać wyników", + "range": { + "inRange": "W normie", + "below": "Poniżej normy", + "above": "Powyżej normy" + }, + "form": { + "analyte": "Biomarker", + "analytePlaceholder": "np. HbA1c, LDL, ferrytyna", + "value": "Wartość", + "unit": "Jednostka", + "unitPlaceholder": "np. mg/dl, %, ng/ml", + "takenAt": "Data pobrania", + "optionalGroup": "Opcjonalne", + "panel": "Panel", + "panelPlaceholder": "np. Morfologia krwi", + "referenceLow": "Norma dolna", + "referenceHigh": "Norma górna", + "referenceLowPlaceholder": "Dolna granica", + "referenceHighPlaceholder": "Górna granica", + "referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.", + "note": "Notatka", + "notePlaceholder": "Opcjonalny kontekst tego wyniku", + "requiredError": "Biomarker, wartość i jednostka są wymagane.", + "rangeOrderError": "Norma dolna nie może przekraczać górnej.", + "save": "Zapisz", + "savedToast": "Wynik zapisany", + "saveError": "Nie udało się zapisać wyniku" + } } } diff --git a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts index 4550fc90c..10b591041 100644 --- a/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/doctor-report-prefs/__tests__/route.test.ts @@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { compliance: true, sleep: false, cycle: false, + labs: true, }; const res = await (PUT as (r: Request) => Promise)(mkPut(body)); expect(res.status).toBe(200); @@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => { compliance: false, sleep: false, cycle: false, + labs: false, }; vi.mocked(prisma.user.findUnique).mockResolvedValue({ doctorReportPrefsJson: current, diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts index 976b297da..50fb75504 100644 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ b/src/app/api/doctor-report/__tests__/pdf-route.test.ts @@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, auditLog: { create: vi.fn() }, }, @@ -71,6 +72,7 @@ function setEmptyDataMocks() { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "testuser", dateOfBirth: null, diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 95b700be2..bc8e1a7c0 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + labResult: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn() }, }, })); @@ -74,6 +75,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); // First findUnique = aggregator profile select; second = route KVNR select. vi.mocked(prisma.user.findUnique).mockResolvedValue({ username: "sample", diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts new file mode 100644 index 000000000..829f88498 --- /dev/null +++ b/src/app/api/labs/[id]/route.ts @@ -0,0 +1,188 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store"; +import { annotate } from "@/lib/logging/context"; +import { + classifyReferenceRange, + updateLabResultSchema, +} from "@/lib/validations/labs"; + +/** + * v1.17.1 — single lab-result resource (`/api/labs/{id}`). + * + * GET returns the row including its decrypted note (the detail view is the + * one place the plaintext note surfaces). PUT applies a partial edit + * (`data` built field-by-field; an explicit `null` clears `panel` / `note` / + * a reference bound, an omitted key leaves it untouched). DELETE soft-deletes + * by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed). + */ + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const row = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!row || row.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + annotate({ action: { name: "labs.get" }, meta: { labResultId: id } }); + + return apiSuccess({ + id: row.id, + panel: row.panel, + analyte: row.analyte, + value: row.value, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + note: row.noteEncrypted + ? decryptNoteFromBytes(row.noteEncrypted) + : null, + rangeStatus: classifyReferenceRange( + row.value, + row.referenceLow, + row.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + }, +); + +export const PUT = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = updateLabResultSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.update.validation-failed" }, + meta: { issue_count: parsed.error.issues.length, labResultId: id }, + }); + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.update.validation-failed", + details: JSON.stringify({ issues: auditIssues, labResultId: id }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const d = parsed.data; + + // Build `data` field-by-field. `undefined` → leave the column untouched; + // an explicit `null` on `panel` / `note` / a reference bound clears it. + const data: Record = {}; + if (d.panel !== undefined) data.panel = d.panel; + if (d.analyte !== undefined) data.analyte = d.analyte; + if (d.value !== undefined) data.value = d.value; + if (d.unit !== undefined) data.unit = d.unit; + if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow; + if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh; + if (d.takenAt !== undefined) data.takenAt = d.takenAt; + if (d.note !== undefined) { + data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null; + } + + const updated = await prisma.labResult.update({ where: { id }, data }); + + await auditLog("labResult.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: id }, + }); + + annotate({ action: { name: "labs.update" }, meta: { labResultId: id } }); + + return apiSuccess({ + id: updated.id, + panel: updated.panel, + analyte: updated.analyte, + value: updated.value, + unit: updated.unit, + referenceLow: updated.referenceLow, + referenceHigh: updated.referenceHigh, + takenAt: updated.takenAt.toISOString(), + source: updated.source, + hasNote: updated.noteEncrypted !== null, + rangeStatus: classifyReferenceRange( + updated.value, + updated.referenceLow, + updated.referenceHigh, + ), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + }); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const existing = await prisma.labResult.findFirst({ + where: { id, deletedAt: null }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Lab result not found", 404); + } + + // Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`, + // so the row is invisible from here on. A re-delete is idempotent. + await prisma.labResult.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("labResult.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: id, analyte: existing.analyte }, + }); + + annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts new file mode 100644 index 000000000..a80ff3f41 --- /dev/null +++ b/src/app/api/labs/route.ts @@ -0,0 +1,188 @@ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; +import { encryptNoteToBytes } from "@/lib/labs/store"; +import { annotate } from "@/lib/logging/context"; +import { + classifyReferenceRange, + createLabResultSchema, + listLabResultsSchema, +} from "@/lib/validations/labs"; + +/** + * v1.17.1 — structured lab-result store (`/api/labs`). + * + * GET lists the caller's live results with optional analyte / panel / date + * filters. POST records a single reading. `userId` is always narrowed from + * the session — never a body field — and the write `data` object is built + * field-by-field (no mass assignment). The free-text note, when present, is + * AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write. + */ + +// The serialised row never echoes the encrypted note bytes back; the badge +// status is computed server-side so the client renders a coherent, neutral +// in/out-of-range verdict without re-deriving the rule. +function serialiseLabResult(row: { + id: string; + panel: string | null; + analyte: string; + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; + source: string; + noteEncrypted: Uint8Array | null; + createdAt: Date; + updatedAt: Date; +}) { + return { + id: row.id, + panel: row.panel, + analyte: row.analyte, + value: row.value, + unit: row.unit, + referenceLow: row.referenceLow, + referenceHigh: row.referenceHigh, + takenAt: row.takenAt.toISOString(), + source: row.source, + hasNote: row.noteEncrypted !== null, + rangeStatus: classifyReferenceRange( + row.value, + row.referenceLow, + row.referenceHigh, + ), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = listLabResultsSchema.safeParse(params); + if (!parsed.success) { + annotate({ + action: { name: "labs.list.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data; + + const where = { + userId: user.id, + deletedAt: null, + ...(analyte && { analyte }), + ...(panel && { panel }), + ...(from || to + ? { + takenAt: { + ...(from && { gte: from }), + ...(to && { lte: to }), + }, + } + : {}), + }; + + const [rows, total] = await Promise.all([ + prisma.labResult.findMany({ + where, + orderBy: { takenAt: sortDir }, + take: limit, + skip: offset, + }), + prisma.labResult.count({ where }), + ]); + + annotate({ + action: { name: "labs.list" }, + meta: { total, limit, offset }, + }); + + return apiSuccess({ + results: rows.map(serialiseLabResult), + meta: { total, limit, offset }, + }); +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult)); + +async function postLabResult(request: NextRequest) { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createLabResultSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "labs.create.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + // Free-text `analyte` / `unit` / `note` could land verbatim in a Zod + // issue message — strip values from the audit-ledger breadcrumb. + const auditIssues = sanitiseZodIssues(parsed.error.issues, { + stripValuesFromMessage: true, + }); + prisma.auditLog + .create({ + data: { + userId: user.id, + action: "labs.create.validation-failed", + details: JSON.stringify({ issues: auditIssues }), + }, + }) + .catch(() => { + /* swallow — the 422 response is the contract */ + }); + return returnAllZodIssues(parsed.error, 422); + } + + const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } = + parsed.data; + + // Field-by-field assignment — never spread `parsed.data`. `source` is + // hardcoded "MANUAL" for this user-facing path; import paths set their own. + const created = await prisma.labResult.create({ + data: { + userId: user.id, + panel: panel ?? null, + analyte, + value, + unit, + referenceLow: referenceLow ?? null, + referenceHigh: referenceHigh ?? null, + takenAt, + source: "MANUAL", + noteEncrypted: note ? encryptNoteToBytes(note) : null, + }, + }); + + await auditLog("labResult.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { labResultId: created.id, analyte: created.analyte }, + }); + + annotate({ + action: { name: "labs.create" }, + meta: { labResultId: created.id }, + }); + + return apiSuccess(serialiseLabResult(created), 201); +} 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/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx index ad53fc728..4af0bd050 100644 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ b/src/components/doctor-report/doctor-report-dialog.tsx @@ -87,6 +87,11 @@ interface SectionAvailability { // the cycle toggle. Present so `keyof DoctorReportPrefs` indexing // typechecks. Optional — the availability endpoint may not provide it. cycle?: boolean; + // v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog; + // the flagship export panel surfaces the toggle. Optional — the + // availability endpoint may not provide it. Present so the + // `keyof DoctorReportPrefs` indexing typechecks. + labs?: boolean; } export interface DoctorReportSubmitPayload { @@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record = { // v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it); // present so the `keyof DoctorReportPrefs` record stays exhaustive. cycle: "doctorReport.sections.cycle", + // v1.17.1 — labs section; the flagship export panel drives the toggle, + // present here so the `keyof DoctorReportPrefs` record stays exhaustive. + labs: "doctorReport.sections.labs", }; /** 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")} +

+
+ +