From 64c72d241c965467971a08711c7716a08d0db397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Apr 2026 12:37:39 +0200 Subject: [PATCH] feat(insights): enrich features with correlations, mood, historical comparisons and improve prompts Add 90-day averages, all-time stats, mood data, cross-metric Pearson correlations, historical comparisons (7d vs previous 30d baseline), and age/gender context to the AI feature extraction. Update all prompts with correlation analysis instructions, clinical significance thresholds, and temporal layer comparisons. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/ai/prompts/base-system.ts | 26 +++ src/lib/ai/prompts/blood-pressure.ts | 7 +- src/lib/ai/prompts/general-status.ts | 6 +- src/lib/ai/prompts/medication-compliance.ts | 6 +- src/lib/ai/prompts/pulse.ts | 7 +- src/lib/ai/prompts/weight.ts | 6 +- src/lib/insights/features.ts | 235 +++++++++++++++++++- 7 files changed, 287 insertions(+), 6 deletions(-) diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 97f0f48db..be6cf1f43 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -10,6 +10,32 @@ GRUNDREGELN: * Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell." - Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich. - Disclaimer: Immer den Standardhinweis verwenden. +- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden. + +KORRELATIONSANALYSE: +- Du erhältst vorberechnete Pearson-Korrelationen zwischen Metriken. +- r > 0.7: starke Korrelation — klinisch relevant, detailliert kommentieren +- r 0.4-0.7: moderate Korrelation — erwähnen, vorsichtig interpretieren +- r < 0.4: schwache/keine Korrelation — nicht überinterpretieren +- Korrelation ≠ Kausalität: immer als "Zusammenhang" formulieren, nicht als "Ursache" + +HISTORISCHER VERGLEICH: +- Vergleiche aktuelle 7-Tage-Werte mit dem 30-Tage-Durchschnitt der Vorperiode +- Klinisch relevante Veränderungen benennen: + * Gewicht: ±2 kg relevant + * Systolisch: ±5 mmHg relevant + * Diastolisch: ±3 mmHg relevant + * Puls: ±5 bpm relevant + +STIMMUNGSDATEN: +- Stimmung (1-5 Skala: 1=LAUSIG, 5=SUPER_GUT) als kontextuellen Faktor einbeziehen +- Korrelation mit Vitalwerten nur erwähnen wenn statistisch auffällig (r > 0.3) +- Stress/Stimmung beeinflusst nachweislich Blutdruck und Herzfrequenz + +TEMPORALE SCHICHTEN: +- Vergleiche kurzfristig (7d) vs. mittelfristig (30d) vs. langfristig (90d/allTime) +- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline +- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema: { diff --git a/src/lib/ai/prompts/blood-pressure.ts b/src/lib/ai/prompts/blood-pressure.ts index 53b4927b0..cf5297257 100644 --- a/src/lib/ai/prompts/blood-pressure.ts +++ b/src/lib/ai/prompts/blood-pressure.ts @@ -17,13 +17,18 @@ FACHSPEZIFISCH — BLUTDRUCK: - Pulsdruck: (Systolisch - Diastolisch) > 60 mmHg als Marker für arterielle Steifigkeit bewerten. - Medikamenten-Korrelation: Einnahmetreue von Antihypertensiva mit Blutdruckverlauf korrelieren. - Gewichts-Korrelation: Gewichtstrend mit Blutdruckverlauf vergleichen (pro kg Gewichtsverlust ca. 1 mmHg Senkung erwartet). -- Stimmungs-Korrelation: Falls Stimmungsdaten verfügbar, Zusammenhang mit Blutdruckvariabilität prüfen.`; +- Stimmungs-Korrelation: Falls Stimmungsdaten verfügbar, Zusammenhang mit Blutdruckvariabilität prüfen. +- Analysiere die weightVsSystolic-Korrelation: Pro kg Gewichtsreduktion kann 1 mmHg systolische Senkung erwartet werden. Bewerte ob der berechnete Zusammenhang diesem klinischen Modell entspricht. +- Analysiere die weightVsDiastolic-Korrelation analog und bewerte die klinische Plausibilität. +- Vergleiche avgSys30 und avgSys90 sowie allTimeAvg um langfristige Trends zu erkennen. +- Nutze historicalComparison.systolic und historicalComparison.diastolic: Bei ≥5 mmHg systolischer bzw. ≥3 mmHg diastolischer Veränderung klinisch bewerten.`; } export function getBloodPressureUserPrompt(snapshotJson: string, todayKey: string): string { return `Datum: ${todayKey} (Europe/Berlin) Analysiere die folgenden Blutdruck-Daten mit Fokus auf Trends, Zielwerterreichung und Medikamentenwirksamkeit. Berücksichtige die Messzeiträume und Datendichte für die Konfidenzeinschätzung. +Nutze die vorberechneten Korrelationen (correlations) und den historischen Vergleich (historicalComparison) für eine fundierte temporale Analyse. ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/general-status.ts b/src/lib/ai/prompts/general-status.ts index 8520a62c3..e95be069a 100644 --- a/src/lib/ai/prompts/general-status.ts +++ b/src/lib/ai/prompts/general-status.ts @@ -10,7 +10,10 @@ FACHSPEZIFISCH — GESAMTBEWERTUNG: - Risikostratifizierung: Bewerte das kardiovaskuläre Gesamtrisiko basierend auf der Kombination aller Faktoren. - Medikamenten-Adhärenz: Gesamteinnahmetreue bewerten und Zusammenhang mit Vitalwerten benennen. - Stimmung: Falls vorhanden, als kontextuellen Faktor einbeziehen (nicht übergewichten). -- Positive Entwicklungen explizit hervorheben — Motivation ist therapeutisch relevant.`; +- Positive Entwicklungen explizit hervorheben — Motivation ist therapeutisch relevant. +- Nutze die correlations-Daten um Zusammenhänge zwischen Gewicht, Blutdruck, Puls und Stimmung zu identifizieren. Vergleiche die aktuellen 7-Tage-Werte mit den Langzeit-Durchschnitten (avg90, allTime). +- Nutze historicalComparison um aktuelle Veränderungen gegenüber der etablierten Baseline einzuordnen. +- Falls Alter und Geschlecht bekannt sind, alters- und geschlechtsspezifische Risikobewertung anwenden.`; } export function getGeneralStatusUserPrompt(snapshotJson: string, todayKey: string): string { @@ -32,6 +35,7 @@ export function buildGeneratePrompts(featuresJson: string, privacyMode: string): userPrompt: `Analysiere die folgenden Gesundheitsdaten. Datenmodus: ${modeLabel} Beachte die coverage-Objekte jeder Metrik für Informationen zu Messhäufigkeit und Zeiträumen. +Nutze die correlations-Daten für Kreuzanalysen und historicalComparison für temporale Einordnung. ${featuresJson}`, }; diff --git a/src/lib/ai/prompts/medication-compliance.ts b/src/lib/ai/prompts/medication-compliance.ts index f1cc292b2..5d8b9a271 100644 --- a/src/lib/ai/prompts/medication-compliance.ts +++ b/src/lib/ai/prompts/medication-compliance.ts @@ -15,12 +15,16 @@ FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ: - Wirksamkeits-Korrelation: Direkte Verbindung zwischen Einnahmetreue und Vitalwert-Veränderungen herstellen. * Beispiel: "In Wochen mit > 90% Einnahmetreue war der mittlere systolische RR 5 mmHg niedriger." - Verpasste Dosen: Konsequenzen differenzieren (Antihypertensiva-Rebound vs. Statine = weniger zeitkritisch). -- Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen.`; +- Motivierende Gesprächsführung: Positive Formulierungen bevorzugen, Fortschritte anerkennen. +- Vergleiche Perioden hoher Adhärenz (>90%) mit den zugehörigen Vitalwerten. Zeige konkret: "In Wochen mit >90% Einnahmetreue war der systolische RR X mmHg niedriger." +- Nutze die vorberechneten Korrelationen (correlations) und historicalComparison um den Einfluss der Adhärenz auf Blutdruck und Puls zu quantifizieren. +- Falls Stimmungsdaten verfügbar: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.`; } export function getMedicationComplianceUserPrompt(snapshotJson: string, todayKey: string): string { return `Datum: ${todayKey} (Europe/Berlin) Analysiere die Medikamenten-Einnahmetreue mit Fokus auf Muster, Wirksamkeitskorrelation und konkrete Verbesserungsvorschläge. +Nutze die Korrelationsdaten und den historischen Vergleich um den Zusammenhang zwischen Adhärenz und Vitalwerten zu belegen. ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/pulse.ts b/src/lib/ai/prompts/pulse.ts index e15a7bec9..f8d998506 100644 --- a/src/lib/ai/prompts/pulse.ts +++ b/src/lib/ai/prompts/pulse.ts @@ -14,12 +14,17 @@ FACHSPEZIFISCH — PULS/HERZFREQUENZ: - Erholung: Sinkender Ruhepuls über Wochen deutet auf verbesserte kardiovaskuläre Fitness hin. - Medikamenten-Einfluss: Betablocker senken die Herzfrequenz — Compliance-Korrelation prüfen. - Blutdruck-Korrelation: Puls × systolischen Druck = Doppelprodukt als Herzbelastungsindikator. -- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen.`; +- Stimmungs-Korrelation: Chronischer Stress kann Ruhepuls erhöhen. +- Nutze die moodVsPulse-Korrelation um Stress-Einfluss auf den Ruhepuls einzuschätzen. Bei r > 0.3 (negative Korrelation = schlechtere Stimmung → höherer Puls) den Zusammenhang klinisch einordnen. +- Nutze die pulseVsSystolic-Korrelation für die Bewertung der hämodynamischen Kopplung. +- Vergleiche avg7 vs avg30 vs avg90 vs allTimeAvg um kurzfristige Abweichungen von der Langzeit-Baseline zu erkennen. +- Nutze historicalComparison.pulse: Bei ≥5 bpm Veränderung gegenüber der Baseline klinisch bewerten.`; } export function getPulseUserPrompt(snapshotJson: string, todayKey: string): string { return `Datum: ${todayKey} (Europe/Berlin) Analysiere die Puls-/Herzfrequenzdaten mit Fokus auf Ruhepulstrend, Variabilität und Zusammenhänge mit Medikation und Stimmung. +Nutze die vorberechneten Korrelationen und den historischen Vergleich für eine fundierte Analyse. ${snapshotJson}`; } diff --git a/src/lib/ai/prompts/weight.ts b/src/lib/ai/prompts/weight.ts index 9583bfffe..82e6f2745 100644 --- a/src/lib/ai/prompts/weight.ts +++ b/src/lib/ai/prompts/weight.ts @@ -11,12 +11,16 @@ FACHSPEZIFISCH — GEWICHT: - Realistische Zielprojektion: Maximal 0.5-1.0 kg/Woche als nachhaltiger Gewichtsverlust (DGE-Empfehlung). - Gewichts-Blutdruck-Korrelation: Pro kg Gewichtsreduktion ca. 1 mmHg systolische Senkung. - Medikamenten-Einfluss: Gewichtsrelevante Medikamente identifizieren (z.B. Betablocker, Cortison). -- Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal).`; +- Tageszeit-Schwankungen: Morgen- vs. Abendmessungen differenzieren (1-2 kg normal). +- Vergleiche das aktuelle 7-Tage-Mittel (historicalComparison.weight.current7dAvg) mit dem 30-Tage-Baseline (previous30dAvg). Bei >2 kg Differenz: klinisch bewerten und Ursachen diskutieren. +- Plateau-Definition: <0.5 kg Veränderung über >14 Tage (prüfe ob avg7 ≈ avg30 ≈ avg90). +- Nutze allTimeMin/allTimeMax/allTimeAvg um den aktuellen Wert historisch einzuordnen.`; } export function getWeightUserPrompt(snapshotJson: string, todayKey: string): string { return `Datum: ${todayKey} (Europe/Berlin) Analysiere die Gewichtsentwicklung mit Fokus auf Trends, BMI-Klassifikation und Zusammenhang mit anderen Vitalwerten. +Nutze die temporalen Schichten (avg7, avg30, avg90, allTime) und den historischen Vergleich für eine differenzierte Bewertung. ${snapshotJson}`; } diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 933212ee6..6f690f1cf 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -5,8 +5,14 @@ */ import { prisma } from "@/lib/db"; import { summarize } from "@/lib/analytics/trends"; +import type { DataPoint } from "@/lib/analytics/trends"; import { calculateCompliance } from "@/lib/analytics/compliance"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + pairByTimestamp, + pearsonCorrelation, + type CorrelationResult, +} from "@/lib/analytics/correlations"; interface DataCoverage { count: number; @@ -21,6 +27,10 @@ export interface AggregatedFeatures { latest: number; avg7: number | null; avg30: number | null; + avg90: number | null; + allTimeAvg: number | null; + allTimeMin: number | null; + allTimeMax: number | null; slope30: number | null; outlierCount: number; bmi: number | null; @@ -29,13 +39,26 @@ export interface AggregatedFeatures { bloodPressure?: { avgSys30: number | null; avgDia30: number | null; + avgSys90: number | null; + avgDia90: number | null; + allTimeAvgSys: number | null; + allTimeAvgDia: number | null; + allTimeMinSys: number | null; + allTimeMaxSys: number | null; + allTimeMinDia: number | null; + allTimeMaxDia: number | null; slopeSys30: number | null; slopeDia30: number | null; pctInTarget: number | null; coverage: DataCoverage; }; pulse?: { + avg7: number | null; avg30: number | null; + avg90: number | null; + allTimeAvg: number | null; + allTimeMin: number | null; + allTimeMax: number | null; slope30: number | null; anomalyCount: number; coverage: DataCoverage; @@ -46,6 +69,43 @@ export interface AggregatedFeatures { slope30: number | null; coverage: DataCoverage; }; + mood?: { + avg7: number | null; + avg30: number | null; + latest: number | null; + trend30: "improving" | "declining" | "stable" | null; + totalEntries: number; + coverage: DataCoverage; + }; + correlations?: { + weightVsSystolic: CorrelationResult | null; + weightVsDiastolic: CorrelationResult | null; + pulseVsSystolic: CorrelationResult | null; + moodVsPulse: CorrelationResult | null; + moodVsSystolic: CorrelationResult | null; + }; + historicalComparison?: { + weight?: { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; + }; + systolic?: { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; + }; + diastolic?: { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; + }; + pulse?: { + current7dAvg: number | null; + previous30dAvg: number | null; + change: number | null; + }; + }; medications?: Array<{ name: string; dose: string; @@ -61,6 +121,8 @@ export interface AggregatedFeatures { dataSpanDays: number; oldestMeasurementDaysAgo: number | null; newestMeasurementDaysAgo: number | null; + ageYears: number | null; + gender: string | null; }; } @@ -74,7 +136,7 @@ export interface RawFeatures extends AggregatedFeatures { function toDataPoints( records: Array<{ value: number; measuredAt: Date }>, -): Array<{ date: Date; value: number }> { +): DataPoint[] { return records.map((r) => ({ date: r.measuredAt, value: r.value })); } @@ -107,6 +169,38 @@ function computeCoverage( }; } +/** Compute average of values within a time window (days ago from now). */ +function avgInWindow( + records: Array<{ value: number; measuredAt: Date }>, + now: number, + fromDaysAgo: number, + toDaysAgo: number = 0, +): number | null { + const fromMs = now - fromDaysAgo * 24 * 60 * 60 * 1000; + const toMs = now - toDaysAgo * 24 * 60 * 60 * 1000; + const filtered = records.filter((r) => { + const t = r.measuredAt.getTime(); + return t >= fromMs && t <= toMs; + }); + if (filtered.length === 0) return null; + const sum = filtered.reduce((s, r) => s + r.value, 0); + return Math.round((sum / filtered.length) * 100) / 100; +} + +/** Compute historical comparison: current 7d avg vs previous 30d avg (days 7-37). */ +function computeHistoricalComparison( + records: Array<{ value: number; measuredAt: Date }>, + now: number, +): { current7dAvg: number | null; previous30dAvg: number | null; change: number | null } { + const current7dAvg = avgInWindow(records, now, 7, 0); + const previous30dAvg = avgInWindow(records, now, 37, 7); + const change = + current7dAvg !== null && previous30dAvg !== null + ? Math.round((current7dAvg - previous30dAvg) * 100) / 100 + : null; + return { current7dAvg, previous30dAvg, change }; +} + export async function extractFeatures( userId: string, includeRaw: boolean, @@ -116,6 +210,7 @@ export async function extractFeatures( select: { heightCm: true, dateOfBirth: true, + gender: true, }, }); @@ -146,6 +241,18 @@ export async function extractFeatures( ) : 0; + // Compute age + let ageYears: number | null = null; + if (user?.dateOfBirth) { + const dob = user.dateOfBirth; + const today = new Date(); + ageYears = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + ageYears--; + } + } + const features: AggregatedFeatures = { context: { heightCm: user?.heightCm ?? null, @@ -162,6 +269,8 @@ export async function extractFeatures( (now - newestMeasurement.getTime()) / (24 * 60 * 60 * 1000), ) : null, + ageYears, + gender: user?.gender ?? null, }, }; @@ -178,6 +287,10 @@ export async function extractFeatures( latest: summary.latest!, avg7: summary.avg7, avg30: summary.avg30, + avg90: avgInWindow(weightData, now, 90), + allTimeAvg: summary.count > 0 ? summary.mean : null, + allTimeMin: summary.count > 0 ? summary.min : null, + allTimeMax: summary.count > 0 ? summary.max : null, slope30: summary.slope30?.slope ?? null, outlierCount: summary.anomalyCount, bmi, @@ -211,6 +324,14 @@ export async function extractFeatures( features.bloodPressure = { avgSys30: sysSummary?.avg30 ?? null, avgDia30: diaSummary?.avg30 ?? null, + avgSys90: sysData.length > 0 ? avgInWindow(sysData, now, 90) : null, + avgDia90: diaData.length > 0 ? avgInWindow(diaData, now, 90) : null, + allTimeAvgSys: sysSummary?.count ? sysSummary.mean : null, + allTimeAvgDia: diaSummary?.count ? diaSummary.mean : null, + allTimeMinSys: sysSummary?.count ? sysSummary.min : null, + allTimeMaxSys: sysSummary?.count ? sysSummary.max : null, + allTimeMinDia: diaSummary?.count ? diaSummary.min : null, + allTimeMaxDia: diaSummary?.count ? diaSummary.max : null, slopeSys30: sysSummary?.slope30?.slope ?? null, slopeDia30: diaSummary?.slope30?.slope ?? null, pctInTarget, @@ -228,7 +349,12 @@ export async function extractFeatures( if (pulseData.length > 0) { const summary = summarize(toDataPoints(pulseData)); features.pulse = { + avg7: summary.avg7, avg30: summary.avg30, + avg90: avgInWindow(pulseData, now, 90), + allTimeAvg: summary.count > 0 ? summary.mean : null, + allTimeMin: summary.count > 0 ? summary.min : null, + allTimeMax: summary.count > 0 ? summary.max : null, slope30: summary.slope30?.slope ?? null, anomalyCount: summary.anomalyCount, coverage: computeCoverage(pulseData, now), @@ -247,6 +373,113 @@ export async function extractFeatures( }; } + // Mood + const moodEntries = await prisma.moodEntry.findMany({ + where: { userId }, + orderBy: { moodLoggedAt: "asc" }, + }); + + if (moodEntries.length > 0) { + const moodNow = Date.now(); + const last7 = moodEntries.filter( + (e) => moodNow - e.moodLoggedAt.getTime() < 7 * 24 * 60 * 60 * 1000, + ); + const last30 = moodEntries.filter( + (e) => moodNow - e.moodLoggedAt.getTime() < 30 * 24 * 60 * 60 * 1000, + ); + + const avg = (entries: typeof moodEntries) => + entries.length > 0 + ? Math.round( + (entries.reduce((s, e) => s + e.score, 0) / entries.length) * 100, + ) / 100 + : null; + + // Compute 30-day trend: compare first half vs second half of last 30 days + let trend30: "improving" | "declining" | "stable" | null = null; + if (last30.length >= 4) { + const firstHalf = last30.filter( + (e) => moodNow - e.moodLoggedAt.getTime() >= 15 * 24 * 60 * 60 * 1000, + ); + const secondHalf = last30.filter( + (e) => moodNow - e.moodLoggedAt.getTime() < 15 * 24 * 60 * 60 * 1000, + ); + if (firstHalf.length >= 2 && secondHalf.length >= 2) { + const avgFirst = avg(firstHalf)!; + const avgSecond = avg(secondHalf)!; + const diff = avgSecond - avgFirst; + if (diff > 0.3) trend30 = "improving"; + else if (diff < -0.3) trend30 = "declining"; + else trend30 = "stable"; + } + } + + const oldest = moodEntries[0].moodLoggedAt; + const newest = moodEntries[moodEntries.length - 1].moodLoggedAt; + const spanDays = Math.round( + (newest.getTime() - oldest.getTime()) / (24 * 60 * 60 * 1000), + ); + const avgDaysBetween = + moodEntries.length > 1 + ? Math.round((spanDays / (moodEntries.length - 1)) * 10) / 10 + : null; + + features.mood = { + avg7: avg(last7), + avg30: avg(last30), + latest: moodEntries[moodEntries.length - 1].score, + trend30, + totalEntries: moodEntries.length, + coverage: { + count: moodEntries.length, + spanDays, + avgDaysBetween, + oldestDaysAgo: Math.round( + (moodNow - oldest.getTime()) / (24 * 60 * 60 * 1000), + ), + newestDaysAgo: Math.round( + (moodNow - newest.getTime()) / (24 * 60 * 60 * 1000), + ), + }, + }; + } + + // Cross-metric correlations + const weightPoints = toDataPoints(weightData); + const sysPoints = toDataPoints(sysData); + const diaPoints = toDataPoints(diaData); + const pulsePoints = toDataPoints(pulseData); + const moodPoints: DataPoint[] = moodEntries.map((e) => ({ + date: e.moodLoggedAt, + value: e.score, + })); + + const computeCorr = (a: DataPoint[], b: DataPoint[]) => + pearsonCorrelation(pairByTimestamp(a, b)); + + features.correlations = { + weightVsSystolic: computeCorr(weightPoints, sysPoints), + weightVsDiastolic: computeCorr(weightPoints, diaPoints), + pulseVsSystolic: computeCorr(pulsePoints, sysPoints), + moodVsPulse: computeCorr(moodPoints, pulsePoints), + moodVsSystolic: computeCorr(moodPoints, sysPoints), + }; + + // Historical comparison: current 7d avg vs previous 30d avg (days 7-37) + features.historicalComparison = {}; + if (weightData.length > 0) { + features.historicalComparison.weight = computeHistoricalComparison(weightData, now); + } + if (sysData.length > 0) { + features.historicalComparison.systolic = computeHistoricalComparison(sysData, now); + } + if (diaData.length > 0) { + features.historicalComparison.diastolic = computeHistoricalComparison(diaData, now); + } + if (pulseData.length > 0) { + features.historicalComparison.pulse = computeHistoricalComparison(pulseData, now); + } + // Medications const medications = await prisma.medication.findMany({ where: { userId, active: true },