mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
3487bdaec8
commit
8c24f3ec2a
10 files changed
+219
-6
No files matched your search
+6
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user