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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent 3487bdaec8
commit 8c24f3ec2a
10 files changed
+219 -6

No files matched your search

+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+6 -1
View File
@@ -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",
+53
View File
@@ -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,
};
}
+82
View File
@@ -208,6 +208,13 @@ const MOOD_LABEL_KEYS: Record<number, string> = {
5: "doctorReport.moodGreat",
};
/** v1.10.0 — display-label key per persisted wellness-score type. */
const WELLNESS_SCORE_LABEL_KEYS: Record<string, string> = {
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
+41
View File
@@ -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<string, string> = {
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) {
+7
View File
@@ -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 {