From 8c24f3ec2a9d11bd9acdb6f2aca28d05f9e842b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:07:37 +0200 Subject: [PATCH] feat(insights): add a wellness-score summary to the doctor report and FHIR export Surface the server-derived nightly scores (recovery / stress / strain) in the health-record export without polluting the clinical layer. The aggregator now emits a wellnessScores summary (latest / avg / min / max / count per score over the window) built from the already-fetched COMPUTED rows. The PDF renders them in a clearly-labelled "Wellness summary" section, separate from the clinical vitals table, with a "descriptive, not a clinical assessment" disclaimer. The FHIR bundle emits one Observation per score under the survey category with a text-only concept and the same disclaimer as an Annotation note, so a viewer never mistakes a band for a diagnosis. --- messages/de.json | 7 ++- messages/en.json | 7 ++- messages/es.json | 7 ++- messages/fr.json | 7 ++- messages/it.json | 7 ++- messages/pl.json | 7 ++- src/lib/doctor-report-data.ts | 53 ++++++++++++++++++++ src/lib/doctor-report-pdf-core.ts | 82 +++++++++++++++++++++++++++++++ src/lib/fhir/build-bundle.ts | 41 ++++++++++++++++ src/lib/fhir/types.ts | 7 +++ 10 files changed, 219 insertions(+), 6 deletions(-) diff --git a/messages/de.json b/messages/de.json index 851afd6a4..d77e2a7d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -158,7 +158,12 @@ "chartsTitle": "Trend-Diagramme", "aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert", "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", - "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog." + "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.", + "wellnessTitle": "Wellness-Zusammenfassung", + "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", + "wellnessRecovery": "Erholung", + "wellnessStress": "Stress", + "wellnessStrain": "Belastung" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", diff --git a/messages/en.json b/messages/en.json index c13a39e48..d43358aae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -158,7 +158,12 @@ "chartsTitle": "Trend charts", "aiSummaryTitle": "AI summary — not clinically validated", "aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.", - "packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog." + "packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.", + "wellnessTitle": "Wellness summary", + "wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.", + "wellnessRecovery": "Recovery", + "wellnessStress": "Stress", + "wellnessStrain": "Strain" }, "errorBoundary": { "title": "Something went wrong", diff --git a/messages/es.json b/messages/es.json index 2b5f9085e..24aaf3011 100644 --- a/messages/es.json +++ b/messages/es.json @@ -158,7 +158,12 @@ "chartsTitle": "Gráficos de tendencia", "aiSummaryTitle": "Resumen de IA — no validado clínicamente", "aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.", - "packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog." + "packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.", + "wellnessTitle": "Resumen de bienestar", + "wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.", + "wellnessRecovery": "Recuperación", + "wellnessStress": "Estrés", + "wellnessStrain": "Esfuerzo" }, "errorBoundary": { "title": "Algo ha salido mal", diff --git a/messages/fr.json b/messages/fr.json index 9e5eb8456..fcbf04373 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -158,7 +158,12 @@ "chartsTitle": "Graphiques de tendance", "aiSummaryTitle": "Résumé IA — non validé cliniquement", "aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.", - "packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog." + "packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.", + "wellnessTitle": "Résumé de bien-être", + "wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.", + "wellnessRecovery": "Récupération", + "wellnessStress": "Stress", + "wellnessStrain": "Effort" }, "errorBoundary": { "title": "Une erreur est survenue", diff --git a/messages/it.json b/messages/it.json index 9131ec561..2faf81e83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -158,7 +158,12 @@ "chartsTitle": "Grafici di tendenza", "aiSummaryTitle": "Riepilogo IA — non validato clinicamente", "aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.", - "packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog." + "packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.", + "wellnessTitle": "Riepilogo del benessere", + "wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.", + "wellnessRecovery": "Recupero", + "wellnessStress": "Stress", + "wellnessStrain": "Sforzo" }, "errorBoundary": { "title": "Qualcosa è andato storto", diff --git a/messages/pl.json b/messages/pl.json index cf5ffd105..5cd74f5aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -158,7 +158,12 @@ "chartsTitle": "Wykresy trendów", "aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie", "aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.", - "packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog." + "packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.", + "wellnessTitle": "Podsumowanie dobrostanu", + "wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.", + "wellnessRecovery": "Regeneracja", + "wellnessStress": "Stres", + "wellnessStrain": "Obciążenie" }, "errorBoundary": { "title": "Coś poszło nie tak", diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 68e72d47a..8e5cb0f23 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -170,8 +170,36 @@ export interface DoctorReportData { weightEndKg: number | null; sideEffects: Array<{ tag: string; count: number }>; } | null; + /** + * v1.10.0 — the server-derived nightly wellness scores + * (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE), summarised over the + * report window. These are 0–100 DESCRIPTIVE composites, NOT clinical + * vitals — they are rendered in a clearly-labelled "Wellness summary" + * section separate from the clinical vitals table, each with a + * "descriptive, not a clinical assessment" note. Null/absent when the + * user has no computed scores in the window (the renderer + FHIR builder + * then skip the section). Optional so pre-v1.10 fixtures still typecheck. + */ + wellnessScores?: Array<{ + /** The score MeasurementType (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE). */ + type: string; + latest: number; + avg: number; + min: number; + max: number; + count: number; + /** ISO timestamp of the latest score. */ + latestAt: string; + }> | null; } +/** The three persisted score types surfaced in the wellness summary. */ +export const WELLNESS_SCORE_REPORT_TYPES = [ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", +] as const; + const GLUCOSE_CONTEXTS: GlucoseContext[] = [ "FASTING", "POSTPRANDIAL", @@ -752,6 +780,30 @@ export async function collectDoctorReportData( }; } + // v1.10.0 — wellness-score summary. The persisted COMPUTED `*_SCORE` + // rows are already in `byType`/`stats` (they're only filtered out of the + // clinical vitals table). Summarise each present score type for the + // separate "Wellness summary" section. Empty array → null so the renderer + // + FHIR builder skip the section entirely. + const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { + const s = stats[type]; + const rows = byType[type]; + if (!s || !rows || rows.length === 0) return []; + return [ + { + type, + latest: Math.round(s.latest), + avg: Math.round(s.avg), + min: Math.round(s.min), + max: Math.round(s.max), + count: s.count, + latestAt: rows[rows.length - 1].measuredAt, + }, + ]; + }); + const wellnessScores = + wellnessScoreSummaries.length > 0 ? wellnessScoreSummaries : null; + return { period: { days, @@ -798,6 +850,7 @@ export async function collectDoctorReportData( : null, mood, glp1, + wellnessScores, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index b640da16b..c21a43327 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -208,6 +208,13 @@ const MOOD_LABEL_KEYS: Record = { 5: "doctorReport.moodGreat", }; +/** v1.10.0 — display-label key per persisted wellness-score type. */ +const WELLNESS_SCORE_LABEL_KEYS: Record = { + RECOVERY_SCORE: "doctorReport.wellnessRecovery", + STRESS_SCORE: "doctorReport.wellnessStress", + STRAIN_SCORE: "doctorReport.wellnessStrain", +}; + const GLUCOSE_LABEL_KEYS = { FASTING: "doctorReport.typeGlucoseFasting", POSTPRANDIAL: "doctorReport.typeGlucosePostprandial", @@ -1140,6 +1147,81 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.10.0 — wellness summary. The server-derived nightly scores + // (recovery / stress / strain) are 0–100 DESCRIPTIVE composites, NOT + // clinical vitals — they render in their own clearly-labelled section, + // separate from the vitals table, with a "descriptive, not a clinical + // assessment" disclaimer so a physician never mistakes a band for a + // finding. Skipped entirely when the aggregator emitted no scores. + if (data.wellnessScores && data.wellnessScores.length > 0) { + const scoreRows = data.wellnessScores.map((s) => [ + t(WELLNESS_SCORE_LABEL_KEYS[s.type] ?? "doctorReport.wellnessTitle"), + String(s.latest), + String(s.avg), + String(s.min), + String(s.max), + String(s.count), + ]); + + // Keep the heading + disclaimer with the first table row. + y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1)); + + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.text(t("doctorReport.wellnessTitle"), margin, y); + y += 6; + + doc.setFontSize(8); + doc.setFont("helvetica", "italic"); + const wellnessDisclaimer = doc.splitTextToSize( + t("doctorReport.wellnessDisclaimer"), + pageWidth - margin * 2, + ); + for (const line of wellnessDisclaimer) { + doc.text(line, margin, y); + y += 4; + } + y += 2; + doc.setFont("helvetica", "normal"); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.colParameter"), + t("doctorReport.colCurrent"), + t("doctorReport.colAvgPeriod"), + t("doctorReport.colMin"), + t("doctorReport.colMax"), + t("doctorReport.colN"), + ], + ], + body: scoreRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 0636ca0a2..bdafc2d66 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -243,6 +243,14 @@ function codeableFromMapping(m: LoincMapping): FhirCodeableConcept { return { text: m.display }; } +/** v1.10.0 — English display per persisted wellness-score type (FHIR + * text-only concept; the score has no published LOINC term). */ +const WELLNESS_SCORE_DISPLAY: Record = { + RECOVERY_SCORE: "Recovery score", + STRESS_SCORE: "Stress score", + STRAIN_SCORE: "Strain score", +}; + function categoryConcept(category: string): FhirCodeableConcept { return { coding: [ @@ -540,6 +548,39 @@ export function buildFhirDocumentBundle( }); } + // --- Wellness-score Observations (descriptive composites) -------------- + // v1.10.0 — the server-derived nightly scores (recovery / stress / + // strain). They have no published LOINC term and are NOT clinical + // findings, so each is emitted under the `survey` category with a + // text-only concept and an explicit "descriptive, not a clinical + // assessment" note — a physician's FHIR viewer never mistakes a band for + // a diagnosis. Absent when the aggregator emitted no scores. + if (data.wellnessScores && data.wellnessScores.length > 0) { + for (const s of data.wellnessScores) { + obsSeq += 1; + pushObservation({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept("survey")], + code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" }, + subject: patientRef, + effectiveDateTime: s.latestAt, + valueQuantity: { + value: s.latest, + unit: "{score}", + system: UCUM_SYSTEM, + code: "{score}", + }, + note: [ + { + text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.", + }, + ], + }); + } + } + // --- MedicationStatement per active medication ------------------------- let medSeq = 0; for (const med of data.medications) { diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index df65bbc80..3c05fb84d 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -63,6 +63,11 @@ export interface FhirPatient { birthDate?: string; // YYYY-MM-DD } +/** R4 `Annotation` — a free-text note attached to a resource. */ +export interface FhirAnnotation { + text: string; +} + export interface FhirObservation { resourceType: "Observation"; id: string; @@ -75,6 +80,8 @@ export interface FhirObservation { valueInteger?: number; valueString?: string; component?: FhirObservationComponent[]; + /** v1.10.0 — descriptive note (e.g. the wellness-score disclaimer). */ + note?: FhirAnnotation[]; } export interface FhirDosage {