diff --git a/messages/de.json b/messages/de.json index d6ae9eaa5..73fbe736e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Coach fragen", "healthScore": { "label": "Gesundheitsscore", - "progressAria": "Gesundheitsscore-Fortschritt", - "deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche", - "deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.", - "restModePaused": "Während einer Krankheitsphase pausiert — dein Score wird heute nicht bewertet.", - "componentBp": "Blutdruck", - "componentWeight": "Gewicht", - "componentMood": "Stimmung", - "componentCompliance": "Therapietreue", - "weightTarget": { - "user": "Gemessen an deinem Ziel, {range}.", - "none": "Nur am Verlauf gemessen, kein Ziel gesetzt." + "algorithmChanged": "Der Score nutzt jetzt veröffentlichte kardiometabolische Referenzbereiche; Gewicht ist nicht mehr enthalten, weil das frühere Ziel aus der Körpergröße statt aus einem selbst gesetzten Ziel abgeleitet wurde, tägliche Stimmungsschwankungen und Medikamententreue sind Handlungen statt Gesundheitszustände, und für Ruhepuls und HRV gibt es keinen allgemeinen Zielwert. Der Gewichtsfortschritt bleibt unten als persönlicher Kontext sichtbar, die anderen Signale bleiben auf ihren eigenen Karten, und eine neue Zahl bedeutet keine Veränderung deiner Gesundheit.", + "dismissAlgorithmChanged": "Hinweis zur Score-Methode schließen", + "band": { + "green": "Im Referenzbereich", + "yellow": "Einige Bereiche brauchen Aufmerksamkeit", + "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, - "deltaExplainer": { - "title": "So wird die Veränderung berechnet", - "description": "Veränderung gegenüber letzter Woche: {delta}", - "body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Therapietreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen." + "method": "Gleich gewichteter Mittelwert der geeigneten Bereiche. Der niedrigste Bereich bestimmt die Gesamtstufe.", + "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", + "composition": "Einbezogen: {pillars}", + "bandSetter": "Gesamtstufe bestimmt durch {pillar}", + "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", + "methodVersion": "Methodenversion {version}.", + "none": "keiner", + "delta": "{delta} gegenüber dem vorherigen vergleichbaren Zeitraum", + "deltaReason": { + "algorithm_changed": "Kein Vergleich, weil sich die Score-Methode geändert hat.", + "composition_changed": "Kein Vergleich, weil sich die einbezogenen Bereiche geändert haben.", + "first_eligibility_window": "Kein Vergleich, weil dies der erste geeignete Zeitraum ist.", + "below_noise_floor": "Die Veränderung liegt innerhalb der gemeinsamen Messschwankung.", + "no_previous_window": "Es ist kein früherer vergleichbarer Zeitraum verfügbar.", + "no_current_score": "Kein Vergleich, weil der aktuelle Zeitraum nicht geeignet ist." }, - "provenance": { - "toggle": "Zusammensetzung", - "mixedBanner": "Einige Komponenten kombinieren mehrere Quellen. Die Aufschlüsselung zeigt den aktuellsten Wert je Komponente.", - "footnote": "Anteile sind nach verfügbaren Daten neu skaliert.", - "asOfLabel": "Stand {date}", - "provisional": "Vorläufig — nur {count} von {total} Datenpunkten verfügbar", - "provisionalBadge": "Vorläufig", - "sourceAria": "Quelle: {source}", - "sources": { - "manual": "Manuell", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Gemischt", - "none": "Keine Daten" - } + "restMode": "Ruhemodus aktiv seit {since}. Der Score dient nur als Kontext und wird nicht verschlechtert.", + "pillar": { + "bloodPressure": "Blutdruck", + "glycaemia": "Blutzucker", + "activity": "Aktivität", + "sleep": "Schlaf", + "adiposity": "Körperfettverteilung", + "wellbeing": "Wohlbefinden", + "fitness": "Gemessene Fitness", + "lipids": "Blutfette" + }, + "observed": "Beobachtet: {value}", + "reference": "Referenz: {value}", + "fastingObserved": "Nüchternglukose: {value} {unit}", + "fastingReference": "{low} bis {high} {unit} nüchtern", + "personalReference": "Dein Ziel: {value}", + "source": "Quelle: {source}, Stand {date}", + "observedActivity": "{value} Schritte pro Tag", + "observedSleep": "{value} Minuten pro Nacht", + "observedAdiposity": "Taille-zu-Körpergröße-Verhältnis {value}", + "observedFitness": "{value} {unit}, gemessen", + "referenceActivity": "Der Nutzen flacht bei {high} Schritten pro Tag ab", + "referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet", + "referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}", + "referenceFitness": "{low} bis {high} {unit}, Referenzbereich nach Alter und Geschlecht", + "referenceHba1c": "HbA1c {low} bis {high} %", + "referenceDetail": "{label} · {source} · {kind} · {low} bis {high}", + "openBound": "offen", + "laboratoryReferenceSource": "Referenzbereiche des auswertenden Labors", + "personalTargetSource": "Persönliches Ziel", + "referenceKind": { + "clinical": "Klinischer Schwellenwert", + "population": "Bevölkerungsperzentil", + "guideline": "Leitlinienbereich" + }, + "reason": { + "readFailed": "Daten konnten nicht geladen werden", + "notTracked": "Nicht erfasst", + "missingProfile": "Alter oder Referenzprofil fehlt", + "missingHeight": "Körpergröße fehlt", + "incompletePanel": "Ein aktuelles vollständiges Panel mit Referenzbereichen ist erforderlich", + "crisis": "Anstelle eines Scores werden Sicherheitshinweise angezeigt", + "insufficientHistory": "Nicht genügend aktuelle geeignete Daten", + "unavailable": "Dieser Bereich kann derzeit nicht bewertet werden" + }, + "pillarInsufficient": "Kein Bereichsscore: {reason}", + "tension": "Die Bereitschaftssignale unterscheiden sich. Unterstützend: {positive}. Belastet: {negative}.", + "returnToBand": "{metric} liegt seit {days} Tagen wieder im üblichen Bereich.", + "weightGoal": { + "title": "Persönliches Gewichtsziel", + "value": "Aktuell {current} {unit}. Ziel {min} bis {max} {unit}. Abstand zum Ziel: {distance} {unit}.", + "delta": "Der Abstand zum Ziel hat sich seit der vorherigen Messung um {delta} {unit} verändert.", + "noDelta": "Für einen Vergleich ist keine frühere Gewichtsmessung verfügbar.", + "no_personal_goal": "Lege ein persönliches Gewichtsziel fest, um hier den Fortschritt zu sehen.", + "weight_not_tracked": "Erfasse dein Gewicht, um den Fortschritt zum Ziel zu sehen.", + "read_failed": "Gewichtsdaten konnten nicht geladen werden. Versuche es erneut.", + "notScored": "Der Fortschritt zum Gewichtsziel ist persönlicher Kontext und beeinflusst den Gesundheitsscore nicht." } }, "suggestedPrompts": { diff --git a/messages/en.json b/messages/en.json index 7ef195fe9..17e4c24ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Ask the coach", "healthScore": { "label": "Health Score", - "progressAria": "Health Score progress", - "deltaVsLastWeek": "{delta} vs last week", - "deltaUnavailable": "No history yet — check back next week.", - "restModePaused": "Paused during illness — your score isn't being judged today.", - "componentBp": "BP", - "componentWeight": "Weight", - "componentMood": "Mood", - "componentCompliance": "Adherence", - "weightTarget": { - "user": "Graded against your target, {range}.", - "none": "Graded on the trend alone, no target set." + "algorithmChanged": "The score now uses published cardiometabolic reference ranges; weight left because its old target came from height, daily mood variability and medication adherence are actions rather than health states, and resting heart rate and HRV have no universal target. Weight progress remains below as personal context, the other signals remain on their own cards, and a new number does not mean your health changed.", + "dismissAlgorithmChanged": "Dismiss score method note", + "band": { + "green": "In the reference range", + "yellow": "Some pillars need attention", + "red": "One or more pillars are outside the reference range" }, - "deltaExplainer": { - "title": "How the delta is computed", - "description": "Change vs last week: {delta}", - "body": "The score combines BP, weight, mood and medication adherence. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up." + "method": "Equal-weight average of eligible pillars. The lowest pillar sets the overall band.", + "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", + "composition": "Included: {pillars}", + "bandSetter": "Overall band set by {pillar}", + "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", + "methodVersion": "Method version {version}.", + "none": "none", + "delta": "{delta} vs the previous comparable window", + "deltaReason": { + "algorithm_changed": "No comparison because the score method changed.", + "composition_changed": "No comparison because the included pillars changed.", + "first_eligibility_window": "No comparison because this is the first eligible window.", + "below_noise_floor": "Change is within the combined measurement noise.", + "no_previous_window": "No comparable earlier window is available.", + "no_current_score": "No comparison because the current window is not eligible." }, - "provenance": { - "toggle": "Driven by", - "mixedBanner": "Some components blend multiple sources. The breakdown shows the most recent value per component.", - "footnote": "Weights are rescaled to the data that's actually available.", - "asOfLabel": "as of {date}", - "provisional": "Provisional — only {count} of {total} inputs available", - "provisionalBadge": "Provisional", - "sourceAria": "Source: {source}", - "sources": { - "manual": "Manual", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Mixed", - "none": "No data" - } + "restMode": "Rest Mode active since {since}. The score is shown for context and is not penalised.", + "pillar": { + "bloodPressure": "Blood pressure", + "glycaemia": "Glycaemia", + "activity": "Activity", + "sleep": "Sleep", + "adiposity": "Adiposity", + "wellbeing": "Wellbeing", + "fitness": "Measured fitness", + "lipids": "Lipids" + }, + "observed": "Observed: {value}", + "reference": "Reference: {value}", + "fastingObserved": "Fasting glucose: {value} {unit}", + "fastingReference": "{low} to {high} {unit} fasting", + "personalReference": "Your target: {value}", + "source": "Source: {source}, as of {date}", + "observedActivity": "{value} steps per day", + "observedSleep": "{value} minutes per night", + "observedAdiposity": "Waist-to-height ratio {value}", + "observedFitness": "{value} {unit}, measured", + "referenceActivity": "Benefit plateaus by {high} steps per day", + "referenceSleep": "At least {low} minutes per night; longer sleep is not penalised", + "referenceAdiposity": "Waist-to-height ratio below {high}", + "referenceFitness": "{low} to {high} {unit}, age and sex reference band", + "referenceHba1c": "HbA1c {low} to {high}%", + "referenceDetail": "{label} · {source} · {kind} · {low} to {high}", + "openBound": "open", + "laboratoryReferenceSource": "Reporting laboratory reference ranges", + "personalTargetSource": "Personal target", + "referenceKind": { + "clinical": "Clinical threshold", + "population": "Population percentile", + "guideline": "Guideline band" + }, + "reason": { + "readFailed": "Data could not be loaded", + "notTracked": "Not tracked", + "missingProfile": "Age or reference profile is missing", + "missingHeight": "Height is missing", + "incompletePanel": "A recent complete panel with reference ranges is needed", + "crisis": "Safety guidance is shown instead of a score", + "insufficientHistory": "Not enough recent eligible data", + "unavailable": "This pillar is not currently eligible" + }, + "pillarInsufficient": "No pillar score: {reason}", + "tension": "Readiness signals differ. Supportive: {positive}. Under strain: {negative}.", + "returnToBand": "{metric} has been back inside your usual range for {days} days.", + "weightGoal": { + "title": "Personal weight goal", + "value": "{current} {unit} now. Goal {min} to {max} {unit}. Distance to goal: {distance} {unit}.", + "delta": "Distance to goal changed by {delta} {unit} since the prior reading.", + "noDelta": "No earlier weight reading is available for comparison.", + "no_personal_goal": "Set a personal weight goal to see progress here.", + "weight_not_tracked": "Log a weight reading to see progress toward your goal.", + "read_failed": "Weight data could not be loaded. Try again.", + "notScored": "Weight-goal progress is personal context and does not affect the Health Score." } }, "suggestedPrompts": { diff --git a/messages/es.json b/messages/es.json index c79cdbe6b..af765875b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Preguntar al coach", "healthScore": { "label": "Puntuación de salud", - "progressAria": "Progreso de la puntuación de salud", - "deltaVsLastWeek": "{delta} respecto a la semana anterior", - "deltaUnavailable": "Aún no hay historial — vuelve la semana que viene.", - "restModePaused": "En pausa durante la enfermedad — hoy tu puntuación no se evalúa.", - "componentBp": "PA", - "componentWeight": "Peso", - "componentMood": "Estado de ánimo", - "componentCompliance": "Cumplimiento", - "weightTarget": { - "user": "Evaluado con tu objetivo, {range}.", - "none": "Evaluado solo por la tendencia, sin objetivo definido." + "algorithmChanged": "La puntuación ahora usa intervalos de referencia cardiometabólicos publicados; el peso ya no se incluye porque el objetivo anterior se derivaba de la estatura en vez de un objetivo personal, la variabilidad diaria del ánimo y la adherencia a la medicación son acciones y no estados de salud, y no existe un objetivo universal para la frecuencia cardiaca en reposo ni la VFC. El progreso de peso sigue apareciendo abajo como contexto personal, las demás señales conservan sus propias tarjetas y una cifra nueva no significa que tu salud haya cambiado.", + "dismissAlgorithmChanged": "Cerrar la nota sobre el método", + "band": { + "green": "Dentro del intervalo de referencia", + "yellow": "Algunos pilares requieren atención", + "red": "Uno o más pilares están fuera del intervalo de referencia" }, - "deltaExplainer": { - "title": "Cómo se calcula el cambio", - "description": "Cambio respecto a la semana pasada: {delta}", - "body": "La puntuación combina PA, peso, ánimo y adherencia. Comparamos la puntuación de hoy con la del mismo momento de la semana anterior. Registra una medición nueva o una nota de ánimo para empujarla hacia arriba." + "method": "Media con el mismo peso para los pilares aptos. El pilar más bajo determina la categoría general.", + "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", + "composition": "Incluidos: {pillars}", + "bandSetter": "Categoría general determinada por {pillar}", + "versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.", + "methodVersion": "Versión del método {version}.", + "none": "ninguno", + "delta": "{delta} frente al periodo comparable anterior", + "deltaReason": { + "algorithm_changed": "No hay comparación porque cambió el método de puntuación.", + "composition_changed": "No hay comparación porque cambiaron los pilares incluidos.", + "first_eligibility_window": "No hay comparación porque este es el primer periodo apto.", + "below_noise_floor": "El cambio está dentro de la variación conjunta de las mediciones.", + "no_previous_window": "No hay un periodo anterior comparable.", + "no_current_score": "No hay comparación porque el periodo actual no es apto." }, - "provenance": { - "toggle": "Compuesto por", - "mixedBanner": "Algunos componentes combinan varias fuentes. El desglose muestra el valor más reciente por componente.", - "footnote": "Los pesos se reescalan según los datos realmente disponibles.", - "asOfLabel": "a fecha de {date}", - "provisional": "Provisional — sólo {count} de {total} entradas disponibles", - "provisionalBadge": "Provisional", - "sourceAria": "Fuente: {source}", - "sources": { - "manual": "Manual", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Mixto", - "none": "Sin datos" - } + "restMode": "Modo de descanso activo desde {since}. La puntuación se muestra como contexto y no se penaliza.", + "pillar": { + "bloodPressure": "Presión arterial", + "glycaemia": "Glucemia", + "activity": "Actividad", + "sleep": "Sueño", + "adiposity": "Adiposidad", + "wellbeing": "Bienestar", + "fitness": "Forma física medida", + "lipids": "Lípidos" + }, + "observed": "Observado: {value}", + "reference": "Referencia: {value}", + "fastingObserved": "Glucosa en ayunas: {value} {unit}", + "fastingReference": "{low} a {high} {unit} en ayunas", + "personalReference": "Tu objetivo: {value}", + "source": "Fuente: {source}, a fecha de {date}", + "observedActivity": "{value} pasos al día", + "observedSleep": "{value} minutos por noche", + "observedAdiposity": "Relación cintura-altura {value}", + "observedFitness": "{value} {unit}, medido", + "referenceActivity": "El beneficio se estabiliza en {high} pasos al día", + "referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza", + "referenceAdiposity": "Relación cintura-altura inferior a {high}", + "referenceFitness": "{low} a {high} {unit}, intervalo de referencia por edad y sexo", + "referenceHba1c": "HbA1c de {low} a {high} %", + "referenceDetail": "{label} · {source} · {kind} · {low} a {high}", + "openBound": "abierto", + "laboratoryReferenceSource": "Intervalos de referencia del laboratorio", + "personalTargetSource": "Objetivo personal", + "referenceKind": { + "clinical": "Umbral clínico", + "population": "Percentil poblacional", + "guideline": "Intervalo de la guía" + }, + "reason": { + "readFailed": "No se pudieron cargar los datos", + "notTracked": "Sin seguimiento", + "missingProfile": "Falta la edad o el perfil de referencia", + "missingHeight": "Falta la altura", + "incompletePanel": "Se necesita un panel reciente completo con intervalos de referencia", + "crisis": "Se muestran indicaciones de seguridad en lugar de una puntuación", + "insufficientHistory": "No hay suficientes datos recientes aptos", + "unavailable": "Este pilar no reúne los requisitos actualmente" + }, + "pillarInsufficient": "Sin puntuación del pilar: {reason}", + "tension": "Las señales de preparación difieren. Favorables: {positive}. Con tensión: {negative}.", + "returnToBand": "{metric} lleva {days} días de nuevo dentro de tu intervalo habitual.", + "weightGoal": { + "title": "Objetivo personal de peso", + "value": "Ahora {current} {unit}. Objetivo de {min} a {max} {unit}. Distancia al objetivo: {distance} {unit}.", + "delta": "La distancia al objetivo cambió {delta} {unit} desde la lectura anterior.", + "noDelta": "No hay una lectura de peso anterior para comparar.", + "no_personal_goal": "Define un objetivo personal de peso para ver aquí el progreso.", + "weight_not_tracked": "Registra una lectura de peso para ver el progreso hacia tu objetivo.", + "read_failed": "No se pudieron cargar los datos de peso. Inténtalo de nuevo.", + "notScored": "El progreso del objetivo de peso es contexto personal y no afecta a la puntuación de salud." } }, "suggestedPrompts": { diff --git a/messages/fr.json b/messages/fr.json index 5fdf326f4..5ac156fcd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Demander au coach", "healthScore": { "label": "Score de santé", - "progressAria": "Progression du score de santé", - "deltaVsLastWeek": "{delta} par rapport à la semaine précédente", - "deltaUnavailable": "Pas encore d’historique — reviens la semaine prochaine.", - "restModePaused": "En pause pendant la maladie — ton score n'est pas évalué aujourd'hui.", - "componentBp": "TA", - "componentWeight": "Poids", - "componentMood": "Humeur", - "componentCompliance": "Observance", - "weightTarget": { - "user": "Évalué par rapport à ton objectif, {range}.", - "none": "Évalué sur la tendance seule, aucun objectif défini." + "algorithmChanged": "Le score utilise désormais des plages de référence cardiométaboliques publiées; le poids n'est plus inclus car son ancienne cible provenait de la taille plutôt que d'un objectif personnel, les variations quotidiennes de l'humeur et l'observance médicamenteuse sont des actions plutôt que des états de santé, et la fréquence cardiaque au repos comme la VFC n'ont pas de cible universelle. La progression du poids reste affichée ci-dessous comme contexte personnel, les autres signaux conservent leurs propres cartes et un nouveau chiffre ne signifie pas que ta santé a changé.", + "dismissAlgorithmChanged": "Fermer la note sur la méthode", + "band": { + "green": "Dans la plage de référence", + "yellow": "Certains piliers demandent une attention", + "red": "Un ou plusieurs piliers sont hors de la plage de référence" }, - "deltaExplainer": { - "title": "Comment l'écart est calculé", - "description": "Écart par rapport à la semaine dernière : {delta}", - "body": "Le score combine TA, poids, humeur et observance. Nous comparons le score d'aujourd'hui à celui de la semaine précédente à la même période. Enregistre une nouvelle mesure ou une note d'humeur pour le faire remonter." + "method": "Moyenne à poids égal des piliers admissibles. Le pilier le plus bas fixe la catégorie globale.", + "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", + "composition": "Inclus : {pillars}", + "bandSetter": "Catégorie globale fixée par {pillar}", + "versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.", + "methodVersion": "Version de la méthode {version}.", + "none": "aucun", + "delta": "{delta} par rapport à la période comparable précédente", + "deltaReason": { + "algorithm_changed": "Aucune comparaison car la méthode de calcul a changé.", + "composition_changed": "Aucune comparaison car les piliers inclus ont changé.", + "first_eligibility_window": "Aucune comparaison car il s'agit de la première période admissible.", + "below_noise_floor": "La variation reste dans la marge de fluctuation combinée des mesures.", + "no_previous_window": "Aucune période comparable antérieure n'est disponible.", + "no_current_score": "Aucune comparaison car la période actuelle n'est pas admissible." }, - "provenance": { - "toggle": "Composé de", - "mixedBanner": "Certains composants combinent plusieurs sources. La ventilation affiche la valeur la plus récente par composant.", - "footnote": "Les pondérations sont recalibrées en fonction des données disponibles.", - "asOfLabel": "au {date}", - "provisional": "Provisoire — seulement {count} entrée(s) sur {total} disponible(s)", - "provisionalBadge": "Provisoire", - "sourceAria": "Source : {source}", - "sources": { - "manual": "Manuelle", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Mixte", - "none": "Aucune donnée" - } + "restMode": "Mode repos actif depuis {since}. Le score est affiché pour contexte et n'est pas pénalisé.", + "pillar": { + "bloodPressure": "Tension artérielle", + "glycaemia": "Glycémie", + "activity": "Activité", + "sleep": "Sommeil", + "adiposity": "Adiposité", + "wellbeing": "Bien-être", + "fitness": "Condition physique mesurée", + "lipids": "Lipides" + }, + "observed": "Observé : {value}", + "reference": "Référence : {value}", + "fastingObserved": "Glycémie à jeun : {value} {unit}", + "fastingReference": "{low} à {high} {unit} à jeun", + "personalReference": "Votre objectif : {value}", + "source": "Source : {source}, au {date}", + "observedActivity": "{value} pas par jour", + "observedSleep": "{value} minutes par nuit", + "observedAdiposity": "Rapport taille-hauteur {value}", + "observedFitness": "{value} {unit}, mesuré", + "referenceActivity": "Le bénéfice se stabilise à {high} pas par jour", + "referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé", + "referenceAdiposity": "Rapport taille-hauteur inférieur à {high}", + "referenceFitness": "{low} à {high} {unit}, plage de référence selon l'âge et le sexe", + "referenceHba1c": "HbA1c de {low} à {high} %", + "referenceDetail": "{label} · {source} · {kind} · {low} à {high}", + "openBound": "ouvert", + "laboratoryReferenceSource": "Plages de référence du laboratoire", + "personalTargetSource": "Objectif personnel", + "referenceKind": { + "clinical": "Seuil clinique", + "population": "Percentile de population", + "guideline": "Plage recommandée" + }, + "reason": { + "readFailed": "Les données n'ont pas pu être chargées", + "notTracked": "Non suivi", + "missingProfile": "L'âge ou le profil de référence manque", + "missingHeight": "La taille manque", + "incompletePanel": "Un bilan récent complet avec plages de référence est requis", + "crisis": "Des consignes de sécurité sont affichées à la place d'un score", + "insufficientHistory": "Pas assez de données récentes admissibles", + "unavailable": "Ce pilier n'est pas admissible actuellement" + }, + "pillarInsufficient": "Aucun score pour ce pilier : {reason}", + "tension": "Les signaux de préparation diffèrent. Favorables : {positive}. Sous tension : {negative}.", + "returnToBand": "{metric} est revenu dans ta plage habituelle depuis {days} jours.", + "weightGoal": { + "title": "Objectif de poids personnel", + "value": "{current} {unit} actuellement. Objectif de {min} à {max} {unit}. Écart à l'objectif : {distance} {unit}.", + "delta": "L'écart à l'objectif a changé de {delta} {unit} depuis la mesure précédente.", + "noDelta": "Aucune mesure de poids antérieure n'est disponible pour la comparaison.", + "no_personal_goal": "Définis un objectif de poids personnel pour suivre ta progression ici.", + "weight_not_tracked": "Enregistre ton poids pour suivre ta progression vers ton objectif.", + "read_failed": "Les données de poids n'ont pas pu être chargées. Réessaie.", + "notScored": "La progression vers l'objectif de poids est un contexte personnel et ne modifie pas le score de santé." } }, "suggestedPrompts": { diff --git a/messages/it.json b/messages/it.json index f3b6992c4..2736b2c57 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Chiedi al coach", "healthScore": { "label": "Punteggio di salute", - "progressAria": "Avanzamento del punteggio di salute", - "deltaVsLastWeek": "{delta} rispetto alla settimana scorsa", - "deltaUnavailable": "Ancora nessuno storico — ripassa la prossima settimana.", - "restModePaused": "In pausa durante la malattia — oggi il tuo punteggio non viene valutato.", - "componentBp": "PA", - "componentWeight": "Peso", - "componentMood": "Umore", - "componentCompliance": "Aderenza", - "weightTarget": { - "user": "Valutato rispetto al tuo obiettivo, {range}.", - "none": "Valutato solo sull'andamento, nessun obiettivo impostato." + "algorithmChanged": "Il punteggio usa ora intervalli di riferimento cardiometabolici pubblicati; il peso non è più incluso perché il vecchio obiettivo derivava dall'altezza anziché da un obiettivo personale, la variabilità quotidiana dell'umore e l'aderenza ai farmaci sono azioni anziché stati di salute, e la frequenza cardiaca a riposo e l'HRV non hanno un obiettivo universale. I progressi del peso restano visibili sotto come contesto personale, gli altri segnali conservano le proprie schede e un numero nuovo non significa che la tua salute sia cambiata.", + "dismissAlgorithmChanged": "Chiudi la nota sul metodo", + "band": { + "green": "Nell'intervallo di riferimento", + "yellow": "Alcuni pilastri richiedono attenzione", + "red": "Uno o più pilastri sono fuori dall'intervallo di riferimento" }, - "deltaExplainer": { - "title": "Come si calcola la variazione", - "description": "Variazione rispetto alla settimana scorsa: {delta}", - "body": "Il punteggio combina PA, peso, umore e aderenza. Confrontiamo il punteggio di oggi con quello dello stesso momento della settimana scorsa. Registra una nuova misurazione o una nota di umore per spingerlo verso l'alto." + "method": "Media con peso uguale dei pilastri idonei. Il pilastro più basso determina la fascia complessiva.", + "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", + "composition": "Inclusi: {pillars}", + "bandSetter": "Fascia complessiva determinata da {pillar}", + "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", + "methodVersion": "Versione del metodo {version}.", + "none": "nessuno", + "delta": "{delta} rispetto al periodo precedente confrontabile", + "deltaReason": { + "algorithm_changed": "Nessun confronto perché è cambiato il metodo di calcolo.", + "composition_changed": "Nessun confronto perché sono cambiati i pilastri inclusi.", + "first_eligibility_window": "Nessun confronto perché questo è il primo periodo idoneo.", + "below_noise_floor": "La variazione rientra nella fluttuazione combinata delle misurazioni.", + "no_previous_window": "Non è disponibile un periodo precedente confrontabile.", + "no_current_score": "Nessun confronto perché il periodo attuale non è idoneo." }, - "provenance": { - "toggle": "Composto da", - "mixedBanner": "Alcuni componenti combinano più fonti. Il dettaglio mostra il valore più recente per componente.", - "footnote": "I pesi sono ricalcolati in base ai dati effettivamente disponibili.", - "asOfLabel": "al {date}", - "provisional": "Provvisorio — solo {count} di {total} valori disponibili", - "provisionalBadge": "Provvisorio", - "sourceAria": "Fonte: {source}", - "sources": { - "manual": "Manuale", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Misto", - "none": "Nessun dato" - } + "restMode": "Modalità riposo attiva dal {since}. Il punteggio è mostrato come contesto e non viene penalizzato.", + "pillar": { + "bloodPressure": "Pressione arteriosa", + "glycaemia": "Glicemia", + "activity": "Attività", + "sleep": "Sonno", + "adiposity": "Adiposità", + "wellbeing": "Benessere", + "fitness": "Forma fisica misurata", + "lipids": "Lipidi" + }, + "observed": "Osservato: {value}", + "reference": "Riferimento: {value}", + "fastingObserved": "Glicemia a digiuno: {value} {unit}", + "fastingReference": "Da {low} a {high} {unit} a digiuno", + "personalReference": "Il tuo obiettivo: {value}", + "source": "Fonte: {source}, al {date}", + "observedActivity": "{value} passi al giorno", + "observedSleep": "{value} minuti per notte", + "observedAdiposity": "Rapporto vita-altezza {value}", + "observedFitness": "{value} {unit}, misurato", + "referenceActivity": "Il beneficio si stabilizza a {high} passi al giorno", + "referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato", + "referenceAdiposity": "Rapporto vita-altezza inferiore a {high}", + "referenceFitness": "Da {low} a {high} {unit}, intervallo di riferimento per età e sesso", + "referenceHba1c": "HbA1c da {low} a {high}%", + "referenceDetail": "{label} · {source} · {kind} · da {low} a {high}", + "openBound": "aperto", + "laboratoryReferenceSource": "Intervalli di riferimento del laboratorio", + "personalTargetSource": "Obiettivo personale", + "referenceKind": { + "clinical": "Soglia clinica", + "population": "Percentile della popolazione", + "guideline": "Intervallo delle linee guida" + }, + "reason": { + "readFailed": "Impossibile caricare i dati", + "notTracked": "Non monitorato", + "missingProfile": "Manca l'età o il profilo di riferimento", + "missingHeight": "Manca l'altezza", + "incompletePanel": "È necessario un pannello recente completo con intervalli di riferimento", + "crisis": "Al posto del punteggio vengono mostrate indicazioni di sicurezza", + "insufficientHistory": "Dati recenti idonei insufficienti", + "unavailable": "Questo pilastro non è attualmente idoneo" + }, + "pillarInsufficient": "Nessun punteggio per il pilastro: {reason}", + "tension": "I segnali di preparazione differiscono. Favorevoli: {positive}. Sotto sforzo: {negative}.", + "returnToBand": "{metric} è di nuovo nel tuo intervallo abituale da {days} giorni.", + "weightGoal": { + "title": "Obiettivo di peso personale", + "value": "Ora {current} {unit}. Obiettivo da {min} a {max} {unit}. Distanza dall'obiettivo: {distance} {unit}.", + "delta": "La distanza dall'obiettivo è cambiata di {delta} {unit} dalla misurazione precedente.", + "noDelta": "Non è disponibile una misurazione del peso precedente per il confronto.", + "no_personal_goal": "Imposta un obiettivo di peso personale per vedere qui i progressi.", + "weight_not_tracked": "Registra il peso per vedere i progressi verso il tuo obiettivo.", + "read_failed": "Non è stato possibile caricare i dati del peso. Riprova.", + "notScored": "I progressi verso l'obiettivo di peso sono un contesto personale e non influenzano il punteggio di salute." } }, "suggestedPrompts": { diff --git a/messages/pl.json b/messages/pl.json index 677979833..90211b0d2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2243,38 +2243,86 @@ "heroActionAskCoach": "Zapytaj coacha", "healthScore": { "label": "Wynik zdrowia", - "progressAria": "Postęp wyniku zdrowia", - "deltaVsLastWeek": "{delta} w porównaniu z ubiegłym tygodniem", - "deltaUnavailable": "Brak historii — zajrzyj w przyszłym tygodniu.", - "restModePaused": "Wstrzymane podczas choroby — Twój wynik nie jest dziś oceniany.", - "componentBp": "RR", - "componentWeight": "Waga", - "componentMood": "Nastrój", - "componentCompliance": "Przestrzeganie", - "weightTarget": { - "user": "Oceniane względem Twojego celu, {range}.", - "none": "Oceniane tylko na podstawie trendu, brak ustawionego celu." + "algorithmChanged": "Wynik korzysta teraz z opublikowanych kardiometabolicznych zakresów referencyjnych; masa ciała nie jest już uwzględniana, ponieważ poprzedni cel wynikał ze wzrostu, a nie z celu ustawionego przez użytkownika, codzienna zmienność nastroju i przestrzeganie zaleceń lekowych są działaniami, a nie stanami zdrowia, zaś tętno spoczynkowe i HRV nie mają uniwersalnego celu. Postęp masy ciała pozostaje poniżej jako osobisty kontekst, pozostałe sygnały zachowują własne karty, a nowa liczba nie oznacza zmiany stanu zdrowia.", + "dismissAlgorithmChanged": "Zamknij informację o metodzie", + "band": { + "green": "W zakresie referencyjnym", + "yellow": "Niektóre filary wymagają uwagi", + "red": "Co najmniej jeden filar jest poza zakresem referencyjnym" }, - "deltaExplainer": { - "title": "Jak liczona jest ta zmiana", - "description": "Zmiana względem ubiegłego tygodnia: {delta}", - "body": "Wynik łączy ciśnienie, wagę, nastrój i przyjmowanie leków. Porównujemy dzisiejszy wynik z tym samym momentem tydzień wcześniej. Dodaj świeży pomiar lub wpis o nastroju, żeby pchnąć go w górę." + "method": "Średnia z równą wagą kwalifikujących się filarów. Najniższy filar wyznacza ogólny zakres.", + "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", + "composition": "Uwzględniono: {pillars}", + "bandSetter": "Ogólny zakres wyznacza {pillar}", + "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", + "methodVersion": "Wersja metody {version}.", + "none": "brak", + "delta": "{delta} względem poprzedniego porównywalnego okresu", + "deltaReason": { + "algorithm_changed": "Brak porównania, ponieważ zmieniła się metoda obliczeń.", + "composition_changed": "Brak porównania, ponieważ zmieniły się uwzględnione filary.", + "first_eligibility_window": "Brak porównania, ponieważ to pierwszy kwalifikujący się okres.", + "below_noise_floor": "Zmiana mieści się w łącznej zmienności pomiarów.", + "no_previous_window": "Brak wcześniejszego porównywalnego okresu.", + "no_current_score": "Brak porównania, ponieważ bieżący okres nie spełnia warunków." }, - "provenance": { - "toggle": "Składa się z", - "mixedBanner": "Niektóre komponenty łączą wiele źródeł. Zestawienie pokazuje najświeższą wartość dla każdego komponentu.", - "footnote": "Wagi są przeskalowywane do dostępnych danych.", - "asOfLabel": "stan na {date}", - "provisional": "Wstępny — dostępne {count} z {total} danych", - "provisionalBadge": "Wstępne", - "sourceAria": "Źródło: {source}", - "sources": { - "manual": "Ręczne", - "withings": "Withings", - "appleHealth": "Apple Health", - "mixed": "Mieszane", - "none": "Brak danych" - } + "restMode": "Tryb odpoczynku aktywny od {since}. Wynik służy jako kontekst i nie jest obniżany.", + "pillar": { + "bloodPressure": "Ciśnienie krwi", + "glycaemia": "Glikemia", + "activity": "Aktywność", + "sleep": "Sen", + "adiposity": "Otłuszczenie", + "wellbeing": "Samopoczucie", + "fitness": "Zmierzona wydolność", + "lipids": "Lipidy" + }, + "observed": "Zaobserwowano: {value}", + "reference": "Zakres referencyjny: {value}", + "fastingObserved": "Glukoza na czczo: {value} {unit}", + "fastingReference": "Od {low} do {high} {unit} na czczo", + "personalReference": "Twój cel: {value}", + "source": "Źródło: {source}, stan na {date}", + "observedActivity": "{value} kroków dziennie", + "observedSleep": "{value} minut na noc", + "observedAdiposity": "Stosunek obwodu talii do wzrostu {value}", + "observedFitness": "{value} {unit}, zmierzone", + "referenceActivity": "Korzyść stabilizuje się przy {high} krokach dziennie", + "referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku", + "referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}", + "referenceFitness": "Od {low} do {high} {unit}, zakres odniesienia według wieku i płci", + "referenceHba1c": "HbA1c od {low} do {high}%", + "referenceDetail": "{label} · {source} · {kind} · od {low} do {high}", + "openBound": "otwarty", + "laboratoryReferenceSource": "Zakresy referencyjne laboratorium", + "personalTargetSource": "Osobisty cel", + "referenceKind": { + "clinical": "Próg kliniczny", + "population": "Percentyl populacji", + "guideline": "Zakres wytycznych" + }, + "reason": { + "readFailed": "Nie udało się wczytać danych", + "notTracked": "Brak pomiarów", + "missingProfile": "Brakuje wieku lub profilu referencyjnego", + "missingHeight": "Brakuje wzrostu", + "incompletePanel": "Potrzebny jest aktualny pełny panel z zakresami referencyjnymi", + "crisis": "Zamiast wyniku wyświetlane są wskazówki bezpieczeństwa", + "insufficientHistory": "Za mało aktualnych danych spełniających kryteria", + "unavailable": "Ten filar obecnie nie spełnia kryteriów" + }, + "pillarInsufficient": "Brak wyniku filaru: {reason}", + "tension": "Sygnały gotowości są rozbieżne. Sprzyjające: {positive}. Obciążone: {negative}.", + "returnToBand": "{metric} od {days} dni ponownie mieści się w typowym zakresie.", + "weightGoal": { + "title": "Osobisty cel wagowy", + "value": "Obecnie {current} {unit}. Cel od {min} do {max} {unit}. Odległość od celu: {distance} {unit}.", + "delta": "Odległość od celu zmieniła się o {delta} {unit} od poprzedniego pomiaru.", + "noDelta": "Brak wcześniejszego pomiaru masy ciała do porównania.", + "no_personal_goal": "Ustaw osobisty cel wagowy, aby zobaczyć tutaj postęp.", + "weight_not_tracked": "Zapisz pomiar masy ciała, aby zobaczyć postęp w kierunku celu.", + "read_failed": "Nie udało się wczytać danych masy ciała. Spróbuj ponownie.", + "notScored": "Postęp celu wagowego jest osobistym kontekstem i nie wpływa na wynik zdrowia." } }, "suggestedPrompts": { diff --git a/src/__tests__/api/analytics/since-cap.test.ts b/src/__tests__/api/analytics/since-cap.test.ts index 3e29b2e0a..d9f481d70 100644 --- a/src/__tests__/api/analytics/since-cap.test.ts +++ b/src/__tests__/api/analytics/since-cap.test.ts @@ -94,8 +94,8 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ }), })); -vi.mock("@/lib/analytics/health-score-fast-path", () => ({ - computeUserHealthScoreFastPath: vi.fn().mockResolvedValue(null), +vi.mock("@/lib/analytics/score/reader", () => ({ + computeUserHealthScore: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/analytics/correlations-fast-path", () => ({ @@ -159,8 +159,8 @@ beforeEach(async () => { await import("@/lib/rollups/measurement-coverage"); const { computeBpInTargetFastPath } = await import("@/lib/analytics/bp-in-target-fast-path"); - const { computeUserHealthScoreFastPath } = - await import("@/lib/analytics/health-score-fast-path"); + const { computeUserHealthScore } = + await import("@/lib/analytics/score/reader"); const { computeCorrelationHypothesesFastPath } = await import("@/lib/analytics/correlations-fast-path"); const { computeSummariesSlice } = @@ -179,9 +179,7 @@ beforeEach(async () => { priorMonth: null, priorYear: null, }); - ( - computeUserHealthScoreFastPath as ReturnType - ).mockResolvedValue(null); + (computeUserHealthScore as ReturnType).mockResolvedValue(null); ( computeCorrelationHypothesesFastPath as ReturnType ).mockResolvedValue({ hypotheses: [] }); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 001908f92..b765ba158 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -6,7 +6,8 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; +import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; @@ -16,14 +17,9 @@ import { computeBpInTargetFastPath, type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; -import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { computeUserHealthScore } from "@/lib/analytics/score/reader"; import { isModuleEnabled } from "@/lib/modules/gate"; import { resolveRestMode } from "@/lib/illness/rest-mode"; -import { - buildHealthScoreBpInputs, - buildHealthScoreWeightTarget, -} from "@/lib/analytics/health-score-inputs"; -import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -298,7 +294,16 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // these envelopes inside `buildHealthScoreBpInputs`. let bpEnvelope: BpInTargetEnvelope | null = null; let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; - const bpTargets = getBpTargets(user.dateOfBirth); + let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null; + const bpTargets = resolveEffectiveBpTargets( + { + dateOfBirth: user.dateOfBirth, + gender: user.gender, + heightCm: user.heightCm ?? null, + }, + user.thresholdsJson, + ); + const clinicalBpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); // v1.4.37 W2 — probe-gated dispatcher replaces the inline chunked @@ -321,29 +326,67 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // pair of `readRollupBuckets` reads on the rollup path and is a // no-op when the user has no BP rows at all. const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const [windows, windowsPriorWeek] = await Promise.all([ - computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now, - coverage, - // v1.4.38 W-A — cross-tz runtime guard. The helper falls back - // to the live SQL path when the user is more than 3 hours - // from UTC, where the rollup table's UTC-midnight day-key - // would slip a calendar day relative to the live aggregator's - // window cuts. - userTz, - }), - computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now: sevenDaysAgo, - coverage, - userTz, - }), - ]); - bpEnvelope = windows; - bpEnvelopePriorWeek = windowsPriorWeek; + const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all( + [ + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, + // v1.4.38 W-A — cross-tz runtime guard. The helper falls back + // to the live SQL path when the user is more than 3 hours + // from UTC, where the rollup table's UTC-midnight day-key + // would slip a calendar day relative to the live aggregator's + // window cuts. + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: fourteenDaysAgo, + coverage, + userTz, + }), + ], + ); + if (bpTargetsEqual(bpTargets, clinicalBpTargets)) { + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; + bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks; + } else if (clinicalBpTargets) { + [bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] = + await Promise.all([ + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now: fourteenDaysAgo, + coverage, + userTz, + }), + ]); + } // v1.17 W1d — the BD-Zielbereich headline standardises on the // trailing-90-day window (labelled "· 90 T" in the tile). All-time // remains carried below for the BP detail page's long view only. @@ -476,64 +519,48 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { : result; } - // v1.4.20 phase B5 — Personal Health Score. Server-deterministic - // composite of BP-in-target % + weight-trend alignment + mood - // stability + medication compliance. The "vs last week" delta - // re-runs the same compute against a 7-day-shifted snapshot. - // - // v1.4.37 W2 — probe-gated helper. The weight pillar derives from - // DAY-bucket means on `measurement_rollups` when the user has full - // coverage; the source-attribution accordion still pulls a narrow - // 2-column projection from `measurements` for the ingest-path - // pills. Falls back to the legacy 37-day raw read on partial / - // missing coverage. Path annotate sits on `meta.healthScore.path`. - // - // v1.17 W1b — the BP-pillar inputs come from the ONE shared - // `buildHealthScoreBpInputs` builder the dashboard snapshot also uses, so - // the ring and the insights card grade the pillar off identical inputs - // (same 90-day window via W1d, same all-time fallback, same graded score, - // same prior-week delta values). The hand-rolled per-surface assembly that - // let the two diverge is gone. - const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); - // v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood - // account drops the mood-stability pillar from the ring instead of - // being silently scored on data hidden everywhere else. The dashboard - // already blanks the mood tile + the coach snapshot unions mood out; - // this closes the matching gap on the Health Score. - const moodEnabled = await isModuleEnabled(user.id, "mood"); - // v1.18.0 B1 — resolve the `glucose` module server-side. The analytics - // route serves core metrics too, so a disabled-glucose account must NOT - // 403 the whole payload; instead the glucose-only surfaces (per-context - // summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they - // never reach a hidden surface. Mirrors the mood-pillar gate above. - const glucoseEnabled = await isModuleEnabled(user.id, "glucose"); + // v1.34 — the versioned cardiometabolic reference report. The shared reader + // admits only evidence-backed pillars with their own windows and coverage + // floors, then applies the three-domain breadth rule. Its BP envelopes come + // from the same rollup/live fast path the dashboard uses; every other domain + // resolves through the canonical reader and fails independently. + const [ + glucoseEnabled, + labsEnabled, + sleepEnabled, + mentalHealthEnabled, + scoreSourcePriority, + ] = await Promise.all([ + isModuleEnabled(user.id, "glucose"), + isModuleEnabled(user.id, "labs"), + isModuleEnabled(user.id, "sleep"), + isModuleEnabled(user.id, "mentalHealth"), + loadUserSourcePriority(user.id), + ]); const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null; const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null; - // The reference instant the score is graded as-of. Reused below so the Rest - // Mode annotation resolves against the SAME window the score covers, not a - // fresh "now" that could fall outside it. const scoredAt = new Date(); - // v1.34 — the weight yardstick comes from the ONE shared - // `buildHealthScoreWeightTarget` builder the dashboard snapshot also uses, - // so the ring and the card grade weight against the identical band. It hands - // over a target ONLY when the account carries an explicit override; every - // other account scores the bare trend. The fabricated BMI-22 band that stood - // in here is gone. - const weightTargetInputs = buildHealthScoreWeightTarget( - { - heightCm: user.heightCm ?? null, + const healthScore = await computeUserHealthScore({ + userId: user.id, + now: scoredAt, + profile: { dateOfBirth: user.dateOfBirth, gender: user.gender, + heightCm: user.heightCm ?? null, + timezone: userTz, + sourcePriorityJson: scoreSourcePriority, + thresholdsJson: user.thresholdsJson ?? null, }, - (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, - ); - const healthScore = await computeUserHealthScoreFastPath({ - userId: user.id, - ...bpInputs, - ...weightTargetInputs, - now: scoredAt, - coverage, - moodEnabled, + modules: { + glucose: glucoseEnabled, + labs: labsEnabled, + sleep: sleepEnabled, + mentalHealth: mentalHealthEnabled, + }, + bpTargets: clinicalBpTargets, + bpEnvelope, + bpEnvelopePriorWeek, + bpEnvelopePriorTwoWeeks, }); // v1.18.1 P4 — Rest Mode annotation. When an illness/condition episode is @@ -648,6 +675,7 @@ async function computeSleepStageBreakdown( .map((n) => { const nightStages: Record = {}; for (const [stage, mins] of Object.entries(n.stages)) { + if (mins == null) continue; nightStages[stage] = mins; stages[stage] = (stages[stage] ?? 0) + mins; totalMinutes += mins; @@ -687,9 +715,3 @@ async function computeSleepStageBreakdown( // `src/lib/analytics/correlations-fast-path.ts`. They were only ever // used by the weight-weekday + day-key helpers inside the old inline // correlation builder. - -// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`, -// and the inline `computeUserHealthScore` body relocated to -// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated -// rollup / live dispatcher can be unit-tested independently of the -// route. The route now delegates through `computeUserHealthScoreFastPath`. diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 5dabd1ccc..3c0fb11ae 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { decryptContextFromBytes, encryptContextToBytes, @@ -169,6 +170,7 @@ export const PUT = apiHandler( } const updated = await prisma.biomarker.update({ where: { id }, data }); + invalidateUserHealthScore(user.id); await auditLog("biomarker.update", { userId: user.id, @@ -206,6 +208,7 @@ export const DELETE = apiHandler( }), prisma.biomarker.delete({ where: { id } }), ]); + invalidateUserHealthScore(user.id); await auditLog("biomarker.delete", { userId: user.id, diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 80a297833..28ca64f05 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -58,7 +58,14 @@ const DIGEST: DailyDigest = { generatedAt: "2026-07-16T09:00:00.000Z", phase: "final", sleepPending: false, - score: { value: 82, band: "good", delta: 3 }, + score: { + value: 82, + band: "good", + delta: 3, + deltaReason: null, + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + }, topSignal: null, briefingLead: "Blood pressure is holding steady.", line: "Blood pressure is holding steady.", diff --git a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts index 1c291db9e..8c2ab3e05 100644 --- a/src/app/api/daily/digest/dismiss/__tests__/route.test.ts +++ b/src/app/api/daily/digest/dismiss/__tests__/route.test.ts @@ -30,6 +30,10 @@ vi.mock("@/lib/rate-limit", () => ({ rateLimitHeaders: vi.fn(() => ({})), })); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserHealthScore: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -56,6 +60,7 @@ import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiError } from "@/lib/api-response"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -129,11 +134,13 @@ describe("POST /api/daily/digest/dismiss", () => { expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); }); - it("upserts the dismissal scoped to the caller, for each dismissible kind", async () => { + it("upserts each approved observational or notice dismissal for the caller", async () => { for (const itemKey of [ "milestone:record_first:WEIGHT:2026-07-16", "ecg_new_recording:2026-07-16T08:00:00.000Z", "tension_window:2026-07-16:afternoon", + "same_time_baseline:2026-07-16:ACTIVITY_STEPS", + "health_score_algorithm:2", ]) { vi.mocked(prisma.dismissedPriorityItem.upsert).mockClear(); const res = await callPost(makeReq({ itemKey })); @@ -161,4 +168,12 @@ describe("POST /api/daily/digest/dismiss", () => { expect(res.status).toBe(429); expect(prisma.dismissedPriorityItem.upsert).not.toHaveBeenCalled(); }); + + it("evicts score-bearing server caches after the algorithm notice write", async () => { + const res = await callPost( + makeReq({ itemKey: "health_score_algorithm:2" }), + ); + expect(res.status).toBe(200); + expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1"); + }); }); diff --git a/src/app/api/daily/digest/dismiss/route.ts b/src/app/api/daily/digest/dismiss/route.ts index 4a0ba5d84..c6f009ccc 100644 --- a/src/app/api/daily/digest/dismiss/route.ts +++ b/src/app/api/daily/digest/dismiss/route.ts @@ -1,12 +1,11 @@ /** * POST /api/daily/digest/dismiss * - * "Dismiss / mark seen" for the Today rail's OBSERVATIONAL `PriorityItem` - * kinds only — `milestone`, `ecg_new_recording`, `tension_window`. The - * ACTIONABLE kinds (`dose_window`, `sync_issue`, `preventive_care`, - * `coach_checkin`) are never reachable here: `dismissPriorityItemSchema` - * rejects any `itemKey` that doesn't carry one of the three dismissible - * kind-prefixes before a lookup ever runs (§`isDismissibleItemKey`). + * "Dismiss / mark seen" for observational Today rail items and versioned + * informational notices. Actionable kinds (`dose_window`, `sync_issue`, + * `preventive_care`, `coach_checkin`) are never reachable here: + * `dismissPriorityItemSchema` rejects any `itemKey` without an approved + * observational or notice prefix before a lookup runs. * * Persisted server-side (`DismissedPriorityItem`, composite-unique on * `userId` + `itemKey`) so the dismissal survives reload / a second device — @@ -27,6 +26,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; @@ -72,6 +72,9 @@ export const POST = apiHandler(async (req: NextRequest) => { update: {}, create: { userId: user.id, itemKey }, }); + if (itemKey.startsWith("health_score_algorithm:")) { + invalidateUserHealthScore(user.id); + } annotate({ action: { name: "daily.digest.item.dismissed" }, diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 5a008ceca..df2e7d197 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -104,11 +104,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!gate.enabled) return gate.response; } - // Profile read once via the shared loader (the same one the batch route - // and the nightly score jobs use), passed into the pure compute function - // — never re-fetched per metric. const profile = await loadBaselineProfile(prisma, user.id); - const derived = await computeDerivedMetric({ metric, userId: user.id, diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 8a01b5db1..c1a5fc1ef 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { type ResolvedBiomarker, serialiseLabResult, @@ -217,6 +218,7 @@ export const PUT = apiHandler( data, include: { biomarker: { select: biomarkerSelect } }, }); + invalidateUserHealthScore(user.id); await auditLog("labResult.update", { userId: user.id, @@ -250,6 +252,7 @@ export const DELETE = apiHandler( where: { id }, data: { deletedAt: new Date() }, }); + invalidateUserHealthScore(user.id); await auditLog("labResult.delete", { userId: user.id, diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index 42b6b1d77..d6bd0b431 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -28,6 +28,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; @@ -191,6 +192,7 @@ async function commitOcrRows(request: NextRequest) { takenAt: created.takenAt, }); } + if (inserted.length > 0) invalidateUserHealthScore(user.id); // S9 — cross-link the freshly inserted labs to the vault document the client // filed the scanned bytes into. Best-effort: the labs are the authoritative diff --git a/src/app/api/labs/restore/route.ts b/src/app/api/labs/restore/route.ts index 07d0bc409..af4c853dc 100644 --- a/src/app/api/labs/restore/route.ts +++ b/src/app/api/labs/restore/route.ts @@ -21,6 +21,7 @@ import { safeJson, } from "@/lib/api-response"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -66,6 +67,7 @@ async function postRestore(request: NextRequest): Promise { where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } }, data: { deletedAt: null }, }); + if (count > 0) invalidateUserHealthScore(user.id); annotate({ action: { name: "labs.restore" }, meta: { restored: count } }); diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 20d4dfd7c..9c60a96ad 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -12,6 +12,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; @@ -254,6 +255,7 @@ async function postLabResult(request: NextRequest) { noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); + invalidateUserHealthScore(user.id); await auditLog("labResult.create", { userId: user.id, diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 66bf93f20..e7a014910 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -17,6 +17,7 @@ */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -338,6 +339,7 @@ async function postAssessment(request: NextRequest): Promise { externalId: `assessment:${assessment.id}`, }, }); + invalidateUserHealthScore(user.id); // v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on // PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 7e22bb727..439522754 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -7,7 +7,6 @@ import Link from "next/link"; import { SlidersHorizontal, TrendingUp } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; -import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -345,27 +344,6 @@ export default function InsightsPageClient() { const heroTension = snapshotHealthScore?.tension ?? null; const heroReturnToBand = snapshotHealthScore?.returnToBand ?? null; - // v1.34 — the weight pillar's yardstick, disclosed on the score card. The - // server sends the band in canonical kg; the reader's display unit is a - // client fact, so the page formats the label and the card renders the line. - // A payload without `targetSource` (an older cached body) discloses nothing - // rather than guessing at one. - const unitDisplay = useUnitDisplay(); - const weightComponent = analytics?.healthScore?.components.weight; - const weightTargetSource = weightComponent?.targetSource ?? null; - const weightTargetBand = weightComponent?.target ?? null; - const heroWeightYardstick = weightTargetSource - ? { - source: weightTargetSource, - targetLabel: weightTargetBand - ? `${unitDisplay.toDisplay("WEIGHT", weightTargetBand.min)}–${unitDisplay.toDisplay( - "WEIGHT", - weightTargetBand.max, - )} ${unitDisplay.unitFor("WEIGHT")}` - : null, - } - : null; - // v1.12.6 — the page owns the ONE overview derived batch. The wellness // strip (above the briefing) and the vitals grid (below it) both read this // single query instance, so the wellness lift adds no second request. @@ -594,8 +572,6 @@ export default function InsightsPageClient() { // forwards them to the score card; null leaves the card quiet. tension={heroTension} returnToBand={heroReturnToBand} - // v1.34 — name the yardstick the weight pillar was graded against. - weightYardstick={heroWeightYardstick} /> {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 4bb403aea..874e5306a 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -52,7 +52,14 @@ function digest(over: Partial = {}): DailyDigest { generatedAt: "2026-07-16T06:00:00.000Z", phase: "final", sleepPending: false, - score: { value: 82, band: "green", delta: 3 }, + score: { + value: 82, + band: "green", + delta: 3, + deltaReason: null, + scoreVersion: 2, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + }, topSignal: { sourceMetric: "bp", tone: "watch", @@ -91,6 +98,24 @@ describe("", () => { expect(html).toContain('data-slot="today-hero-score-delta"'); }); + it("does not present a suppressed algorithm jump as score movement", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="today-hero-score-delta"'); + }); + it("wires each PriorityItem action to its existing destination via href", () => { const html = render(); // Every S1 rail action carries an href, so PriorityCard renders it as a diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index b31693c83..b28a536ca 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -229,7 +229,9 @@ export function TodayHero({ /> - {digest.score && digest.score.delta !== null ? ( + {digest.score && + digest.score.delta !== null && + digest.score.deltaReason === null ? ( {node}, - ); -} - -describe(" — provenance accordion (W8e)", () => { - it("renders the toggle button with aria-expanded=false when collapsed", () => { - const html = ssr( - , - ); - expect(html).toMatch( - /data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="false"/, - ); - // Toggle text uses the localised "Driven by" copy. - expect(html).toContain("Driven by"); - // Panel is NOT rendered when collapsed. - expect(html).not.toContain( - 'data-slot="health-score-card-provenance-panel"', - ); - }); - - it("renders the panel + sets aria-expanded=true when initiallyExpanded", () => { - const html = ssr( - , - ); - expect(html).toMatch( - /data-slot="health-score-card-provenance-toggle"[^>]*aria-expanded="true"/, - ); - expect(html).toContain('data-slot="health-score-card-provenance-panel"'); - }); - - it("aria-controls on the toggle points at the rendered panel's id", () => { - const html = ssr( - , - ); - // Extract aria-controls value from the toggle and confirm the - // panel section uses the same id. - const controlsMatch = html.match( - /data-slot="health-score-card-provenance-toggle"[^>]*aria-controls="([^"]+)"/, - ); - expect(controlsMatch).not.toBeNull(); - const id = controlsMatch![1]; - // The panel section must carry the same id; we don't pin the exact - // attribute order because additional ARIA attributes (e.g. - // `aria-labelledby`) may sit between `id` and `data-slot`. - const panelMatch = html.match( - new RegExp( - `]*\\bid="${id}"[^>]*data-slot="health-score-card-provenance-panel"`, - ), - ); - expect(panelMatch).not.toBeNull(); - }); - - it("sorts rows by effective weight descending (null components sink to bottom)", () => { - const components: HealthScoreCardProps["components"] = { - bp: { value: null, weight: 0, source: "none" }, - weight: { value: 60, weight: 0.4, source: "manual" }, - mood: { value: 80, weight: 0.3, source: "manual" }, - compliance: { value: 70, weight: 0.3, source: "manual" }, - }; - const html = ssr( - , - ); - // Pull every `data-component=` token from the provenance rows. - const rowMatches = Array.from( - html.matchAll( - /data-slot="health-score-card-provenance-row"[^>]*data-component="([^"]+)"/g, - ), - ).map((m) => m[1]); - // Weight (0.4) > mood (0.3) > compliance (0.3) — mood vs compliance - // tie-break is the alphabetical key order. BP at weight=0 sinks - // to the bottom. - expect(rowMatches).toEqual(["weight", "compliance", "mood", "bp"]); - }); - - it("renders source pills with locale-resolved labels (EN)", () => { - const html = ssr( - , - ); - // Withings + Manual both present in the data. - expect(html).toContain("Withings"); - expect(html).toContain("Manual"); - }); - - it("renders source pills with locale-resolved labels (DE)", () => { - const html = ssr( - , - "de", - ); - // Manuell is the DE label for "manual". - expect(html).toContain("Manuell"); - expect(html).toContain("Withings"); - expect(html).toContain("Zusammensetzung"); - }); - - it("renders the mixed-source banner with role=status when any row is mixed", () => { - const components: HealthScoreCardProps["components"] = { - ...componentsAllPresent, - bp: { value: 82, weight: 0.3, source: "mixed" }, - }; - const html = ssr( - , - ); - expect(html).toMatch( - /data-slot="health-score-card-provenance-mixed-banner"[^>]*role="status"/, - ); - // The pill on the BP row reads "Mixed" (EN). - expect(html).toContain("Mixed"); - }); - - it("does NOT render the mixed-source banner when no row is mixed", () => { - const html = ssr( - , - ); - expect(html).not.toContain( - 'data-slot="health-score-card-provenance-mixed-banner"', - ); - }); - - it("dims empty-state rows when source is `none`", () => { - const components: HealthScoreCardProps["components"] = { - ...componentsAllPresent, - mood: { value: null, weight: 0, source: "none" }, - }; - const html = ssr( - , - ); - // The dimmed row carries opacity-50 alongside the `data-source="none"` - // attribute. - expect(html).toMatch( - /data-component="mood"[^>]*data-source="none"[^>]*class="[^"]*opacity-50/, - ); - // The source pill reads "No data" (EN). - expect(html).toContain("No data"); - }); - - it("renders the provisional badge when fewer than 50% of inputs are present", () => { - // Three out of four components null → 1 of 4 = 25 % present. - const components: HealthScoreCardProps["components"] = { - bp: { - value: 80, - weight: 1, - source: "withings", - asOf: "2026-05-14T08:00:00.000Z", - }, - weight: { value: null, weight: 0, source: "none" }, - mood: { value: null, weight: 0, source: "none" }, - compliance: { value: null, weight: 0, source: "none" }, - }; - const html = ssr( - , - ); - expect(html).toContain('data-slot="health-score-card-provisional-badge"'); - // EN badge copy. - expect(html).toContain("Provisional"); - }); - - it("does NOT render the provisional badge when at least 50% of inputs are present", () => { - // 2 of 4 = 50 % → NOT provisional. - const components: HealthScoreCardProps["components"] = { - bp: { - value: 80, - weight: 0.6, - source: "withings", - asOf: "2026-05-14T08:00:00.000Z", - }, - weight: { - value: 70, - weight: 0.4, - source: "manual", - asOf: "2026-05-13T08:00:00.000Z", - }, - mood: { value: null, weight: 0, source: "none" }, - compliance: { value: null, weight: 0, source: "none" }, - }; - const html = ssr( - , - ); - expect(html).not.toContain( - 'data-slot="health-score-card-provisional-badge"', - ); - }); - - it("renders the source-pill aria-label with localised source name", () => { - const html = ssr( - , - ); - // BP row uses Withings; the aria-label uses the localised - // "Source: Withings" form. - expect(html).toContain('aria-label="Source: Withings"'); - }); - - it("renders the value bar at 0% width for empty-state rows", () => { - const components: HealthScoreCardProps["components"] = { - ...componentsAllPresent, - compliance: { value: null, weight: 0, source: "none" }, - }; - const html = ssr( - , - ); - // Every row inside the panel carries an inline-styled width. - // We confirm the compliance (none) row's value-bar block paints - // 0% by searching for the matching row. - const complianceRowMatch = html.match( - /data-slot="health-score-card-provenance-row"[^>]*data-component="compliance"[\s\S]*?width:\s*([0-9.]+%)/, - ); - expect(complianceRowMatch).not.toBeNull(); - expect(complianceRowMatch?.[1]).toBe("0%"); - }); - - it("forwards the asOf timestamp into the source-pill `title` tooltip", () => { - const html = ssr( - , - ); - // The BP row's pill carries the title formatted via Intl.DateTimeFormat; - // we only need to confirm `as of` appears (the exact date depends on - // the JS engine's locale data). - expect(html).toContain("as of"); - // The data-as-of attribute carries the raw ISO timestamp. - expect(html).toContain('data-as-of="2026-05-14T08:00:00.000Z"'); - }); - - it("renders the footnote inside the expanded panel", () => { - const html = ssr( - , - ); - expect(html).toContain('data-slot="health-score-card-provenance-footnote"'); - }); -}); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index c1f798c3e..3292f9859 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -1,433 +1,342 @@ -import { describe, it, expect } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; + import { I18nProvider } from "@/lib/i18n/context"; +import type { Locale } from "@/lib/i18n/config"; +import type { HealthScoreReport } from "@/lib/analytics/score/types"; import { HealthScoreCard, - type HealthScoreCardProps, + markAlgorithmNoticeDismissed, } from "../health-score-card"; - -/** - * v1.4.20 phase B5 — Personal Health Score card. - * - * SSR markup pins the slot wrappers so future polish can't silently - * drop the score number, sub-bars or disclaimer. The inline - * Ask-the-Coach button retired in v1.4.27 B1 — the hero strip - * carries the CTA. The negative-assertion test below pins that - * decision so a future revival has to update the test in lock-step. - */ - -const baseComponents: HealthScoreCardProps["components"] = { - bp: { value: 80, weight: 0.3 }, - weight: { value: 70, weight: 0.2 }, - mood: { value: 90, weight: 0.2 }, - compliance: { value: 100, weight: 0.3 }, +let mockAuthUser: { unitPreference: string; glucoseUnit: string } = { + unitPreference: "metric", + glucoseUnit: "mg/dL", }; -function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: mockAuthUser, + }), +})); + +const provenance = { + inputs: ["BLOOD_PRESSURE"], + source: "live" as const, + windowDays: 90, + computedAt: "2026-07-28T12:00:00.000Z", +}; +const coverage = { + requiredInputs: 3, + presentInputs: 1, + historyDays: 7, + missing: ["ACTIVITY", "SLEEP"], +}; + +function render(report: HealthScoreReport, locale: Locale = "en") { return renderToStaticMarkup( - {node}, + + + , ); } +function report(overrides: Partial = {}): HealthScoreReport { + return { + composite: { + status: "insufficient", + coverage, + provenance, + reason: "three_domains_required", + }, + pillars: [ + { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 82, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + personalReference: { + kind: "guideline-band", + low: 115, + high: 125, + label: "115 to 125/65 to 75 mmHg", + source: "personal target", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }, + { + id: "ACTIVITY", + domain: "activity", + result: { + status: "insufficient", + coverage: { + requiredInputs: 21, + presentInputs: 7, + historyDays: 7, + missing: ["14 days"], + }, + provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 }, + reason: "below_day_floor_or_stale", + }, + }, + ], + delta: null, + deltaReason: "no_current_score", + scoreVersion: 2, + weightGoal: { + status: "insufficient", + coverage: { + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: ["weight reading", "personal weight target"], + }, + provenance: { ...provenance, inputs: ["WEIGHT"] }, + reason: "no_personal_goal", + }, + algorithmNotice: null, + ...overrides, + }; +} + +beforeEach(() => { + mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" }; +}); + describe("", () => { - it("renders the slot wrapper + band data attribute (green)", () => { - const html = ssr( - , - ); - expect(html).toMatch(/data-slot="health-score-card"/); - expect(html).toMatch(/data-band="green"/); + it("keeps below-floor pillars visible without a composite headline", () => { + const html = render(report()); + expect(html).toContain('data-status="insufficient"'); + expect(html).toContain('data-contributor="BLOOD_PRESSURE"'); + expect(html).toContain('data-contributor="ACTIVITY"'); + expect(html).toContain("Not enough recent eligible data"); + expect(html).not.toContain("below_day_floor_or_stale"); + expect(html).toContain("Your target: 115 to 125/65 to 75 mmHg"); + expect(html).not.toContain('data-slot="score-ring"'); }); - it("renders the score number, /100 suffix, and band number colour", () => { - const html = ssr( - , + it("shows personal goal progress as explicitly unscored context", () => { + const html = render( + report({ + weightGoal: { + status: "ok", + value: { + currentKg: 81, + target: { min: 74, max: 78 }, + distanceKg: 3, + deltaKg: 1, + asOf: "2026-07-27T08:00:00.000Z", + source: "MANUAL", + }, + coverage: { + requiredInputs: 2, + presentInputs: 2, + historyDays: 1, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }), ); - expect(html).toMatch(/data-slot="health-score-card-number"/); - expect(html).toContain(">86<"); - expect(html).toContain("/ 100"); - // v1.12.4 — the headline number paints through the semantic `--success` - // token (AA-safe on both themes) rather than the raw dracula primitive. - expect(html).toContain("text-success"); + expect(html).toContain('data-slot="health-score-weight-goal"'); + expect(html).toContain('data-status="ok"'); + expect(html).toContain("does not affect the Health Score"); }); - it("paints the yellow band correctly", () => { - const html = ssr( - , + it("renders the personal weight goal in the account unit system", () => { + mockAuthUser = { unitPreference: "imperial", glucoseUnit: "mg/dL" }; + const html = render( + report({ + weightGoal: { + status: "ok", + value: { + currentKg: 81, + target: { min: 74, max: 78 }, + distanceKg: 3, + deltaKg: 1, + asOf: "2026-07-27T08:00:00.000Z", + source: "MANUAL", + }, + coverage: { + requiredInputs: 2, + presentInputs: 2, + historyDays: 1, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }), ); - expect(html).toMatch(/data-band="yellow"/); - expect(html).toContain("text-warning"); + expect(html).toContain("178.6 lb now"); + expect(html).toContain("Goal 163.1 to 172 lb"); + expect(html).toContain("6.6 lb"); + expect(html).not.toContain("81 kg"); }); - it("paints the red band correctly", () => { - const html = ssr( - , + it("renders fasting glucose and its reference in the account unit", () => { + mockAuthUser = { unitPreference: "metric", glucoseUnit: "mmol/L" }; + const base = report(); + const html = render( + report({ + pillars: [ + { + id: "GLYCAEMIA", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 100, + unit: "mg/dL", + label: "Fasting glucose", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 70, + high: 99, + label: "70 to 99 mg/dL fasting", + source: "ADA 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "fasting_glucose", + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }, + ...base.pillars.slice(1), + ], + }), ); - expect(html).toMatch(/data-band="red"/); - expect(html).toContain("text-destructive"); + expect(html).toContain("Fasting glucose: 5.5 mmol/L"); + expect(html).toContain("3.9 to 5.5 mmol/L fasting"); + expect(html).not.toContain("100 mg/dL"); + expect(html).not.toContain("70 to 99 mg/dL"); }); - it("renders the unavailable-delta caption when delta is null", () => { - const html = ssr( - , + it("renders the one-time method note and every score identity field", () => { + const html = render( + report({ + algorithmNotice: { + itemKey: "health_score_algorithm:2", + dismissed: false, + }, + }), ); - expect(html).toContain("No history yet"); - // No delta chip in the corner. - expect(html).not.toContain('data-slot="health-score-card-delta-chip"'); + expect(html).toContain('data-slot="health-score-algorithm-notice"'); + expect(html).toContain("cardiometabolic reference ranges"); }); - it("renders the up-arrow + delta chip when delta > 0", () => { - const html = ssr( - , - ); - expect(html).toMatch(/data-slot="health-score-card-delta-chip"/); - expect(html).toContain("+5"); - expect(html).toContain("vs last week"); - }); - - it("renders the down-arrow line when delta < 0", () => { - const html = ssr( - , - ); - // Negative delta does NOT render the green chip. - expect(html).not.toContain('data-slot="health-score-card-delta-chip"'); - expect(html).toContain("-7 vs last week"); - }); - - it("threads aria-describedby from the delta text to the explainer body", () => { - // FB-I1 a11y — the delta `` carries aria-describedby; the inline - // explainer caption carries the same id. v1.22 — the read no longer - // hides behind a "?" trigger (no aria-controls); the body renders inline - // and the shared `useId()` thread still connects "−3 vs last week" to the - // three-sentence read. - const html = ssr( - , - ); - // The aria-describedby attribute lands on the delta span. - const deltaSpan = html.match( - /]*>[^<]*vs last week[^<]*<\/span>/, - ); - expect(deltaSpan).not.toBeNull(); - const threadedId = deltaSpan?.[1] ?? ""; - expect(threadedId).not.toBe(""); - // The same id sits on the inline explainer body. - expect(html).toMatch( - new RegExp( - `]*id="${threadedId}"[^>]*data-slot="health-score-delta-explainer-body"`, - ), - ); - }); - - it("stacks the explainer caption BELOW the delta line, not beside it", () => { - // The caption is a multi-sentence read; rendering it inside the - // delta's inline-flex row squeezed the short delta into a narrow - // multi-line column on every viewport. The wrapper stacks instead. - const html = ssr( - , - ); - const wrapper = html.match( - /data-slot="health-score-card-delta"[^>]*class="([^"]*)"/, - ); - expect(wrapper).not.toBeNull(); - expect(wrapper?.[1]).toContain("space-y-1"); - expect(wrapper?.[1]).not.toContain("inline-flex"); - // The delta line's

closes before the explainer body opens — - // the caption is a sibling below, never a flex neighbour. - expect(html).toMatch( - /vs last week[^<]*<\/span><\/p>]*data-slot="health-score-delta-explainer-body"/, - ); - }); - - it("renders four component rows with their values", () => { - const html = ssr( - , - ); - for (const key of ["bp", "weight", "mood", "compliance"]) { - const re = new RegExp(`data-component="${key}"`); - expect(html).toMatch(re); - } - // Hand-picked values from baseComponents. - expect(html).toContain(">80<"); - expect(html).toContain(">70<"); - expect(html).toContain(">90<"); - expect(html).toContain(">100<"); - }); - - it("renders an em-dash for null component values", () => { - const html = ssr( - , - ); - // The mood row exists but its value cell is "—". - const moodSliceMatch = html.match( - /data-component="mood"[\s\S]*?data-slot="health-score-card-component-value"[^>]*>([^<]+) { - // The hero strip's existing "Ask the coach" action covers this - // surface; the card-internal duplicate button retired. v1.18.6 (L13) - // dropped the now-dead `onAskCoach` prop too — the card renders no - // Coach affordance at all. - const html = ssr( - , - ); - expect(html).not.toContain('data-slot="health-score-card-ask-coach"'); - }); - - it("renders German strings", () => { - const html = ssr( - , + it("localises structured activity labels, references, and reasons", () => { + const base = report(); + const html = render( + report({ + pillars: [ + { + id: "ACTIVITY", + domain: "activity", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 8_000, + unit: "steps/day", + label: "8,000 steps/day", + asOf: "2026-07-27T00:00:00.000Z", + sources: ["APPLE_HEALTH"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 10_000, + label: "benefit plateaus by 10,000 steps/day", + source: "Paluch 2022", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "ACTIVITY_STEPS", + }, + coverage: { + requiredInputs: 21, + presentInputs: 21, + historyDays: 21, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, + }, + ...base.pillars.slice(1), + ], + }), "de", ); - expect(html).toContain("Gesundheitsscore"); - expect(html).toContain("im Vergleich zur Vorwoche"); - // v1.4.27 B1 — the inline Coach button retired; hero strip carries - // the action now. - expect(html).not.toContain("Coach fragen"); + expect(html).toContain("Schritte pro Tag"); + expect(html).toContain("Der Nutzen flacht"); + expect(html).toContain("Leitlinienbereich"); + expect(html).not.toContain("benefit plateaus by"); + expect(html).not.toContain("guideline-band"); }); - - it("uses tabular-nums on the headline number for stable layout", () => { - const html = ssr( - , + it("patches the cached analytics report after the notice is dismissed", () => { + const payload = { + healthScore: report({ + algorithmNotice: { + itemKey: "health_score_algorithm:2", + dismissed: false, + }, + }), + }; + const patched = markAlgorithmNoticeDismissed( + payload, + "health_score_algorithm:2", ); - // The headline number wrapper uses the tabular-nums utility. - const numberMatch = html.match( - /]*data-slot="health-score-card-number"[^>]*>/, - ); - expect(numberMatch).not.toBeNull(); - expect(numberMatch?.[0]).toContain("tabular-nums"); - }); - - it("clamps the progress bar width between 0 and 100", () => { - const html = ssr( - , - ); - // Even a > 100 score must render at exactly 100 % width. - const progressMatch = html.match( - /data-slot="health-score-card-progress"[\s\S]*?width:\s*([0-9.]+%)/, - ); - expect(progressMatch).not.toBeNull(); - expect(progressMatch?.[1]).toBe("100%"); - }); - - // ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ─────── - it("opts the wrapper into the parent's flex-stretch row via h-full + flex-col", () => { - // The hero strip's row now uses `items-stretch`; the card has to - // declare `h-full flex flex-col` so the stretched shell actually - // affects its rendered height. Pin both classes so a refactor - // can't silently drop one. - const html = ssr( - , - ); - const cardOpen = html.match(/]*data-slot="health-score-card"[^>]*>/); - expect(cardOpen).not.toBeNull(); - expect(cardOpen?.[0]).toContain("h-full"); - expect(cardOpen?.[0]).toContain("flex"); - expect(cardOpen?.[0]).toContain("flex-col"); - }); - - // v1.4.37 W4a item 1 — the inner column switched from `flex - // flex-col gap-3` to a 7-row grid so the slack collects on row 6 - // (the provenance accordion) instead of clumping under the - // disclaimer. Pin both the grid switch and the `1fr` slack row so - // a future refactor can't silently revert to flex and re-introduce - // the "Karte hört bei der Trennlinie auf" maintainer-reported bug. - it("uses a 7-row grid on the inner column so slack distributes on the accordion row", () => { - const html = ssr( - , - ); - // The inner column lives directly after the card wrapper. Match - // the first

that opens after the wrapper and assert the - // grid + grid-rows-[...1fr...] contract. - const innerColumn = html.match( - /]*data-slot="health-score-card"[^>]*>\s*(?:\s*)?]*)>/, - ); - expect(innerColumn).not.toBeNull(); - const innerAttrs = innerColumn?.[1] ?? ""; - expect(innerAttrs).toContain("grid"); - expect(innerAttrs).toContain("flex-1"); - expect(innerAttrs).toContain( - "grid-rows-[auto_auto_auto_auto_auto_1fr_auto]", - ); - }); - - it("exposes the progressbar role + ARIA values for screen readers", () => { - const html = ssr( - , - ); - const progressMatch = html.match( - /]*data-slot="health-score-card-progress"[^>]*>/, - ); - expect(progressMatch).not.toBeNull(); - expect(progressMatch?.[0]).toContain('role="progressbar"'); - expect(progressMatch?.[0]).toContain('aria-valuenow="86"'); - expect(progressMatch?.[0]).toContain('aria-valuemin="0"'); - expect(progressMatch?.[0]).toContain('aria-valuemax="100"'); - }); -}); - -/** - * v1.34 — the weight row names its yardstick. Before this the score graded - * weight against a band the user never set and the card said nothing at all, - * which is why the defect survived for months. - */ -describe(" — weight yardstick disclosure", () => { - it("names the user's own target band", () => { - const html = ssr( - , - ); - const line = html.match( - /]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/, - ); - expect(line).not.toBeNull(); - expect(line?.[0]).toContain('data-target-source="user"'); - expect(line?.[1]).toContain("64-70 kg"); - }); - - it("says plainly when no target graded the row", () => { - const html = ssr( - , - ); - const line = html.match( - /]*data-slot="health-score-card-weight-yardstick"[^>]*>([\s\S]*?)<\/p>/, - ); - expect(line).not.toBeNull(); - expect(line?.[0]).toContain('data-target-source="none"'); - expect(line?.[1]).toMatch(/trend/i); - }); - - it("discloses nothing when the payload carries no yardstick", () => { - // An older cached body has no `targetSource`; silence beats a guess. - const html = ssr( - , - ); - expect(html).not.toContain("health-score-card-weight-yardstick"); - }); - - it("stays quiet when the weight pillar itself has no value", () => { - const html = ssr( - , - ); - expect(html).not.toContain("health-score-card-weight-yardstick"); + expect(patched).toMatchObject({ + healthScore: { + algorithmNotice: { + itemKey: "health_score_algorithm:2", + dismissed: true, + }, + }, + }); }); }); diff --git a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx b/src/components/insights/__tests__/health-score-delta-explainer.test.tsx deleted file mode 100644 index ba54d1f8c..000000000 --- a/src/components/insights/__tests__/health-score-delta-explainer.test.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { I18nProvider, useTranslations } from "@/lib/i18n/context"; -import { HealthScoreDeltaExplainer } from "../health-score-delta-explainer"; - -/** - * v1.4.28 R3c-Insights — FB-I1 — the "vs last week" delta carries a - * three-sentence read of which components contributed, what the comparison - * window is, and one concrete next step. - * - * v1.22 — the read no longer hides behind a "?" glyph + popover; it renders - * inline as a muted caption beside the delta line. These tests run through - * SSR (no browser surface) and pin: the inline body paints by default, it - * threads the parent-supplied id for aria-describedby, and every locale - * ships the body copy. - */ - -function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -/** - * Helper that paints the explainer body via the same `t()` call the caption - * uses. If a future refactor drops a locale key the test fails at the `t()` - * call site rather than silently rendering the literal key path. - */ -function BodyProbe() { - const { t } = useTranslations(); - return ( - - {t("insights.healthScore.deltaExplainer.body")} - - ); -} - -describe("", () => { - it("renders the read inline as a muted caption (no trigger glyph)", () => { - const html = ssr(); - expect(html).toContain('data-slot="health-score-delta-explainer-body"'); - // The old icon-only disclosure trigger is gone. - expect(html).not.toContain( - 'data-slot="health-score-delta-explainer-trigger"', - ); - expect(html).not.toContain("aria-expanded"); - }); - - it("uses the parent-supplied bodyId so aria-describedby can thread to it", () => { - const html = ssr( - , - ); - expect(html).toMatch( - /]*id="parent-id-x"[^>]*data-slot="health-score-delta-explainer-body"/, - ); - }); - - it("paints the body copy on the initial SSR snapshot (always visible)", () => { - const html = ssr(); - expect(html).toContain("BP, weight, mood and medication adherence"); - }); - - it("ships the EN body copy through the locale payload (3-sentence read)", () => { - const html = renderToStaticMarkup( - - - , - ); - const body = html.match( - /]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/, - ); - expect(body).not.toBeNull(); - const text = body?.[1] ?? ""; - // Components — window — action triad pinned. - expect(text).toContain("BP, weight, mood and medication adherence"); - expect(text).toContain("same time last week"); - expect(text).toContain("Log a fresh reading"); - // 3-sentence ceiling. Soft check: at most 4 sentence-ending - // punctuation marks (covering the Oxford comma + 3 periods). - const periods = (text.match(/\./g) ?? []).length; - expect(periods).toBeLessThanOrEqual(4); - }); - - it("ships the DE body copy with the same structure (3-sentence translation)", () => { - const html = renderToStaticMarkup( - - - , - ); - const body = html.match( - /]*data-slot="probe-body"[^>]*>([^<]+)<\/span>/, - ); - expect(body).not.toBeNull(); - const text = body?.[1] ?? ""; - expect(text).toContain("Blutdruck"); - expect(text).toContain("Stimmung"); - expect(text).toContain("Therapietreue"); - expect(text).toContain("vor einer Woche"); - }); - - it("ships the body key in every locale supported by R3c-Insights", () => { - // Pinning every locale stops a future feature branch from shipping an - // English fallback for the explainer body key. - for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { - const html = renderToStaticMarkup( - - - , - ); - // The probe re-uses the same `t()` payload the caption does, so any - // missing key surfaces as the literal key path which never contains a - // space. - expect(html).not.toContain("insights.healthScore.deltaExplainer.body"); - } - }); -}); diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 3716d3009..5f2a00bd1 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { HeroStrip } from "../hero-strip"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; +import type { HealthScoreReport } from "@/lib/analytics/score/types"; // The "generated" freshness line reads the profile timezone via `useAuth`; stub // it so the SSR render does not reach for a QueryClient the test omits. @@ -40,6 +41,53 @@ const sampleBriefing: DailyBriefingPayload = { const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin +const SCORE_REPORT: HealthScoreReport = { + composite: { + status: "ok", + value: { + score: 86, + band: "green", + bandSetter: null, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + noiseFloor: 3, + scoreVersion: 2, + }, + coverage: { + requiredInputs: 3, + presentInputs: 3, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance: { + inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + source: "live", + windowDays: 90, + computedAt: "2026-05-10T07:00:00.000Z", + }, + }, + pillars: [], + delta: 5, + deltaReason: null, + scoreVersion: 2, + weightGoal: { + status: "insufficient", + coverage: { + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: ["weight reading", "personal weight target"], + }, + provenance: { + inputs: ["WEIGHT"], + source: "none", + windowDays: 90, + computedAt: "2026-05-10T07:00:00.000Z", + }, + reason: "no_personal_goal", + }, + algorithmNotice: null, +}; describe("", () => { it("renders the slot wrapper + Dracula gradient utility", () => { @@ -250,17 +298,7 @@ describe("", () => { , ); expect(html).toMatch(/data-slot="health-score-card"/); @@ -277,17 +315,7 @@ describe("", () => { , ); expect(html).not.toContain('data-slot="health-score-card-ask-coach"'); @@ -299,17 +327,7 @@ describe("", () => { , ); // The wrapper picks up the `lg:flex-row` modifier when the score @@ -329,17 +347,7 @@ describe("", () => { , ); expect(html).toContain("md:items-stretch"); @@ -395,17 +403,7 @@ describe("", () => { briefing={null} now={morningLocal} healthScorePending - healthScore={{ - score: 86, - band: "green", - components: { - bp: { value: 80, weight: 0.3 }, - weight: { value: 70, weight: 0.2 }, - mood: { value: 90, weight: 0.2 }, - compliance: { value: 100, weight: 0.3 }, - }, - delta: 5, - }} + healthScore={SCORE_REPORT} />, ); expect(html).toContain('data-slot="health-score-card"'); diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx index aed092d6a..63523f1a6 100644 --- a/src/components/insights/derived/score-anatomy-view.tsx +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -90,6 +90,8 @@ export interface ScoreAnatomyViewProps { insufficient?: boolean; /** Localised "not enough data" nudge for the insufficient state. */ insufficientNote?: ReactNode; + /** Keep the shared provisional ring unless the caller requires rows only. */ + showProvisionalRing?: boolean; className?: string; } @@ -107,6 +109,7 @@ export function ScoreAnatomyView({ standard, insufficient = false, insufficientNote, + showProvisionalRing = true, className, }: ScoreAnatomyViewProps) { const { t } = useTranslations(); @@ -142,15 +145,17 @@ export function ScoreAnatomyView({
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is never clipped top/bottom by the hero container. */} -
- -
+ {!insufficient || showProvisionalRing ? ( +
+ +
+ ) : null} {caption && !insufficient ? (

- ) : ( + ) : null} + {contributors.length > 0 ? (

{t("insights.derived.anatomy.contributorsLabel")} @@ -185,7 +191,7 @@ export function ScoreAnatomyView({ ))}

- )} + ) : null} {/* The plain-language method caption closes the card, in normal flow. `ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index c81e29b22..ef0480939 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -188,4 +188,11 @@ export const METRIC_PROVENANCE: Record = }, caveatKey: "insights.derived.composite.SAME_TIME_BASELINE.caveat", }, + HEALTH_SCORE: { + methodKey: "insights.healthScore.method", + standard: { + name: "WHO HEARTS technical package", + url: "https://www.who.int/publications/i/item/9789240001367", + }, + }, }; diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index d3e8b70ba..7a5884baa 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,765 +1,538 @@ "use client"; -import { useId, useMemo, useState } from "react"; -import { - ArrowDown, - ArrowUp, - ChevronDown, - CornerDownLeft, - Minus, - Moon, - Scale, -} from "lucide-react"; +import { useState } from "react"; +import { X } from "lucide-react"; + +import { ScoreAnatomyView } from "@/components/insights/derived/score-anatomy-view"; +import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; +import { Button } from "@/components/ui/button"; +import { apiPost } from "@/lib/api/api-fetch"; +import { useOptionalQueryClient } from "@/hooks/_internal/use-query-client-safe"; +import { queryKeys } from "@/lib/query-keys"; +import { useAuth } from "@/hooks/use-auth"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import type { + HealthScoreReport, + PillarReference, + ScorePillarId, + ScoreBand, + ScorePillarResult, +} from "@/lib/analytics/score/types"; import { useTranslations } from "@/lib/i18n/context"; -import { readStoredTimezone } from "@/lib/timezone-mirror"; import { cn } from "@/lib/utils"; -import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer"; -/** - * v1.4.20 B5 — Personal Health Score panel. - * - * Lives on the right side of the `` band on `lg+` viewports - * and stacks below the title block on ` = { + BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure", + GLYCAEMIA: "insights.healthScore.pillar.glycaemia", + ACTIVITY: "insights.healthScore.pillar.activity", + SLEEP: "insights.healthScore.pillar.sleep", + ADIPOSITY: "insights.healthScore.pillar.adiposity", + WELLBEING: "insights.healthScore.pillar.wellbeing", + FITNESS: "insights.healthScore.pillar.fitness", + LIPIDS: "insights.healthScore.pillar.lipids", +}; -export type HealthScoreBand = "green" | "yellow" | "red"; +const REASON_KEY: Record = { + read_failed: "readFailed", + not_tracked: "notTracked", + missing_reference_profile: "missingProfile", + missing_height: "missingHeight", + incomplete_or_stale_panel: "incompletePanel", + crisis_signposting: "crisis", + below_day_floor_or_stale: "insufficientHistory", + below_reading_floor: "insufficientHistory", + stale_or_untracked: "insufficientHistory", + unverified_or_stale: "insufficientHistory", + below_reading_floor_or_stale: "insufficientHistory", + below_night_floor_or_stale: "insufficientHistory", + stale: "insufficientHistory", +}; -/** - * v1.4.25 W8e — per-component source attribution. Mirrors the analytics - * `HealthScoreComponentSource` type (intentional duplication so the - * client doesn't pull a heavy analytics import for one string union). - */ -export type HealthScoreComponentSource = - "manual" | "withings" | "appleHealth" | "mixed" | "none"; - -export interface HealthScoreCardComponent { - value: number | null; - weight: number; - /** - * v1.4.25 W8e — optional source label. When omitted the row falls - * back to `manual` for present values / `none` for null values. - */ - source?: HealthScoreComponentSource; - /** - * v1.4.25 W8e — optional ISO timestamp of the freshest contributing - * measurement. The provenance accordion surfaces it under "as of - * {date}" on each row. - */ - asOf?: string; +export function markAlgorithmNoticeDismissed( + payload: unknown, + itemKey: string, +): unknown { + if (!payload || typeof payload !== "object" || !("healthScore" in payload)) { + return payload; + } + const healthScore = payload.healthScore; + if ( + !healthScore || + typeof healthScore !== "object" || + !("algorithmNotice" in healthScore) + ) { + return payload; + } + const notice = healthScore.algorithmNotice; + if ( + !notice || + typeof notice !== "object" || + !("itemKey" in notice) || + notice.itemKey !== itemKey + ) { + return payload; + } + return { + ...payload, + healthScore: { + ...healthScore, + algorithmNotice: { ...notice, dismissed: true }, + }, + }; } export interface HealthScoreCardProps { - score: number; - band: HealthScoreBand; - components: { - bp: HealthScoreCardComponent; - weight: HealthScoreCardComponent; - mood: HealthScoreCardComponent; - compliance: HealthScoreCardComponent; - }; - delta: number | null; - /** - * v1.4.25 W8e — opens the provenance accordion on first render. - * The SSR tests use it to exercise the expanded markup without a - * `useEffect` round-trip; in production the user-driven toggle - * starts collapsed. - */ - initiallyExpanded?: boolean; - /** - * v1.18.0 R4 — when the `mood` module is disabled for the account the - * server already drops the mood-stability pillar from the score (the - * pillar arrives null-weighted). This hides the Mood row from the - * component + provenance lists too so the card never advertises a - * pillar the user turned off. Default-on so omitting it keeps the - * four-row layout for the common (mood-enabled) case. - */ - moodEnabled?: boolean; - /** - * v1.18.6 — Rest Mode annotation. When an illness episode is active the - * server suppresses (never penalises) the score; the card itself then - * carries an explicit "paused during illness — not being judged today" - * line so a frozen/held number reads as intentional rather than as a - * silent drop. Value-free: the card only needs to know that it is - * active, not the episode details. Default off (the common case). - */ - restModeActive?: boolean; - /** - * v1.21.2 (A5) — Tension Verdict line. The server resolves the honest - * "internal read" when the readiness/recovery composite's contributors - * DISAGREE (good sleep but a rising resting pulse, …). The card renders it - * as one short line: the favourable contributor, the unfavourable one, and - * the band the composite lands on. Purely DTO-rendered — the card never - * recomputes the disagreement. Absent (the common case) when the - * contributors agree. - */ + report: HealthScoreReport; tension?: { - band: HealthScoreBand; - /** Favourable contributors, already localised by the resolver. */ + band: ScoreBand; positive: string[]; - /** Unfavourable contributors, already localised by the resolver. */ negative: string[]; } | null; - /** - * v1.21.2 (A6) — return-to-baseline line. Present only when a metric has - * come BACK inside the user's own usual range after a prior out-of-band run. - * Actively closes a worry ("back inside your usual range after last week's - * dip"). Server-resolved DTO; the label is already localised. - */ - returnToBand?: { - /** The metric's display label, already localised by the resolver. */ - metricLabel: string; - /** Days the metric has now sat back inside its range. */ - daysInside: number; - } | null; - /** - * v1.34 — the weight row's yardstick, disclosed. - * - * `"user"` renders "vs your target 64-70 kg" (the parent formats - * `targetLabel` in the reader's display unit); `"none"` renders the - * trend-only line. The score used to grade weight against a BMI-22 band - * the user never set, and no surface said so — that silence is what this - * line ends. Omitted only by callers that render no weight pillar. - */ - weightYardstick?: { - source: "user" | "none"; - /** Already unit-formatted band, e.g. "64-70 kg". Null when source is "none". */ - targetLabel: string | null; - } | null; + returnToBand?: { metricLabel: string; daysInside: number } | null; + className?: string; } -type ComponentKey = keyof HealthScoreCardProps["components"]; - -const SOURCE_LABEL_KEY: Record = { - manual: "insights.healthScore.provenance.sources.manual", - withings: "insights.healthScore.provenance.sources.withings", - appleHealth: "insights.healthScore.provenance.sources.appleHealth", - mixed: "insights.healthScore.provenance.sources.mixed", - none: "insights.healthScore.provenance.sources.none", -}; - -/** - * Per-source pill colour vocabulary — mirrors the Coach provenance - * `` accent for `withings` (info/cyan) so the user reads - * "same provenance grammar across surfaces". Routed through the - * theme-aware semantic tokens (dose-accent / info / warning) which carry - * an AA-safe override in light mode; the raw `--dracula-*` text primitives - * read illegibly there. The pill always renders its source label, so the - * label — not the hue — is the disambiguator. `none` is a muted slate so - * the empty-state row reads as dimmed without losing the pill affordance. - */ -const SOURCE_PILL_CLASS: Record = { - manual: "border-dose-accent/30 text-dose-accent/90", - withings: "border-info/30 text-info/90", - appleHealth: "border-dose-accent/30 text-dose-accent/90", - mixed: "border-warning/30 text-warning/90", - none: "border-muted-foreground/30 text-muted-foreground", -}; - -// v1.12.4 — the headline score number used the raw `--dracula-*` primitives, -// which carry no light-mode override and render illegibly on the Alucard -// light theme (the band-tokens helper documents the same trap). Route through -// the semantic `--success` / `--warning` / `--destructive` tokens, which both -// themes override to an AA-safe tone — same mapping the wellness-score rings -// already use. -const BAND_NUMBER_CLASS: Record = { - green: "text-success", - yellow: "text-warning", - red: "text-destructive", -}; - -const BAND_BORDER_CLASS: Record = { - green: "border-success/40", - yellow: "border-warning/40", - red: "border-destructive/40", -}; - -const BAND_PROGRESS_CLASS: Record = { - green: "bg-success", - yellow: "bg-warning", - red: "bg-destructive", -}; - -const COMPONENT_LABEL_KEY: Record< - keyof HealthScoreCardProps["components"], - string -> = { - bp: "insights.healthScore.componentBp", - weight: "insights.healthScore.componentWeight", - mood: "insights.healthScore.componentMood", - compliance: "insights.healthScore.componentCompliance", -}; - -// v1.4.25 W8e — provenance row order. Module-scope so the array is -// allocated once per process rather than on every card render; the -// downstream `.map()` is non-mutating, so a fresh copy isn't needed. -const COMPONENT_ORDER: readonly ComponentKey[] = [ - "bp", - "weight", - "mood", - "compliance", -]; - export function HealthScoreCard({ - score, - band, - components, - delta, - initiallyExpanded = false, - moodEnabled = true, - restModeActive = false, - tension = null, - returnToBand = null, - weightYardstick = null, + report, + tension, + returnToBand, + className, }: HealthScoreCardProps) { - const { t, locale } = useTranslations(); - // The asOf timestamps render under the source pill as a tooltip - // (`title`) so the row layout stays one-line. Dates format via the - // user's locale to honour the EU comma / dot convention; on bad input - // we fall through to the raw ISO so the user still sees something. - // The formatter is memoised per locale so the row map below doesn't - // build a fresh `Intl.DateTimeFormat` instance per row. - // The timezone is as load-bearing as the locale and was missing: without it - // the formatter used the browser's, so a reading taken late in the evening - // could print yesterday's date for someone whose profile timezone is east of - // where their laptop happens to be. The locale still decides the separator - // convention; the timezone decides which day it was. - const asOfFormatter = useMemo( - () => - new Intl.DateTimeFormat(locale, { - year: "numeric", - month: "short", - day: "2-digit", - // `readStoredTimezone` returns "" when nothing is stored or during - // SSR, and an empty string makes Intl throw RangeError rather than - // fall back. `undefined` is the value that means "use the default". - timeZone: readStoredTimezone() || undefined, - }), - [locale], + const { t } = useTranslations(); + const { user } = useAuth(); + const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); + const queryClient = useOptionalQueryClient(); + const [noticeDismissed, setNoticeDismissed] = useState( + report.algorithmNotice?.dismissed ?? false, ); - const formatAsOf = (asOf: string | null): string | null => { - if (!asOf) return null; - const parsed = new Date(asOf); - if (Number.isNaN(parsed.getTime())) return null; - return asOfFormatter.format(parsed); - }; - // v1.21.2 (A5) — locale-aware conjunction list for the Tension Verdict's - // favourable / unfavourable contributor lists ("sleep and mood" / "resting - // heart rate, HRV balance, and mood"). Memoised per locale. - const listFormatter = useMemo( - () => new Intl.ListFormat(locale, { style: "long", type: "conjunction" }), - [locale], - ); - const [expanded, setExpanded] = useState(initiallyExpanded); - // `useId` keeps the aria-controls/section-id pair unique even when - // the card is mounted twice on the same page (lg+ hero strip vs - // smaller stacked previews in tests). - const panelId = useId(); - // FB-I1 a11y — id wired from the delta `` (aria-describedby) - // to the explainer popover/sheet body. The thread lets screen - // readers connect "−3 vs last week" to the three-sentence read - // without relying on visual proximity alone. - const deltaExplainerId = useId(); - // v1.18.0 R4 — drop the Mood pillar from the card's row lists when the - // module is disabled. The server already null-weights it in the score; - // hiding the row keeps the card from naming a pillar the account turned - // off (it would otherwise render as a permanently empty "Mood" line). - const visibleKeys = (Object.keys(components) as Array).filter( - (key) => moodEnabled || key !== "mood", - ); - const componentEntries = visibleKeys.map((key) => ({ - key, - label: t(COMPONENT_LABEL_KEY[key]), - value: components[key].value, + function reasonText(reason: string): string { + const key = REASON_KEY[reason] ?? "unavailable"; + return t(`insights.healthScore.reason.${key}`); + } + + function referenceKindText(reference: PillarReference): string { + return t( + `insights.healthScore.referenceKind.${reference.kind === "clinical-threshold" ? "clinical" : reference.kind === "population-percentile" ? "population" : "guideline"}`, + ); + } + + function formatReference( + reference: PillarReference, + label: string, + source = reference.source, + ): string { + return t("insights.healthScore.referenceDetail", { + label, + source, + kind: referenceKindText(reference), + low: reference.low ?? t("insights.healthScore.openBound"), + high: reference.high ?? t("insights.healthScore.openBound"), + }); + } + + function observedText(pillar: ScorePillarResult): string { + if (pillar.result.status !== "ok") return ""; + const observed = pillar.result.value.observed; + if (pillar.id === "GLYCAEMIA" && observed.unit === "mg/dL") { + return t("insights.healthScore.fastingObserved", { + value: convertGlucose(observed.value, glucoseUnit), + unit: glucoseUnit, + }); + } + if (pillar.id === "ACTIVITY") { + return t("insights.healthScore.observedActivity", { + value: observed.value, + }); + } + if (pillar.id === "SLEEP") { + return t("insights.healthScore.observedSleep", { + value: observed.value, + }); + } + if (pillar.id === "ADIPOSITY") { + return t("insights.healthScore.observedAdiposity", { + value: observed.value, + }); + } + if (pillar.id === "FITNESS") { + return t("insights.healthScore.observedFitness", { + value: observed.value, + unit: observed.unit, + }); + } + return observed.label; + } + + function scoredReferenceText(pillar: ScorePillarResult): string { + if (pillar.result.status !== "ok") return ""; + const value = pillar.result.value; + const reference = value.reference; + if (pillar.id === "GLYCAEMIA" && value.observed.unit === "mg/dL") { + const low = + reference.low == null + ? null + : convertGlucose(reference.low, glucoseUnit); + const high = + reference.high == null + ? null + : convertGlucose(reference.high, glucoseUnit); + const converted = { ...reference, low, high }; + return formatReference( + converted, + t("insights.healthScore.fastingReference", { + low: low ?? "", + high: high ?? "", + unit: glucoseUnit, + }), + ); + } + const label = + pillar.id === "ACTIVITY" + ? t("insights.healthScore.referenceActivity", { + high: reference.high ?? "", + }) + : pillar.id === "SLEEP" + ? t("insights.healthScore.referenceSleep", { + low: reference.low ?? "", + }) + : pillar.id === "ADIPOSITY" + ? t("insights.healthScore.referenceAdiposity", { + high: reference.high ?? "", + }) + : pillar.id === "FITNESS" + ? t("insights.healthScore.referenceFitness", { + low: reference.low ?? "", + high: reference.high ?? "", + unit: value.observed.unit, + }) + : pillar.id === "GLYCAEMIA" + ? t("insights.healthScore.referenceHba1c", { + low: reference.low ?? "", + high: reference.high ?? "", + }) + : reference.label; + return formatReference( + reference, + label, + pillar.id === "LIPIDS" + ? t("insights.healthScore.laboratoryReferenceSource") + : reference.source, + ); + } + const composite = report.composite; + const eligibleCount = report.pillars.filter( + (pillar) => pillar.result.status === "ok", + ).length; + const contributors = report.pillars.map((pillar) => ({ + key: pillar.id, + label: t(PILLAR_LABEL_KEY[pillar.id]), + value: pillar.result.status === "ok" ? pillar.result.value.score : null, + weight: + pillar.result.status === "ok" && eligibleCount > 0 + ? 1 / eligibleCount + : 0, })); + const algorithmNotice = report.algorithmNotice; - // v1.4.25 W8e — sort the provenance rows by effective weight - // descending so the biggest contributor sits first. Components with - // null values sink to the bottom (`weight * 0 === 0`); the tie-break - // is the alphabetical key order so determinism holds across renders. - const provenanceRows = COMPONENT_ORDER.filter( - (key) => moodEnabled || key !== "mood", - ) - .map((key) => { - const c = components[key]; - const inferredSource: HealthScoreComponentSource = - c.source ?? (c.value === null ? "none" : "manual"); - return { - key, - label: t(COMPONENT_LABEL_KEY[key]), - value: c.value, - weight: c.weight, - source: inferredSource, - asOf: c.asOf ?? null, - effective: c.weight * (c.value !== null ? 1 : 0), - }; - }) - .sort((a, b) => b.effective - a.effective || a.key.localeCompare(b.key)); - - const presentCount = provenanceRows.filter((r) => r.value !== null).length; - const totalCount = provenanceRows.length; - const hasMixed = provenanceRows.some((r) => r.source === "mixed"); - // v1.4.25 W8e — "provisional" when less than half of the configured - // inputs have data. Subtle badge above the headline number; copy is - // localised via `provenance.provisional`. - const isProvisional = - totalCount > 0 && presentCount > 0 && presentCount < totalCount / 2; + async function dismissAlgorithmNotice() { + if (!algorithmNotice) return; + setNoticeDismissed(true); + try { + await apiPost("/api/daily/digest/dismiss", { + itemKey: algorithmNotice.itemKey, + }); + queryClient?.setQueriesData( + { queryKey: queryKeys.analytics() }, + (payload) => + markAlgorithmNoticeDismissed(payload, algorithmNotice.itemKey), + ); + await queryClient?.invalidateQueries({ + queryKey: queryKeys.analytics(), + refetchType: "active", + }); + } catch { + setNoticeDismissed(false); + } + } return (
- {/* v1.4.37 W4a item 1 — swap the inner column from `flex - flex-col` to a 7-row grid so the recovered vertical slack - (when the parent hero-row stretches the card past the - natural content height) collects on row 6 (the provenance - accordion) instead of clumping under the disclaimer. The - score number stays anchored at the top, the disclaimer at - the bottom, and the card fills the row down to the - "Wirkt mein Medikament?" chip — closing the maintainer's "Karte hört - bei der Trennlinie auf" report against v1.4.36. */} -
-
-
-

- {t("insights.healthScore.label")} -

- {isProvisional && ( - - {t("insights.healthScore.provenance.provisionalBadge")} - - )} -
- {delta !== null && delta > 0 && ( - - +{delta} - - )} -
- -
- - {score} - - -
- + {algorithmNotice && !noticeDismissed ? (
-
-
- - {/* v1.4.28 R3c-Insights — FB-I1 — the delta line gets a - sibling `?` glyph that opens a 3-sentence read of which - components contributed, what the comparison window is, - and one concrete next step. Popover on `md+`, bottom-sheet - on phone-class viewports. The explainer only mounts when - a numeric delta is available; the "no history yet" - branch keeps the existing single-line caption. */} - {/* The delta digit and the explainer caption stack — the caption - is a multi-sentence read, and rendering it INSIDE the delta's - inline-flex row squeezed the short delta line into a narrow - multi-line column beside it on every viewport. The delta line - keeps its icon row; the caption sits below at full width. */} -
- {delta === null ? ( -

{t("insights.healthScore.deltaUnavailable")}

- ) : ( - <> -

- {delta > 0 && ( -

- - - )} -
- - {/* v1.18.6 — Rest Mode legibility. While an illness episode is - active the server suppresses (never penalises) the score, so - the card states plainly that the number is paused and not being - judged today. Without this the held score reads as a silent - decline. */} - {restModeActive && ( -

-

+ {t("insights.healthScore.algorithmChanged")}

- )} - - {/* v1.21.2 (A5) — Tension Verdict. The honest "internal read" when the - composite's contributors disagree, resolved server-side and rendered - as one short line. The card never recomputes the disagreement; it - only renders the resolved favourable / unfavourable contributors and - the band the composite lands on. */} - {tension && - tension.positive.length > 0 && - tension.negative.length > 0 && ( -

-

- )} - - {/* v1.21.2 (A6) — return-to-baseline. Present only when a metric came - back inside the user's own usual range after a prior out-of-band run. - Actively closes a worry rather than reporting a number. */} - {returnToBand && ( -

-

- )} - -
    - {componentEntries.map(({ key, label, value }) => ( -
  • -
    - {/* v1.4.25 W3 — widened label column from w-16 (64px) to - w-24 (96px) so the longest German label - ("Einnahmetreue" — 13 chars at 11px) sits inside the - column without spilling into the bar/value chip. */} - - {label} - - - {/* v1.34 — the weight row names its yardstick. Either the user's - own target band or an explicit "trend only, no target set". - Indented to the label column so it reads as that row's - caption rather than a new row. */} - {key === "weight" && weightYardstick && value !== null && ( -

    - {weightYardstick.source === "user" && - weightYardstick.targetLabel - ? t("insights.healthScore.weightTarget.user", { - range: weightYardstick.targetLabel, - }) - : t("insights.healthScore.weightTarget.none")} -

    - )} -
  • - ))} -
- - {/* v1.4.25 W8e — tap-to-expand provenance accordion. - Kept inside the card so the visual "owner" of the - breakdown remains the score tile (concept-cohesion). - No top border here — the components list above already - owns the divider stride; a second border would read - visually heavy. */} -
- - - {expanded && ( -
- {hasMixed && ( -

- {t("insights.healthScore.provenance.mixedBanner")} -

- )} -
    - {provenanceRows.map((row) => { - const sourceLabel = t(SOURCE_LABEL_KEY[row.source]); - const isEmpty = row.source === "none" || row.value === null; - const asOfFormatted = formatAsOf(row.asOf); - const asOfLine = asOfFormatted - ? t("insights.healthScore.provenance.asOfLabel", { - date: asOfFormatted, - }) - : null; - return ( -
  • - - {row.label} - - {/* value bar — same vocabulary as the existing - sub-bar row so the two stride lines align */} -
  • - ); - })} -
-

- {t("insights.healthScore.provenance.footnote")} -

-
- )} + +
+ ) : null} - {/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment" - card disclaimer is removed; the one-time onboarding acknowledgment - now covers the not-a-diagnosis framing app-wide. */} -
+ + + {composite.status === "ok" || + report.deltaReason || + report.restMode?.active ? ( +
+ {composite.status === "ok" ? ( + <> +

+ {t("insights.healthScore.composition", { + pillars: composite.value.composition + .map((id) => t(PILLAR_LABEL_KEY[id])) + .join(", "), + })} +

+

+ {t("insights.healthScore.bandSetter", { + pillar: composite.value.bandSetter + ? t(PILLAR_LABEL_KEY[composite.value.bandSetter]) + : t("insights.healthScore.none"), + })} +

+

+ {t("insights.healthScore.versionAndNoise", { + version: report.scoreVersion, + noise: composite.value.noiseFloor, + })} +

+ + ) : ( +

+ {t("insights.healthScore.methodVersion", { + version: report.scoreVersion, + })} +

+ )} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

+ ) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} +
+ ) : null} + +
    + {report.pillars.map((pillar) => ( +
  • +
    +
    +

    + {t(PILLAR_LABEL_KEY[pillar.id])} +

    + {pillar.result.status === "ok" ? ( + <> +

    + {t("insights.healthScore.observed", { + value: observedText(pillar), + })} +

    +

    + {t("insights.healthScore.reference", { + value: scoredReferenceText(pillar), + })} +

    + {pillar.result.value.personalReference ? ( +

    + {t("insights.healthScore.personalReference", { + value: formatReference( + pillar.result.value.personalReference, + pillar.result.value.personalReference.label, + t("insights.healthScore.personalTargetSource"), + ), + })} +

    + ) : null} +

    + {t("insights.healthScore.source", { + source: + pillar.result.value.observed.sources.join(", ") || + pillar.result.provenance.source, + date: pillar.result.value.observed.asOf, + })} +

    + + ) : ( + <> +

    + {t("insights.healthScore.pillarInsufficient", { + reason: reasonText(pillar.result.reason), + })} +

    +

    + {t("insights.healthScore.source", { + source: pillar.result.provenance.source, + date: pillar.result.provenance.computedAt, + })} +

    + + )} +
    + {pillar.result.status === "ok" ? ( + + {Math.round(pillar.result.value.score)} + + ) : null} +
    + +
  • + ))} +
+ + + {tension || returnToBand ? ( +
+ {tension ? ( +

+ {t("insights.healthScore.tension", { + positive: tension.positive.join(", "), + negative: tension.negative.join(", "), + })} +

+ ) : null} + {returnToBand ? ( +

+ {t("insights.healthScore.returnToBand", { + metric: returnToBand.metricLabel, + days: returnToBand.daysInside, + })} +

+ ) : null} +
+ ) : null} +
+ ); +} + +function WeightGoalRow({ report }: { report: HealthScoreReport }) { + const { t } = useTranslations(); + const goal = report.weightGoal; + const units = useUnitDisplay(); + const unit = units.unitFor("WEIGHT"); + return ( +
+

+ {t("insights.healthScore.weightGoal.title")} +

+ {goal.status === "ok" ? ( + <> +

+ {t("insights.healthScore.weightGoal.value", { + current: units.toDisplay("WEIGHT", goal.value.currentKg), + min: units.toDisplay("WEIGHT", goal.value.target.min), + max: units.toDisplay("WEIGHT", goal.value.target.max), + distance: units.toDisplayDelta("WEIGHT", goal.value.distanceKg), + unit, + })} +

+

+ {goal.value.deltaKg == null + ? t("insights.healthScore.weightGoal.noDelta") + : t("insights.healthScore.weightGoal.delta", { + delta: units.toDisplayDelta("WEIGHT", goal.value.deltaKg), + unit, + })} +

+

+ {t("insights.healthScore.source", { + source: goal.value.source, + date: goal.value.asOf, + })} +

+ + ) : ( +

+ {t(`insights.healthScore.weightGoal.${goal.reason}`)} +

+ )} + +

+ {t("insights.healthScore.weightGoal.notScored")} +

); } diff --git a/src/components/insights/health-score-delta-explainer.tsx b/src/components/insights/health-score-delta-explainer.tsx deleted file mode 100644 index 995ab4770..000000000 --- a/src/components/insights/health-score-delta-explainer.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { useId } from "react"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -/** - * v1.4.28 R3c-Insights — explain the "vs last week" delta. - * - * v1.22 — the icon-only `?` glyph that gated the three-sentence read behind - * a popover / bottom-sheet is removed; the maintainer wants the trailing - * help affordance gone across the insights surface. The descriptive read - * now renders inline as a muted caption beside the delta line, so it is - * always visible rather than disclosure-only. The body keeps its id so the - * parent's `aria-describedby` on the delta digit still threads to it. - * - * Operator suppression is preserved: when `healthScoreExplainer` is off the - * caption does not render (the parent delta digit stays visible because the - * parent paints it directly). - */ - -interface HealthScoreDeltaExplainerProps { - /** The delta value the parent paints on the line above. Retained for callers. */ - delta: number; - /** Optional className for the caption so the parent can tune its spacing. */ - className?: string; - /** - * Optional id the parent owns and threads to the caption. The same id sits - * on the parent's delta `` as `aria-describedby` so screen readers - * connect the digit to the read. - */ - bodyId?: string; -} - -export function HealthScoreDeltaExplainer({ - className, - bodyId, -}: HealthScoreDeltaExplainerProps) { - const { t } = useTranslations(); - const flags = useFeatureFlags(); - // Stable fallback id when the parent doesn't supply one. The hook sits - // above the conditional return so the hook order stays stable. - const generatedId = useId(); - const resolvedBodyId = bodyId ?? generatedId; - - // v1.4.31 — operator can hide the read; silent suppression per the - // architecture brief. - if (!flags.healthScoreExplainer) return null; - - return ( - - {t("insights.healthScore.deltaExplainer.body")} - - ); -} diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 803592411..b540a68f5 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -10,15 +10,11 @@ import { } from "@/lib/i18n/context"; import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; import { useAuth } from "@/hooks/use-auth"; -import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; -import { - HealthScoreCard, - type HealthScoreCardProps, - type HealthScoreBand, -} from "./health-score-card"; +import { HealthScoreCard } from "./health-score-card"; +import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; /** @@ -78,29 +74,8 @@ interface HeroStripProps { * footer hint below. */ noProviderStale?: boolean; - /** - * v1.4.20 phase B5 — Personal Health Score panel data. When supplied - * the right side of the hero band paints the score card. When the - * parent omits the field the right side stays empty. - */ - healthScore?: { - score: HealthScoreCardProps["score"]; - band: HealthScoreCardProps["band"]; - components: HealthScoreCardProps["components"]; - delta: HealthScoreCardProps["delta"]; - /** - * v1.18.6 — value-free Rest Mode annotation off the analytics - * payload. When present + active the score card states it is paused - * during illness (suppressed, not penalised). - */ - restMode?: { active: boolean } | null; - } | null; - /** - * v1.34 — the weight pillar's yardstick, already resolved into the reader's - * display unit by the page (the hero carries no unit context of its own). - * Forwarded verbatim to the score card, which renders the disclosure line. - */ - weightYardstick?: HealthScoreCardProps["weightYardstick"]; + /** Complete transparent Health Score report from the analytics route. */ + healthScore?: HealthScoreReport | null; /** * v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band` * is the readiness composite's band; `positive` / `negative` carry the @@ -112,7 +87,7 @@ interface HeroStripProps { * is present. */ tension?: { - band: HealthScoreBand; + band: ScoreBand; positive: ReadinessContributorKey[]; negative: ReadinessContributorKey[]; } | null; @@ -194,7 +169,6 @@ export function HeroStrip({ noProviderStale = false, tension = null, returnToBand = null, - weightYardstick = null, }: HeroStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -230,11 +204,6 @@ export function HeroStrip({ daysInside: returnToBand.daysInside, } : null; - // v1.18.0 R4 — mood-module state so the score card hides its Mood row - // when the account turned the module off (the server already drops the - // pillar from the number itself). SSR-safe + default-on, matching the - // disabled-allowlist gate. - const moodEnabled = useModuleEnabled("mood"); // Greeting hour in the user's configured zone, not the browser's — a // traveller / VPN user / wrong device clock otherwise sees the wrong // salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`). @@ -411,15 +380,9 @@ export function HeroStrip({ )} {healthScore && ( )}
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 084af1270..4f17160b8 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -16,7 +16,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { EmptyState } from "@/components/ui/empty-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; -import type { RestModeAnnotation } from "@/lib/analytics/health-score"; +import type { RestModeAnnotation } from "@/lib/analytics/score/types"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-metric-block"; diff --git a/src/components/insights/rest-mode-banner.tsx b/src/components/insights/rest-mode-banner.tsx index d48216b51..3f4e1af87 100644 --- a/src/components/insights/rest-mode-banner.tsx +++ b/src/components/insights/rest-mode-banner.tsx @@ -3,7 +3,7 @@ import { HeartPulse } from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import type { RestModeAnnotation } from "@/lib/analytics/health-score"; +import type { RestModeAnnotation } from "@/lib/analytics/score/types"; /** * v1.18.1 — calm, neutral Rest Mode indicator. diff --git a/src/hooks/_internal/use-query-client-safe.ts b/src/hooks/_internal/use-query-client-safe.ts index 26a99eac4..a404383b7 100644 --- a/src/hooks/_internal/use-query-client-safe.ts +++ b/src/hooks/_internal/use-query-client-safe.ts @@ -1,16 +1,15 @@ "use client"; import { useContext } from "react"; -import { QueryClientContext } from "@tanstack/react-query"; +import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; /** * v1.4.48 M4 — shared SSR-safety helper for hooks that delegate to * React Query under a ``. * - * A long tail of legacy presentational components (``, - * ``, ``, ``, - * ``, …) ships with unit tests that render - * the component in isolation without wrapping it in + * Several presentational components (``, ``, + * ``, and ``) ship with unit tests that + * render the component in isolation without wrapping it in * ``. Hooks that call `useQuery` directly crash * in that environment. * @@ -31,3 +30,8 @@ export function useQueryClientMounted(): boolean { ); return ctx != null; } + +/** Return the mounted client without throwing in isolated SSR renders. */ +export function useOptionalQueryClient(): QueryClient | undefined { + return useContext(QueryClientContext); +} diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts index 1f1dd4e2e..7606c79f0 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -70,9 +70,8 @@ async function fetchFeatureFlags(): Promise { * remount within a minute. The hook fails open — any network error * returns the all-on default so the user keeps seeing the surface. * - * The hook is referenced from a long tail of legacy presentational - * components (``, ``) - * whose unit tests render the component without a + * The hook is referenced from presentational components such as + * `` whose unit tests render without a * ``. To keep those tests valid we delegate * the React-Query call to a child hook that's only invoked when a * client is mounted. The branch is stable across the component's diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index 9dae78924..44084cd26 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -338,18 +338,7 @@ describe("i18n locale file integrity", () => { }, ); - /** - * v1.4.22 A6 — DE locale must not leak English nouns for the - * Health-Score component contract. - * - * The four `componentBp` / `componentWeight` / `componentMood` / - * `componentCompliance` keys are rendered as the four sub-bar - * labels on the Health Score card. Up to v1.4.21 the DE locale - * shipped `componentMood: "Mood"` (an English noun voice-to-text - * rendered as "Mut" on a quick read). Pin the German values so a - * future copy-paste regression can't reintroduce the leak. - */ - it("DE locale renders Health-Score component labels in German", () => { + it("DE locale renders Health Score pillar labels in German", () => { const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record< string, unknown @@ -357,50 +346,32 @@ describe("i18n locale file integrity", () => { const flat: [string, string][] = []; flattenValues(de, "", flat); const byKey = new Map(flat); - - const EXPECTED: Array<[string, string]> = [ - ["insights.healthScore.componentBp", "Blutdruck"], - ["insights.healthScore.componentWeight", "Gewicht"], - ["insights.healthScore.componentMood", "Stimmung"], - ["insights.healthScore.componentCompliance", "Therapietreue"], + const expected: Array<[string, string]> = [ + ["insights.healthScore.pillar.bloodPressure", "Blutdruck"], + ["insights.healthScore.pillar.glycaemia", "Blutzucker"], + ["insights.healthScore.pillar.activity", "Aktivität"], + ["insights.healthScore.pillar.sleep", "Schlaf"], + ["insights.healthScore.pillar.wellbeing", "Wohlbefinden"], + ["insights.healthScore.pillar.fitness", "Gemessene Fitness"], + ["insights.healthScore.pillar.lipids", "Blutfette"], ]; - - for (const [key, expectedDe] of EXPECTED) { - const actual = byKey.get(key); - expect(actual, `DE label for ${key}`).toBe(expectedDe); + for (const [key, expectedDe] of expected) { + expect(byKey.get(key), `DE label for ${key}`).toBe(expectedDe); } }); - /** - * v1.4.25 W8e — Health-Score provenance accordion drift-guard. - * - * Mirrors the `measurement-list-meta` enum-coverage pattern: every - * i18n key the accordion renders must resolve in every shipped - * locale. If a future copy-paste regression drops a key or a new - * source token appears in the analytics layer without a matching - * locale entry, this test fails fast. - * - * The expected key set is hand-pinned (not extracted from the - * accordion component) so the test catches both directions — - * a missing locale value AND an accidentally dropped accordion - * call-site. - */ - it("Health-Score provenance keys resolve in every locale", () => { - const PROVENANCE_KEYS = [ - "insights.healthScore.provenance.toggle", - "insights.healthScore.provenance.mixedBanner", - "insights.healthScore.provenance.footnote", - "insights.healthScore.provenance.asOfLabel", - "insights.healthScore.provenance.provisional", - "insights.healthScore.provenance.provisionalBadge", - "insights.healthScore.provenance.sourceAria", - "insights.healthScore.provenance.sources.manual", - "insights.healthScore.provenance.sources.withings", - "insights.healthScore.provenance.sources.appleHealth", - "insights.healthScore.provenance.sources.mixed", - "insights.healthScore.provenance.sources.none", + it("Health Score transparency keys resolve in every locale", () => { + const transparencyKeys = [ + "insights.healthScore.algorithmChanged", + "insights.healthScore.method", + "insights.healthScore.composition", + "insights.healthScore.bandSetter", + "insights.healthScore.versionAndNoise", + "insights.healthScore.observed", + "insights.healthScore.reference", + "insights.healthScore.source", + "insights.healthScore.weightGoal.notScored", ] as const; - for (const { locale, path } of ALL_LOCALES) { const data = JSON.parse(readFileSync(path, "utf8")) as Record< string, @@ -409,7 +380,7 @@ describe("i18n locale file integrity", () => { const flat: [string, string][] = []; flattenValues(data, "", flat); const byKey = new Map(flat); - for (const key of PROVENANCE_KEYS) { + for (const key of transparencyKeys) { expect( byKey.get(key), `${locale} locale missing or empty: ${key}`, diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 8c7e42372..863f00cd0 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -4,6 +4,7 @@ import { getAllEffectiveRanges, METRIC_BOUNDS, resolveWeightTargetOverride, + resolveEffectiveBpTargets, type UserProfileForRange, type ThresholdOverridesJson, } from "../effective-range"; @@ -127,6 +128,48 @@ describe("getAllEffectiveRanges", () => { }); }); +describe("resolveEffectiveBpTargets", () => { + it("merges the user's systolic and diastolic target overrides", () => { + expect( + resolveEffectiveBpTargets(baseProfile, { + BLOOD_PRESSURE_SYS: { min: 115, max: 125 }, + BLOOD_PRESSURE_DIA: { min: 65, max: 75 }, + }), + ).toEqual({ + sysLow: 115, + sysHigh: 125, + diaLow: 65, + diaHigh: 75, + }); + }); + + it("uses the evidence default for an axis without an override", () => { + expect( + resolveEffectiveBpTargets(baseProfile, { + BLOOD_PRESSURE_SYS: { min: 115, max: 125 }, + }), + ).toEqual({ + sysLow: 115, + sysHigh: 125, + diaLow: 70, + diaHigh: 79, + }); + }); + + it("rejects malformed target pairs without losing valid defaults", () => { + expect( + resolveEffectiveBpTargets(baseProfile, { + BLOOD_PRESSURE_SYS: { min: 130, max: 120 }, + }), + ).toEqual({ + sysLow: 120, + sysHigh: 129, + diaLow: 70, + diaHigh: 79, + }); + }); +}); + // Audit-2026-05-07 / phase P0 / closes audit C-15: TOTAL_BODY_WATER and // BONE_MASS lacked threshold definitions. Severity logic returned `nominal` // for any value, so users would see "all healthy" regardless of input. diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts deleted file mode 100644 index 60585e8a7..000000000 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ /dev/null @@ -1,1027 +0,0 @@ -/** - * v1.4.37 W2 — unit pin for the probe-gated Health Score helper. - * - * Mocks Prisma + the rollup-coverage probe so the path-selection - * contract is verifiable without a Postgres container. Real-rollup - * coverage lives in the integration suite at - * `tests/integration/analytics-health-score.test.ts`. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { findMany: vi.fn() }, - measurementRollup: { findMany: vi.fn() }, - // v1.11.1 — readRollupBuckets lazy-loads the source-priority blob. - user: { findUnique: vi.fn() }, - moodEntry: { findMany: vi.fn() }, - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - }, -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/rollups/measurement-coverage", () => ({ - isFullyCovered: vi.fn(), - probeRollupCoverage: vi.fn(), -})); - -import { prisma } from "@/lib/db"; -import { annotate } from "@/lib/logging/context"; -import { - isFullyCovered, - probeRollupCoverage, -} from "@/lib/rollups/measurement-coverage"; -import { computeUserHealthScoreFastPath } from "../health-score-fast-path"; - -const MEASUREMENT_FIND_MANY = prisma.measurement - .findMany as unknown as ReturnType; -const ROLLUP_FIND_MANY = prisma.measurementRollup - .findMany as unknown as ReturnType; -const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType< - typeof vi.fn ->; -const MEDICATION_FIND_MANY = prisma.medication - .findMany as unknown as ReturnType; -const INTAKE_FIND_MANY = prisma.medicationIntakeEvent - .findMany as unknown as ReturnType; -const PROBE = probeRollupCoverage as unknown as ReturnType; -const FULLY_COVERED = isFullyCovered as unknown as ReturnType; -const ANNOTATE = annotate as unknown as ReturnType; - -beforeEach(() => { - MEASUREMENT_FIND_MANY.mockReset(); - ROLLUP_FIND_MANY.mockReset(); - MOOD_FIND_MANY.mockReset(); - MEDICATION_FIND_MANY.mockReset(); - INTAKE_FIND_MANY.mockReset(); - PROBE.mockReset(); - FULLY_COVERED.mockReset(); - ANNOTATE.mockReset(); - - // Defaults — empty everywhere. Individual tests override. - ROLLUP_FIND_MANY.mockResolvedValue([]); - MOOD_FIND_MANY.mockResolvedValue([]); - MEDICATION_FIND_MANY.mockResolvedValue([]); - INTAKE_FIND_MANY.mockResolvedValue([]); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("computeUserHealthScoreFastPath", () => { - describe("rollup-fast-path — WEIGHT fully covered", () => { - it("derives the weight series from DAY buckets and pins path:rollup", async () => { - const coverage = new Map([ - ["WEIGHT", true], - ["BLOOD_PRESSURE_SYS", true], - ["BLOOD_PRESSURE_DIA", true], - ]); - FULLY_COVERED.mockReturnValue(true); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - // Two day-buckets inside the trailing 30 days, descending mean. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-05-10T00:00:00.000Z"), - count: 1, - mean: 82.5, - minValue: 82.5, - maxValue: 82.5, - sd: null, - slope: null, - r2: null, - computedAt: now, - }, - { - bucketStart: new Date("2026-05-14T00:00:00.000Z"), - count: 1, - mean: 81.5, - minValue: 81.5, - maxValue: 81.5, - sd: null, - slope: null, - r2: null, - computedAt: now, - }, - ]); - // v1.4.40 W-WMY-WIRE — the parallel long-window read fires - // alongside the DAY-bucket read; mock its `findMany` to return - // a coverage miss so the annotate carries `weightLongWindow: - // null` and the score shape is unchanged. - ROLLUP_FIND_MANY.mockResolvedValueOnce([]); - // Weight source-attribution read (narrow 2-column). - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-10T08:00:00.000Z"), - source: "WITHINGS", - }, - { - measuredAt: new Date("2026-05-14T08:00:00.000Z"), - source: "WITHINGS", - }, - ]) - // BP-SYS source attribution read (parallel after rollup). - .mockResolvedValueOnce([]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-rollup", - bpInTargetPct: 85, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - // Path annotate proves the rollup branch fired. - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const pathCall = calls.find( - (c) => c?.meta?.healthScore?.path === "rollup", - ); - expect(pathCall).toBeDefined(); - // The rollup branch must NOT have called `measurement.findMany` - // for the 37-day raw weight read; only the narrow 2-column source - // pull + the BP source pull. - expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); - // Both findMany calls projected a narrow select (no `value`). - const calledArgs = MEASUREMENT_FIND_MANY.mock.calls.map((c) => c[0]); - for (const arg of calledArgs) { - expect(arg.select.value).toBeUndefined(); - } - // Weight pillar value should reflect the downward slope from - // bucket means (helper reports a numeric weight value). - expect(result?.components.weight.value).not.toBeNull(); - }); - }); - - /** - * v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through - * `readBestGranularityRollups`. The score shape is unchanged; the - * weightLongWindow annotate makes the rollup-tier consumption - * verifiable in production wide-events. - */ - describe("long-window weight wiring (W-WMY-WIRE)", () => { - it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => { - const coverage = new Map([["WEIGHT", true]]); - FULLY_COVERED.mockReturnValue(true); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - // DAY-bucket read (trailing 37 days) — single bucket, irrelevant - // for the long-window assertion. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-05-10T00:00:00.000Z"), - count: 1, - mean: 82.5, - minValue: 82.5, - maxValue: 82.5, - sd: null, - slope: null, - r2: null, - computedAt: now, - }, - ]); - // readBestGranularityRollups(WEIGHT, 365) — first call is the - // MONTH probe (floor 181 d). Two MONTH buckets, count-weighted - // mean = (12*82 + 18*80) / 30 = 80.8. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2025-08-01T00:00:00.000Z"), - count: 12, - mean: 82, - minValue: 80, - maxValue: 84, - sd: 1, - slope: 0, - r2: 0, - sumValue: null, - }, - { - bucketStart: new Date("2025-09-01T00:00:00.000Z"), - count: 18, - mean: 80, - minValue: 78, - maxValue: 83, - sd: 1, - slope: 0, - r2: 0, - sumValue: null, - }, - ]); - // Source attribution + BP-SYS attribution reads. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-10T08:00:00.000Z"), - source: "WITHINGS", - }, - ]).mockResolvedValueOnce([]); - - await computeUserHealthScoreFastPath({ - userId: "user-longwindow", - bpInTargetPct: 85, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const pathCall = calls.find( - (c) => c?.meta?.healthScore?.path === "rollup", - ); - expect(pathCall).toBeDefined(); - const longWindow = pathCall?.meta?.healthScore?.weightLongWindow; - expect(longWindow).not.toBeNull(); - expect(longWindow?.granularity).toBe("MONTH"); - expect(longWindow?.buckets).toBe(2); - // Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded. - expect(longWindow?.mean).toBeCloseTo(80.8, 5); - }); - - it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => { - const coverage = new Map([["WEIGHT", true]]); - FULLY_COVERED.mockReturnValue(true); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - // DAY-bucket read. - ROLLUP_FIND_MANY.mockResolvedValueOnce([ - { - bucketStart: new Date("2026-05-10T00:00:00.000Z"), - count: 1, - mean: 82.5, - minValue: 82.5, - maxValue: 82.5, - sd: null, - slope: null, - r2: null, - computedAt: now, - }, - ]); - // Long-window read — every granularity returns empty (router - // walks MONTH → WEEK → DAY; 365 d skips YEAR floor). - ROLLUP_FIND_MANY.mockResolvedValue([]); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-10T08:00:00.000Z"), - source: "WITHINGS", - }, - ]).mockResolvedValueOnce([]); - - await computeUserHealthScoreFastPath({ - userId: "user-no-longwindow", - bpInTargetPct: 85, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const pathCall = calls.find( - (c) => c?.meta?.healthScore?.path === "rollup", - ); - expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull(); - }); - - it("omits the long-window read on the live fallback branch", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - // Weight live read + BP source attribution. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-10T08:00:00.000Z"), - value: 82.5, - source: "MANUAL", - }, - ]).mockResolvedValueOnce([]); - - await computeUserHealthScoreFastPath({ - userId: "user-live-nowmy", - bpInTargetPct: 80, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - // The live branch never issues the long-window probe — guards - // against accidentally double-reading on cold mounts where the - // raw weight findMany is already doing the work. - expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const pathCall = calls.find((c) => c?.meta?.healthScore?.path === "live"); - expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull(); - }); - }); - - describe("live fallback — WEIGHT not covered", () => { - it("reads raw weight rows and pins path:live", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - MEASUREMENT_FIND_MANY - // Weight (live) — 37-day window, 3-column projection. - .mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-10T08:00:00.000Z"), - value: 82.5, - source: "MANUAL", - }, - { - measuredAt: new Date("2026-05-14T08:00:00.000Z"), - value: 81.5, - source: "MANUAL", - }, - ]) - // BP-SYS source attribution. - .mockResolvedValueOnce([]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-live", - bpInTargetPct: 75, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const pathCall = calls.find((c) => c?.meta?.healthScore?.path === "live"); - expect(pathCall).toBeDefined(); - // Live path read raw weight (value selected). - const weightCall = MEASUREMENT_FIND_MANY.mock.calls[0][0]; - expect(weightCall.select.value).toBe(true); - expect(weightCall.where.type).toBe("WEIGHT"); - expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); - }); - - // Regression pin: a height-less user (no derivable weight target) - // who DOES have weight readings must still get a populated, weighted - // weight pillar. Before this fix the pillar came back - // `{value:null, weight:0, source:"none"}` because the only target - // source was `defaultWeightTargetFromHeight(null)` → null, and the - // pillar was gated on having a target. The trend-only scoring path - // now scores the bare weight trend instead. - it("populates the weight pillar from a trend-only score when height is null", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-05-17T12:00:00.000Z"); - MEASUREMENT_FIND_MANY - // Weight (live) — multiple readings inside the window, gentle decline. - .mockResolvedValueOnce([ - { - measuredAt: new Date("2026-05-04T08:00:00.000Z"), - value: 84.0, - source: "WITHINGS", - }, - { - measuredAt: new Date("2026-05-09T08:00:00.000Z"), - value: 83.5, - source: "WITHINGS", - }, - { - measuredAt: new Date("2026-05-14T08:00:00.000Z"), - value: 83.0, - source: "WITHINGS", - }, - ]) - // BP-SYS source attribution. - .mockResolvedValueOnce([]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-no-height", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - const weight = result?.components.weight; - // The pillar is populated and weighted even with no target. - expect(weight?.value).not.toBeNull(); - expect(weight?.weight).toBeGreaterThan(0); - expect(weight?.source).not.toBe("none"); - // The source reflects the contributing ingest path, not the empty state. - expect(weight?.source).toBe("withings"); - }); - }); - - describe("empty path", () => { - it("returns null with reason:no_components_available when nothing is computable", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - // No weight rows, no BP rows, no mood, no medications. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-empty", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now: new Date("2026-05-17T12:00:00.000Z"), - coverage, - moodEnabled: true, - }); - - expect(result).toBeNull(); - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const nullCall = calls.find( - (c) => c?.meta?.healthScore?.reason === "no_components_available", - ); - expect(nullCall).toBeDefined(); - }); - }); - - /** - * Issue #582 — a raw-record-presence check let a `0`/red score through - * when every pillar still failed its own eligibility bar (weight needs - * >= 2 readings, mood needs >= 5 entries, BP needs a paired reading, - * compliance needs an active scheduled medication). The fix checks the - * COMPUTED result's components instead of raw row counts. - */ - describe("insufficient-data guard (#582)", () => { - it("returns null — not a 0 score — when only one mood entry exists and no other pillar is eligible", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - const now = new Date("2026-06-07T12:00:00.000Z"); - - // No weight rows, no BP rows, no medications. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - // Exactly one mood entry — below the 5-entry stability floor, so - // moodStability() resolves to null even though the raw row count - // is nonzero. - MOOD_FIND_MANY.mockResolvedValue([{ score: 4, moodLoggedAt: now }]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-one-mood-entry", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).toBeNull(); - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const nullCall = calls.find( - (c) => c?.meta?.healthScore?.reason === "no_components_available", - ); - expect(nullCall).toBeDefined(); - }); - - it("returns null — not a 0 score — when only one weight reading exists and no other pillar is eligible", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - const now = new Date("2026-06-07T12:00:00.000Z"); - - // Exactly one weight reading — below the 2-reading regression floor, - // so weightTrendAlignment() resolves to null. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-06-05T08:00:00.000Z"), - value: 82.5, - source: "MANUAL", - }, - ]).mockResolvedValueOnce([]); // BP-SYS source attribution, empty. - - const result = await computeUserHealthScoreFastPath({ - userId: "user-one-weight-reading", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).toBeNull(); - const calls = ANNOTATE.mock.calls.map((c) => c[0]); - const nullCall = calls.find( - (c) => c?.meta?.healthScore?.reason === "no_components_available", - ); - expect(nullCall).toBeDefined(); - }); - - it("returns null when raw records exist across multiple pillars but none clears its own minimum", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - const now = new Date("2026-06-07T12:00:00.000Z"); - - // One weight reading (< 2) + one mood entry (< 5) + no BP + no - // active medications. Every pillar has SOME data, but none is - // individually eligible. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([ - { - measuredAt: new Date("2026-06-05T08:00:00.000Z"), - value: 82.5, - source: "MANUAL", - }, - ]).mockResolvedValueOnce([]); - MOOD_FIND_MANY.mockResolvedValue([{ score: 3, moodLoggedAt: now }]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-multi-pillar-ineligible", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).toBeNull(); - }); - - it("preserves a legitimately computed score of 0 rather than treating it as insufficient data", async () => { - const coverage = new Map([["WEIGHT", false]]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - const now = new Date("2026-06-07T12:00:00.000Z"); - - // No weight, no mood, no medications — but a real, computed BP - // pillar that lands on exactly 0. This must stay a genuine `0` - // score, not get swept into the null branch. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([ - { measuredAt: new Date("2026-06-05T08:00:00.000Z"), source: "MANUAL" }, - ]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-legit-zero", - bpInTargetPct: 0, - bpGradedScore: 0, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - expect(result?.components.bp.value).toBe(0); - expect(result?.score).toBe(0); - }); - }); - - describe("coverage probing", () => { - it("probes coverage when the caller omits the map", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - await computeUserHealthScoreFastPath({ - userId: "user-fresh", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now: new Date("2026-05-17T12:00:00.000Z"), - moodEnabled: true, - }); - - expect(PROBE).toHaveBeenCalledWith("user-fresh"); - }); - }); - - // v1.4.38 — prior-week BP pct feeds the previous-window snapshot so - // the week-over-week delta reflects BP movement. Legacy callers that - // omit the field keep the pre-v1.4.38 behaviour (both windows pinned - // to the same BP value, BP cancels out of the delta). - describe("prior-week BP pct contract", () => { - it("feeds bpInTargetPctPriorWeek into the previous-window snapshot when set", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-bpdelta", - bpInTargetPct: 90, - bpInTargetPctPriorWeek: 70, - weightTarget: null, - weightTargetSource: "none", - now: new Date("2026-05-17T12:00:00.000Z"), - coverage, - moodEnabled: true, - }); - - // With only BP data the helper still returns a score. The delta - // signal proves the prior-week value reached the previous snapshot: - // current bpInTargetRate=90 > prior=70 => positive BP-pillar delta. - expect(result).not.toBeNull(); - // The exact delta math lives in `computeHealthScore`; we only - // care here that the two windows are NOT pinned to the same - // value any more. - const componentDelta = result?.delta; - expect( - typeof componentDelta === "number" || componentDelta === null, - ).toBe(true); - }); - - it("falls back to bpInTargetPct when bpInTargetPctPriorWeek is omitted", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - // Both runs use the same bpInTargetPct in both windows; the BP - // pillar contributes zero to the delta. Pre-v1.4.38 behaviour - // preserved for callers that don't opt in. - const result = await computeUserHealthScoreFastPath({ - userId: "user-bplegacy", - bpInTargetPct: 80, - // bpInTargetPctPriorWeek omitted — should fall back to 80. - weightTarget: null, - weightTargetSource: "none", - now: new Date("2026-05-17T12:00:00.000Z"), - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - }); - }); - - // v1.5.0 — regression pin for the cadence-aware compliance migration - // (closes #214). Before this release the score helper fed every - // medication-compliance pillar through `schedules.length * 30` as the - // denominator, so a weekly Ozempic schedule with 4 taken Mondays - // reported ~13% and dragged the score down ~10–15 points. The - // adapter now matches the cadence chart's denominator (Mondays-in- - // window) and the weekly med contributes its true 100% rate. - describe("cadence-aware medication compliance (closes #214)", () => { - function mondaysInWindow(now: Date, days: number): Date[] { - const out: Date[] = []; - const from = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); - const cursor = new Date(from); - while (cursor <= now) { - if (cursor.getUTCDay() === 1) { - out.push(new Date(cursor)); - } - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - return out; - } - - it("weekly Mondays-only med with all Mondays taken contributes a 100% pillar", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - // Weight + BP attribution reads return empty so only the - // medication compliance pillar is non-null. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - const now = new Date("2026-05-13T12:00:00.000Z"); // Wed - MEDICATION_FIND_MANY.mockResolvedValueOnce([ - { - id: "med-weekly", - createdAt: new Date("2026-01-01T00:00:00.000Z"), - schedules: [ - { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: "1" }, - ], - }, - ]); - // One intake per Monday inside the trailing 30 days. The - // cadence pairing reads `scheduledFor` for both anchor and - // event, so the Mondays-only timeline lines up cleanly. - const events = mondaysInWindow(now, 30).map((mon) => ({ - medicationId: "med-weekly", - scheduledFor: new Date( - Date.UTC( - mon.getUTCFullYear(), - mon.getUTCMonth(), - mon.getUTCDate(), - 8, - 30, - ), - ), - takenAt: new Date( - Date.UTC( - mon.getUTCFullYear(), - mon.getUTCMonth(), - mon.getUTCDate(), - 8, - 35, - ), - ), - skipped: false, - })); - // The helper reads the 37-day window (prevSince30d → now) so - // the prior-week snapshot has data too — return the same set - // unfiltered; the helper filters internally. - INTAKE_FIND_MANY.mockResolvedValueOnce(events); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-weekly-med", - bpInTargetPct: 80, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - // The medication pillar must be populated and reflect a 100% - // rate (or at least ≥ 50, well above the pre-fix ~13%). The - // exact pillar number depends on `computeHealthScore`'s - // weighting; we assert the directional contract — the score is - // computed AND the compliance branch fired. - expect(result?.components.compliance.value).not.toBeNull(); - const complianceValue = result?.components.compliance.value ?? 0; - // Pre-fix this user reported ~13% adherence → ~13 here. The - // post-fix path produces ≥ 90 (one Monday per week, all taken - // → 100% with the new denominator). 50 is a wide safety margin - // that catches the regression with zero flake risk. - expect(complianceValue).toBeGreaterThanOrEqual(50); - }); - - it("daily-only med with all doses taken still reports 100% (no regression on the path that already worked)", async () => { - const coverage = new Map([]); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - - const now = new Date("2026-05-13T12:00:00.000Z"); // Wed - MEDICATION_FIND_MANY.mockResolvedValueOnce([ - { - id: "med-daily", - createdAt: new Date("2026-01-01T00:00:00.000Z"), - schedules: [ - { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, - ], - }, - ]); - // One taken event per day over the trailing 30 days — including - // today's already-past slot (08:00 UTC < NOW = 12:00). - const events = []; - for (let d = 0; d < 30; d++) { - const day = new Date(now.getTime() - d * 24 * 60 * 60 * 1000); - const at = new Date( - Date.UTC( - day.getUTCFullYear(), - day.getUTCMonth(), - day.getUTCDate(), - 8, - 30, - ), - ); - if (at.getTime() > now.getTime()) continue; - events.push({ - medicationId: "med-daily", - scheduledFor: at, - takenAt: new Date(at.getTime() + 5 * 60_000), - skipped: false, - }); - } - INTAKE_FIND_MANY.mockResolvedValueOnce(events); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-daily-med", - bpInTargetPct: 80, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - // Daily-only path was correct under the legacy denominator and - // stays correct under the cadence denominator. The contract: - // taken=N, missed=0 → rate=100; pillar reads ≥ 90 with whatever - // weighting `computeHealthScore` applies. - expect(result?.components.compliance.value).toBeGreaterThanOrEqual(90); - }); - }); - - /** - * v1.18.0 R4 — module-aware mood pillar. With the mood module disabled - * the helper must NOT read mood, must null-weight the mood pillar, and - * must re-normalise the surviving pillars so the score isn't penalised - * (or inflated) by a pillar the user can no longer see. Golden fixture: - * an identical profile with strong, stable mood scored mood-on vs - * mood-off must produce a different, correctly re-weighted score. - * - * v1.34 — the flag is required. It used to default to on, and the - * dashboard snapshot never passed it, so the hero ring scored a pillar - * the account had switched off while the Insights card dropped it. - */ - describe("module-aware mood pillar (R4)", () => { - // A profile with BP + steady high mood (low variance → high - // stability) + nothing else, so the mood pillar's contribution is - // unambiguous. Six entries clears the 5-entry stability floor. - function steadyMood(now: Date) { - return Array.from({ length: 6 }, (_v, i) => ({ - score: 4, - moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - })); - } - - it("weights the mood pillar when moodEnabled is true", async () => { - const coverage = new Map(); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - const now = new Date("2026-06-07T12:00:00.000Z"); - MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-mood-on", - bpInTargetPct: 60, - bpGradedScore: 60, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - // Mood read fired and the pillar carries a non-zero weight. - expect(MOOD_FIND_MANY).toHaveBeenCalled(); - expect(result?.components.mood.value).not.toBeNull(); - expect(result?.components.mood.weight).toBeGreaterThan(0); - }); - - it("drops the mood pillar and re-normalises the score when moodEnabled is false", async () => { - const coverage = new Map(); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - const now = new Date("2026-06-07T12:00:00.000Z"); - // Even if mood rows existed they must be ignored — the helper - // should never issue the read when the module is off. - MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-mood-off", - bpInTargetPct: 60, - bpGradedScore: 60, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: false, - }); - - expect(result).not.toBeNull(); - // The disabled module is off the round-trip budget entirely. - expect(MOOD_FIND_MANY).not.toHaveBeenCalled(); - // Mood pillar null-weighted; surviving pillar (BP) absorbs the - // freed weight via proportional redistribution. - expect(result?.components.mood.value).toBeNull(); - expect(result?.components.mood.weight).toBe(0); - expect(result?.components.bp.weight).toBe(1); - // BP is the only present pillar → score is the BP graded value. - expect(result?.score).toBe(60); - }); - - it("golden: mood-on vs mood-off yield different, correctly re-weighted scores", async () => { - const coverage = new Map(); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - const now = new Date("2026-06-07T12:00:00.000Z"); - - // Mood-ON: BP=60 (0.30) + perfect-stability mood=100 (0.20). - // present base weights = 0.50 → bp 0.6, mood 0.4 → - // 60*0.6 + 100*0.4 = 36 + 40 = 76. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - MOOD_FIND_MANY.mockResolvedValue( - // Constant score → coefficient of variation 0 → stability 100. - Array.from({ length: 6 }, (_v, i) => ({ - score: 5, - moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - })), - ); - const onResult = await computeUserHealthScoreFastPath({ - userId: "user-golden-on", - bpInTargetPct: 60, - bpGradedScore: 60, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - // Mood-OFF: only BP=60 survives → score 60. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); - const offResult = await computeUserHealthScoreFastPath({ - userId: "user-golden-off", - bpInTargetPct: 60, - bpGradedScore: 60, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: false, - }); - - expect(onResult?.score).toBe(76); - expect(offResult?.score).toBe(60); - // The mood-off score is NOT silently dragged toward 0 by a phantom - // pillar — it equals the honest single-pillar BP score. - expect(offResult?.score).not.toBe(onResult?.score); - }); - }); - - /** - * v1.15.12 A1 — the graded clinical-proximity score, when supplied, - * becomes the BP pillar VALUE; the binary in-target rate only gates - * presence. A "borderline-stage-1" profile that read ~16/100 under the - * old binary contract must now read in the graded band. - */ - describe("graded BP pillar (A1)", () => { - it("uses bpGradedScore as the pillar value, not the binary rate", async () => { - const coverage = new Map(); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-06-07T12:00:00.000Z"); - // Live fallback — no weight/mood/med data; only BP signal present. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // weight raw read (live fallback) - .mockResolvedValueOnce([]); // BP-SYS source attribution - - const result = await computeUserHealthScoreFastPath({ - userId: "user-graded-bp", - // Binary all-time rate is catastrophic (the old pillar value)… - bpInTargetPct: 16, - // …but the graded clinical-proximity score says borderline. - bpGradedScore: 55, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - // Pillar reads the graded score, NOT the binary 16. - expect(result?.components.bp.value).toBe(55); - }); - - it("keeps the pillar absent when no BP readings exist (presence gate)", async () => { - const coverage = new Map(); - FULLY_COVERED.mockReturnValue(false); - PROBE.mockResolvedValue(coverage); - - const now = new Date("2026-06-07T12:00:00.000Z"); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // weight raw read - .mockResolvedValueOnce([]); // BP-SYS source attribution - // Mood present so the score is computable even without BP. - MOOD_FIND_MANY.mockResolvedValue( - Array.from({ length: 6 }, (_v, i) => ({ - score: 4, - moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), - })), - ); - - const result = await computeUserHealthScoreFastPath({ - userId: "user-no-bp", - // No paired BP readings → rate is null → pillar absent even if a - // stray graded score were supplied. - bpInTargetPct: null, - bpGradedScore: 80, - weightTarget: null, - weightTargetSource: "none", - now, - coverage, - moodEnabled: true, - }); - - expect(result).not.toBeNull(); - expect(result?.components.bp.value).toBeNull(); - expect(result?.components.bp.weight).toBe(0); - }); - }); -}); diff --git a/src/lib/analytics/__tests__/health-score-inputs.test.ts b/src/lib/analytics/__tests__/health-score-inputs.test.ts deleted file mode 100644 index df9b9f4d0..000000000 --- a/src/lib/analytics/__tests__/health-score-inputs.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - buildHealthScoreBpInputs, - buildHealthScoreWeightTarget, -} from "../health-score-inputs"; -import type { BpInTargetEnvelope } from "../bp-in-target-fast-path"; - -function env(partial: Partial): BpInTargetEnvelope { - return { - last7Days: null, - last30Days: null, - last90Days: null, - last90EarliestAt: null, - allTime: null, - priorMonth: null, - priorYear: null, - path: "live", - rowCount: 0, - gradedScore: null, - ...partial, - }; -} - -describe("buildHealthScoreBpInputs — single Health-Score BP input builder", () => { - /** - * v1.17 W1b — the dashboard ring and the insights card must grade the BP - * pillar off identical inputs. This pins the contract: given the same - * current + prior-week envelopes, the builder produces one BP-input shape, - * so whichever surface calls it sees the same pillar presence, value and - * delta. - */ - it("reads the 90-day window for the pillar rate, NOT 30-day or all-time", () => { - const current = env({ - last30Days: { pct: 80, pairs: 10 }, - last90Days: { pct: 65, pairs: 40 }, - allTime: { pct: 50, pairs: 200 }, - gradedScore: 72, - }); - const out = buildHealthScoreBpInputs(current, null); - expect(out.bpInTargetPct).toBe(65); - expect(out.bpGradedScore).toBe(72); - }); - - it("falls back to all-time only when the 90-day window is null", () => { - const current = env({ - last90Days: null, - allTime: { pct: 42, pairs: 100 }, - gradedScore: 60, - }); - const out = buildHealthScoreBpInputs(current, null); - expect(out.bpInTargetPct).toBe(42); - }); - - it("uses the prior-week 90-day window for the delta inputs", () => { - const current = env({ - last90Days: { pct: 65, pairs: 40 }, - gradedScore: 72, - }); - const priorWeek = env({ - last90Days: { pct: 58, pairs: 38 }, - gradedScore: 68, - }); - const out = buildHealthScoreBpInputs(current, priorWeek); - expect(out.bpInTargetPctPriorWeek).toBe(58); - expect(out.bpGradedScorePriorWeek).toBe(68); - }); - - it("suppresses the pillar when the 90-day window is below the confidence floor and no all-time rescues it", () => { - // 4 pairs in the last 90 days — below the floor of 5. Without an all-time - // rate the pillar must disappear (rate + graded score both null) so a - // thin-data user does not get a confident BP pillar. - const current = env({ - last90Days: { pct: 100, pairs: 4 }, - allTime: { pct: 100, pairs: 4 }, - gradedScore: 95, - }); - const out = buildHealthScoreBpInputs(current, null); - // all-time is itself the same 4 pairs → rate present (deep history rescue - // path), but here it is genuinely thin so the rate is the all-time pct. - // Pin the stricter case: when all-time is also null the pillar collapses. - const thin = buildHealthScoreBpInputs( - env({ - last90Days: { pct: 100, pairs: 4 }, - allTime: null, - gradedScore: 95, - }), - null, - ); - expect(thin.bpInTargetPct).toBeNull(); - expect(thin.bpGradedScore).toBeNull(); - // The all-time fallback still rescues a deep-history account. - expect(out.bpInTargetPct).toBe(100); - }); - - it("suppresses the prior-week graded score when the prior-week window is thin and no all-time rescues it", () => { - // Current window healthy, prior-week 90-day window below the floor with no - // all-time fallback → the prior-week graded score must not grade off the - // thin sample, so the week-over-week graded delta stays consistent. - const current = env({ - last90Days: { pct: 65, pairs: 40 }, - gradedScore: 72, - }); - const priorWeek = env({ - last90Days: { pct: 90, pairs: 2 }, - allTime: null, - gradedScore: 95, - }); - const out = buildHealthScoreBpInputs(current, priorWeek); - expect(out.bpGradedScore).toBe(72); - expect(out.bpInTargetPctPriorWeek).toBeNull(); - expect(out.bpGradedScorePriorWeek).toBeNull(); - }); - - it("collapses every field to null when the current envelope is absent", () => { - const out = buildHealthScoreBpInputs(null, null); - expect(out).toEqual({ - bpInTargetPct: null, - bpInTargetPctPriorWeek: null, - bpGradedScore: null, - bpGradedScorePriorWeek: null, - }); - }); - - it("pillar presence is identical for two callers passing the same envelopes", () => { - // Same data → same `bpInTargetPct !== null` presence verdict on both - // surfaces. A user with BP history outside 30 days but inside 90 days - // keeps the pillar on BOTH the dashboard ring and the insights card. - const current = env({ - last30Days: null, // no readings in the last month - last90Days: { pct: 55, pairs: 12 }, - allTime: { pct: 48, pairs: 300 }, - gradedScore: 64, - }); - const a = buildHealthScoreBpInputs(current, null); - const b = buildHealthScoreBpInputs(current, null); - expect(a).toEqual(b); - expect(a.bpInTargetPct).not.toBeNull(); - }); -}); - -/** - * v1.34 — the weight yardstick builder. The score used to grade weight against - * `22 x height^2` +/- 2 kg, a band no user set and no surface named. The - * builder is the one place the two Health-Score callers resolve it, and its - * whole job is to refuse to hand over anything but a real user target. - */ -describe("buildHealthScoreWeightTarget — the one weight-yardstick resolver", () => { - const profile = { - heightCm: 178, - dateOfBirth: new Date("1985-06-15T00:00:00.000Z"), - gender: "MALE", - }; - - it("hands over the user's own band when they set one", () => { - const result = buildHealthScoreWeightTarget(profile, { - WEIGHT: { min: 74, max: 78 }, - }); - expect(result.weightTargetSource).toBe("user"); - expect(result.weightTarget).toEqual({ min: 74, max: 78 }); - }); - - it("refuses the evidence-based default that sits under the override", () => { - // `getEffectiveRange` resolves a height-derived WHO band for this profile. - // It is a reference band, not a goal the person chose, so it must never - // reach the score — that substitution IS the defect. - const result = buildHealthScoreWeightTarget(profile, null); - expect(result.weightTargetSource).toBe("none"); - expect(result.weightTarget).toBeNull(); - }); - - it("ignores overrides on other metrics", () => { - const result = buildHealthScoreWeightTarget(profile, { - BLOOD_PRESSURE_SYS: { min: 110, max: 130 }, - }); - expect(result.weightTargetSource).toBe("none"); - expect(result.weightTarget).toBeNull(); - }); - - it("returns no target for a profile with no height and no override", () => { - const result = buildHealthScoreWeightTarget( - { heightCm: null, dateOfBirth: null, gender: null }, - null, - ); - expect(result.weightTargetSource).toBe("none"); - expect(result.weightTarget).toBeNull(); - }); - - it("honours an override even when the profile carries no height", () => { - const result = buildHealthScoreWeightTarget( - { heightCm: null, dateOfBirth: null, gender: null }, - { WEIGHT: { min: 60, max: 65 } }, - ); - expect(result.weightTargetSource).toBe("user"); - expect(result.weightTarget).toEqual({ min: 60, max: 65 }); - }); -}); diff --git a/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts b/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts deleted file mode 100644 index aa31e0967..000000000 --- a/src/lib/analytics/__tests__/health-score-rollup-live-parity.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * F1 — rollup-vs-live golden for the Health Score. - * - * `computeUserHealthScoreFastPath` reads the weight series two ways: per-day - * MEAN buckets off `measurement_rollups` when the WEIGHT type is covered, raw - * `measurements` rows otherwise. The scoring math cannot diverge — there is one - * scorer and both branches call it — but the INPUT SERIES can, and nothing in - * the suite asserted the two branches land on the same number for the same - * underlying readings. The existing fast-path tests exercise each branch alone. - * - * This file encodes one series both ways and pins the equivalence, plus the one - * documented case where the two legitimately differ: with several readings on a - * day, the rollup branch regresses over the day's mean while the live branch - * regresses over every reading. That is a collapse, not a bug — so the second - * suite pins that the rollup branch equals the live branch fed those same daily - * means, which is the exact claim the fast-path's own comment makes. - */ -import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { findMany: vi.fn() }, - measurementRollup: { findMany: vi.fn() }, - user: { findUnique: vi.fn() }, - moodEntry: { findMany: vi.fn() }, - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - }, -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -vi.mock("@/lib/rollups/measurement-coverage", () => ({ - isFullyCovered: vi.fn(), - probeRollupCoverage: vi.fn(), -})); - -import { prisma } from "@/lib/db"; -import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; -import { computeUserHealthScoreFastPath } from "../health-score-fast-path"; - -const MEASUREMENT_FIND_MANY = prisma.measurement - .findMany as unknown as ReturnType; -const ROLLUP_FIND_MANY = prisma.measurementRollup - .findMany as unknown as ReturnType; -const MOOD_FIND_MANY = prisma.moodEntry.findMany as unknown as ReturnType< - typeof vi.fn ->; -const MEDICATION_FIND_MANY = prisma.medication - .findMany as unknown as ReturnType; -const PROBE = probeRollupCoverage as unknown as ReturnType; - -const NOW = new Date("2026-05-20T12:00:00.000Z"); - -/** A weight reading in the shared fixture vocabulary. */ -interface Reading { - /** Days before `NOW` the reading was taken. */ - daysAgo: number; - /** Hour of that day, so several readings can share a day. */ - hour: number; - kg: number; -} - -function measuredAt(r: Reading): Date { - const d = new Date(NOW.getTime() - r.daysAgo * 24 * 60 * 60 * 1000); - d.setUTCHours(r.hour, 0, 0, 0); - return d; -} - -/** UTC midnight of a reading's day — the DAY bucket key. */ -function bucketStart(r: Reading): Date { - const d = measuredAt(r); - return new Date( - Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()), - ); -} - -/** Collapse readings into the per-day MEAN buckets the rollup tier stores. */ -function toDayBuckets(readings: Reading[]) { - const byDay = new Map(); - for (const r of readings) { - const key = bucketStart(r).getTime(); - const slot = byDay.get(key) ?? { sum: 0, count: 0 }; - slot.sum += r.kg; - slot.count += 1; - byDay.set(key, slot); - } - return [...byDay.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([ms, slot]) => ({ - bucketStart: new Date(ms), - count: slot.count, - mean: slot.sum / slot.count, - minValue: slot.sum / slot.count, - maxValue: slot.sum / slot.count, - sd: null, - slope: null, - r2: null, - computedAt: NOW, - })); -} - -beforeEach(() => { - MEASUREMENT_FIND_MANY.mockReset(); - ROLLUP_FIND_MANY.mockReset(); - MOOD_FIND_MANY.mockReset(); - MEDICATION_FIND_MANY.mockReset(); - PROBE.mockReset(); - - MOOD_FIND_MANY.mockResolvedValue([]); - MEDICATION_FIND_MANY.mockResolvedValue([]); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -/** Score the readings through the ROLLUP branch (WEIGHT covered). */ -async function scoreViaRollup(readings: Reading[]) { - const coverage = new Map([["WEIGHT", true]]); - PROBE.mockResolvedValue(coverage); - const buckets = toDayBuckets(readings); - // The DAY read and the long-window (WEEK/MONTH/YEAR) read fire in parallel, - // so key the mock on the requested granularity rather than call order. - ROLLUP_FIND_MANY.mockImplementation( - async ({ where }: { where: { granularity: string } }) => - where.granularity === "DAY" ? buckets : [], - ); - // Source attribution still comes off the raw rows (2-column projection), - // then the BP-SYS attribution read. - MEASUREMENT_FIND_MANY.mockResolvedValueOnce( - readings.map((r) => ({ measuredAt: measuredAt(r), source: "MANUAL" })), - ).mockResolvedValueOnce([]); - - return computeUserHealthScoreFastPath({ - userId: "user-rollup", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now: NOW, - coverage, - moodEnabled: true, - }); -} - -/** Score the readings through the LIVE branch (WEIGHT uncovered). */ -async function scoreViaLive(readings: Reading[]) { - const coverage = new Map([["WEIGHT", false]]); - PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce( - readings.map((r) => ({ - measuredAt: measuredAt(r), - value: r.kg, - source: "MANUAL", - })), - ).mockResolvedValueOnce([]); - - return computeUserHealthScoreFastPath({ - userId: "user-live", - bpInTargetPct: null, - weightTarget: null, - weightTargetSource: "none", - now: NOW, - coverage, - moodEnabled: true, - }); -} - -describe("Health Score — rollup / live parity on one weigh-in per day", () => { - // The canonical cadence: one weigh-in a day, the pattern the fast path's own - // comment says the equivalence rests on. - const dailyDecline: Reading[] = [ - { daysAgo: 24, hour: 7, kg: 84.0 }, - { daysAgo: 20, hour: 7, kg: 83.6 }, - { daysAgo: 16, hour: 7, kg: 83.1 }, - { daysAgo: 12, hour: 7, kg: 82.7 }, - { daysAgo: 8, hour: 7, kg: 82.2 }, - { daysAgo: 4, hour: 7, kg: 81.8 }, - { daysAgo: 1, hour: 7, kg: 81.5 }, - ]; - - it("produces the same score, band and weight pillar on both branches", async () => { - const rollup = await scoreViaRollup(dailyDecline); - const live = await scoreViaLive(dailyDecline); - - expect(rollup).not.toBeNull(); - expect(live).not.toBeNull(); - expect(rollup?.score).toBe(live?.score); - expect(rollup?.band).toBe(live?.band); - expect(rollup?.components.weight.value).toBe(live?.components.weight.value); - // The weight pillar is the whole score here (no BP, mood or medications), - // so an equal score is only meaningful if the pillar actually resolved. - expect(rollup?.components.weight.value).not.toBeNull(); - }); - - it("agrees on the week-over-week delta too", async () => { - // The delta re-runs the scorer over a 7-day-shifted window, so it exercises - // the branch's series construction a second time on a different slice. - const rollup = await scoreViaRollup(dailyDecline); - const live = await scoreViaLive(dailyDecline); - expect(rollup?.delta).toBe(live?.delta); - }); - - it("agrees on a rising series as well as a falling one", async () => { - const rise = dailyDecline.map((r) => ({ ...r, kg: 160 - r.kg })); - const rollup = await scoreViaRollup(rise); - const live = await scoreViaLive(rise); - expect(rollup?.score).toBe(live?.score); - expect(rollup?.components.weight.value).toBe(live?.components.weight.value); - }); -}); - -describe("Health Score — rollup / live on an uneven weigh-in cadence", () => { - // Three weigh-ins on the older day, one on the recent day. Per-reading - // regression weights that older day three times over; per-day-mean regression - // gives it one point. This is the shape where the two branches genuinely part - // company, so it is the shape worth pinning. - const uneven: Reading[] = [ - { daysAgo: 20, hour: 6, kg: 85.0 }, - { daysAgo: 20, hour: 12, kg: 84.0 }, - { daysAgo: 20, hour: 20, kg: 83.0 }, - { daysAgo: 2, hour: 7, kg: 83.6 }, - ]; - - /** The same readings collapsed to one per day at their daily mean. */ - const collapsed: Reading[] = toDayBuckets(uneven).map((b) => ({ - daysAgo: Math.round( - (NOW.getTime() - b.bucketStart.getTime()) / (24 * 60 * 60 * 1000), - ), - hour: 0, - kg: b.mean, - })); - - it("the two branches do differ here — the fixture is not a no-op", async () => { - // Guard the guard: if raw and collapsed ever scored the same for this - // series, the equality below would prove nothing. - const liveOnRaw = await scoreViaLive(uneven); - const liveOnMeans = await scoreViaLive(collapsed); - expect(liveOnRaw?.components.weight.value).not.toBe( - liveOnMeans?.components.weight.value, - ); - }); - - it("the rollup branch equals the live branch fed the same daily means", async () => { - // The whole difference between the branches is the mean-collapse and - // nothing else: replay the collapsed series through the LIVE branch and the - // two agree again. That is the claim the fast path's own comment makes, - // stated as a test rather than left as prose. - const rollup = await scoreViaRollup(uneven); - const liveOnMeans = await scoreViaLive(collapsed); - - expect(rollup?.score).toBe(liveOnMeans?.score); - expect(rollup?.components.weight.value).toBe( - liveOnMeans?.components.weight.value, - ); - }); -}); diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts deleted file mode 100644 index 2c678aa06..000000000 --- a/src/lib/analytics/__tests__/health-score.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - coefficientOfVariation, - complianceRate, - computeHealthScore, - linearRegressionSlope, - moodStability, - weightTrendAlignment, - type HealthScoreInput, -} from "../health-score"; - -/** - * Build a 30-day mood series with a deterministic pattern. The helper - * keeps the variance tractable by hand so test expectations don't drift. - */ -function moodEntries(scores: number[]): Array<{ date: string; score: number }> { - return scores.map((score, i) => ({ - date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(), - score, - })); -} - -function weightSeries(values: number[]): Array<{ date: string; kg: number }> { - return values.map((kg, i) => ({ - date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(), - kg, - })); -} - -// ── Pure helpers ───────────────────────────────────────────────────── - -describe("linearRegressionSlope", () => { - it("returns null for fewer than two points", () => { - expect(linearRegressionSlope([])).toBeNull(); - expect( - linearRegressionSlope([{ date: "2026-05-01T00:00:00Z", value: 80 }]), - ).toBeNull(); - }); - - it("detects a clean upward slope (units / day)", () => { - const slope = linearRegressionSlope( - weightSeries([80, 81, 82, 83, 84]).map((p) => ({ - date: p.date, - value: p.kg, - })), - ); - expect(slope).not.toBeNull(); - expect(slope!).toBeCloseTo(1, 5); - }); - - it("detects a clean downward slope", () => { - const slope = linearRegressionSlope( - weightSeries([85, 84, 83, 82, 81]).map((p) => ({ - date: p.date, - value: p.kg, - })), - ); - expect(slope!).toBeCloseTo(-1, 5); - }); -}); - -describe("coefficientOfVariation", () => { - it("returns null for fewer than two values", () => { - expect(coefficientOfVariation([])).toBeNull(); - expect(coefficientOfVariation([4])).toBeNull(); - }); - - it("returns 0 for a constant series", () => { - expect(coefficientOfVariation([4, 4, 4, 4])).toBe(0); - }); - - it("scales with relative spread", () => { - const tight = coefficientOfVariation([4, 4, 4, 4, 5])!; - const loose = coefficientOfVariation([1, 5, 1, 5, 1])!; - expect(loose).toBeGreaterThan(tight); - }); -}); - -describe("weightTrendAlignment", () => { - it("returns 100 when latest reading is inside the band", () => { - const series = weightSeries([90, 88, 85, 80]); - const result = weightTrendAlignment(series, { min: 78, max: 82 }); - expect(result).toBe(100); - }); - - it("returns >50 when above the band and trending down (closing the gap)", () => { - const series = weightSeries([95, 94, 93, 92, 91]); - const result = weightTrendAlignment(series, { min: 75, max: 80 }); - expect(result).not.toBeNull(); - expect(result!).toBeGreaterThan(50); - }); - - it("returns <50 when above the band and trending up (drifting away)", () => { - const series = weightSeries([90, 91, 92, 93, 94]); - const result = weightTrendAlignment(series, { min: 75, max: 80 }); - expect(result).not.toBeNull(); - expect(result!).toBeLessThan(50); - }); - - it("scores the bare trend (non-null) when the target is missing but ≥2 readings exist", () => { - // No target → fall back to trend-only scoring instead of returning - // null, so a height-less user with weight data gets a populated pillar. - const result = weightTrendAlignment( - weightSeries([80, 80.5, 81, 81.5]), - null, - ); - expect(result).not.toBeNull(); - expect(result!).toBeGreaterThanOrEqual(0); - expect(result!).toBeLessThanOrEqual(100); - }); - - it("anchors a flat trend at 75 when there is no target", () => { - expect(weightTrendAlignment(weightSeries([80, 80, 80, 80]), null)).toBe(75); - }); - - it("rewards a gentle decline (>75) when there is no target", () => { - const result = weightTrendAlignment( - weightSeries([84, 83.5, 83, 82.5]), - null, - ); - expect(result).not.toBeNull(); - expect(result!).toBeGreaterThan(75); - }); - - it("penalises a sustained rise (<75) when there is no target", () => { - const result = weightTrendAlignment( - weightSeries([80, 81, 82, 83, 84]), - null, - ); - expect(result).not.toBeNull(); - expect(result!).toBeLessThan(75); - }); - - it("returns null with no target and fewer than two readings", () => { - expect(weightTrendAlignment(weightSeries([80]), null)).toBeNull(); - expect(weightTrendAlignment([], null)).toBeNull(); - }); - - it("returns null with fewer than two readings", () => { - expect( - weightTrendAlignment(weightSeries([80]), { min: 75, max: 80 }), - ).toBeNull(); - }); -}); - -describe("moodStability", () => { - it("returns null for fewer than 5 entries", () => { - expect(moodStability(moodEntries([4, 4, 4, 4]))).toBeNull(); - }); - - it("returns 100 for a constant high-mood series", () => { - expect(moodStability(moodEntries([5, 5, 5, 5, 5, 5]))).toBe(100); - }); - - it("scores tight variance higher than loose variance", () => { - const tight = moodStability(moodEntries([4, 4, 4, 4, 4, 5]))!; - const loose = moodStability(moodEntries([1, 5, 1, 5, 1, 5]))!; - expect(tight).toBeGreaterThan(loose); - }); -}); - -describe("complianceRate", () => { - it("returns null for an empty list", () => { - expect(complianceRate([])).toBeNull(); - }); - - it("returns the rounded mean of compliance percentages", () => { - expect(complianceRate([100, 80, 60])).toBe(80); - }); -}); - -// ── Composite scenarios ────────────────────────────────────────────── - -describe("computeHealthScore — strong positive case", () => { - it("lands in the green band (>=75)", () => { - const input: HealthScoreInput = { - bpInTargetRate: 90, - weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]), - weightTarget: { min: 76, max: 80 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]), - medicationCompliance30: [95, 100, 90], - }; - const result = computeHealthScore(input); - expect(result.score).toBeGreaterThanOrEqual(75); - expect(result.band).toBe("green"); - expect(result.components.bp.value).toBe(90); - expect(result.components.compliance.value).toBe(95); - }); -}); - -describe("computeHealthScore — mixed case", () => { - it("lands in the yellow band (50..74)", () => { - const input: HealthScoreInput = { - bpInTargetRate: 60, - // Above the band, drifting up — alignment <50. - weightSeriesLast30d: weightSeries([85, 86, 87, 88]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]), - medicationCompliance30: [100, 100], - }; - const result = computeHealthScore(input); - expect(result.score).toBeGreaterThanOrEqual(50); - expect(result.score).toBeLessThan(75); - expect(result.band).toBe("yellow"); - }); -}); - -describe("computeHealthScore — poor case", () => { - it("lands in the red band (<50)", () => { - const input: HealthScoreInput = { - bpInTargetRate: 20, - weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]), - medicationCompliance30: [40, 30, 20], - }; - const result = computeHealthScore(input); - expect(result.score).toBeLessThan(50); - expect(result.band).toBe("red"); - }); -}); - -describe("computeHealthScore — null component redistribution", () => { - it("redistributes weights when bp is null", () => { - const input: HealthScoreInput = { - bpInTargetRate: null, - weightSeriesLast30d: weightSeries([80, 80, 80, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), - medicationCompliance30: [100, 100], - }; - const result = computeHealthScore(input); - // bp weight 0; remaining base weights sum 0.7. Each scales by /0.7. - expect(result.components.bp.weight).toBe(0); - expect(result.components.weight.weight).toBeCloseTo(0.2 / 0.7, 5); - expect(result.components.mood.weight).toBeCloseTo(0.2 / 0.7, 5); - expect(result.components.compliance.weight).toBeCloseTo(0.3 / 0.7, 5); - // Score should be 100 (every present component at 100). - expect(result.score).toBe(100); - }); - - it("handles bp + mood both null — weight + compliance carry the score", () => { - const input: HealthScoreInput = { - bpInTargetRate: null, - weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - // <5 mood entries → moodStability returns null. - moodEntriesLast30d: moodEntries([4, 4]), - medicationCompliance30: [80], - }; - const result = computeHealthScore(input); - expect(result.components.bp.value).toBeNull(); - expect(result.components.mood.value).toBeNull(); - // Remaining base weights sum 0.5; weight + compliance scale to 0.4 + 0.6. - expect(result.components.weight.weight).toBeCloseTo(0.4, 5); - expect(result.components.compliance.weight).toBeCloseTo(0.6, 5); - // weight 100 (in-band) * 0.4 + compliance 80 * 0.6 = 40 + 48 = 88. - expect(result.score).toBe(88); - }); - - it("handles all null except compliance — score equals complianceRate", () => { - const input: HealthScoreInput = { - bpInTargetRate: null, - weightSeriesLast30d: [], - weightTarget: null, - weightTargetSource: "none", - moodEntriesLast30d: [], - medicationCompliance30: [73], - }; - const result = computeHealthScore(input); - expect(result.components.compliance.weight).toBeCloseTo(1, 5); - expect(result.score).toBe(73); - expect(result.band).toBe("yellow"); - }); - - it("returns score 0 when every component is null", () => { - const input: HealthScoreInput = { - bpInTargetRate: null, - weightSeriesLast30d: [], - weightTarget: null, - weightTargetSource: "none", - moodEntriesLast30d: [], - medicationCompliance30: [], - }; - const result = computeHealthScore(input); - expect(result.score).toBe(0); - expect(result.band).toBe("red"); - for (const key of ["bp", "weight", "mood", "compliance"] as const) { - expect(result.components[key].value).toBeNull(); - expect(result.components[key].weight).toBe(0); - } - }); -}); - -describe("computeHealthScore — determinism", () => { - const input: HealthScoreInput = { - bpInTargetRate: 75, - weightSeriesLast30d: weightSeries([82, 81, 80, 79]), - weightTarget: { min: 76, max: 80 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]), - medicationCompliance30: [85, 90, 80], - }; - - it("same input → same output", () => { - const a = computeHealthScore(input); - const b = computeHealthScore(input); - expect(b).toEqual(a); - }); - - // v1.4.25 Fix-G — the legacy fallback used to be `new Date()`, which - // made `asOf` drift by a millisecond between consecutive calls and - // turned the determinism guard above into a flake on loaded runners. - // The synthesised window-end now derives from input dates, not wall - // clock, so the entire result is byte-identical across calls. - it("derives a deterministic asOf for present components when attribution is omitted", () => { - const a = computeHealthScore(input); - const b = computeHealthScore(input); - expect(b.components.bp.asOf).toBe(a.components.bp.asOf); - expect(b.components.weight.asOf).toBe(a.components.weight.asOf); - expect(b.components.mood.asOf).toBe(a.components.mood.asOf); - expect(b.components.compliance.asOf).toBe(a.components.compliance.asOf); - }); -}); - -describe("computeHealthScore — delta vs previous week", () => { - const baseInput: HealthScoreInput = { - bpInTargetRate: 80, - weightSeriesLast30d: weightSeries([82, 81, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]), - medicationCompliance30: [100, 100], - }; - const previousInput: HealthScoreInput = { - ...baseInput, - bpInTargetRate: 60, - medicationCompliance30: [70, 80], - }; - - it("returns null when no previous input is supplied", () => { - const result = computeHealthScore(baseInput); - expect(result.delta).toBeNull(); - }); - - it("returns positive delta when current is higher than previous", () => { - const result = computeHealthScore(baseInput, previousInput); - expect(result.delta).not.toBeNull(); - expect(result.delta!).toBeGreaterThan(0); - }); - - it("returns negative delta when current is lower than previous", () => { - const result = computeHealthScore(previousInput, baseInput); - expect(result.delta).not.toBeNull(); - expect(result.delta!).toBeLessThan(0); - }); -}); - -// ── v1.4.25 W8e — per-component source attribution ────────────────── - -describe("computeHealthScore — source attribution", () => { - const WINDOW_END = "2026-05-14T12:00:00.000Z"; - const BP_LATEST = "2026-05-14T08:00:00.000Z"; - const WEIGHT_LATEST = "2026-05-13T18:00:00.000Z"; - const MOOD_LATEST = "2026-05-14T07:30:00.000Z"; - - function baseInputWithAttribution( - attribution: HealthScoreInput["attribution"], - ): HealthScoreInput { - return { - bpInTargetRate: 90, - weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]), - medicationCompliance30: [95, 100], - attribution, - }; - } - - it("collapses a single-source component to that source label", () => { - const result = computeHealthScore( - baseInputWithAttribution({ - bpSources: ["withings"], - asOfBp: BP_LATEST, - weightSources: ["withings"], - asOfWeight: WEIGHT_LATEST, - moodSources: ["manual"], - asOfMood: MOOD_LATEST, - complianceSources: ["manual"], - asOfCompliance: WINDOW_END, - windowEndAt: WINDOW_END, - }), - ); - - expect(result.components.bp.source).toBe("withings"); - expect(result.components.bp.asOf).toBe(BP_LATEST); - expect(result.components.weight.source).toBe("withings"); - expect(result.components.weight.asOf).toBe(WEIGHT_LATEST); - expect(result.components.mood.source).toBe("manual"); - expect(result.components.mood.asOf).toBe(MOOD_LATEST); - expect(result.components.compliance.source).toBe("manual"); - }); - - it("marks a component `mixed` when multiple sources contribute", () => { - const result = computeHealthScore( - baseInputWithAttribution({ - bpSources: ["withings", "manual"], - asOfBp: BP_LATEST, - weightSources: ["withings", "appleHealth"], - asOfWeight: WEIGHT_LATEST, - moodSources: ["manual"], - asOfMood: MOOD_LATEST, - complianceSources: ["manual"], - asOfCompliance: WINDOW_END, - windowEndAt: WINDOW_END, - }), - ); - - expect(result.components.bp.source).toBe("mixed"); - expect(result.components.weight.source).toBe("mixed"); - // Single-source components still resolve to their token. - expect(result.components.mood.source).toBe("manual"); - }); - - it("falls through to `none` + window-end asOf when a component has no value", () => { - const input: HealthScoreInput = { - bpInTargetRate: null, - weightSeriesLast30d: [], - weightTarget: null, - weightTargetSource: "none", - // <5 mood entries → moodStability returns null. - moodEntriesLast30d: moodEntries([4, 4]), - medicationCompliance30: [], - attribution: { - bpSources: [], - asOfBp: null, - weightSources: [], - asOfWeight: null, - moodSources: [], - asOfMood: null, - complianceSources: [], - asOfCompliance: null, - windowEndAt: WINDOW_END, - }, - }; - const result = computeHealthScore(input); - expect(result.components.bp.source).toBe("none"); - expect(result.components.bp.asOf).toBe(WINDOW_END); - expect(result.components.weight.source).toBe("none"); - expect(result.components.mood.source).toBe("none"); - expect(result.components.compliance.source).toBe("none"); - }); - - it("backward-compat: omitted attribution defaults present components to `manual`", () => { - const result = computeHealthScore({ - bpInTargetRate: 80, - weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), - medicationCompliance30: [100], - }); - // No `attribution` supplied — the legacy contract had no source - // field, so the helper defaults present components to "manual". - expect(result.components.bp.source).toBe("manual"); - expect(result.components.weight.source).toBe("manual"); - expect(result.components.mood.source).toBe("manual"); - expect(result.components.compliance.source).toBe("manual"); - // The asOf falls back to a wall-clock ISO string — just verify the - // type/shape, not the exact value. - expect(typeof result.components.bp.asOf).toBe("string"); - }); - - it("falls back to window-end asOf when a present component has no asOf supplied", () => { - const result = computeHealthScore({ - bpInTargetRate: 90, - weightSeriesLast30d: weightSeries([80, 80, 80]), - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "user", - moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]), - medicationCompliance30: [100], - attribution: { - bpSources: ["withings"], - asOfBp: null, - windowEndAt: WINDOW_END, - }, - }); - expect(result.components.bp.source).toBe("withings"); - // No asOf supplied → fall back to window end. - expect(result.components.bp.asOf).toBe(WINDOW_END); - }); -}); - -/** - * v1.34 — the weight yardstick. - * - * Until this release the pillar graded every account with a height on file - * against `22 x height^2` +/- 2 kg: a band nobody set and no surface named. - * These pin the replacement contract — the band arrives whole from the caller, - * a missing band scores the bare trend, and the result says which of the two - * happened. - */ -describe("computeHealthScore — weight yardstick", () => { - const stable = weightSeries([80, 80, 80, 80]); - - function scoreWeightOnly( - series: Array<{ date: string; kg: number }>, - target: { min: number; max: number } | null, - ) { - return computeHealthScore({ - bpInTargetRate: null, - weightSeriesLast30d: series, - weightTarget: target, - weightTargetSource: target ? "user" : "none", - moodEntriesLast30d: [], - medicationCompliance30: [], - }); - } - - it("takes the band whole — no expansion of the caller's bounds", () => { - // 80 kg sits OUTSIDE 76-79. A +/- 2 kg widening would pull it in and - // score 100; the band must be used exactly as handed over. - expect( - scoreWeightOnly(stable, { min: 76, max: 79 }).components.weight.value, - ).toBe(50); - // The same reading INSIDE the band scores the in-band 100. - expect( - scoreWeightOnly(stable, { min: 78, max: 82 }).components.weight.value, - ).toBe(100); - }); - - it("scores the bare trend when no target is set", () => { - // Stable weight, no target: the trend-only anchor, not the target-aware - // "neither" midpoint of 50, and never a fabricated band. - const result = scoreWeightOnly(stable, null); - expect(result.components.weight.value).toBe(75); - expect(result.components.weight.weight).toBeGreaterThan(0); - }); - - it("carries the resolved yardstick on the weight component", () => { - const withTarget = scoreWeightOnly(stable, { min: 78, max: 82 }); - expect(withTarget.components.weight.targetSource).toBe("user"); - expect(withTarget.components.weight.target).toEqual({ min: 78, max: 82 }); - - const without = scoreWeightOnly(stable, null); - expect(without.components.weight.targetSource).toBe("none"); - expect(without.components.weight.target).toBeNull(); - }); - - it("never reports a band when the source is `none`", () => { - // Defensive: a caller that passes a band while declaring no source must - // not have that band surface as though the user had set it. - const result = computeHealthScore({ - bpInTargetRate: null, - weightSeriesLast30d: stable, - weightTarget: { min: 78, max: 82 }, - weightTargetSource: "none", - moodEntriesLast30d: [], - medicationCompliance30: [], - }); - expect(result.components.weight.target).toBeNull(); - expect(result.components.weight.targetSource).toBe("none"); - }); -}); diff --git a/src/lib/analytics/bp-grade.ts b/src/lib/analytics/bp-grade.ts index a9ce45ea3..a1842eba2 100644 --- a/src/lib/analytics/bp-grade.ts +++ b/src/lib/analytics/bp-grade.ts @@ -199,12 +199,11 @@ export interface BpPairPoint { const HALF_LIFE_DAYS = 45; const DAY_MS = 24 * 60 * 60 * 1000; -export function gradeBpScoreFromSeries(input: { +export function representativeBpFromSeries(input: { pairs: ReadonlyArray; - target: BpTargets; now: Date; -}): number | null { - const { pairs, target, now } = input; +}): { sys: number; dia: number } | null { + const { pairs, now } = input; if (pairs.length === 0) return null; const nowMs = now.getTime(); @@ -223,8 +222,19 @@ export function gradeBpScoreFromSeries(input: { diaWeighted += weight * p.dia; } if (weightSum === 0) return null; + return { + sys: sysWeighted / weightSum, + dia: diaWeighted / weightSum, + }; +} - const sys = sysWeighted / weightSum; - const dia = diaWeighted / weightSum; - return gradeBpScore({ sys, dia, target }); +export function gradeBpScoreFromSeries(input: { + pairs: ReadonlyArray; + target: BpTargets; + now: Date; +}): number | null { + const representative = representativeBpFromSeries(input); + return representative + ? gradeBpScore({ ...representative, target: input.target }) + : null; } diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index db9f6e7e5..d754c3626 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -69,7 +69,7 @@ import { isBpReadingInTarget, type BpReading, } from "./bp-in-target"; -import { gradeBpScoreFromSeries } from "./bp-grade"; +import { gradeBpScoreFromSeries, representativeBpFromSeries } from "./bp-grade"; import type { BpTargets } from "./bp-targets"; /** @@ -111,6 +111,10 @@ export interface BpInTargetEnvelope { * in-target rate AS THE SCORE. `null` when no pairs formed. */ gradedScore: number | null; + /** Same recency-weighted pair that `gradedScore` grades. */ + representative: { sys: number; dia: number } | null; + /** Freshest paired reading in the trailing 90-day score window. */ + last90LatestAt: Date | null; } /** @@ -304,12 +308,16 @@ async function computeFromRollups( const ninetyDaysAgoMs = ninetyDaysAgo.getTime(); const nowMs = now.getTime(); let last90EarliestAt: Date | null = null; + let last90LatestAt: Date | null = null; for (const p of pairsByDay) { const dayMs = p.day.getTime(); if (dayMs < ninetyDaysAgoMs || dayMs >= nowMs) continue; if (last90EarliestAt === null || dayMs < last90EarliestAt.getTime()) { last90EarliestAt = p.day; } + if (last90LatestAt === null || dayMs > last90LatestAt.getTime()) { + last90LatestAt = p.day; + } } // All-time on the rollup path is bounded to the 395-day read window. // Documented above — same trade-off the per-event path makes when the @@ -361,28 +369,37 @@ async function computeFromRollups( // produced different graded scores for the same data depending on // DAY-bucket warmth (the in-target RATE already multiplied by // perDayPairCount; the graded score now does the equivalent). + const scorePairs = pairsByDay + .filter((pair) => pair.day >= ninetyDaysAgo && pair.day < now) + .map((pair) => ({ + at: pair.day, + sys: pair.sys, + dia: pair.dia, + count: pair.perDayPairCount, + })); const gradedScore = gradeBpScoreFromSeries({ - pairs: pairsByDay.map((p) => ({ - at: p.day, - sys: p.sys, - dia: p.dia, - count: p.perDayPairCount, - })), + pairs: scorePairs, target: targets, now, }); + const representative = representativeBpFromSeries({ + pairs: scorePairs, + now, + }); return { last7Days: last7, last30Days: last30, last90Days: last90, last90EarliestAt, + last90LatestAt, allTime, priorMonth, priorYear, path: "rollup", rowCount, gradedScore, + representative, }; } @@ -525,22 +542,34 @@ async function computeFromLive( ); // v1.15.12 A1 — graded BP pillar score from the recency-weighted // representative of every accepted per-event pair over the read window. + const scorePairs = collectBpPairs(sysData, diaData, userTz).filter( + (pair) => pair.at >= new Date(now.getTime() - 90 * DAY_MS) && pair.at < now, + ); const gradedScore = gradeBpScoreFromSeries({ - pairs: collectBpPairs(sysData, diaData, userTz), + pairs: scorePairs, target: targets, now, }); + const representative = representativeBpFromSeries({ + pairs: scorePairs, + now, + }); return { last7Days: windows.last7Days, last30Days: windows.last30Days, last90Days: windows.last90Days, last90EarliestAt: windows.last90EarliestAt, + last90LatestAt: scorePairs.reduce( + (latest, pair) => (!latest || pair.at > latest ? pair.at : latest), + null, + ), allTime: windows.allTime, priorMonth: windows.priorMonth, priorYear: windows.priorYear, path: "live", rowCount, gradedScore, + representative, }; } diff --git a/src/lib/analytics/bp-targets.ts b/src/lib/analytics/bp-targets.ts index b4b38b214..c663239a6 100644 --- a/src/lib/analytics/bp-targets.ts +++ b/src/lib/analytics/bp-targets.ts @@ -28,6 +28,18 @@ export interface BpTargets { diaHigh: number; } +export function bpTargetsEqual( + left: BpTargets | null, + right: BpTargets | null, +): boolean { + return ( + left?.sysLow === right?.sysLow && + left?.sysHigh === right?.sysHigh && + left?.diaLow === right?.diaLow && + left?.diaHigh === right?.diaHigh + ); +} + function getAge(dateOfBirth: Date): number { const today = new Date(); let age = today.getFullYear() - dateOfBirth.getFullYear(); diff --git a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts index 209ff82c9..af4b53233 100644 --- a/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts +++ b/src/lib/analytics/compliance/__tests__/as-needed-compliance-exclusion.test.ts @@ -6,23 +6,14 @@ * * Source pins: each call site that feeds the compliance engine fetches * its medications with an explicit `asNeeded: false` predicate. The pin - * walks the medication-feeding `findMany` of every rate-surfacing call - * site (the documented set: Coach prompt, BP-status gate, health-score - * pillar, insight features / targets / comprehensive, the nightly - * compliance status, the batched card-compliance payload) and asserts - * the predicate is present — a refactor that drops it fails here before - * a PRN medication can drag a user's rate to 0% (never-taken-on-no- - * schedule) or pad it to 100% (vacuous denominator). + * walks every rate-surfacing `findMany` and fails if a PRN medication + * could enter a scheduled-dose rate. */ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const CALL_SITES: Array<{ file: string; surface: string }> = [ - { - file: "src/lib/analytics/health-score-fast-path.ts", - surface: "health-score compliance pillar (dashboard)", - }, { file: "src/lib/insights/blood-pressure-status.ts", surface: "BP-status compliance gate", diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index eb2b8798b..0c46b89fc 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -16,7 +16,7 @@ */ import { binaryReferenceSex } from "@/lib/profile/sex"; -import { getBpTargets } from "./bp-targets"; +import { getBpTargets, type BpTargets } from "./bp-targets"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -332,6 +332,45 @@ export function resolveWeightTargetOverride( return { min, max }; } +function resolveThresholdOverride( + thresholdsJson: unknown, + metric: ThresholdMetric, +): { min: number; max: number } | null { + if (!thresholdsJson || typeof thresholdsJson !== "object") return null; + const value = (thresholdsJson as Record)[metric]; + if (!value || typeof value !== "object") return null; + const { min, max } = value as { min?: unknown; max?: unknown }; + if (typeof min !== "number" || typeof max !== "number") return null; + if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return null; + return { min, max }; +} + +export function resolveEffectiveBpTargets( + profile: UserProfileForRange, + thresholdsJson: unknown, +): BpTargets | null { + const sysOverride = resolveThresholdOverride( + thresholdsJson, + "BLOOD_PRESSURE_SYS", + ); + const diaOverride = resolveThresholdOverride( + thresholdsJson, + "BLOOD_PRESSURE_DIA", + ); + const overrides: ThresholdOverridesJson = {}; + if (sysOverride) overrides.BLOOD_PRESSURE_SYS = sysOverride; + if (diaOverride) overrides.BLOOD_PRESSURE_DIA = diaOverride; + const sys = getEffectiveRange("BLOOD_PRESSURE_SYS", profile, overrides).range; + const dia = getEffectiveRange("BLOOD_PRESSURE_DIA", profile, overrides).range; + if (!sys || !dia) return null; + return { + sysLow: sys.greenMin, + sysHigh: sys.greenMax, + diaLow: dia.greenMin, + diaHigh: dia.greenMax, + }; +} + /** * Convenience: resolve every supported metric at once. Useful for /targets * and the insight generator. diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts deleted file mode 100644 index 13bc067bd..000000000 --- a/src/lib/analytics/health-score-fast-path.ts +++ /dev/null @@ -1,618 +0,0 @@ -/** - * v1.4.37 W2 — probe-gated Health Score builder. - * - * The `/api/analytics` default slice builds the Personal Health Score - * by combining BP-in-target % (already computed upstream) with three - * additional pillars: weight-trend alignment, mood stability, and - * medication compliance. Up to v1.4.37 the route inlined four parallel - * `findMany` reads (weight 37 days, BP-SYS 30 days for source pills, - * mood 37 days, active medications) plus a fifth for intake events. - * On a cold connection pool the round-trip latency stacks — the - * v1.4.36 perf-verify recorded the bp_in_target + healthScore + - * correlations fan-out at 111 s wall-clock against the maintainer's - * 311 779-row tenant. - * - * Read shape (v1.4.37) - * -------------------- - * 1. **Probe** — `probeRollupCoverage` returns the per-type DAY-bucket - * coverage map. When WEIGHT is covered we take the rollup-fast-path - * for the weight pillar; the slope/alignment math runs against - * per-day MEAN weight derived from `measurement_rollups` instead - * of the raw `measurements` table. - * - * 2. **Source attribution** — only the raw rows know which ingest - * path produced each reading, so the per-component - * `HealthScoreSourceAttribution` slice still needs a narrow read - * against `measurements` (selecting only `source` + `measuredAt`). - * That read is cheap (30-day window, 2-column projection) and - * stays live regardless of coverage. - * - * 3. **Live fallback** — when the probe shows WEIGHT is not covered - * (brand-new user, no buckets yet) the helper reads the raw - * weight rows the same way the legacy route did so the score - * stays correct on first cold-mount. - * - * The mood / medication / compliance pillars don't have rollup - * equivalents (the rollup table only carries `Measurement` aggregates) - * — those reads remain live regardless. The path annotate on - * `meta.healthScore.path` makes the branch selection visible in prod - * logs. - */ -import { prisma } from "@/lib/db"; -import { annotate } from "@/lib/logging/context"; -import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; -import { - probeRollupCoverage, - type RollupCoverageMap, -} from "@/lib/rollups/measurement-coverage"; -import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; -import { - buildComplianceMedicationContext, - calculateCompliance, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "./compliance"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; -import { - computeHealthScore, - type ContributingSource, - type HealthScoreInput, - type HealthScoreResult, - type WeightTargetSource, -} from "./health-score"; -import type { MeasurementSource } from "@/generated/prisma/client"; - -/** - * v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto - * the camelCase token set the health-score analytics layer consumes. - * - * - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both - * surface as `"manual"`. - * - `WITHINGS` and `APPLE_HEALTH` ride one-to-one. - * - * Returns `null` for source values that don't fall into the three - * exposed buckets — defence-in-depth in case the enum grows before the - * client is taught about it. - */ -function mapMeasurementSourceToLabel( - source: MeasurementSource, -): ContributingSource | null { - switch (source) { - case "MANUAL": - case "IMPORT": - return "manual"; - case "WITHINGS": - return "withings"; - case "APPLE_HEALTH": - return "appleHealth"; - default: - return null; - } -} - -function uniqueComponentSources( - rows: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - for (const src of rows) { - const label = mapMeasurementSourceToLabel(src); - if (label) seen.add(label); - } - return Array.from(seen); -} - -export interface HealthScoreFastPathInput { - userId: string; - bpInTargetPct: number | null; - /** - * v1.4.38 — prior-week BP-in-target pct so the week-over-week delta - * reflects BP movement instead of always landing at zero for the - * pillar. The caller is expected to run a second - * `computeBpInTargetFastPath` against `now - 7d` and pass the - * resulting `last30Days.pct` through. When omitted (legacy callers - * predating v1.4.38) the helper falls back to `bpInTargetPct` — - * preserves the pre-v1.4.38 behaviour exactly so the bump is - * additive. - */ - bpInTargetPctPriorWeek?: number | null; - /** - * v1.15.12 A1 — graded clinical-proximity BP score (0..100) from a - * recency-weighted representative reading. When supplied it becomes - * the BP pillar VALUE; the binary `bpInTargetPct` only decides pillar - * presence and is surfaced as a secondary stat. Omit (legacy callers) - * to keep the pre-v1.15.12 behaviour where the rate IS the score. - */ - bpGradedScore?: number | null; - /** - * v1.15.12 A1 — prior-week graded BP score for the week-over-week - * delta. Omit to fall back to `bpGradedScore` (BP cancels out of the - * delta), mirroring the `bpInTargetPctPriorWeek` fallback. - */ - bpGradedScorePriorWeek?: number | null; - /** - * v1.34 — the weight yardstick, resolved by the caller through - * `buildHealthScoreWeightTarget`. The band the user set, or `null` - * when they set none. Required, not defaulted: a forgotten caller must - * fail to compile rather than silently fall back to a number nobody - * chose — which is exactly how `22 × height²` graded every account for - * months. - */ - weightTarget: { min: number; max: number } | null; - /** Which yardstick `weightTarget` came from. Required for the same reason. */ - weightTargetSource: WeightTargetSource; - now: Date; - /** - * Per-type rollup coverage map. The caller (analytics route) probes - * once and shares the result across all three fast-path helpers so - * the probe cost stays flat in the fan-out. - */ - coverage?: RollupCoverageMap; - /** - * v1.18.0 R4 — module-aware mood pillar. When the caller resolves the - * `mood` module as disabled it threads `moodEnabled: false` and the - * helper drops the 0.20 mood-stability pillar entirely: the mood read - * is skipped and the series stays empty, so `moodStability` returns - * `null` and the existing proportional null-redistribution re-scales - * the surviving pillars to sum to 100. Without this a disabled-mood - * account is silently penalised (or rewarded) by a pillar it can no - * longer see anywhere else in the product. - * - * v1.34 — required. The field used to default to `true`, and the - * dashboard snapshot never passed it: a mood-off account got a mood - * pillar in the hero ring and none in the Insights card, the same - * score composed two different ways on two surfaces. A required flag - * makes the compiler name every caller. - */ - moodEnabled: boolean; -} - -/** - * Public entrypoint. Returns the same shape `computeHealthScore` - * returns, plus a `path` annotate emitted on the meta dict. - * - * Returns null when no component can be computed (the route surfaces - * `null` to the UI so the hero panel hides cleanly). - */ -export async function computeUserHealthScoreFastPath( - input: HealthScoreFastPathInput, -): Promise { - const DAY_MS = 24 * 60 * 60 * 1000; - const { - userId, - bpInTargetPct, - now, - moodEnabled, - weightTarget, - weightTargetSource, - } = input; - // v1.4.38 — prior-week BP fallback. When the caller omits the field - // we pin to the current value (pre-v1.4.38 behaviour) so the helper - // stays a drop-in replacement for legacy callers; when the caller - // supplies it we feed it into the previous-window snapshot so the - // delta reflects week-over-week BP movement instead of always - // zeroing the BP pillar. - const bpInTargetPctPriorWeek = - input.bpInTargetPctPriorWeek === undefined - ? bpInTargetPct - : input.bpInTargetPctPriorWeek; - const since30d = new Date(now.getTime() - 30 * DAY_MS); - const prevSince30d = new Date(now.getTime() - 37 * DAY_MS); - const prevUntil = new Date(now.getTime() - 7 * DAY_MS); - - const coverage = input.coverage ?? (await probeRollupCoverage(userId)); - // v1.4.38.8 — gate only on the WEIGHT type this helper reads. The - // prior `isFullyCovered &&` AND poisoned the score fast-path with - // any unrelated brand-new type that lacked rollup coverage. Per- - // type coverage is the correct gate. - const weightCovered = coverage.get("WEIGHT") === true; - - // Weight series — rollup-fast-path when fully covered, raw findMany - // otherwise. The raw read selects only the columns the slope helper - // and the source-attribution accordion need (value + measuredAt + - // source) so the projection stays narrow. - let weightSeriesLast30d: Array<{ date: string; kg: number }>; - let weightSeriesPrev30d: Array<{ date: string; kg: number }>; - let weightSourcesIn30d: ReadonlyArray; - let latestWeightAsOf: string | null; - // v1.4.40 W-WMY-WIRE — long-window weight baseline served from the - // WEEK / MONTH / YEAR rollup tier. Used for the diagnostic annotate - // so operators can verify the WMY readers actually serve production - // traffic instead of sitting as dead write amplification (see - // `.planning/round-v1438-perf-analysis.md` §2 + §5 P6). The score - // shape stays unchanged; the long-window mean is annotate-only so - // the v1.5 Coach drawer can opt into it later without breaking the - // current contract. - let weightLongWindowGranularity: string | null = null; - let weightLongWindowMean: number | null = null; - let weightLongWindowBucketCount = 0; - - if (weightCovered) { - // Rollup-fast-path — per-day MEAN weight from `measurement_rollups`. - // Linear regression on per-day means produces a slope equivalent - // to per-event regression for a series with consistent sampling - // cadence (one weigh-in per day is the canonical pattern). - // - // v1.4.40 W-WMY-WIRE — the long-window weight read runs in - // parallel with the canonical DAY-bucket read so the wall-clock - // cost stays flat. `readBestGranularityRollups(..., 365)` routes - // the trailing-year window through MONTH (floor 181 d) by default - // — typically 12 monthly buckets vs the ~365 DAY rows the live - // path would scan. count / mean are linearly composable across - // MONTH buckets so the derived mean is mathematically equivalent - // to the per-row average over the same year-long window. - // Coverage-fallback in the helper means a tenant with WEEK or DAY - // coverage but no MONTH buckets still resolves to a usable mean. - const [dayBuckets, longWindow] = await Promise.all([ - readRollupBuckets(userId, "WEIGHT", "DAY", prevSince30d, now), - readBestGranularityRollups(userId, "WEIGHT", 365), - ]); - weightSeriesLast30d = dayBuckets - .filter((b) => b.bucketStart >= since30d) - .map((b) => ({ - date: b.bucketStart.toISOString(), - kg: b.mean, - })); - weightSeriesPrev30d = dayBuckets - .filter( - (b) => b.bucketStart >= prevSince30d && b.bucketStart <= prevUntil, - ) - .map((b) => ({ - date: b.bucketStart.toISOString(), - kg: b.mean, - })); - if (longWindow && longWindow.rows.length > 0) { - weightLongWindowGranularity = longWindow.granularity; - weightLongWindowBucketCount = longWindow.rows.length; - let totalCount = 0; - let weighted = 0; - for (const row of longWindow.rows) { - totalCount += row.count; - weighted += row.count * row.mean; - } - if (totalCount > 0) { - weightLongWindowMean = weighted / totalCount; - } - } - // Source attribution still needs the raw rows — only the raw table - // carries the `source` enum. Pull the narrow 30-day window with a - // 2-column projection so the round-trip stays minimal. - const sourceRows = await prisma.measurement.findMany({ - where: { - userId, - type: "WEIGHT", - measuredAt: { gte: since30d, lte: now }, - deletedAt: null, - }, - select: { measuredAt: true, source: true }, - orderBy: { measuredAt: "asc" }, - }); - weightSourcesIn30d = uniqueComponentSources( - sourceRows.map((r) => r.source), - ); - latestWeightAsOf = sourceRows.at(-1)?.measuredAt.toISOString() ?? null; - } else { - // Live fallback — raw rows over the 37-day window. Mirrors the - // pre-v1.4.37 behaviour exactly. - const weightRows = await prisma.measurement.findMany({ - where: { - userId, - type: "WEIGHT", - measuredAt: { gte: prevSince30d, lte: now }, - deletedAt: null, - }, - select: { value: true, measuredAt: true, source: true }, - orderBy: { measuredAt: "asc" }, - }); - weightSeriesLast30d = weightRows - .filter((r) => r.measuredAt >= since30d) - .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); - weightSeriesPrev30d = weightRows - .filter((r) => r.measuredAt >= prevSince30d && r.measuredAt <= prevUntil) - .map((r) => ({ date: r.measuredAt.toISOString(), kg: r.value })); - weightSourcesIn30d = uniqueComponentSources( - weightRows.filter((r) => r.measuredAt >= since30d).map((r) => r.source), - ); - latestWeightAsOf = - weightRows - .filter((r) => r.measuredAt >= since30d) - .at(-1) - ?.measuredAt.toISOString() ?? null; - } - - // Remaining reads — mood, BP-SYS source attribution, medications + - // intake — run in parallel. None has a rollup equivalent today so - // these are always live. - const [bpSysRowsForSource, moodRows, medications] = await Promise.all([ - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_PRESSURE_SYS", - measuredAt: { gte: since30d, lte: now }, - deletedAt: null, - }, - select: { measuredAt: true, source: true }, - orderBy: { measuredAt: "asc" }, - }), - // v1.18.0 R4 — skip the read entirely when the mood module is off. - // An empty series feeds `moodStability` → null → the 0.20 pillar - // weight redistributes across the surviving pillars. Skipping the - // query (rather than reading-then-discarding) keeps the disabled - // module off the round-trip budget too. - moodEnabled - ? prisma.moodEntry.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - moodLoggedAt: { gte: prevSince30d, lte: now }, - }, - select: { score: true, moodLoggedAt: true }, - orderBy: { moodLoggedAt: "asc" }, - }) - : Promise.resolve([] as Array<{ score: number; moodLoggedAt: Date }>), - prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications carry no expected doses - // and are excluded from every compliance rate: they must - // contribute neither 0% nor 100% to the score pillar. - where: { userId, active: true, asNeeded: false }, - select: { - id: true, - createdAt: true, - // v1.7.0 SB-SCHED-2 — the medication course-window fields the - // canonical engine needs to expand expected slots. - startsOn: true, - endsOn: true, - oneShot: true, - // v1.25 M-MED3 — route through the SHARED compliance select like every - // other caller, so the Health-Score pillar honours the same per-dose - // on-time windows + cadence fields the dashboard / detail surfaces use. - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.25 M-MED3 — archived schedule eras so a past day scores against - // the schedule that was live THEN, not the current one (every other - // compliance caller already threads these). The builder copies them - // through into the context. - scheduleRevisions: { - orderBy: { validFrom: "asc" }, - select: { - id: true, - validFrom: true, - validUntil: true, - payload: true, - supersededByRevisionId: true, - }, - }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }), - ]); - - let medicationCompliance30: number[] = []; - let medicationCompliance30Previous: number[] = []; - if (medications.length > 0) { - const medIds = medications.map((m) => m.id); - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - medicationId: { in: medIds }, - scheduledFor: { gte: prevSince30d, lte: now }, - }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - }, - }); - const eventsByMed = new Map(); - for (const ev of intakeEvents) { - const list = eventsByMed.get(ev.medicationId); - if (list) list.push(ev); - else eventsByMed.set(ev.medicationId, [ev]); - } - // v1.7.0 SB-SCHED-2 — resolve the user timezone once so the - // compliance pillar routes its denominator through the canonical - // engine. The pinned `now` is kept so the pillar still agrees with - // the score's other pillars. - const userTz = await resolveUserTimezone(userId); - medicationCompliance30 = medications.map((med) => { - const events = eventsByMed.get(med.id) ?? []; - const medicationContext = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(events), - userTz, - ); - // v1.5.0 — pass the helper's pinned `now` so the cadence-aware - // window math agrees with the score's other pillars (which also - // anchor to the same `now`). Closes #214. - return calculateCompliance(events, med.schedules, 30, med.createdAt, { - now, - medicationContext, - }).rate; - }); - medicationCompliance30Previous = medications.map((med) => { - const events = (eventsByMed.get(med.id) ?? []).filter( - (e) => e.scheduledFor <= prevUntil, - ); - // Shift forward by 7 days so the helper's `now` anchor still - // captures the same logical 30 days (pre-v1.4.37 behaviour). - const shifted = events.map((e) => ({ - scheduledFor: new Date(e.scheduledFor.getTime() + 7 * DAY_MS), - takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null, - skipped: e.skipped, - })); - // v1.25 M-MED3 — `med` (and so its scheduleRevisions + pauseEras) is - // passed UNshifted while the intake events shift +7d (the pre-existing - // approximation that lets the pinned `now` anchor still capture the prior - // logical 30 days). The revisions / pause eras are absolute historical - // records; no canonical previous-window caller shifts them, so they stay - // as-is here for consistency with the rest of the engine. Trade-off: in - // the trailing-30-day DELTA the era / pause segmentation can be up to 7 - // days out of phase with the shifted slots. The primary (current-window) - // score — `medicationCompliance30` above — is exact; only the secondary - // trend delta carries this best-effort approximation. - const medicationContext = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(shifted), - userTz, - ); - return calculateCompliance(shifted, med.schedules, 30, med.createdAt, { - now, - medicationContext, - }).rate; - }); - } - - const moodSeriesLast30d = moodRows - .filter((r) => r.moodLoggedAt >= since30d) - .map((r) => ({ - date: r.moodLoggedAt.toISOString(), - score: r.score, - })); - const moodSeriesPrev30d = moodRows - .filter( - (r) => r.moodLoggedAt >= prevSince30d && r.moodLoggedAt <= prevUntil, - ) - .map((r) => ({ - date: r.moodLoggedAt.toISOString(), - score: r.score, - })); - - const windowEndAt = now.toISOString(); - - const bpSourceTokens = uniqueComponentSources( - bpSysRowsForSource.map((r) => r.source), - ); - const latestBpInWindow = bpSysRowsForSource.at(-1); - - // Mood doesn't yet have a non-manual ingest (v1.5 will introduce - // Apple Health mood) — preserve the manual-only contract. - const moodSourceTokens = - moodSeriesLast30d.length > 0 ? (["manual"] as const) : []; - const latestMoodInWindow = moodRows - .filter((r) => r.moodLoggedAt >= since30d) - .at(-1); - - const complianceSourceTokens = - medicationCompliance30.length > 0 ? (["manual"] as const) : []; - - const current: HealthScoreInput = { - bpInTargetRate: bpInTargetPct, - // v1.15.12 A1 — graded BP score drives the pillar value when present - // (the binary rate above only gates presence + is the secondary stat). - bpGradedScore: input.bpGradedScore ?? null, - weightSeriesLast30d, - weightTarget, - weightTargetSource, - moodEntriesLast30d: moodSeriesLast30d, - medicationCompliance30, - attribution: { - bpSources: bpSourceTokens, - asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null, - weightSources: weightSourcesIn30d, - asOfWeight: latestWeightAsOf, - moodSources: moodSourceTokens, - asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null, - complianceSources: complianceSourceTokens, - asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null, - windowEndAt, - }, - }; - - // Previous-window snapshot — same logic, prior-week-shifted series. - // - // v1.4.38 — when the caller supplies `bpInTargetPctPriorWeek` we - // use it so the delta reflects week-over-week BP movement. Legacy - // callers that omit the field keep the pre-v1.4.38 behaviour where - // BP cancels out of the delta because the helper pins both windows - // to the same value. Re-computing the prior-week BP costs one - // extra rollup-coverage read on the route side; the helper itself - // stays single-pass. - const previous: HealthScoreInput = { - bpInTargetRate: bpInTargetPctPriorWeek, - // v1.15.12 A1 — prior-week graded score so the week-over-week delta - // reflects graded BP movement. Falls back to the current graded - // score (delta cancels to zero for BP) when the caller omits it. - bpGradedScore: - input.bpGradedScorePriorWeek === undefined - ? (input.bpGradedScore ?? null) - : input.bpGradedScorePriorWeek, - weightSeriesLast30d: weightSeriesPrev30d, - weightTarget, - weightTargetSource, - moodEntriesLast30d: moodSeriesPrev30d, - medicationCompliance30: medicationCompliance30Previous, - }; - - const result = computeHealthScore(current, previous); - - // No-pillar-eligible early-out — nothing computable, hero hides cleanly. - // - // The naive raw-record check this replaced only asked whether any pillar - // had a nonzero row count, not whether that count cleared the pillar's own - // eligibility bar: weight needs >= 2 readings, mood needs >= 5 entries, BP - // needs a paired reading, compliance needs an active scheduled medication. - // An account that clears the row-count check but misses every pillar's - // bar (e.g. exactly one mood entry, or a single weight reading) fell - // through to `computeHealthScore`, which sums no components and returns a - // raw `0` — indistinguishable from a genuinely poor score. Checking the - // COMPUTED result's components (the same eligibility rules the score - // itself already applies) instead of re-deriving the thresholds here means - // this can never drift out of sync with `computeHealthScore`. A component - // that is legitimately computed as `0` still has `value: 0`, not `null`, - // so it is never swept into this branch. - const noComponentEligible = - result.components.bp.value === null && - result.components.weight.value === null && - result.components.mood.value === null && - result.components.compliance.value === null; - - if (noComponentEligible) { - annotate({ - meta: { - healthScore: { - score: null, - reason: "no_components_available", - path: weightCovered ? "rollup" : "live", - weightLongWindow: - weightLongWindowMean !== null - ? { - mean: Math.round(weightLongWindowMean * 100) / 100, - granularity: weightLongWindowGranularity, - buckets: weightLongWindowBucketCount, - } - : null, - }, - }, - }); - return null; - } - - annotate({ - meta: { - healthScore: { - score: result.score, - band: result.band, - delta: result.delta, - path: weightCovered ? "rollup" : "live", - // v1.4.40 W-WMY-WIRE — the long-window weight baseline drawn - // from the WEEK / MONTH / YEAR rollup tier. `null` when the - // user lacks long-tail coverage or when the live fallback - // branch ran (no probe issued). Surfaces the granularity the - // router landed on so operators can see "rollup tier served - // the year-long mean from MONTH buckets" in the wide-event. - weightLongWindow: - weightLongWindowMean !== null - ? { - mean: Math.round(weightLongWindowMean * 100) / 100, - granularity: weightLongWindowGranularity, - buckets: weightLongWindowBucketCount, - } - : null, - }, - }, - }); - return result; -} diff --git a/src/lib/analytics/health-score-inputs.ts b/src/lib/analytics/health-score-inputs.ts deleted file mode 100644 index 60526b730..000000000 --- a/src/lib/analytics/health-score-inputs.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * v1.17 W1b — single Health-Score BP-pillar input builder. - * - * The Personal Health Score is computed by `computeUserHealthScoreFastPath` - * from two server callers: the analytics route (the Insights health-score - * card) and the dashboard snapshot (the hero ring). Before this builder the - * two callers assembled the BP-pillar inputs by hand and DIVERGED: - * - * - the analytics route fed the BP pillar from the all-time in-target rate - * and passed the prior-week values for the week-over-week delta; - * - the dashboard snapshot fed the pillar from the trailing-30-day rate and - * omitted the prior-week values entirely (so the ring's delta zeroed BP - * out and the pillar's presence gate flipped on a user with BP history - * but no readings in the last month). - * - * Same user, two different scores and two different bands. v1.17 W1d already - * unified the WINDOW (both read the trailing-90-day rate); this builder - * unifies the ASSEMBLY so the window choice, the all-time fallback, the - * graded-score wiring and the prior-week delta inputs can never drift between - * the two surfaces again. Both callers pass the identical - * `BpInTargetEnvelope` pair through here and consume the result verbatim. - * - * v1.34 — the WEIGHT yardstick joins the file for the same reason. The score - * used to grade weight against `22 × height²` ± 2 kg: a target no user ever - * set and no surface ever named. `buildHealthScoreWeightTarget` replaces it - * with the one honest source, `getEffectiveRange("WEIGHT", …)`, and hands the - * band over ONLY when it is a real user override. Both callers route through - * it so the ring and the card can never grade weight against two different - * yardsticks. - * - * Pure & deterministic — no I/O. The callers own the - * `computeBpInTargetFastPath` reads (current + prior-week) and hand the two - * envelopes in, and own the `User` row the weight profile / overrides come - * from. - */ -import type { BpInTargetEnvelope } from "./bp-in-target-fast-path"; -import { - getEffectiveRange, - type ThresholdOverridesJson, - type UserProfileForRange, -} from "./effective-range"; -import type { WeightTargetSource } from "./health-score"; -import { isWindowSufficient } from "./window-confidence"; - -/** - * The BP-pillar slice of `HealthScoreFastPathInput`. Both Health-Score - * callers spread this into their `computeUserHealthScoreFastPath` call so the - * pillar is graded off identical inputs regardless of surface. - */ -export interface HealthScoreBpInputs { - bpInTargetPct: number | null; - bpInTargetPctPriorWeek: number | null; - bpGradedScore: number | null; - bpGradedScorePriorWeek: number | null; -} - -/** - * Build the BP-pillar inputs from the current-window and prior-week - * `computeBpInTargetFastPath` envelopes. - * - * - The pillar rate (presence gate + secondary stat) reads the canonical - * trailing-90-day window (W1d), falling back to the all-time rate only when - * the 90-day window is itself null so a sparse-but-historical account keeps - * its BP pillar. - * - v1.17 W1b — when the 90-day window holds fewer than the confidence floor - * (`isWindowSufficient`) the pillar collapses to `null` so a thin-data user - * does not get a confident BP pillar grading off a handful of readings. The - * dashboard tile shows "collecting data" for the same input; the pillar and - * the tile agree on the gate. The all-time fallback still rescues an account - * with a deep history but a quiet last quarter. - * - The prior-week rate uses the prior-week run's 90-day window so the - * week-over-week delta compares the same window on both ends. - * - The graded clinical-proximity score (the pillar VALUE) and its prior-week - * counterpart come straight off the envelopes. - * - * `null` for the current envelope (no BP targets / no readings) collapses - * every field to `null`, which the score helper reads as "pillar absent". - */ -export function buildHealthScoreBpInputs( - current: BpInTargetEnvelope | null, - priorWeek: BpInTargetEnvelope | null, -): HealthScoreBpInputs { - if (!current) { - return { - bpInTargetPct: null, - bpInTargetPctPriorWeek: null, - bpGradedScore: null, - bpGradedScorePriorWeek: null, - }; - } - // v1.17 W1b — a thin 90-day window (below the confidence floor) is not a - // trustworthy pillar input. Fall through to the all-time rate when the - // 90-day window is itself sparse so a deep-history account survives a quiet - // quarter, but never grade off a sub-floor 90-day sample. - const last90Sufficient = - current.last90Days !== null && isWindowSufficient(current.last90Days.pairs); - const bpInTargetPct = last90Sufficient - ? (current.last90Days?.pct ?? null) - : (current.allTime?.pct ?? null); - const priorWeek90Sufficient = - priorWeek?.last90Days != null && - isWindowSufficient(priorWeek.last90Days.pairs); - const bpInTargetPctPriorWeek = priorWeek90Sufficient - ? (priorWeek?.last90Days?.pct ?? null) - : (priorWeek?.allTime?.pct ?? null); - // The graded clinical score is the pillar VALUE; suppress it together with - // the rate when the 90-day window is thin AND no all-time rate rescues the - // pillar, so the pillar disappears cleanly instead of grading a thin sample. - // The prior-week graded score keys off the PRIOR-WEEK window's own presence - // (not the current one) so the week-over-week delta never pairs a confident - // current value against a thin-sample prior value. - const pillarPresent = bpInTargetPct !== null; - const priorPillarPresent = bpInTargetPctPriorWeek !== null; - return { - bpInTargetPct, - bpInTargetPctPriorWeek, - bpGradedScore: pillarPresent ? current.gradedScore : null, - bpGradedScorePriorWeek: priorPillarPresent - ? (priorWeek?.gradedScore ?? null) - : null, - }; -} - -/** - * The weight-pillar slice of `HealthScoreFastPathInput`. Both Health-Score - * callers spread this into their `computeUserHealthScoreFastPath` call so the - * pillar is graded against the identical yardstick regardless of surface. - */ -export interface HealthScoreWeightTargetInputs { - weightTarget: { min: number; max: number } | null; - weightTargetSource: WeightTargetSource; -} - -/** - * Resolve the weight yardstick for the Health Score. - * - * The score grades weight against the user's OWN target or against nothing. - * `getEffectiveRange` layers an evidence-based default under the override, and - * that default is exactly what must NOT reach the score: it is a reference - * band the person never chose and no score surface ever named. So the green - * band is handed over only when `isOverride` is true; every other case resolves - * to `null`, which routes the pillar through the honest trend-only path. - * - * Pure. `overrides` is the parsed `User.thresholdsJson` value (may be null). - */ -export function buildHealthScoreWeightTarget( - profile: UserProfileForRange, - overrides: ThresholdOverridesJson | null, -): HealthScoreWeightTargetInputs { - const effective = getEffectiveRange("WEIGHT", profile, overrides); - if (!effective.isOverride || !effective.range) { - return { weightTarget: null, weightTargetSource: "none" }; - } - return { - weightTarget: { - min: effective.range.greenMin, - max: effective.range.greenMax, - }, - weightTargetSource: "user", - }; -} diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts deleted file mode 100644 index 606455746..000000000 --- a/src/lib/analytics/health-score.ts +++ /dev/null @@ -1,612 +0,0 @@ -/** - * v1.4.20 phase B5 — Personal Health Score. - * - * A composite 0–100 score that blends four health pillars into one - * easy-to-read number. Server-deterministic: the same inputs always - * produce the same output, so a curious user can recompute it mentally - * (or in a spreadsheet) from the four components. - * - * Formula (when every component is present): - * score = round( - * 0.30 * bpGradedScore // v1.15.12 A1 — graded clinical - * // proximity of a recency-weighted - * // representative BP (was the binary - * // bpInTargetRate, kept as a secondary - * // "X% in range" stat) - * + 0.20 * weightTrendAlignment // 0..100, see weightTrendAlignment() - * + 0.20 * moodStability // 0..100, see moodStability() - * + 0.30 * complianceRate // mean of medication compliance30 - * ) - * - * When a component is null (insufficient data, no medications, etc.) - * the remaining weights are scaled proportionally so they still sum to - * 100 — the score never lies about what was actually measured. - * - * Weight yardstick: the pillar grades against the user's OWN target - * band or against nothing at all. There is no population default and no - * derived stand-in — a band the user never set is not a band. Callers - * resolve the target through `buildHealthScoreWeightTarget` - * (`./health-score-inputs`) and pass `weightTarget: null` + - * `weightTargetSource: "none"` when the account carries no override; the - * trend is then scored on its own via `bareWeightTrendScore`. The - * resolved yardstick rides the result on - * `components.weight.targetSource` so every surface can say which - * yardstick graded the row. - * - * Bands: - * green >= 75 - * yellow 50..74 - * red < 50 - * - * The score is **descriptive, not prescriptive** — see the disclaimer - * the UI surfaces ("Indicative — not a clinical assessment."). - */ - -export type HealthScoreBand = "green" | "yellow" | "red"; - -/** - * v1.4.25 W8e — per-component source attribution. - * - * `manual` / `withings` / `appleHealth` map onto the `MeasurementSource` - * enum already persisted on the underlying rows. `mixed` is the - * aggregate label when the component blends entries from more than one - * source. `none` is the explicit empty-state — no measurement contributed. - * - * The token list is a UI-facing camelCase view of the persisted - * `MeasurementSource` enum so the i18n keys (`provenance.sources.*`) - * stay locale-stable even if the storage enum grows. - */ -/** - * Source tokens that can contribute to a component before the - * `resolveSourceLabel` aggregation kicks in. Exported here so the - * analytics route, the health-score helper, and the React card all - * read the same union — the literal `"manual" | "withings" | - * "appleHealth"` was previously spelled at four call sites. - */ -export type ContributingSource = "manual" | "withings" | "appleHealth"; - -/** - * Which yardstick graded the weight pillar. - * - * - `"user"` — an explicit target the user set on the targets surface - * (`User.thresholdsJson`, resolved through `getEffectiveRange`). - * - `"none"` — no target on file; the pillar scores the bare trend and - * the surfaces say so. - * - * There is deliberately no third token: a score must never grade a - * person against a band nobody set and nothing discloses. - */ -export type WeightTargetSource = "user" | "none"; - -export type HealthScoreComponentSource = ContributingSource | "mixed" | "none"; - -/** - * v1.4.25 W8e — per-component source attribution as it arrives from the - * Prisma layer (the route maps Withings/Apple-Health/Manual rows for - * each pillar and forwards the contributing source set plus the - * freshest timestamp). `complianceSources` is `null` when no medications - * are active so the redistribution helper keeps treating compliance as - * "no signal" rather than "manual with zero entries". - */ -export interface HealthScoreSourceAttribution { - /** Sources that contributed to the BP-in-target rate. */ - bpSources?: ReadonlyArray; - asOfBp?: string | null; - /** Sources that contributed to the weight series. */ - weightSources?: ReadonlyArray; - asOfWeight?: string | null; - /** Sources that contributed to the mood entries. */ - moodSources?: ReadonlyArray; - asOfMood?: string | null; - /** Sources that contributed to medication compliance. */ - complianceSources?: ReadonlyArray; - asOfCompliance?: string | null; - /** Wall-clock anchor for the as-of-window-end fallback. */ - windowEndAt?: string; -} - -export interface HealthScoreInput { - /** - * All-time BP-in-target rate, already 0..100 (or null). - * - * v1.15.12 A1 — this is now a SECONDARY honest stat ("X% of readings - * in range"), no longer the pillar score itself. When `bpGradedScore` - * is supplied it drives the pillar value; `bpInTargetRate` is kept so - * the pillar can still be computed (presence check) and the rate - * surfaced alongside. Legacy callers that omit `bpGradedScore` keep - * the pre-v1.15.12 behaviour where the rate IS the score. - */ - bpInTargetRate: number | null; - /** - * v1.15.12 A1 — graded clinical-proximity BP score, 0..100 (or null). - * Computed from a recency-weighted representative reading via - * `gradeBpScoreFromSeries`. When present it becomes the BP pillar - * value; the binary `bpInTargetRate` only decides presence and is - * surfaced as a secondary stat. Closes the "borderline-stage-1 reads - * 16/100" trust bug. - */ - bpGradedScore?: number | null; - /** Weight readings over the last 30 days, ascending or unsorted (we sort). */ - weightSeriesLast30d: Array<{ date: string; kg: number }>; - /** - * The user's own target weight band in kg, or `null` when they have - * not set one. The band arrives whole — the scorer neither widens nor - * narrows it, and it never synthesises one from height, BMI, or the - * user's own median. `null` routes the pillar through - * `bareWeightTrendScore`. - */ - weightTarget: { min: number; max: number } | null; - /** - * Which yardstick the weight pillar is graded against. `"user"` means - * `weightTarget` carries an explicit override the user set on the - * targets surface; `"none"` means no target exists and the pillar - * scores the bare trend. Required so a caller cannot pass a band - * without declaring where it came from. - */ - weightTargetSource: WeightTargetSource; - /** Mood entries over the last 30 days. `score` 1..5 in HealthLog. */ - moodEntriesLast30d: Array<{ date: string; score: number }>; - /** Per-active-medication 30-day compliance %. Empty array → null. */ - medicationCompliance30: number[]; - /** - * v1.4.25 W8e — optional source attribution. When omitted the - * resulting `HealthScoreComponentDetail.source` falls back to `none` - * for null components and `manual` for present ones (the - * pre-v1.4.25 implicit default). - */ - attribution?: HealthScoreSourceAttribution; -} - -export interface HealthScoreComponentDetail { - /** Sub-score 0..100, or null if the component had insufficient data. */ - value: number | null; - /** Effective weight after null-redistribution, 0..1. */ - weight: number; - /** - * v1.4.25 W8e — which ingest path produced the underlying - * measurement(s) for this component. `mixed` when two or more - * sources contributed; `none` when the value is null. - */ - source: HealthScoreComponentSource; - /** - * ISO timestamp of the most recent contributing measurement, or - * the as-of-window-end when no measurement contributed. - */ - asOf: string; -} - -/** - * The weight pillar carries its yardstick alongside the value, so every - * surface can name what the row was graded against instead of leaving - * the reader to assume. The disclosure is the point: a target that grades - * silently is the defect this field closes. - */ -export interface HealthScoreWeightComponentDetail extends HealthScoreComponentDetail { - /** `"user"` when an explicit target graded the pillar, else `"none"`. */ - targetSource: WeightTargetSource; - /** - * The band the pillar was graded against, in kg. Null exactly when - * `targetSource` is `"none"` — the pillar then scored the bare trend. - */ - target: { min: number; max: number } | null; -} - -export interface HealthScoreResult { - /** 0..100, integer. Always present (defaults to 0 with all-null input). */ - score: number; - band: HealthScoreBand; - components: { - bp: HealthScoreComponentDetail; - weight: HealthScoreWeightComponentDetail; - mood: HealthScoreComponentDetail; - compliance: HealthScoreComponentDetail; - }; - /** Difference vs. last week's score; null when no historical input. */ - delta: number | null; - /** - * v1.18.1 P4 — Rest Mode context. When an illness/condition episode is - * active the route attaches this so the surface can frame the score ("you - * were unwell during this window") WITHOUT changing it: `computeHealthScore` - * stays pure and the number is never penalised. Absent / null when the - * account is not in Rest Mode (the pure computation never sets it). - */ - restMode?: RestModeAnnotation | null; -} - -/** - * v1.18.1 P4 — the score-payload view of Rest Mode. A compact, value-free - * annotation: never a decrypted note, never a score adjustment. iOS mirrors it - * verbatim (server-authoritative). The full context lives on - * `@/lib/illness/rest-mode`; this is the slice a score result carries. - */ -export interface RestModeAnnotation { - active: boolean; - /** ISO onset of the earliest active episode. */ - since: string | null; - episodeCount: number; -} - -const BASE_WEIGHTS = { - bp: 0.3, - weight: 0.2, - mood: 0.2, - compliance: 0.3, -} as const; - -// ── Pure helpers ───────────────────────────────────────────────────── - -/** - * Linear-regression slope (units / day) over a time-series of points. - * Pure least-squares fit. Returns null when there are fewer than two - * distinct x-values (the denominator collapses to zero). - */ -export function linearRegressionSlope( - points: Array<{ date: string; value: number }>, -): number | null { - if (points.length < 2) return null; - const sorted = [...points].sort((a, b) => - a.date < b.date ? -1 : a.date > b.date ? 1 : 0, - ); - const startMs = new Date(sorted[0].date).getTime(); - if (Number.isNaN(startMs)) return null; - const xy = sorted.map((p) => { - const t = new Date(p.date).getTime(); - return { - x: (t - startMs) / (24 * 60 * 60 * 1000), - y: p.value, - }; - }); - const n = xy.length; - const sumX = xy.reduce((s, p) => s + p.x, 0); - const sumY = xy.reduce((s, p) => s + p.y, 0); - const sumXY = xy.reduce((s, p) => s + p.x * p.y, 0); - const sumXX = xy.reduce((s, p) => s + p.x * p.x, 0); - const denominator = n * sumXX - sumX * sumX; - if (denominator === 0) return null; - return (n * sumXY - sumX * sumY) / denominator; -} - -/** - * Coefficient of variation: stdev / |mean|. Returns null when the mean - * is zero (the metric is undefined) or when fewer than two values are - * supplied (population variance over n=1 is zero by definition but - * carries no signal). - */ -export function coefficientOfVariation(values: number[]): number | null { - if (values.length < 2) return null; - const mean = values.reduce((s, v) => s + v, 0) / values.length; - if (mean === 0) return null; - const variance = - values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length; - const stdev = Math.sqrt(variance); - return stdev / Math.abs(mean); -} - -/** - * Score the recent weight trend on its own, with no target band. - * - * Used whenever the user has weight readings but has not set a target. - * Without a goal we can't say "closing the gap", but a sustained upward - * weight trend is the one direction that is broadly an adverse signal - * independent of any individual target, so we score on that asymmetry: - * - * - Flat / stable (slope ≈ 0) → 75 (maintenance is a solid result). - * - Gentle-to-steep decline (slope<0) → 75 → 100 (favourable; saturating). - * - Gentle-to-steep rise (slope>0) → 75 → 25 (unfavourable; saturating). - * - * The 75 anchor keeps a stable-but-noisy series mid-high (it lands at - * 75, not at the 50 "neither" midpoint the target-aware path uses) so a - * user simply maintaining weight isn't penalised for lacking a goal. - * The same `SATURATION = 0.05 kg/day` (≈ 1.5 kg/month) curve as the - * target-aware path keeps the two scoring modes numerically consistent. - * - * Returns null when fewer than two readings are supplied (caller already - * guards this, but the helper stays self-contained for direct tests). - */ -function bareWeightTrendScore( - series: Array<{ date: string; kg: number }>, -): number | null { - if (series.length < 2) return null; - const points = series.map((p) => ({ date: p.date, value: p.kg })); - const slope = linearRegressionSlope(points); - if (slope === null) return null; - if (slope === 0) return 75; // exactly stable → maintenance anchor - - const SATURATION = 0.05; - const normalised = Math.tanh(Math.abs(slope) / SATURATION); - // Decline lifts the anchor toward 100; rise drops it toward 25. The - // 25-point span keeps the unfavourable floor honest (a steep sustained - // gain reads "concerning", not "failing") while the favourable ceiling - // reaches the full 100. - const score = slope < 0 ? 75 + 25 * normalised : 75 - 50 * normalised; - return Math.round(Math.max(0, Math.min(100, score))); -} - -/** - * Score how well the recent weight trend is closing the gap toward the - * target band [targetMin..targetMax]. - * - * - Already inside the band → 100 (the trend doesn't need to do anything). - * - Above the band → a downward slope is "closing the gap"; magnitude - * maps slope to 0..100 with a saturating curve so a 0.5 kg/day drop - * doesn't read more positive than a 0.05 kg/day drop. - * - Below the band → an upward slope is closing. - * - * No target set but ≥ 2 readings → fall back to scoring the **bare - * trend** on its own, so a user with weight data still gets a populated, - * weighted pillar instead of an empty `{value:null, weight:0, - * source:"none"}`. See `bareWeightTrendScore` for the curve and its - * rationale. We do NOT fabricate a target — not from height, not from - * the user's own median — and pretend it's a goal; the trend is scored - * honestly, without one. - * - * Insufficient data (< 2 readings) → null regardless of target. - */ -export function weightTrendAlignment( - series: Array<{ date: string; kg: number }>, - target: { min: number; max: number } | null, -): number | null { - if (series.length < 2) return null; - if (!target) return bareWeightTrendScore(series); - const points = series.map((p) => ({ date: p.date, value: p.kg })); - // Latest reading drives the "are we currently in the band" check. - const sorted = [...points].sort((a, b) => - a.date < b.date ? -1 : a.date > b.date ? 1 : 0, - ); - const latest = sorted[sorted.length - 1].value; - if (latest >= target.min && latest <= target.max) return 100; - - const slope = linearRegressionSlope(points); - if (slope === null) return null; - - // Saturate a 0.1 kg/day movement to ~75 % alignment so noisy data - // doesn't cliff the score. The mapping uses a tanh-style curve scaled - // around 0.05 kg/day (≈ 1.5 kg/month — a clinically reasonable rate). - const SATURATION = 0.05; - const normalised = Math.tanh(Math.abs(slope) / SATURATION); - - // Above the band: slope < 0 closes the gap. - // Below the band: slope > 0 closes the gap. - const closing = latest > target.max ? slope < 0 : slope > 0; - if (slope === 0) return 50; // exactly stable counts as "neither" - return Math.round(closing ? 50 + 50 * normalised : 50 - 50 * normalised); -} - -/** - * Mood stability scored as `100 - CV*100`, clamped to 0..100. Returns - * null when there are fewer than 5 entries (the variance is too noisy - * to draw a stability conclusion from). - */ -export function moodStability( - entries: Array<{ date: string; score: number }>, -): number | null { - if (entries.length < 5) return null; - const values = entries.map((e) => e.score); - const cv = coefficientOfVariation(values); - if (cv === null) return null; - const score = 100 - cv * 100; - return Math.max(0, Math.min(100, Math.round(score))); -} - -/** - * Mean of medication-compliance percentages. Returns null when no - * medications are active so the score doesn't penalise people who - * haven't logged any. - */ -export function complianceRate(values: number[]): number | null { - if (values.length === 0) return null; - const sum = values.reduce((s, v) => s + v, 0); - return Math.round(sum / values.length); -} - -// ── Composite ──────────────────────────────────────────────────────── - -function clampToHundred(v: number): number { - return Math.max(0, Math.min(100, v)); -} - -function bandFor(score: number): HealthScoreBand { - if (score >= 75) return "green"; - if (score >= 50) return "yellow"; - return "red"; -} - -/** - * Compute the composite score + sub-component breakdown. Pure. - * - * The optional `previous` parameter is used by the route to plug the - * "vs last week" delta — when omitted the result's `delta` is null. - */ -export function computeHealthScore( - input: HealthScoreInput, - previous?: HealthScoreInput, -): HealthScoreResult { - // v1.15.12 A1 — the BP pillar value is the graded clinical-proximity - // score when supplied; otherwise it falls back to the legacy binary - // in-target rate (pre-v1.15.12 contract). The pillar is present only - // when the user actually has BP data — gated on `bpInTargetRate` - // (which the caller computes from paired readings) so a graded score - // can never resurrect a pillar that has no underlying readings. - const bpValue = - input.bpInTargetRate === null - ? null - : input.bpGradedScore !== undefined && input.bpGradedScore !== null - ? clampToHundred(input.bpGradedScore) - : clampToHundred(input.bpInTargetRate); - - // The band arrives whole from the caller's `getEffectiveRange` read — - // no expansion, no fabrication. `null` scores the bare trend. - const weightTarget = input.weightTarget; - const weightValue = weightTrendAlignment( - input.weightSeriesLast30d, - weightTarget, - ); - - const moodValue = moodStability(input.moodEntriesLast30d); - - const complianceValue = complianceRate(input.medicationCompliance30); - - const attribution = input.attribution ?? {}; - const windowEndAt = attribution.windowEndAt ?? deriveWindowEndAt(input); - const components = redistribute( - { - bp: bpValue, - weight: weightValue, - mood: moodValue, - compliance: complianceValue, - }, - { - bp: { - sources: attribution.bpSources ?? null, - asOf: attribution.asOfBp ?? null, - }, - weight: { - sources: attribution.weightSources ?? null, - asOf: attribution.asOfWeight ?? null, - }, - mood: { - sources: attribution.moodSources ?? null, - asOf: attribution.asOfMood ?? null, - }, - compliance: { - sources: attribution.complianceSources ?? null, - asOf: attribution.asOfCompliance ?? null, - }, - }, - windowEndAt, - { - source: input.weightTargetSource, - target: input.weightTargetSource === "user" ? weightTarget : null, - }, - ); - - let raw = 0; - for (const key of ["bp", "weight", "mood", "compliance"] as const) { - const c = components[key]; - if (c.value !== null) raw += c.value * c.weight; - } - const score = Math.round(clampToHundred(raw)); - const band = bandFor(score); - - let delta: number | null = null; - if (previous) { - const prev = computeHealthScore(previous); - delta = score - prev.score; - } - - return { score, band, components, delta }; -} - -/** - * Collapse a list of contributing source tokens into the single label - * the UI renders. `null`/empty when the underlying value is missing - * → `none`. Two or more distinct tokens → `mixed`. Exactly one token - * → that token. Pre-v1.4.25 inputs (no attribution supplied) default - * to `manual` for present values so the contract stays backward-compat - * — the manual-entry path has been HealthLog's only ingest since - * v1.0. - */ -function resolveSourceLabel( - hasValue: boolean, - sources: ReadonlyArray | null, -): HealthScoreComponentSource { - if (!hasValue) return "none"; - if (!sources || sources.length === 0) return "manual"; - const unique = Array.from(new Set(sources)); - if (unique.length === 1) return unique[0]; - return "mixed"; -} - -/** - * Re-scale the four base weights to skip any component whose value is - * null. The remaining components keep the same proportional ratio - * (e.g. BP and Compliance both at 30 % of original 80 % → 37.5 %). - * - * v1.4.25 W8e — the helper also folds the per-component - * `HealthScoreSourceAttribution` slice into each output detail so the - * client can render the provenance accordion without a second pass over - * the analytics payload. - */ -function redistribute( - values: { - bp: number | null; - weight: number | null; - mood: number | null; - compliance: number | null; - }, - attribution: Record< - keyof typeof BASE_WEIGHTS, - { - sources: ReadonlyArray | null; - asOf: string | null; - } - >, - windowEndAt: string, - weightYardstick: { - source: WeightTargetSource; - target: { min: number; max: number } | null; - }, -): HealthScoreResult["components"] { - const present: Array = []; - for (const key of ["bp", "weight", "mood", "compliance"] as const) { - if (values[key] !== null) present.push(key); - } - const totalBaseWeight = present.reduce((s, key) => s + BASE_WEIGHTS[key], 0); - const weightFor = (key: keyof typeof BASE_WEIGHTS): number => { - if (values[key] === null) return 0; - if (totalBaseWeight === 0) return 0; - return BASE_WEIGHTS[key] / totalBaseWeight; - }; - const detailFor = ( - key: keyof typeof BASE_WEIGHTS, - ): HealthScoreComponentDetail => { - const value = values[key]; - const attr = attribution[key]; - const source = resolveSourceLabel(value !== null, attr.sources); - // When a component is `none`, fall back to the window-end anchor so - // the UI can still render "as of " without inventing a date. - const asOf = source === "none" ? windowEndAt : (attr.asOf ?? windowEndAt); - return { - value, - weight: weightFor(key), - source, - asOf, - }; - }; - return { - bp: detailFor("bp"), - weight: { - ...detailFor("weight"), - targetSource: weightYardstick.source, - target: weightYardstick.target, - }, - mood: detailFor("mood"), - compliance: detailFor("compliance"), - }; -} - -/** - * v1.4.25 Fix-G — when the caller doesn't supply `attribution.windowEndAt` - * we synthesise it deterministically from the input data so - * `computeHealthScore` stays pure (same input → same output). The route - * always supplies its own `windowEndAt`; this fallback exists so unit - * tests and ad-hoc callers don't pull `new Date()` into the result. - * - * Strategy: take the most recent date across the weight and mood series. - * If neither has any entries (e.g. compliance-only input), fall back to - * the Unix epoch so the value is stable and obviously synthetic — the - * UI never renders this string when attribution is provided. - */ -function deriveWindowEndAt(input: HealthScoreInput): string { - let latest = -Infinity; - for (const p of input.weightSeriesLast30d) { - const t = new Date(p.date).getTime(); - if (Number.isFinite(t) && t > latest) latest = t; - } - for (const p of input.moodEntriesLast30d) { - const t = new Date(p.date).getTime(); - if (Number.isFinite(t) && t > latest) latest = t; - } - if (latest === -Infinity) return new Date(0).toISOString(); - return new Date(latest).toISOString(); -} diff --git a/src/lib/analytics/score/__tests__/composite.test.ts b/src/lib/analytics/score/__tests__/composite.test.ts new file mode 100644 index 000000000..08c867b69 --- /dev/null +++ b/src/lib/analytics/score/__tests__/composite.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from "vitest"; + +import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; +import { + attachScoreDelta, + computeComposite, + SCORE_ALGORITHM_CHANGED_AT, +} from "../composite"; +import type { PillarValue, ScorePillarId, ScorePillarResult } from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +const DOMAIN_BY_PILLAR: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + +function pillar( + id: ScorePillarId, + score: number, + options: { + deltaEligible?: boolean; + noiseFloor?: number; + historyDays?: number; + deltaIdentity?: string; + } = {}, +): ScorePillarResult { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: options.historyDays ?? 28, + missing: [], + fullHistoryDays: 28, + }); + const value: PillarValue = { + score, + observed: { + label: `${score}`, + value: score, + unit: "score", + asOf: NOW.toISOString(), + sources: ["MANUAL"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 100, + label: "test reference", + source: "Test 2026", + }, + noiseFloor: options.noiseFloor ?? 1, + deltaEligible: options.deltaEligible ?? true, + deltaIdentity: options.deltaIdentity ?? id, + }; + const result: Derived = buildOk({ + value, + coverage, + confidence, + provenance: { + inputs: [id], + source: "live", + windowDays: 28, + computedAt: NOW.toISOString(), + }, + }); + return { id, domain: DOMAIN_BY_PILLAR[id], result }; +} + +function evaluation( + entries: ScorePillarResult[], + asOf: Date, + available = entries.map((entry) => entry.id), +) { + return computeComposite({ + pillars: entries, + availablePillars: available, + asOf, + }); +} + +describe("reference-score composite", () => { + it("requires three domains and one measured physiological domain", () => { + const tooNarrow = evaluation( + [pillar("ACTIVITY", 90), pillar("WELLBEING", 80)], + NOW, + ); + expect(tooNarrow.status).toBe("insufficient"); + + const eligible = evaluation( + [pillar("ACTIVITY", 90), pillar("WELLBEING", 80), pillar("SLEEP", 70)], + NOW, + ); + expect(eligible.status).toBe("ok"); + }); + + it("reports breadth coverage as distinct eligible domains", () => { + const result = evaluation( + [ + pillar("BLOOD_PRESSURE", 90), + pillar("GLYCAEMIA", 85), + pillar("LIPIDS", 80), + pillar("ACTIVITY", 75), + ], + NOW, + ); + + expect(result.status).toBe("insufficient"); + expect(result.coverage.requiredInputs).toBe(3); + expect(result.coverage.presentInputs).toBe(2); + if (result.status === "insufficient") { + expect(result.reason).toBe("three_domains_required"); + } + }); + + it("uses equal weights in registry order", () => { + const result = evaluation( + [ + pillar("SLEEP", 90), + pillar("BLOOD_PRESSURE", 60), + pillar("ACTIVITY", 75), + ], + NOW, + ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + ); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBe(75); + expect(result.value.composition).toEqual([ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + ]); + } + }); + + it("caps the composite band at the worst pillar and names the setter", () => { + const result = evaluation( + [ + pillar("BLOOD_PRESSURE", 45), + pillar("ACTIVITY", 100), + pillar("SLEEP", 100), + ], + NOW, + ); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBe(82); + expect(result.value.band).toBe("red"); + expect(result.value.bandSetter).toBe("BLOOD_PRESSURE"); + } + }); + + it("suppresses delta across a composition change", () => { + const current = evaluation( + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + pillar("WELLBEING", 80), + ], + NOW, + ); + const previous = evaluation( + [ + pillar("BLOOD_PRESSURE", 75), + pillar("ACTIVITY", 75), + pillar("SLEEP", 75), + ], + new Date(NOW.getTime() - 7 * 86_400_000), + ); + const report = attachScoreDelta(current, previous, previous, NOW); + expect(report.delta).toBeNull(); + expect(report.deltaReason).toBe("composition_changed"); + }); + + it("suppresses the first comparable eligibility window", () => { + const current = evaluation( + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + ], + NOW, + ); + const previous = evaluation( + [ + pillar("BLOOD_PRESSURE", 75), + pillar("ACTIVITY", 75), + pillar("SLEEP", 75), + ], + new Date(NOW.getTime() - 7 * 86_400_000), + ); + const previousPrevious = evaluation( + [pillar("BLOOD_PRESSURE", 70), pillar("ACTIVITY", 70)], + new Date(NOW.getTime() - 14 * 86_400_000), + ); + const report = attachScoreDelta(current, previous, previousPrevious, NOW); + expect(report.delta).toBeNull(); + expect(report.deltaReason).toBe("first_eligibility_window"); + }); + + it("suppresses the algorithm boundary and movements below the combined noise floor", () => { + const changedAt = new Date(SCORE_ALGORITHM_CHANGED_AT); + const currentAtBoundary = evaluation( + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + ], + changedAt, + ); + const beforeBoundary = evaluation( + [ + pillar("BLOOD_PRESSURE", 75), + pillar("ACTIVITY", 75), + pillar("SLEEP", 75), + ], + new Date(changedAt.getTime() - 7 * 86_400_000), + ); + const algorithm = attachScoreDelta( + currentAtBoundary, + beforeBoundary, + beforeBoundary, + changedAt, + ); + expect(algorithm.deltaReason).toBe("algorithm_changed"); + + const current = evaluation( + [ + pillar("BLOOD_PRESSURE", 81, { noiseFloor: 4 }), + pillar("ACTIVITY", 80, { noiseFloor: 4 }), + pillar("SLEEP", 80, { noiseFloor: 4 }), + ], + NOW, + ); + const previous = evaluation( + [ + pillar("BLOOD_PRESSURE", 80, { noiseFloor: 4 }), + pillar("ACTIVITY", 80, { noiseFloor: 4 }), + pillar("SLEEP", 80, { noiseFloor: 4 }), + ], + new Date(NOW.getTime() - 7 * 86_400_000), + ); + const stablePrior = evaluation( + [ + pillar("BLOOD_PRESSURE", 80, { noiseFloor: 4 }), + pillar("ACTIVITY", 80, { noiseFloor: 4 }), + pillar("SLEEP", 80, { noiseFloor: 4 }), + ], + new Date(NOW.getTime() - 14 * 86_400_000), + ); + const belowNoise = attachScoreDelta(current, previous, stablePrior, NOW); + expect(belowNoise.delta).toBeNull(); + expect(belowNoise.deltaReason).toBe("below_noise_floor"); + }); + + it("excludes slow pillars from the weekly delta", () => { + const current = evaluation( + [ + pillar("BLOOD_PRESSURE", 90), + pillar("ACTIVITY", 90), + pillar("SLEEP", 90), + pillar("LIPIDS", 20, { deltaEligible: false }), + ], + NOW, + ); + const previous = evaluation( + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + pillar("LIPIDS", 100, { deltaEligible: false }), + ], + new Date(NOW.getTime() - 7 * 86_400_000), + ); + const stablePrior = evaluation( + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + pillar("LIPIDS", 100, { deltaEligible: false }), + ], + new Date(NOW.getTime() - 14 * 86_400_000), + ); + const report = attachScoreDelta( + current, + previous, + stablePrior, + NOW, + [ + pillar("BLOOD_PRESSURE", 90), + pillar("ACTIVITY", 90), + pillar("SLEEP", 90), + pillar("LIPIDS", 20, { deltaEligible: false }), + ], + [ + pillar("BLOOD_PRESSURE", 80), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + pillar("LIPIDS", 100, { deltaEligible: false }), + ], + ); + expect(report.delta).toBe(10); + expect(report.deltaReason).toBeNull(); + }); + + it("excludes a pillar whose input mode changed from the delta intersection", () => { + const currentPillars = [ + pillar("BLOOD_PRESSURE", 80), + pillar("GLYCAEMIA", 100, { deltaIdentity: "hba1c" }), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + ]; + const previousPillars = [ + pillar("BLOOD_PRESSURE", 80), + pillar("GLYCAEMIA", 20, { deltaIdentity: "fasting_glucose" }), + pillar("ACTIVITY", 80), + pillar("SLEEP", 80), + ]; + const current = evaluation(currentPillars, NOW); + const previous = evaluation( + previousPillars, + new Date(NOW.getTime() - 7 * 86_400_000), + ); + const stablePrior = evaluation( + previousPillars, + new Date(NOW.getTime() - 14 * 86_400_000), + ); + + const report = attachScoreDelta( + current, + previous, + stablePrior, + NOW, + currentPillars, + previousPillars, + ); + + expect(report.delta).toBeNull(); + expect(report.deltaReason).toBe("below_noise_floor"); + }); +}); diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts new file mode 100644 index 000000000..2681dd4a9 --- /dev/null +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -0,0 +1,513 @@ +import { describe, expect, it } from "vitest"; + +import { computeActivityPillar } from "../activity"; +import { computeAdiposityPillar } from "../adiposity"; +import { computeBloodPressurePillar } from "../blood-pressure"; +import { computeFitnessPillar } from "../fitness"; +import { computeGlycaemiaPillar } from "../glycaemia"; +import { computeLipidsPillar } from "../lipids"; +import { computeSleepPillar } from "../sleep"; +import { computeWellbeingPillar } from "../wellbeing"; + +const NOW = new Date("2026-07-28T12:00:00.000Z"); +const live = { + source: "live" as const, + readFailed: false, + timezone: "Europe/Berlin", +}; + +function days(count: number, value: number) { + return Array.from({ length: count }, (_, index) => ({ + day: new Date(NOW.getTime() - index * 86_400_000) + .toISOString() + .slice(0, 10), + value, + })); +} + +describe("reference-score pillars", () => { + it("requires twelve fresh paired blood-pressure readings", () => { + const below = computeBloodPressurePillar({ + ...live, + pairCount: 11, + gradedScore: 85, + representative: { sys: 129, dia: 79 }, + oldestAt: new Date("2026-07-01T08:00:00Z"), + latestAt: new Date("2026-07-27T08:00:00Z"), + target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 }, + sources: ["MANUAL"], + asOf: NOW, + }); + expect(below.status).toBe("insufficient"); + expect(below.coverage.presentInputs).toBe(11); + + const eligible = computeBloodPressurePillar({ + ...live, + pairCount: 12, + gradedScore: 85, + representative: { sys: 129, dia: 79 }, + oldestAt: new Date("2026-07-01T08:00:00Z"), + latestAt: new Date("2026-07-27T08:00:00Z"), + target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 }, + sources: ["MANUAL"], + asOf: NOW, + }); + expect(eligible.status).toBe("ok"); + if (eligible.status === "ok") { + expect(eligible.value.score).toBe(85); + expect(eligible.value.reference.source).toBe("ESH 2023"); + } + }); + + it("keeps BP scoring clinical while exposing a different personal band", () => { + const result = computeBloodPressurePillar({ + ...live, + pairCount: 12, + gradedScore: 85, + representative: { sys: 129, dia: 79 }, + oldestAt: new Date("2026-07-01T08:00:00Z"), + latestAt: new Date("2026-07-27T08:00:00Z"), + target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 }, + personalTarget: { + sysLow: 115, + sysHigh: 125, + diaLow: 65, + diaHigh: 75, + }, + sources: ["MANUAL"], + asOf: NOW, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBe(85); + expect(result.value.reference.label).toBe("120–129/70–79 mmHg"); + expect(result.value.personalReference?.label).toBe("115–125/65–75 mmHg"); + } + }); + + it("prefers a fresh HbA1c result and applies the lower reference floor", () => { + const result = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [ + { + value: 4.5, + unit: "%", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + ], + fastingGlucose: days(12, 92).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })), + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.observed.label).toContain("HbA1c"); + expect(result.value.reference.low).toBe(5); + expect(result.value.score).toBeLessThan(100); + expect(result.value.deltaEligible).toBe(false); + } + }); + + it("uses eight fasting readings only when HbA1c is absent", () => { + const below = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [], + fastingGlucose: days(7, 92).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })), + }); + expect(below.status).toBe("insufficient"); + + const eligible = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [], + fastingGlucose: days(8, 92).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })), + }); + expect(eligible.status).toBe("ok"); + }); + + it("does not use fasting fallback when the preferred lab read failed", () => { + const result = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1cReadFailed: true, + hba1c: [], + fastingGlucose: days(8, 92).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })), + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("read_failed"); + } + }); + + it("requires 21 activity days and saturates at the age-specific step plateau", () => { + expect( + computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 40, + days: days(20, 10_000), + sources: ["APPLE_HEALTH"], + }).status, + ).toBe("insufficient"); + + const younger = computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 40, + days: days(21, 12_000), + sources: ["APPLE_HEALTH"], + }); + const older = computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 68, + days: days(21, 8_000), + sources: ["APPLE_HEALTH"], + }); + expect(younger.status).toBe("ok"); + expect(older.status).toBe("ok"); + if (younger.status === "ok" && older.status === "ok") { + expect(younger.value.score).toBe(100); + expect(older.value.score).toBe(100); + expect(younger.value.reference.high).toBe(10_000); + expect(older.value.reference.high).toBe(8_000); + } + }); + + it("keeps activity scores identical across rollup and live provenance", () => { + const input = { + readFailed: false, + asOf: NOW, + timezone: "Europe/Berlin", + ageYears: 40, + days: days(21, 8_500), + sources: ["APPLE_HEALTH"], + }; + const liveResult = computeActivityPillar({ ...input, source: "live" }); + const rollupResult = computeActivityPillar({ ...input, source: "DAY" }); + expect(liveResult.status).toBe("ok"); + expect(rollupResult.status).toBe("ok"); + if (liveResult.status === "ok" && rollupResult.status === "ok") { + expect(rollupResult.value.score).toBe(liveResult.value.score); + expect(rollupResult.provenance.source).toBe("DAY"); + expect(liveResult.provenance.source).toBe("live"); + } + }); + + it("scores exactly 28 activity calendar keys", () => { + const points = days(29, 10_000); + points[28] = { ...points[28], value: 0 }; + const result = computeActivityPillar({ + ...live, + asOf: NOW, + ageYears: 40, + days: points, + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.value.score).toBe(100); + }); + + it("requires fourteen sleep nights, never penalises long sleep, and adds regularity at 21 nights", () => { + const below = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights: days(13, 480).map((point, index) => ({ + night: point.day, + asleepMinutes: point.value, + midpoint: 180 + index, + })), + sources: ["APPLE_HEALTH"], + }); + expect(below.status).toBe("insufficient"); + + const longSleep = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights: days(21, 600).map((point) => ({ + night: point.day, + asleepMinutes: point.value, + midpoint: 180, + })), + sources: ["APPLE_HEALTH"], + }); + expect(longSleep.status).toBe("ok"); + if (longSleep.status === "ok") { + expect(longSleep.value.score).toBe(100); + expect(longSleep.value.reference.high).toBeNull(); + } + }); + + it("scores exactly 28 sleep calendar keys", () => { + const nights = days(29, 600).map((point) => ({ + night: point.day, + asleepMinutes: point.value, + midpoint: 180, + })); + nights[28] = { ...nights[28], asleepMinutes: 0 }; + const result = computeSleepPillar({ + ...live, + asOf: NOW, + ageYears: 40, + nights, + sources: ["APPLE_HEALTH"], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.value.score).toBe(100); + }); + + it("scores waist-to-height only from a fresh waist-derived value", () => { + const result = computeAdiposityPillar({ + ...live, + asOf: NOW, + heightCm: 180, + rows: [ + { + type: "WAIST_CIRCUMFERENCE", + value: 90, + unit: "cm", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + ], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.observed.value).toBe(0.5); + expect(result.value.reference.high).toBe(0.5); + expect(result.value.reference.source).toBe("NICE 2022"); + } + }); + + it("uses the freshest complete adiposity signal", () => { + const result = computeAdiposityPillar({ + ...live, + asOf: NOW, + heightCm: 180, + rows: [ + { + type: "WAIST_TO_HEIGHT", + value: 0.6, + unit: "ratio", + at: new Date("2026-06-01T08:00:00Z"), + source: "MANUAL", + }, + { + type: "WAIST_CIRCUMFERENCE", + value: 90, + unit: "cm", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + ], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.observed.value).toBe(0.5); + expect(result.value.observed.asOf).toContain("2026-07-20"); + } + }); + + it("suppresses a PHQ-9 result when item 9 was flagged", () => { + const result = computeWellbeingPillar({ + ...live, + asOf: NOW, + assessments: [ + { + instrument: "PHQ9", + totalScore: 3, + item9Flagged: true, + at: new Date("2026-07-20T08:00:00Z"), + }, + ], + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("crisis_signposting"); + } + }); + + it("scores validated wellbeing instruments on their published scales", () => { + const phq = computeWellbeingPillar({ + ...live, + asOf: NOW, + assessments: [ + { + instrument: "PHQ9", + totalScore: 4, + item9Flagged: false, + at: new Date("2026-07-20T08:00:00Z"), + }, + ], + }); + const who = computeWellbeingPillar({ + ...live, + asOf: NOW, + assessments: [ + { + instrument: "WHO5", + totalScore: 52, + item9Flagged: false, + at: new Date("2026-07-20T08:00:00Z"), + }, + ], + }); + expect(phq.status).toBe("ok"); + expect(who.status).toBe("ok"); + if (phq.status === "ok") expect(phq.value.score).toBe(85); + if (who.status === "ok") expect(who.value.score).toBe(52); + }); + + it("admits fitness only when the source proves a measured test", () => { + const unverified = computeFitnessPillar({ + ...live, + asOf: NOW, + ageYears: 40, + sex: "MALE", + rows: [ + { + value: 42, + at: new Date("2026-07-20T08:00:00Z"), + source: "APPLE_HEALTH", + measured: false, + }, + ], + }); + expect(unverified.status).toBe("insufficient"); + + const measured = computeFitnessPillar({ + ...live, + asOf: NOW, + ageYears: 40, + sex: "MALE", + rows: [ + { + value: 42, + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + measured: true, + }, + ], + }); + expect(measured.status).toBe("ok"); + if (measured.status === "ok") { + expect(measured.value.reference.source).toBe("FRIEND (Kaminsky 2015)"); + } + }); + + it("requires a fresh lipid panel with lab-reported reference bounds", () => { + const result = computeLipidsPillar({ + ...live, + asOf: NOW, + rows: [ + { + marker: "LDL", + value: 120, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 130, + panel: "Lipids", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + { + marker: "HDL", + value: 50, + unit: "mg/dL", + referenceLow: 40, + referenceHigh: null, + panel: "Lipids", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + ], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBe(100); + expect(result.value.observed.label).toContain("LDL 120 mg/dL"); + } + }); + + it("accepts one fresh lipid panel result with a reported range", () => { + const result = computeLipidsPillar({ + ...live, + asOf: NOW, + rows: [ + { + marker: "LDL", + value: 120, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 130, + panel: "Lipids", + at: new Date("2026-07-20T08:00:00Z"), + source: "MANUAL", + }, + ], + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.coverage.requiredInputs).toBe(1); + expect(result.value.observed.value).toBe(1); + } + }); + + it("keeps stale data absent for every sparse pillar", () => { + const staleAt = new Date("2025-01-01T08:00:00Z"); + expect( + computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [{ value: 5.3, unit: "%", at: staleAt, source: "MANUAL" }], + fastingGlucose: [], + }).status, + ).toBe("insufficient"); + expect( + computeFitnessPillar({ + ...live, + asOf: NOW, + ageYears: 40, + sex: "MALE", + rows: [{ value: 42, at: staleAt, source: "MANUAL", measured: true }], + }).status, + ).toBe("insufficient"); + expect( + computeLipidsPillar({ + ...live, + asOf: NOW, + rows: [ + { + marker: "LDL", + value: 120, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 130, + panel: "Lipids", + at: staleAt, + source: "MANUAL", + }, + ], + }).status, + ).toBe("insufficient"); + }); +}); diff --git a/src/lib/analytics/score/__tests__/reader.test.ts b/src/lib/analytics/score/__tests__/reader.test.ts new file mode 100644 index 000000000..3a68a6356 --- /dev/null +++ b/src/lib/analytics/score/__tests__/reader.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PrismaClient } from "@/generated/prisma/client"; + +import { readActivityDays, readFastingGlucoseMeasurements } from "../reader"; + +function dbWithRows(rows: unknown[]) { + return { + measurement: { findMany: vi.fn().mockResolvedValue(rows) }, + } as unknown as PrismaClient; +} + +function stepRow( + measuredAt: string, + value: number, + deviceType: "watch" | "phone" = "watch", + source: "APPLE_HEALTH" | "WITHINGS" = "APPLE_HEALTH", +) { + return { + id: `${measuredAt}-${deviceType}`, + type: "ACTIVITY_STEPS" as const, + value, + unit: "count", + source, + measuredAt: new Date(measuredAt), + deviceType, + glucoseContext: null, + sleepStage: null, + }; +} + +describe("score reader bounds and source parity", () => { + it("reads only fasting glucose across the 90-day window plus two comparisons", async () => { + const db = dbWithRows([]); + const now = new Date("2026-07-28T12:00:00.000Z"); + + await readFastingGlucoseMeasurements(db, "user-1", now); + + expect(db.measurement.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + type: { in: ["BLOOD_GLUCOSE"] }, + glucoseContext: "FASTING", + measuredAt: { + gte: new Date(now.getTime() - 104 * 86_400_000), + }, + }), + }), + ); + const where = vi.mocked(db.measurement.findMany).mock.calls[0]?.[0]?.where; + expect(where).not.toHaveProperty("source"); + }); + + it("retains a HealthKit glucose row when the client supplied fasting provenance", async () => { + const appleFasting = { + type: "BLOOD_GLUCOSE", + value: 96, + unit: "mg/dL", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-07-27T07:00:00.000Z"), + deviceType: "watch", + glucoseContext: "FASTING", + sleepStage: null, + }; + const rows = await readFastingGlucoseMeasurements( + dbWithRows([appleFasting]), + "user-1", + new Date("2026-07-28T12:00:00.000Z"), + ); + expect(rows).toEqual([appleFasting]); + }); + + it("aggregates activity by the user's local day rather than a UTC rollup day", async () => { + const db = dbWithRows([ + stepRow("2026-07-01T22:30:00.000Z", 1_000), + stepRow("2026-07-02T21:30:00.000Z", 2_000), + ]); + + const result = await readActivityDays({ + db, + userId: "user-1", + since: new Date("2026-07-01T00:00:00.000Z"), + priority: null, + timezone: "Europe/Berlin", + asOf: new Date("2026-07-03T12:00:00.000Z"), + }); + + expect(result.path).toBe("live"); + expect(result.days).toEqual([{ day: "2026-07-02", value: 3_000 }]); + }); + + it("keeps the canonical device instead of summing Watch and phone steps", async () => { + const db = dbWithRows([ + stepRow("2026-07-02T09:00:00.000Z", 1_000, "watch"), + stepRow("2026-07-02T09:00:00.000Z", 5_000, "phone"), + ]); + + const result = await readActivityDays({ + db, + userId: "user-1", + since: new Date("2026-07-01T00:00:00.000Z"), + priority: null, + timezone: "Europe/Berlin", + asOf: new Date("2026-07-03T12:00:00.000Z"), + }); + + expect(result.days).toEqual([{ day: "2026-07-02", value: 1_000 }]); + }); + + it("excludes source writers outside the current 28-day score window", async () => { + const db = dbWithRows([ + stepRow("2026-06-15T09:00:00.000Z", 8_000, "watch", "APPLE_HEALTH"), + stepRow("2026-05-31T09:00:00.000Z", 8_000, "watch", "WITHINGS"), + ]); + const result = await readActivityDays({ + db, + userId: "user-1", + since: new Date("2026-05-20T00:00:00.000Z"), + asOf: new Date("2026-07-01T12:00:00.000Z"), + priority: null, + timezone: "Europe/Berlin", + }); + expect(result.sources).toEqual(["APPLE_HEALTH"]); + }); +}); diff --git a/src/lib/analytics/score/__tests__/weight-goal.test.ts b/src/lib/analytics/score/__tests__/weight-goal.test.ts new file mode 100644 index 000000000..2a867f7bd --- /dev/null +++ b/src/lib/analytics/score/__tests__/weight-goal.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { computeWeightGoal } from "../weight-goal"; + +const AS_OF = new Date("2026-07-28T12:00:00.000Z"); +const target = { min: 74, max: 78 }; + +describe("personal weight-goal window", () => { + it("ignores stale and future rows instead of presenting them as current", () => { + const result = computeWeightGoal({ + rows: [ + { + value: 80, + at: new Date("2026-05-01T12:00:00.000Z"), + source: "MANUAL", + }, + { + value: 76, + at: new Date("2026-07-29T12:00:00.000Z"), + source: "MANUAL", + }, + ], + target, + source: "live", + asOf: AS_OF, + }); + + expect(result.status).toBe("insufficient"); + expect(result.coverage.presentInputs).toBe(1); + if (result.status === "insufficient") { + expect(result.reason).toBe("weight_not_tracked"); + } + }); + + it("uses only in-window rows for the current value and comparison", () => { + const result = computeWeightGoal({ + rows: [ + { + value: 90, + at: new Date("2026-05-01T12:00:00.000Z"), + source: "MANUAL", + }, + { + value: 84, + at: new Date("2026-07-10T12:00:00.000Z"), + source: "MANUAL", + }, + { + value: 81, + at: new Date("2026-07-27T12:00:00.000Z"), + source: "MANUAL", + }, + { + value: 70, + at: new Date("2026-07-29T12:00:00.000Z"), + source: "MANUAL", + }, + ], + target, + source: "live", + asOf: AS_OF, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.currentKg).toBe(81); + expect(result.value.distanceKg).toBe(3); + expect(result.value.deltaKg).toBe(3); + expect(result.provenance.windowDays).toBe(60); + } + }); +}); diff --git a/src/lib/analytics/score/activity.ts b/src/lib/analytics/score/activity.ts new file mode 100644 index 000000000..911710870 --- /dev/null +++ b/src/lib/analytics/score/activity.ts @@ -0,0 +1,99 @@ +import type { Derived } from "@/lib/insights/derived/types"; +import { userDayKey } from "@/lib/tz/format"; + +import type { ActivityPillarInput, PillarValue } from "./types"; +import { + clampScore, + coverage, + failedPillar, + insufficientPillar, + mean, + okPillar, + standardDeviation, +} from "./shared"; + +export const ACTIVITY_WINDOW_DAYS = 28; +export const ACTIVITY_MIN_DAYS = 21; +export const OLDER_ADULT_AGE = 60; +export const YOUNGER_STEP_PLATEAU = 10_000; +export const OLDER_STEP_PLATEAU = 8_000; + +export function computeActivityPillar( + input: ActivityPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "ACTIVITY", + asOf: input.asOf, + windowDays: ACTIVITY_WINDOW_DAYS, + }); + } + + const asOfDay = userDayKey(input.asOf, input.timezone); + const sinceDay = userDayKey( + new Date(input.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * 86_400_000), + input.timezone, + ); + const points = input.days.filter( + (point) => point.day <= asOfDay && point.day >= sinceDay, + ); + if (points.length < ACTIVITY_MIN_DAYS) { + return insufficientPillar({ + id: "ACTIVITY", + source: input.source, + asOf: input.asOf, + windowDays: ACTIVITY_WINDOW_DAYS, + requiredInputs: ACTIVITY_MIN_DAYS, + presentInputs: points.length, + historyDays: points.length, + missing: [`${Math.max(0, ACTIVITY_MIN_DAYS - points.length)} days`], + reason: + input.days.length > 0 ? "below_day_floor_or_stale" : "not_tracked", + }); + } + + const values = points.map((point) => point.value); + const observed = mean(values); + const plateau = + input.ageYears != null && input.ageYears >= OLDER_ADULT_AGE + ? OLDER_STEP_PLATEAU + : YOUNGER_STEP_PLATEAU; + const score = clampScore((observed / plateau) * 100); + const noiseFloor = Math.max( + 1, + Math.round(((0.5 * standardDeviation(values)) / plateau) * 100), + ); + const latestDay = [...points].sort((a, b) => b.day.localeCompare(a.day))[0]; + + return okPillar({ + id: "ACTIVITY", + source: input.source, + asOf: input.asOf, + windowDays: ACTIVITY_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: ACTIVITY_MIN_DAYS, + presentInputs: ACTIVITY_MIN_DAYS, + historyDays: points.length, + }), + value: { + score, + observed: { + value: Math.round(observed), + unit: "steps/day", + label: `${Math.round(observed).toLocaleString("en-US")} steps/day`, + asOf: `${latestDay.day}T00:00:00.000Z`, + sources: input.sources, + }, + reference: { + kind: "guideline-band", + low: 0, + high: plateau, + label: `benefit plateaus by ${plateau.toLocaleString("en-US")} steps/day`, + source: "Paluch 2022", + }, + noiseFloor, + deltaEligible: true, + deltaIdentity: "ACTIVITY_STEPS", + }, + }); +} diff --git a/src/lib/analytics/score/adiposity.ts b/src/lib/analytics/score/adiposity.ts new file mode 100644 index 000000000..6f0d370e3 --- /dev/null +++ b/src/lib/analytics/score/adiposity.ts @@ -0,0 +1,100 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import type { AdiposityPillarInput, PillarValue } from "./types"; +import { + coverage, + failedPillar, + insufficientPillar, + okPillar, + scoreReferenceBand, + withinWindow, +} from "./shared"; + +export const ADIPOSITY_WINDOW_DAYS = 180; +export const WAIST_TO_HEIGHT_REFERENCE_HIGH = 0.5; + +export function computeAdiposityPillar( + input: AdiposityPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "ADIPOSITY", + asOf: input.asOf, + windowDays: ADIPOSITY_WINDOW_DAYS, + }); + } + + const rows = input.rows + .filter((row) => withinWindow(row.at, input.asOf, ADIPOSITY_WINDOW_DAYS)) + .sort((a, b) => b.at.getTime() - a.at.getTime()); + const candidate = rows + .map((row) => { + if (row.type === "WAIST_TO_HEIGHT") return { row, ratio: row.value }; + if (input.heightCm && input.heightCm > 0) { + return { row, ratio: row.value / input.heightCm }; + } + return null; + }) + .find((entry) => entry !== null); + const row = candidate?.row ?? null; + const ratio = candidate?.ratio ?? null; + const direct = row?.type === "WAIST_TO_HEIGHT"; + const freshWaist = rows.some((entry) => entry.type === "WAIST_CIRCUMFERENCE"); + + if (ratio == null || row == null) { + return insufficientPillar({ + id: "ADIPOSITY", + source: input.source, + asOf: input.asOf, + windowDays: ADIPOSITY_WINDOW_DAYS, + requiredInputs: freshWaist ? 2 : 1, + presentInputs: freshWaist ? 1 : 0, + historyDays: 0, + missing: + freshWaist && !input.heightCm ? ["height"] : ["waist measurement"], + reason: + freshWaist && !input.heightCm + ? "missing_height" + : input.rows.length > 0 + ? "stale" + : "not_tracked", + }); + } + + const roundedRatio = Math.round(ratio * 1000) / 1000; + return okPillar({ + id: "ADIPOSITY", + source: input.source, + asOf: input.asOf, + windowDays: ADIPOSITY_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: direct ? 1 : 2, + presentInputs: direct ? 1 : 2, + historyDays: 1, + }), + value: { + score: scoreReferenceBand( + roundedRatio, + 0, + WAIST_TO_HEIGHT_REFERENCE_HIGH, + ), + observed: { + value: roundedRatio, + unit: "ratio", + label: `waist-to-height ${roundedRatio.toFixed(3)}`, + asOf: row.at.toISOString(), + sources: [row.source], + }, + reference: { + kind: "clinical-threshold", + low: 0, + high: WAIST_TO_HEIGHT_REFERENCE_HIGH, + label: `< ${WAIST_TO_HEIGHT_REFERENCE_HIGH}`, + source: "NICE 2022", + }, + noiseFloor: 0, + deltaEligible: false, + deltaIdentity: "waist_to_height", + }, + }); +} diff --git a/src/lib/analytics/score/blood-pressure.ts b/src/lib/analytics/score/blood-pressure.ts new file mode 100644 index 000000000..0fc5d3534 --- /dev/null +++ b/src/lib/analytics/score/blood-pressure.ts @@ -0,0 +1,116 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import type { BloodPressurePillarInput, PillarValue } from "./types"; +import { + coverage, + daysBetween, + failedPillar, + insufficientPillar, + okPillar, + withinWindow, +} from "./shared"; + +export const BLOOD_PRESSURE_WINDOW_DAYS = 90; +export const BLOOD_PRESSURE_MIN_PAIRS = 12; + +export function computeBloodPressurePillar( + input: BloodPressurePillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "BLOOD_PRESSURE", + asOf: input.asOf, + windowDays: BLOOD_PRESSURE_WINDOW_DAYS, + }); + } + + if (!input.target) { + return insufficientPillar({ + id: "BLOOD_PRESSURE", + source: input.source, + asOf: input.asOf, + windowDays: BLOOD_PRESSURE_WINDOW_DAYS, + requiredInputs: BLOOD_PRESSURE_MIN_PAIRS, + presentInputs: 0, + historyDays: 0, + missing: ["date of birth"], + reason: "missing_reference_profile", + }); + } + + const latestFresh = + input.latestAt != null && + withinWindow(input.latestAt, input.asOf, BLOOD_PRESSURE_WINDOW_DAYS); + const presentPairs = latestFresh ? input.pairCount : 0; + const historyDays = latestFresh + ? daysBetween(input.oldestAt, input.latestAt) + : 0; + + if ( + presentPairs < BLOOD_PRESSURE_MIN_PAIRS || + input.gradedScore == null || + input.representative == null || + input.latestAt == null + ) { + return insufficientPillar({ + id: "BLOOD_PRESSURE", + source: input.source, + asOf: input.asOf, + windowDays: BLOOD_PRESSURE_WINDOW_DAYS, + requiredInputs: BLOOD_PRESSURE_MIN_PAIRS, + presentInputs: Math.min(presentPairs, BLOOD_PRESSURE_MIN_PAIRS), + historyDays, + missing: [ + `${Math.max(0, BLOOD_PRESSURE_MIN_PAIRS - presentPairs)} paired readings`, + ], + reason: latestFresh ? "below_reading_floor" : "stale_or_untracked", + }); + } + + const representativeSys = Math.round(input.representative.sys); + const representativeDia = Math.round(input.representative.dia); + const target = input.target; + return okPillar({ + id: "BLOOD_PRESSURE", + source: input.source, + asOf: input.asOf, + windowDays: BLOOD_PRESSURE_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: BLOOD_PRESSURE_MIN_PAIRS, + presentInputs: BLOOD_PRESSURE_MIN_PAIRS, + historyDays, + }), + value: { + score: input.gradedScore, + observed: { + value: representativeSys, + unit: "mmHg", + label: `${representativeSys}/${representativeDia} mmHg`, + asOf: input.latestAt.toISOString(), + sources: input.sources, + }, + reference: { + kind: "clinical-threshold", + low: target.sysLow, + high: target.sysHigh, + label: `${target.sysLow}–${target.sysHigh}/${target.diaLow}–${target.diaHigh} mmHg`, + source: "ESH 2023", + }, + personalReference: input.personalTarget + ? { + kind: "guideline-band", + low: input.personalTarget.sysLow, + high: input.personalTarget.sysHigh, + label: `${input.personalTarget.sysLow}–${input.personalTarget.sysHigh}/${input.personalTarget.diaLow}–${input.personalTarget.diaHigh} mmHg`, + source: "personal target", + } + : undefined, + // The existing grade moves roughly four points over the first 1 mmHg + // above its ceiling. One score point is therefore the smallest honest + // integer movement; the pair-series aggregation handles reading noise. + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + }); +} diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts new file mode 100644 index 000000000..9ce934be4 --- /dev/null +++ b/src/lib/analytics/score/composite.ts @@ -0,0 +1,280 @@ +import { + buildInsufficient, + buildOk, + deriveCoverage, +} from "@/lib/insights/derived/coverage"; +import type { Derived } from "@/lib/insights/derived/types"; + +import { + SCORE_PILLAR_IDS, + SCORE_VERSION, + type CompositeValue, + type HealthScoreReport, + type ScoreBand, + type ScorePillarId, + type ScorePillarResult, +} from "./types"; +import { mean, provenance, scoreBand } from "./shared"; + +export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z"; +export const SCORE_MIN_ELIGIBLE_DOMAINS = 3; + +const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "SLEEP", + "ADIPOSITY", + "FITNESS", + "LIPIDS", +]); + +const BAND_RANK: Record = { + green: 2, + yellow: 1, + red: 0, +}; + +export interface CompositeInput { + pillars: ScorePillarResult[]; + availablePillars: ScorePillarId[]; + asOf: Date; +} + +function orderedUnique(ids: readonly ScorePillarId[]): ScorePillarId[] { + const present = new Set(ids); + return SCORE_PILLAR_IDS.filter((id) => present.has(id)); +} + +export function computeComposite( + input: CompositeInput, +): Derived { + const available = orderedUnique(input.availablePillars); + const byId = new Map(input.pillars.map((pillar) => [pillar.id, pillar])); + const eligible = available + .map((id) => byId.get(id)) + .filter( + (pillar): pillar is ScorePillarResult => pillar?.result.status === "ok", + ); + const composition = eligible.map((pillar) => pillar.id); + const eligibleDomains = new Set(eligible.map((pillar) => pillar.domain)); + const hasPhysiological = composition.some((id) => + PHYSIOLOGICAL_PILLARS.has(id), + ); + const availableDomains = new Set( + input.pillars + .filter((pillar) => available.includes(pillar.id)) + .map((pillar) => pillar.domain), + ); + const missing = [...availableDomains].filter( + (domain) => !eligibleDomains.has(domain), + ); + const historyDays = eligible.reduce( + (minimum, pillar) => + pillar.result.status === "ok" + ? Math.min(minimum, pillar.result.coverage.historyDays) + : minimum, + Number.POSITIVE_INFINITY, + ); + const { coverage, confidence } = deriveCoverage({ + requiredInputs: SCORE_MIN_ELIGIBLE_DOMAINS, + presentInputs: eligibleDomains.size, + historyDays: Number.isFinite(historyDays) ? historyDays : 0, + missing, + fullHistoryDays: 28, + }); + const compositeProvenance = provenance({ + inputs: composition, + source: + eligible.length > 0 && + eligible.every( + (pillar) => + pillar.result.provenance.source !== "live" && + pillar.result.provenance.source !== "none", + ) + ? "DAY" + : eligible.length > 0 + ? "live" + : "none", + windowDays: Math.max( + 0, + ...eligible.map((pillar) => pillar.result.provenance.windowDays), + ), + asOf: input.asOf, + }); + + if (eligibleDomains.size < SCORE_MIN_ELIGIBLE_DOMAINS || !hasPhysiological) { + return buildInsufficient({ + coverage, + provenance: compositeProvenance, + reason: !hasPhysiological + ? "measured_physiological_domain_required" + : "three_domains_required", + }); + } + + const values = eligible.map((pillar) => { + if (pillar.result.status !== "ok") { + throw new Error("Eligible score pillar lost its narrowed state"); + } + return pillar.result.value; + }); + const score = Math.round(mean(values.map((value) => value.score))); + const meanBand = scoreBand(score); + const bandSetterIndex = values.reduce( + (worstIndex, value, index) => + BAND_RANK[scoreBand(value.score)] < + BAND_RANK[scoreBand(values[worstIndex].score)] + ? index + : worstIndex, + 0, + ); + const bandSetterValue = values[bandSetterIndex]; + const worstBand = scoreBand(bandSetterValue.score); + const band = + BAND_RANK[worstBand] < BAND_RANK[meanBand] ? worstBand : meanBand; + const deltaValues = values.filter((value) => value.deltaEligible); + const noiseFloor = + deltaValues.length === 0 + ? 0 + : Math.ceil( + Math.sqrt( + deltaValues.reduce((sum, value) => sum + value.noiseFloor ** 2, 0), + ) / deltaValues.length, + ); + + return buildOk({ + value: { + score, + band, + bandSetter: + BAND_RANK[worstBand] < BAND_RANK[meanBand] + ? composition[bandSetterIndex] + : null, + composition, + noiseFloor, + scoreVersion: SCORE_VERSION, + }, + coverage, + confidence, + provenance: compositeProvenance, + }); +} + +function sameComposition( + left: Derived, + right: Derived, +): boolean { + return ( + left.status === "ok" && + right.status === "ok" && + left.value.composition.length === right.value.composition.length && + left.value.composition.every( + (pillar, index) => pillar === right.value.composition[index], + ) + ); +} + +function comparableDynamicPillars( + report: Derived, + other: Derived, + pillars: ScorePillarResult[], + otherPillars: ScorePillarResult[], +): { + current: number; + previous: number; + noiseFloor: number; +} | null { + if (report.status !== "ok" || other.status !== "ok") return null; + const otherById = new Map(otherPillars.map((pillar) => [pillar.id, pillar])); + const pairs = pillars.flatMap((pillar) => { + const previous = otherById.get(pillar.id); + if ( + !report.value.composition.includes(pillar.id) || + !other.value.composition.includes(pillar.id) || + pillar.result.status !== "ok" || + previous?.result.status !== "ok" || + !pillar.result.value.deltaEligible || + !previous.result.value.deltaEligible || + pillar.result.value.deltaIdentity !== previous.result.value.deltaIdentity + ) { + return []; + } + return [ + { + current: pillar.result.value.score, + previous: previous.result.value.score, + noiseFloor: Math.max( + pillar.result.value.noiseFloor, + previous.result.value.noiseFloor, + ), + }, + ]; + }); + if (pairs.length === 0) return null; + return { + current: mean(pairs.map((pair) => pair.current)), + previous: mean(pairs.map((pair) => pair.previous)), + noiseFloor: Math.ceil( + Math.sqrt(pairs.reduce((sum, pair) => sum + pair.noiseFloor ** 2, 0)) / + pairs.length, + ), + }; +} + +export function attachScoreDelta( + current: Derived, + previous: Derived, + previousPrevious: Derived, + asOf: Date, + currentPillars: ScorePillarResult[] = [], + previousPillars: ScorePillarResult[] = [], +): Pick { + if (current.status !== "ok") { + return { delta: null, deltaReason: "no_current_score" }; + } + if (previous.status !== "ok") { + return { delta: null, deltaReason: "no_previous_window" }; + } + + const previousAt = new Date(previous.provenance.computedAt); + const boundary = new Date(SCORE_ALGORITHM_CHANGED_AT); + if ( + current.value.scoreVersion !== previous.value.scoreVersion || + (asOf >= boundary && previousAt < boundary) + ) { + return { delta: null, deltaReason: "algorithm_changed" }; + } + if (!sameComposition(current, previous)) { + return { delta: null, deltaReason: "composition_changed" }; + } + if ( + previousPrevious.status !== "ok" || + !sameComposition(previous, previousPrevious) + ) { + return { delta: null, deltaReason: "first_eligibility_window" }; + } + + const comparable = + currentPillars.length > 0 || previousPillars.length > 0 + ? comparableDynamicPillars( + current, + previous, + currentPillars, + previousPillars, + ) + : null; + if ( + (currentPillars.length > 0 || previousPillars.length > 0) && + !comparable + ) { + return { delta: null, deltaReason: "composition_changed" }; + } + const delta = Math.round( + (comparable?.current ?? current.value.score) - + (comparable?.previous ?? previous.value.score), + ); + if (Math.abs(delta) < (comparable?.noiseFloor ?? current.value.noiseFloor)) { + return { delta: null, deltaReason: "below_noise_floor" }; + } + return { delta, deltaReason: null }; +} diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts new file mode 100644 index 000000000..4ae00e227 --- /dev/null +++ b/src/lib/analytics/score/derived.ts @@ -0,0 +1,76 @@ +import type { Derived } from "@/lib/insights/derived/types"; +import { prisma } from "@/lib/db"; +import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +import { computeUserHealthScore } from "./reader"; +import type { CompositeValue } from "./types"; + +const DAY_MS = 86_400_000; + +/** Load the same full Health Score composite served by the analytics surfaces. */ +export async function computeHealthScoreDerived( + userId: string, +): Promise> { + const user = await prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { + dateOfBirth: true, + gender: true, + heightCm: true, + timezone: true, + thresholdsJson: true, + }, + }); + const now = new Date(); + const timezone = user.timezone ?? DEFAULT_TIMEZONE; + const bpTargets = getBpTargets(user.dateOfBirth); + const bpAt = (at: Date) => + bpTargets + ? computeBpInTargetFastPath({ + userId, + targets: bpTargets, + now: at, + userTz: timezone, + }) + : Promise.resolve(null); + const [ + modules, + sourcePriorityJson, + bpEnvelope, + bpEnvelopePriorWeek, + bpEnvelopePriorTwoWeeks, + ] = await Promise.all([ + resolveModuleMap(userId), + loadUserSourcePriority(userId), + bpAt(now), + bpAt(new Date(now.getTime() - 7 * DAY_MS)), + bpAt(new Date(now.getTime() - 14 * DAY_MS)), + ]); + const report = await computeUserHealthScore({ + userId, + now, + profile: { + dateOfBirth: user.dateOfBirth, + gender: user.gender, + heightCm: user.heightCm, + timezone, + sourcePriorityJson, + thresholdsJson: user.thresholdsJson, + }, + modules: { + glucose: modules.glucose !== false, + labs: modules.labs !== false, + sleep: modules.sleep !== false, + mentalHealth: modules.mentalHealth !== false, + }, + bpTargets, + bpEnvelope, + bpEnvelopePriorWeek, + bpEnvelopePriorTwoWeeks, + }); + return report.composite; +} diff --git a/src/lib/analytics/score/fitness.ts b/src/lib/analytics/score/fitness.ts new file mode 100644 index 000000000..2ac8ec3e9 --- /dev/null +++ b/src/lib/analytics/score/fitness.ts @@ -0,0 +1,94 @@ +import { lookupNormalRange } from "@/lib/insights/derived/norms"; +import type { Derived } from "@/lib/insights/derived/types"; + +import type { FitnessPillarInput, PillarValue } from "./types"; +import { + coverage, + failedPillar, + insufficientPillar, + okPillar, + scoreReferenceBand, + withinWindow, +} from "./shared"; + +export const FITNESS_WINDOW_DAYS = 365; + +export function computeFitnessPillar( + input: FitnessPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "FITNESS", + asOf: input.asOf, + windowDays: FITNESS_WINDOW_DAYS, + }); + } + + const norm = lookupNormalRange("VO2_MAX", input.ageYears, input.sex); + if (!norm) { + return insufficientPillar({ + id: "FITNESS", + source: input.source, + asOf: input.asOf, + windowDays: FITNESS_WINDOW_DAYS, + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: ["age and binary reference sex"], + reason: "missing_reference_profile", + }); + } + + const measured = input.rows + .filter( + (row) => + row.measured && withinWindow(row.at, input.asOf, FITNESS_WINDOW_DAYS), + ) + .sort((a, b) => b.at.getTime() - a.at.getTime()); + const latest = measured[0]; + if (!latest) { + return insufficientPillar({ + id: "FITNESS", + source: input.source, + asOf: input.asOf, + windowDays: FITNESS_WINDOW_DAYS, + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: ["measured VO₂max test"], + reason: input.rows.length > 0 ? "unverified_or_stale" : "not_tracked", + }); + } + + return okPillar({ + id: "FITNESS", + source: input.source, + asOf: input.asOf, + windowDays: FITNESS_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + }), + value: { + score: scoreReferenceBand(latest.value, norm.low, null), + observed: { + value: latest.value, + unit: "mL/(kg·min)", + label: `${latest.value} mL/(kg·min) measured`, + asOf: latest.at.toISOString(), + sources: [latest.source], + }, + reference: { + kind: "population-percentile", + low: norm.low, + high: norm.high, + label: `${norm.low}–${norm.high} mL/(kg·min) age/sex band`, + source: "FRIEND (Kaminsky 2015)", + }, + noiseFloor: 0, + deltaEligible: false, + deltaIdentity: "measured_vo2max", + }, + }); +} diff --git a/src/lib/analytics/score/glycaemia.ts b/src/lib/analytics/score/glycaemia.ts new file mode 100644 index 000000000..7dfdbfbec --- /dev/null +++ b/src/lib/analytics/score/glycaemia.ts @@ -0,0 +1,175 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import type { GlycaemiaPillarInput, PillarValue } from "./types"; +import { + coverage, + daysBetween, + failedPillar, + insufficientPillar, + median, + okPillar, + scoreReferenceBand, + standardDeviation, + uniqueSources, + withinWindow, +} from "./shared"; + +export const HBA1C_WINDOW_DAYS = 180; +export const FASTING_GLUCOSE_WINDOW_DAYS = 90; +export const FASTING_GLUCOSE_MIN_READINGS = 8; +export const HBA1C_REFERENCE = { low: 5, high: 5.6 } as const; +export const FASTING_GLUCOSE_REFERENCE = { low: 70, high: 99 } as const; + +export function computeGlycaemiaPillar( + input: GlycaemiaPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "GLYCAEMIA", + asOf: input.asOf, + windowDays: HBA1C_WINDOW_DAYS, + }); + } + + const freshHba1c = input.hba1c + .filter( + (row) => + row.unit.trim() === "%" && + withinWindow(row.at, input.asOf, HBA1C_WINDOW_DAYS), + ) + .sort((a, b) => b.at.getTime() - a.at.getTime()); + const hba1c = freshHba1c[0]; + if (hba1c) { + return okPillar({ + id: "GLYCAEMIA", + source: input.source, + asOf: input.asOf, + windowDays: HBA1C_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + }), + value: { + score: scoreReferenceBand( + hba1c.value, + HBA1C_REFERENCE.low, + HBA1C_REFERENCE.high, + ), + observed: { + value: hba1c.value, + unit: "%", + label: `HbA1c ${hba1c.value}%`, + asOf: hba1c.at.toISOString(), + sources: [hba1c.source], + }, + reference: { + kind: "clinical-threshold", + low: HBA1C_REFERENCE.low, + high: HBA1C_REFERENCE.high, + label: `${HBA1C_REFERENCE.low}–${HBA1C_REFERENCE.high}%`, + source: "ADA 2025; Selvin 2010", + }, + noiseFloor: 0, + deltaEligible: false, + deltaIdentity: "hba1c", + }, + }); + } + + if (input.hba1cReadFailed) { + return failedPillar({ + id: "GLYCAEMIA", + asOf: input.asOf, + windowDays: HBA1C_WINDOW_DAYS, + }); + } + + const fasting = input.fastingGlucose + .filter((row) => + withinWindow(row.at, input.asOf, FASTING_GLUCOSE_WINDOW_DAYS), + ) + .sort((a, b) => a.at.getTime() - b.at.getTime()); + const historyDays = daysBetween( + fasting[0]?.at ?? null, + fasting.at(-1)?.at ?? null, + ); + if (input.fastingReadFailed) { + return failedPillar({ + id: "GLYCAEMIA", + asOf: input.asOf, + windowDays: FASTING_GLUCOSE_WINDOW_DAYS, + }); + } + + if (fasting.length < FASTING_GLUCOSE_MIN_READINGS) { + return insufficientPillar({ + id: "GLYCAEMIA", + source: input.source, + asOf: input.asOf, + windowDays: FASTING_GLUCOSE_WINDOW_DAYS, + requiredInputs: FASTING_GLUCOSE_MIN_READINGS, + presentInputs: fasting.length, + historyDays, + missing: [ + `${Math.max(0, FASTING_GLUCOSE_MIN_READINGS - fasting.length)} fasting readings`, + ], + reason: + input.hba1c.length > 0 || input.fastingGlucose.length > 0 + ? "below_reading_floor_or_stale" + : "not_tracked", + }); + } + + const values = fasting.map((row) => row.value); + const observed = median(values); + // The behavioural/device noise convention from the design is one half of + // within-person SD, translated onto this pillar's proportional score scale. + const halfSd = standardDeviation(values) * 0.5; + const floorScore = scoreReferenceBand( + Math.max(0, observed - halfSd), + FASTING_GLUCOSE_REFERENCE.low, + FASTING_GLUCOSE_REFERENCE.high, + ); + const ceilingScore = scoreReferenceBand( + observed + halfSd, + FASTING_GLUCOSE_REFERENCE.low, + FASTING_GLUCOSE_REFERENCE.high, + ); + + return okPillar({ + id: "GLYCAEMIA", + source: input.source, + asOf: input.asOf, + windowDays: FASTING_GLUCOSE_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: FASTING_GLUCOSE_MIN_READINGS, + presentInputs: FASTING_GLUCOSE_MIN_READINGS, + historyDays, + }), + value: { + score: scoreReferenceBand( + observed, + FASTING_GLUCOSE_REFERENCE.low, + FASTING_GLUCOSE_REFERENCE.high, + ), + observed: { + value: observed, + unit: "mg/dL", + label: `Fasting median ${Math.round(observed)} mg/dL`, + asOf: fasting.at(-1)!.at.toISOString(), + sources: uniqueSources(fasting.map((row) => row.source)), + }, + reference: { + kind: "clinical-threshold", + low: FASTING_GLUCOSE_REFERENCE.low, + high: FASTING_GLUCOSE_REFERENCE.high, + label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`, + source: "ADA 2025; Selvin 2010", + }, + noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)), + deltaEligible: true, + deltaIdentity: "fasting_glucose", + }, + }); +} diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts new file mode 100644 index 000000000..3d5f6ffb2 --- /dev/null +++ b/src/lib/analytics/score/index.ts @@ -0,0 +1,97 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import { computeActivityPillar } from "./activity"; +import { computeAdiposityPillar } from "./adiposity"; +import { computeBloodPressurePillar } from "./blood-pressure"; +import { computeComposite } from "./composite"; +import { computeFitnessPillar } from "./fitness"; +import { computeGlycaemiaPillar } from "./glycaemia"; +import { computeLipidsPillar } from "./lipids"; +import { computeSleepPillar } from "./sleep"; +import { + SCORE_PILLAR_IDS, + SCORE_VERSION, + type CompositeValue, + type HealthScoreReport, + type PillarInputs, + type ScorePillarId, + type ScorePillarResult, + type WeightGoalValue, +} from "./types"; +import { computeWellbeingPillar } from "./wellbeing"; + +export interface ComputeHealthScoreInput { + asOf: Date; + availablePillars: ScorePillarId[]; + pillars: PillarInputs; + weightGoal: Derived; + delta?: number | null; + deltaReason?: HealthScoreReport["deltaReason"]; +} + +export function computeHealthScore( + input: ComputeHealthScoreInput, +): HealthScoreReport { + const byId: Record = { + BLOOD_PRESSURE: { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: computeBloodPressurePillar(input.pillars.BLOOD_PRESSURE), + }, + GLYCAEMIA: { + id: "GLYCAEMIA", + domain: "cardiometabolic", + result: computeGlycaemiaPillar(input.pillars.GLYCAEMIA), + }, + ACTIVITY: { + id: "ACTIVITY", + domain: "activity", + result: computeActivityPillar(input.pillars.ACTIVITY), + }, + SLEEP: { + id: "SLEEP", + domain: "sleep", + result: computeSleepPillar(input.pillars.SLEEP), + }, + ADIPOSITY: { + id: "ADIPOSITY", + domain: "adiposity", + result: computeAdiposityPillar(input.pillars.ADIPOSITY), + }, + WELLBEING: { + id: "WELLBEING", + domain: "wellbeing", + result: computeWellbeingPillar(input.pillars.WELLBEING), + }, + FITNESS: { + id: "FITNESS", + domain: "fitness", + result: computeFitnessPillar(input.pillars.FITNESS), + }, + LIPIDS: { + id: "LIPIDS", + domain: "cardiometabolic", + result: computeLipidsPillar(input.pillars.LIPIDS), + }, + }; + const available = new Set(input.availablePillars); + const pillars = SCORE_PILLAR_IDS.filter((id) => available.has(id)).map( + (id) => byId[id], + ); + return { + composite: computeComposite({ + pillars, + availablePillars: input.availablePillars, + asOf: input.asOf, + }), + pillars, + delta: input.delta ?? null, + deltaReason: input.deltaReason ?? "no_previous_window", + scoreVersion: SCORE_VERSION, + weightGoal: input.weightGoal, + algorithmNotice: null, + }; +} + +export type { CompositeValue, HealthScoreReport }; +export * from "./types"; diff --git a/src/lib/analytics/score/lipids.ts b/src/lib/analytics/score/lipids.ts new file mode 100644 index 000000000..062db9a6b --- /dev/null +++ b/src/lib/analytics/score/lipids.ts @@ -0,0 +1,113 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import type { LipidReading, LipidsPillarInput, PillarValue } from "./types"; +import { + coverage, + failedPillar, + insufficientPillar, + mean, + okPillar, + scoreReferenceBand, + uniqueSources, + withinWindow, +} from "./shared"; + +export const LIPIDS_WINDOW_DAYS = 365; +export const LIPIDS_MIN_PANELS = 1; + +function panelKey(row: LipidReading): string { + return `${row.at.toISOString().slice(0, 10)}|${row.panel ?? ""}`; +} + +export function computeLipidsPillar( + input: LipidsPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "LIPIDS", + asOf: input.asOf, + windowDays: LIPIDS_WINDOW_DAYS, + }); + } + + const fresh = input.rows + .filter( + (row) => + (row.referenceLow != null || row.referenceHigh != null) && + withinWindow(row.at, input.asOf, LIPIDS_WINDOW_DAYS), + ) + .sort((a, b) => b.at.getTime() - a.at.getTime()); + const latestKey = fresh[0] ? panelKey(fresh[0]) : null; + const panel = latestKey + ? fresh.filter((row) => panelKey(row) === latestKey) + : []; + + if (panel.length === 0) { + return insufficientPillar({ + id: "LIPIDS", + source: input.source, + asOf: input.asOf, + windowDays: LIPIDS_WINDOW_DAYS, + requiredInputs: LIPIDS_MIN_PANELS, + presentInputs: 0, + historyDays: 0, + missing: ["lipid panel with lab reference ranges"], + reason: + input.rows.length > 0 ? "incomplete_or_stale_panel" : "not_tracked", + }); + } + + const markerScores = panel.map((row) => + scoreReferenceBand(row.value, row.referenceLow, row.referenceHigh), + ); + const latestAt = panel.reduce( + (latest, row) => (row.at > latest ? row.at : latest), + panel[0].at, + ); + const observedLabel = panel + .map((row) => `${row.marker} ${row.value} ${row.unit}`) + .join("; "); + const referenceLabel = panel + .map((row) => { + const bounds = + row.referenceLow != null && row.referenceHigh != null + ? `${row.referenceLow}–${row.referenceHigh}` + : row.referenceHigh != null + ? `≤ ${row.referenceHigh}` + : `≥ ${row.referenceLow}`; + return `${row.marker} ${bounds} ${row.unit}`; + }) + .join("; "); + + return okPillar({ + id: "LIPIDS", + source: input.source, + asOf: input.asOf, + windowDays: LIPIDS_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: LIPIDS_MIN_PANELS, + presentInputs: LIPIDS_MIN_PANELS, + historyDays: 1, + }), + value: { + score: mean(markerScores), + observed: { + value: panel.length, + unit: panel.length === 1 ? "result" : "results", + label: observedLabel, + asOf: latestAt.toISOString(), + sources: uniqueSources(panel.map((row) => row.source)), + }, + reference: { + kind: "clinical-threshold", + low: null, + high: null, + label: referenceLabel, + source: "reporting laboratory reference ranges", + }, + noiseFloor: 0, + deltaEligible: false, + deltaIdentity: `panel:${latestKey}`, + }, + }); +} diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts new file mode 100644 index 000000000..3f7c137a3 --- /dev/null +++ b/src/lib/analytics/score/reader.ts @@ -0,0 +1,857 @@ +import type { + GlucoseContext, + MeasurementSource, + MeasurementType, + PrismaClient, + SleepStage, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import type { BpInTargetEnvelope } from "@/lib/analytics/bp-in-target-fast-path"; +import { collectBpPairs } from "@/lib/analytics/bp-in-target"; +import { + gradeBpScoreFromSeries, + representativeBpFromSeries, +} from "@/lib/analytics/bp-grade"; +import type { BpTargets } from "@/lib/analytics/bp-targets"; +import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { reconstructNights } from "@/lib/insights/derived/sleep-score"; +import { pickCumulativeDaySum } from "@/lib/measurements/cumulative-day-sum"; +import { userDayKey } from "@/lib/tz/format"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { toProfileSex } from "@/lib/profile/sex"; +import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key"; +import { annotate } from "@/lib/logging/context"; + +import { ACTIVITY_WINDOW_DAYS } from "./activity"; +import { SLEEP_WINDOW_DAYS } from "./sleep"; +import { attachScoreDelta } from "./composite"; +import { computeHealthScore } from "./index"; +import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; +import { computeWeightGoal } from "./weight-goal"; +import { DAY_MS, uniqueSources } from "./shared"; +import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; + +const HISTORY_OFFSET_DAYS = 14; +const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; + +export interface ScoreReaderProfile { + dateOfBirth: Date | null; + gender: string | null; + heightCm: number | null; + timezone: string; + sourcePriorityJson: unknown; + thresholdsJson: unknown; +} + +export interface ScoreReaderModules { + glucose: boolean; + labs: boolean; + sleep: boolean; + mentalHealth: boolean; +} + +export interface UserHealthScoreInput { + prisma?: PrismaClient; + userId: string; + now: Date; + profile: ScoreReaderProfile; + modules: ScoreReaderModules; + bpTargets: BpTargets | null; + bpEnvelope: BpInTargetEnvelope | null; + bpEnvelopePriorWeek: BpInTargetEnvelope | null; + bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null; +} + +type MeasurementRow = { + type: MeasurementType; + value: number; + unit: string; + source: MeasurementSource; + measuredAt: Date; + deviceType: string | null; + glucoseContext?: GlucoseContext | null; + sleepStage?: SleepStage | null; +}; + +type Settled = { ok: true; value: T } | { ok: false; value: T }; + +interface ActivityRead { + days: Array<{ day: string; value: number }>; + sources: string[]; + sourceDays: Array<{ day: string; source: string }>; + path: "rollup" | "live"; +} + +interface ScoreBpEnvelope { + path: "rollup" | "live" | undefined; + last90Days: { pairs: number } | null; + gradedScore: number | null; + representative: { sys: number; dia: number } | null; + last90EarliestAt: Date | null; + last90LatestAt: Date | null; + readFailed: boolean; + target: BpTargets | null; +} + +async function capture( + promise: Promise, + fallback: T, + failure?: + ScorePillarId | "WEIGHT_GOAL" | "ALGORITHM_NOTICE" | ScorePillarId[], +): Promise> { + try { + return { ok: true, value: await promise }; + } catch { + const failures = Array.isArray(failure) + ? failure + : failure + ? [failure] + : []; + if (failures.length > 0) { + annotate({ + meta: { + analytics: { + health_score: Object.fromEntries( + failures.map((failed) => [failed, "read_failed"]), + ), + }, + }, + }); + } + return { ok: false, value: fallback }; + } +} + +async function readMeasurements( + db: PrismaClient, + userId: string, + types: MeasurementType[], + since: Date, + glucoseContext?: GlucoseContext, +): Promise { + return db.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: since }, + deletedAt: null, + ...(glucoseContext ? { glucoseContext } : {}), + }, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + select: { + type: true, + value: true, + unit: true, + source: true, + measuredAt: true, + deviceType: true, + glucoseContext: true, + sleepStage: true, + }, + }); +} +export function readFastingGlucoseMeasurements( + db: PrismaClient, + userId: string, + now: Date, +): Promise { + return readMeasurements( + db, + userId, + ["BLOOD_GLUCOSE"], + new Date(now.getTime() - (90 + HISTORY_OFFSET_DAYS) * DAY_MS), + "FASTING", + ); +} + +export async function readActivityDays(args: { + db: PrismaClient; + userId: string; + since: Date; + priority: unknown; + timezone: string; + asOf: Date; +}): Promise { + // DAY rollups are UTC buckets and have already summed all devices for a + // source. Neither the user's local-day boundary nor Watch/phone + // canonicalisation can be recovered from that aggregate, so score parity + // requires the raw canonical path for every timezone. + const rows = await readMeasurements( + args.db, + args.userId, + ["ACTIVITY_STEPS"], + args.since, + ); + const canonical = pickCanonicalSourceRows( + rows, + "steps", + args.priority, + (date) => userDayKey(date, args.timezone), + ).canonicalRows; + return { + days: pickCumulativeDaySum(canonical, (date) => + userDayKey(date, args.timezone), + ).map((point) => ({ + day: userDayKey(point.date, args.timezone), + value: point.value, + })), + sources: uniqueSources( + canonical + .filter((row) => { + const day = userDayKey(row.measuredAt, args.timezone); + const endDay = userDayKey(args.asOf, args.timezone); + const startDay = userDayKey( + new Date(args.asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS), + args.timezone, + ); + return day >= startDay && day <= endDay; + }) + .map((row) => row.source), + ), + sourceDays: canonical.map((row) => ({ + day: userDayKey(row.measuredAt, args.timezone), + source: row.source, + })), + path: "live", + }; +} + +function normaliseAnalyte(value: string): string { + return value.toLowerCase().replaceAll(/[^a-z0-9]/g, ""); +} + +const HBA1C_NAMES: Record = { + hba1c: true, + hemoglobina1c: true, + haemoglobina1c: true, + glycatedhemoglobin: true, + glycatedhaemoglobin: true, +}; + +const LIPID_NAMES: Record = { + ldl: true, + ldlcholesterol: true, + ldlc: true, + hdl: true, + hdlcholesterol: true, + hdlc: true, + triglycerides: true, + triglyceride: true, + totalcholesterol: true, + cholesteroltotal: true, + apob: true, + apolipoproteinb: true, +}; + +function pickStableSourceRows( + rows: T[], + key: (row: T) => string, +): T[] { + return Array.from(Map.groupBy(rows, key).values()).flatMap((group) => { + const source = [...new Set(group.map((row) => row.source))].sort()[0]; + return group.filter((row) => row.source === source); + }); +} + +function personalBpTarget( + thresholdsJson: unknown, + clinical: BpTargets | null, +): BpTargets | null { + if (!clinical || !thresholdsJson || typeof thresholdsJson !== "object") { + return null; + } + const thresholds = thresholdsJson as Record; + const readBand = (key: string): { min: number; max: number } | null => { + const value = thresholds[key]; + if (!value || typeof value !== "object") return null; + const { min, max } = value as { min?: unknown; max?: unknown }; + return typeof min === "number" && + Number.isFinite(min) && + typeof max === "number" && + Number.isFinite(max) && + min < max + ? { min, max } + : null; + }; + const sys = readBand("BLOOD_PRESSURE_SYS"); + const dia = readBand("BLOOD_PRESSURE_DIA"); + if (!sys && !dia) return null; + return { + sysLow: sys?.min ?? clinical.sysLow, + sysHigh: sys?.max ?? clinical.sysHigh, + diaLow: dia?.min ?? clinical.diaLow, + diaHigh: dia?.max ?? clinical.diaHigh, + }; +} + +function canonicalBpRowsForWindow( + rows: MeasurementRow[], + input: UserHealthScoreInput, + at: Date, +): MeasurementRow[] { + const since = new Date(at.getTime() - 90 * DAY_MS); + return pickCanonicalSourceRows( + rows.filter((row) => row.measuredAt >= since && row.measuredAt <= at), + "bloodPressure", + input.profile.sourcePriorityJson, + (date) => userDayKey(date, input.profile.timezone), + ).canonicalRows; +} + +function scoreBpEnvelope(args: { + base: BpInTargetEnvelope | null; + at: Date; + input: UserHealthScoreInput; + rows: Settled; +}): { envelope: ScoreBpEnvelope; sources: string[] } { + if (args.base) { + return { + envelope: { + path: args.base.path, + last90Days: args.base.last90Days, + gradedScore: args.base.gradedScore, + representative: args.base.representative, + last90EarliestAt: args.base.last90EarliestAt, + last90LatestAt: args.base.last90LatestAt, + readFailed: false, + target: args.input.bpTargets, + }, + sources: args.rows.ok + ? uniqueSources( + canonicalBpRowsForWindow(args.rows.value, args.input, args.at).map( + (row) => row.source, + ), + ) + : [args.base.path], + }; + } + + const target = args.input.bpTargets; + if (!args.rows.ok) { + return { + envelope: { + path: undefined, + last90Days: null, + gradedScore: null, + representative: null, + last90EarliestAt: null, + last90LatestAt: null, + readFailed: true, + target, + }, + sources: [], + }; + } + + const canonical = canonicalBpRowsForWindow( + args.rows.value, + args.input, + args.at, + ); + const pairs = collectBpPairs( + canonical + .filter((row) => row.type === "BLOOD_PRESSURE_SYS") + .map((row) => ({ measuredAt: row.measuredAt, value: row.value })), + canonical + .filter((row) => row.type === "BLOOD_PRESSURE_DIA") + .map((row) => ({ measuredAt: row.measuredAt, value: row.value })), + args.input.profile.timezone, + ).filter( + (pair) => + pair.at <= args.at && + pair.at >= new Date(args.at.getTime() - 90 * DAY_MS), + ); + return { + envelope: { + path: "live", + last90Days: { pairs: pairs.length }, + gradedScore: target + ? gradeBpScoreFromSeries({ + pairs, + target, + now: args.at, + }) + : null, + representative: representativeBpFromSeries({ pairs, now: args.at }), + last90EarliestAt: pairs[0]?.at ?? null, + last90LatestAt: pairs.at(-1)?.at ?? null, + readFailed: false, + target, + }, + sources: uniqueSources(canonical.map((row) => row.source)), + }; +} + +function scoreInputsFor(args: { + asOf: Date; + input: UserHealthScoreInput; + bp: ScoreBpEnvelope; + bpSources: string[]; + glucose: Settled; + activity: Settled; + sleep: Settled; + adiposity: Settled; + fitness: Settled; + assessments: Settled< + Array<{ + instrument: "PHQ9" | "GAD7" | "WHO5" | "SCI"; + totalScore: number; + item9Flagged: boolean; + takenAt: Date; + }> + >; + labs: Settled< + Array<{ + analyte: string; + value: number | null; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + takenAt: Date; + source: string; + biomarker: { + name: string; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + } | null; + }> + >; +}): PillarInputs { + const { asOf, input } = args; + const ageYears = getAgeFromDateOfBirth(input.profile.dateOfBirth); + const asOfDay = userDayKey(asOf, input.profile.timezone); + const activitySinceDay = userDayKey( + new Date(asOf.getTime() - (ACTIVITY_WINDOW_DAYS - 1) * DAY_MS), + input.profile.timezone, + ); + const sleepSinceDay = userDayKey( + new Date(asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * DAY_MS), + input.profile.timezone, + ); + const sex = toProfileSex(input.profile.gender); + const fastingCanonical = pickStableSourceRows( + args.glucose.value.filter((row) => row.glucoseContext === "FASTING"), + (row) => userDayKey(row.measuredAt, input.profile.timezone), + ); + const sleepNights = reconstructNights( + args.sleep.value.map((row) => ({ + value: row.value, + measuredAt: row.measuredAt, + sleepStage: row.sleepStage ?? null, + source: row.source, + deviceType: row.deviceType, + })), + input.profile.timezone, + input.profile.sourcePriorityJson, + ); + const hba1c = args.labs.value.filter((row) => { + const name = normaliseAnalyte(row.biomarker?.name ?? row.analyte); + return row.value != null && HBA1C_NAMES[name] === true; + }); + const lipids = args.labs.value.filter((row) => { + const name = normaliseAnalyte(row.biomarker?.name ?? row.analyte); + return row.value != null && LIPID_NAMES[name] === true; + }); + const adiposityCanonical = pickStableSourceRows( + args.adiposity.value, + (row) => + `${row.type}|${userDayKey(row.measuredAt, input.profile.timezone)}`, + ); + const fitnessCanonical = pickCanonicalSourceRows( + args.fitness.value, + "vo2Max", + input.profile.sourcePriorityJson, + (date) => userDayKey(date, input.profile.timezone), + ).canonicalRows; + + return { + BLOOD_PRESSURE: { + source: readSource(args.bp.path), + readFailed: args.bp.readFailed, + asOf, + pairCount: args.bp.last90Days?.pairs ?? 0, + gradedScore: args.bp.gradedScore, + representative: args.bp.representative, + oldestAt: args.bp.last90EarliestAt, + latestAt: args.bp.last90LatestAt, + target: args.bp.target, + personalTarget: personalBpTarget( + input.profile.thresholdsJson, + args.bp.target, + ), + sources: args.bpSources, + }, + GLYCAEMIA: { + source: "live", + readFailed: + (!input.modules.glucose || !args.glucose.ok) && + (!input.modules.labs || !args.labs.ok), + asOf, + hba1cReadFailed: input.modules.labs && !args.labs.ok, + fastingReadFailed: input.modules.glucose && !args.glucose.ok, + hba1c: input.modules.labs + ? hba1c.map((row) => ({ + value: row.value!, + unit: row.biomarker?.unit ?? row.unit, + at: row.takenAt, + source: row.source, + })) + : [], + fastingGlucose: input.modules.glucose + ? fastingCanonical.map((row) => ({ + value: row.value, + at: row.measuredAt, + source: row.source, + })) + : [], + }, + ACTIVITY: { + source: args.activity.ok ? readSource(args.activity.value.path) : "none", + readFailed: !args.activity.ok, + timezone: input.profile.timezone, + asOf, + ageYears, + days: args.activity.value.days, + sources: uniqueSources( + args.activity.value.sourceDays + .filter((row) => row.day >= activitySinceDay && row.day <= asOfDay) + .map((row) => row.source), + ), + }, + SLEEP: { + source: "live", + readFailed: !args.sleep.ok, + timezone: input.profile.timezone, + asOf, + ageYears, + nights: sleepNights.map((night) => ({ + night: night.night, + asleepMinutes: night.asleepMinutes, + midpoint: night.midpoint, + })), + sources: uniqueSources( + args.sleep.value + .filter((row) => { + const day = userDayKey(row.measuredAt, input.profile.timezone); + return day >= sleepSinceDay && day <= asOfDay; + }) + .map((row) => row.source), + ), + }, + ADIPOSITY: { + source: "live", + readFailed: !args.adiposity.ok, + asOf, + heightCm: input.profile.heightCm, + rows: adiposityCanonical.map((row) => ({ + type: row.type as "WAIST_CIRCUMFERENCE" | "WAIST_TO_HEIGHT", + value: row.value, + unit: row.unit, + at: row.measuredAt, + source: row.source, + })), + }, + WELLBEING: { + source: "live", + readFailed: !args.assessments.ok, + asOf, + assessments: args.assessments.value + .filter((row) => row.instrument !== "SCI") + .map((row) => ({ + instrument: row.instrument as "PHQ9" | "GAD7" | "WHO5", + totalScore: row.totalScore, + item9Flagged: row.item9Flagged, + at: row.takenAt, + })), + }, + FITNESS: { + source: "live", + readFailed: !args.fitness.ok, + asOf, + ageYears, + sex, + // The schema records VO₂max but does not distinguish CPET measurements + // from device estimates. No current row can therefore prove `measured`. + rows: fitnessCanonical.map((row) => ({ + value: row.value, + at: row.measuredAt, + source: row.source, + measured: false, + })), + }, + LIPIDS: { + source: "live", + readFailed: !args.labs.ok, + asOf, + rows: lipids.map((row) => ({ + marker: row.biomarker?.name ?? row.analyte, + value: row.value!, + unit: row.unit || row.biomarker?.unit || "", + referenceLow: row.referenceLow ?? row.biomarker?.referenceLow ?? null, + referenceHigh: + row.referenceHigh ?? row.biomarker?.referenceHigh ?? null, + panel: row.panel, + at: row.takenAt, + source: row.source, + })), + }, + }; +} + +function readSource( + path: "rollup" | "live" | undefined, +): DerivedProvenanceSource { + if (path === "rollup") return "DAY"; + return path ?? "none"; +} + +export async function computeUserHealthScore( + input: UserHealthScoreInput, +): Promise { + const db = input.prisma ?? prisma; + const since = new Date(input.now.getTime() - SCORE_READ_DAYS * DAY_MS); + const activitySince = new Date( + input.now.getTime() - (28 + HISTORY_OFFSET_DAYS) * DAY_MS, + ); + const [ + glucose, + activity, + sleep, + adiposity, + fitness, + assessments, + labs, + weights, + bpRows, + ] = await Promise.all([ + input.modules.glucose + ? capture( + readFastingGlucoseMeasurements(db, input.userId, input.now), + [], + "GLYCAEMIA", + ) + : Promise.resolve({ ok: true as const, value: [] }), + capture( + readActivityDays({ + db, + userId: input.userId, + since: activitySince, + priority: input.profile.sourcePriorityJson, + asOf: input.now, + timezone: input.profile.timezone, + }), + { days: [], sources: [], sourceDays: [], path: "live" as const }, + "ACTIVITY", + ), + input.modules.sleep + ? capture( + readMeasurements(db, input.userId, ["SLEEP_DURATION"], activitySince), + [], + "SLEEP", + ) + : Promise.resolve({ ok: true as const, value: [] }), + capture( + readMeasurements( + db, + input.userId, + ["WAIST_CIRCUMFERENCE", "WAIST_TO_HEIGHT"], + since, + ), + [], + "ADIPOSITY", + ), + capture( + readMeasurements(db, input.userId, ["VO2_MAX"], since), + [], + "FITNESS", + ), + input.modules.mentalHealth + ? capture( + db.mentalHealthAssessment.findMany({ + where: { + userId: input.userId, + takenAt: { gte: since }, + deletedAt: null, + }, + orderBy: { takenAt: "asc" }, + select: { + instrument: true, + totalScore: true, + item9Flagged: true, + takenAt: true, + }, + }), + [], + "WELLBEING", + ) + : Promise.resolve({ ok: true as const, value: [] }), + input.modules.labs + ? capture( + db.labResult.findMany({ + where: { + userId: input.userId, + takenAt: { gte: since }, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "asc" }, + select: { + analyte: true, + value: true, + unit: true, + referenceLow: true, + referenceHigh: true, + panel: true, + takenAt: true, + source: true, + biomarker: { + select: { + name: true, + unit: true, + referenceLow: true, + referenceHigh: true, + }, + }, + }, + }), + [], + ["GLYCAEMIA", "LIPIDS"], + ) + : Promise.resolve({ ok: true as const, value: [] }), + capture( + readMeasurements(db, input.userId, ["WEIGHT"], since), + [], + "WEIGHT_GOAL", + ), + capture( + readMeasurements( + db, + input.userId, + ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], + new Date(input.now.getTime() - 104 * DAY_MS), + ), + [], + "BLOOD_PRESSURE", + ), + ]); + + const availablePillars: ScorePillarId[] = [ + "BLOOD_PRESSURE", + ...(input.modules.glucose || input.modules.labs + ? ["GLYCAEMIA" as const] + : []), + "ACTIVITY", + ...(input.modules.sleep ? ["SLEEP" as const] : []), + "ADIPOSITY", + ...(input.modules.mentalHealth ? ["WELLBEING" as const] : []), + "FITNESS", + ...(input.modules.labs ? ["LIPIDS" as const] : []), + ]; + const previousAt = new Date(input.now.getTime() - 7 * DAY_MS); + const previousPreviousAt = new Date(input.now.getTime() - 14 * DAY_MS); + const currentBp = scoreBpEnvelope({ + base: input.bpEnvelope, + at: input.now, + input, + rows: bpRows, + }); + const previousBp = scoreBpEnvelope({ + base: input.bpEnvelopePriorWeek, + at: previousAt, + input, + rows: bpRows, + }); + const previousPreviousBp = scoreBpEnvelope({ + base: input.bpEnvelopePriorTwoWeeks, + at: previousPreviousAt, + input, + rows: bpRows, + }); + const common = { + input, + bpSources: currentBp.sources, + glucose, + activity, + sleep, + adiposity, + fitness, + assessments, + labs, + }; + const canonicalWeights = pickCanonicalSourceRows( + weights.value, + "weight", + input.profile.sourcePriorityJson, + (date) => userDayKey(date, input.profile.timezone), + ).canonicalRows; + const target = resolveWeightTargetOverride(input.profile.thresholdsJson); + const weightGoal = computeWeightGoal({ + rows: canonicalWeights.map((row) => ({ + value: row.value, + at: row.measuredAt, + source: row.source, + })), + target, + source: weights.ok ? "live" : "none", + readFailed: !weights.ok, + asOf: input.now, + }); + const current = computeHealthScore({ + asOf: input.now, + availablePillars, + pillars: scoreInputsFor({ + ...common, + asOf: input.now, + bp: currentBp.envelope, + }), + weightGoal, + }); + const previous = computeHealthScore({ + asOf: previousAt, + availablePillars, + pillars: scoreInputsFor({ + ...common, + asOf: previousAt, + bp: previousBp.envelope, + }), + weightGoal, + }); + const previousPrevious = computeHealthScore({ + asOf: previousPreviousAt, + availablePillars, + pillars: scoreInputsFor({ + ...common, + asOf: previousPreviousAt, + bp: previousPreviousBp.envelope, + }), + weightGoal, + }); + const delta = attachScoreDelta( + current.composite, + previous.composite, + previousPrevious.composite, + input.now, + current.pillars, + previous.pillars, + ); + const itemKey = healthScoreAlgorithmItemKey(current.scoreVersion); + const dismissal = await capture( + db.dismissedPriorityItem.findUnique({ + where: { userId_itemKey: { userId: input.userId, itemKey } }, + select: { itemKey: true }, + }), + null, + "ALGORITHM_NOTICE", + ); + return { + ...current, + ...delta, + algorithmNotice: { + itemKey, + dismissed: dismissal.value != null, + }, + }; +} diff --git a/src/lib/analytics/score/shared.ts b/src/lib/analytics/score/shared.ts new file mode 100644 index 000000000..a661e2514 --- /dev/null +++ b/src/lib/analytics/score/shared.ts @@ -0,0 +1,197 @@ +import { + buildInsufficient, + buildOk, + deriveCoverage, +} from "@/lib/insights/derived/coverage"; +import type { + Derived, + DerivedCoverage, + DerivedProvenance, + DerivedProvenanceSource, +} from "@/lib/insights/derived/types"; + +import type { + DomainReadState, + PillarValue, + ScoreBand, + ScorePillarId, +} from "./types"; + +export const DAY_MS = 86_400_000; + +export function clampScore(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function scoreBand(score: number): ScoreBand { + if (score >= 75) return "green"; + if (score >= 50) return "yellow"; + return "red"; +} + +/** + * Score proximity to an inclusive published band without minting another + * clinical threshold. Values inside score 100; outside, the ratio to the + * nearest published edge declines smoothly and remains bounded. + */ +export function scoreReferenceBand( + value: number, + low: number | null, + high: number | null, +): number { + if (!Number.isFinite(value)) return 0; + if (low != null && value < low) { + return low > 0 ? clampScore((value / low) * 100) : 0; + } + if (high != null && value > high) { + return value > 0 ? clampScore((high / value) * 100) : 0; + } + return 100; +} + +export function mean(values: readonly number[]): number { + return values.length === 0 + ? Number.NaN + : values.reduce((sum, value) => sum + value, 0) / values.length; +} + +export function median(values: readonly number[]): number { + if (values.length === 0) return Number.NaN; + const ordered = [...values].sort((a, b) => a - b); + const middle = Math.floor(ordered.length / 2); + return ordered.length % 2 === 0 + ? (ordered[middle - 1] + ordered[middle]) / 2 + : ordered[middle]; +} + +export function standardDeviation(values: readonly number[]): number { + if (values.length < 2) return 0; + const center = mean(values); + const variance = mean(values.map((value) => (value - center) ** 2)); + return Math.sqrt(variance); +} + +export function uniqueSources(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort(); +} + +export function daysBetween(oldest: Date | null, latest: Date | null): number { + if (!oldest || !latest) return 0; + return Math.max( + 1, + Math.floor((latest.getTime() - oldest.getTime()) / DAY_MS) + 1, + ); +} + +export function withinWindow( + at: Date, + asOf: Date, + windowDays: number, +): boolean { + const time = at.getTime(); + return time <= asOf.getTime() && time >= asOf.getTime() - windowDays * DAY_MS; +} + +export function provenance(args: { + inputs: string[]; + source: DerivedProvenanceSource; + windowDays: number; + asOf: Date; +}): DerivedProvenance { + return { + inputs: args.inputs, + source: args.source, + windowDays: args.windowDays, + computedAt: args.asOf.toISOString(), + }; +} + +export function insufficientPillar(args: { + id: ScorePillarId; + source: DerivedProvenanceSource; + asOf: Date; + windowDays: number; + requiredInputs: number; + presentInputs: number; + historyDays: number; + missing: string[]; + reason: string; +}): Derived { + const { coverage } = deriveCoverage({ + requiredInputs: args.requiredInputs, + presentInputs: args.presentInputs, + historyDays: args.historyDays, + missing: args.missing, + fullHistoryDays: args.windowDays, + }); + return buildInsufficient({ + coverage, + provenance: provenance({ + inputs: [args.id], + source: args.source, + windowDays: args.windowDays, + asOf: args.asOf, + }), + reason: args.reason, + }); +} + +export function failedPillar(args: { + id: ScorePillarId; + asOf: Date; + windowDays: number; +}): Derived { + return insufficientPillar({ + ...args, + source: "none", + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [args.id], + reason: "read_failed", + }); +} + +export function okPillar(args: { + id: ScorePillarId; + source: DerivedProvenanceSource; + asOf: Date; + windowDays: number; + coverage: DerivedCoverage; + value: PillarValue; +}): Derived { + const { confidence } = deriveCoverage({ + ...args.coverage, + fullHistoryDays: args.windowDays, + }); + return buildOk({ + value: args.value, + coverage: args.coverage, + confidence, + provenance: provenance({ + inputs: [args.id], + source: args.source, + windowDays: args.windowDays, + asOf: args.asOf, + }), + }); +} + +export function coverage(args: { + requiredInputs: number; + presentInputs: number; + historyDays: number; + missing?: string[]; +}): DerivedCoverage { + return { + requiredInputs: args.requiredInputs, + presentInputs: Math.min(args.presentInputs, args.requiredInputs), + historyDays: Math.max(0, args.historyDays), + missing: args.missing ?? [], + }; +} + +export function readFailure(state: DomainReadState): boolean { + return state.readFailed; +} diff --git a/src/lib/analytics/score/sleep.ts b/src/lib/analytics/score/sleep.ts new file mode 100644 index 000000000..086fd887c --- /dev/null +++ b/src/lib/analytics/score/sleep.ts @@ -0,0 +1,113 @@ +import { + scoreConsistency, + scoreSufficiency, + sleepNeedMinutes, +} from "@/lib/insights/derived/sleep-score"; +import type { Derived } from "@/lib/insights/derived/types"; +import { userDayKey } from "@/lib/tz/format"; + +import type { PillarValue, SleepPillarInput } from "./types"; +import { + coverage, + failedPillar, + insufficientPillar, + mean, + okPillar, + standardDeviation, +} from "./shared"; + +export const SLEEP_WINDOW_DAYS = 28; +export const SLEEP_MIN_NIGHTS = 14; +export const SLEEP_REGULARITY_MIN_NIGHTS = 21; + +export function computeSleepPillar( + input: SleepPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "SLEEP", + asOf: input.asOf, + windowDays: SLEEP_WINDOW_DAYS, + }); + } + + const asOfDay = userDayKey(input.asOf, input.timezone); + const sinceDay = userDayKey( + new Date(input.asOf.getTime() - (SLEEP_WINDOW_DAYS - 1) * 86_400_000), + input.timezone, + ); + const nights = input.nights + .filter((night) => night.night <= asOfDay && night.night >= sinceDay) + .sort((a, b) => a.night.localeCompare(b.night)); + + if (nights.length < SLEEP_MIN_NIGHTS) { + return insufficientPillar({ + id: "SLEEP", + source: input.source, + asOf: input.asOf, + windowDays: SLEEP_WINDOW_DAYS, + requiredInputs: SLEEP_MIN_NIGHTS, + presentInputs: nights.length, + historyDays: nights.length, + missing: [`${Math.max(0, SLEEP_MIN_NIGHTS - nights.length)} nights`], + reason: + input.nights.length > 0 ? "below_night_floor_or_stale" : "not_tracked", + }); + } + + const asleepValues = nights.map((night) => night.asleepMinutes); + const observed = mean(asleepValues); + const need = sleepNeedMinutes(input.ageYears); + const sufficiency = scoreSufficiency(observed, need) ?? 0; + const midpoints = nights + .map((night) => night.midpoint) + .filter((midpoint): midpoint is number => midpoint != null); + const regularity = + nights.length >= SLEEP_REGULARITY_MIN_NIGHTS + ? scoreConsistency(midpoints) + : null; + // These are the existing Sleep Score's own declared sub-score weights, + // renormalised over the two inputs admitted here: sufficiency 0.3 and + // consistency 0.2 become 60/40. Below 21 nights the level stands alone. + const score = + regularity == null + ? sufficiency + : Math.round(sufficiency * 0.6 + regularity * 0.4); + const noiseFloor = Math.max( + 1, + Math.round(((0.5 * standardDeviation(asleepValues)) / need) * 100), + ); + const latest = nights.at(-1)!; + + return okPillar({ + id: "SLEEP", + source: input.source, + asOf: input.asOf, + windowDays: SLEEP_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: SLEEP_MIN_NIGHTS, + presentInputs: SLEEP_MIN_NIGHTS, + historyDays: nights.length, + }), + value: { + score, + observed: { + value: Math.round(observed), + unit: "minutes/night", + label: `${Math.round(observed)} min/night${regularity == null ? "" : " + regularity"}`, + asOf: `${latest.night}T00:00:00.000Z`, + sources: input.sources, + }, + reference: { + kind: "guideline-band", + low: need, + high: null, + label: `at least ${need} min/night; long sleep is not penalised`, + source: "NSF 2015; Ai 2021; Daghlas 2019; Windred 2024", + }, + noiseFloor, + deltaEligible: true, + deltaIdentity: "sleep_duration_rhythm", + }, + }); +} diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts new file mode 100644 index 000000000..8b88d7846 --- /dev/null +++ b/src/lib/analytics/score/types.ts @@ -0,0 +1,257 @@ +import type { BpTargets } from "@/lib/analytics/bp-targets"; +import type { + Derived, + DerivedProvenanceSource, +} from "@/lib/insights/derived/types"; +import type { ProfileSex } from "@/lib/profile/sex"; + +export const SCORE_VERSION = 2 as const; + +export const SCORE_PILLAR_IDS = [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", +] as const; + +export type ScorePillarId = (typeof SCORE_PILLAR_IDS)[number]; +export type ScoreBand = "green" | "yellow" | "red"; +export type ScoreDomain = + | "cardiometabolic" + | "activity" + | "sleep" + | "adiposity" + | "wellbeing" + | "fitness"; + +export type PillarReferenceKind = + "clinical-threshold" | "population-percentile" | "guideline-band"; + +export interface PillarObserved { + value: number; + unit: string; + /** Complete display value, including paired or panel values where needed. */ + label: string; + asOf: string; + /** Persisted source labels that contributed to the observation. */ + sources: string[]; +} + +export interface PillarReference { + kind: PillarReferenceKind; + low: number | null; + high: number | null; + /** Complete display band for paired or multi-marker references. */ + label: string; + source: string; +} + +export interface PillarValue { + score: number; + observed: PillarObserved; + reference: PillarReference; + /** Optional user-authored yardstick, displayed beside the scored reference. */ + personalReference?: PillarReference; + /** Score points that a weekly move must clear before it is narrated. */ + noiseFloor: number; + /** Slow markers contribute to level but never to the weekly delta. */ + deltaEligible: boolean; + /** Input/scoring mode identity used to form like-for-like deltas. */ + deltaIdentity: string; +} + +export interface ScorePillarResult { + id: ScorePillarId; + domain: ScoreDomain; + result: Derived; +} + +export interface CompositeValue { + score: number; + band: ScoreBand; + /** Non-null only when the worst pillar lowers the mean score's band. */ + bandSetter: ScorePillarId | null; + /** Registry-ordered eligible pillar ids. Part of the number's identity. */ + composition: ScorePillarId[]; + /** Equal-weighted floor across delta-eligible pillars. */ + noiseFloor: number; + scoreVersion: typeof SCORE_VERSION; +} + +export type ScoreDeltaReason = + | "algorithm_changed" + | "composition_changed" + | "first_eligibility_window" + | "below_noise_floor" + | "no_previous_window" + | "no_current_score"; + +export interface WeightGoalValue { + currentKg: number; + target: { min: number; max: number }; + distanceKg: number; + /** Positive means the distance to the personal band narrowed. */ + deltaKg: number | null; + asOf: string; + source: string; +} + +export interface RestModeAnnotation { + active: true; + since: string | null; + episodeCount: number; +} + +export interface HealthScoreReport { + composite: Derived; + pillars: ScorePillarResult[]; + delta: number | null; + deltaReason: ScoreDeltaReason | null; + scoreVersion: typeof SCORE_VERSION; + weightGoal: Derived; + algorithmNotice: { itemKey: string; dismissed: boolean } | null; + restMode?: RestModeAnnotation | null; +} + +export interface ScoreProfile { + ageYears: number | null; + sex: ProfileSex; + heightCm: number | null; +} + +export interface DomainReadState { + source: DerivedProvenanceSource; + readFailed: boolean; +} + +export interface BloodPressurePillarInput extends DomainReadState { + asOf: Date; + pairCount: number; + gradedScore: number | null; + representative: { sys: number; dia: number } | null; + oldestAt: Date | null; + latestAt: Date | null; + target: BpTargets | null; + personalTarget?: BpTargets | null; + sources: string[]; +} + +export interface HbA1cReading { + value: number; + unit: string; + at: Date; + source: string; +} + +export interface GlucoseReading { + value: number; + at: Date; + source: string; +} + +export interface GlycaemiaPillarInput extends DomainReadState { + asOf: Date; + hba1cReadFailed?: boolean; + fastingReadFailed?: boolean; + hba1c: HbA1cReading[]; + fastingGlucose: GlucoseReading[]; +} + +export interface ActivityDay { + day: string; + value: number; +} + +export interface ActivityPillarInput extends DomainReadState { + asOf: Date; + timezone: string; + ageYears: number | null; + days: ActivityDay[]; + sources: string[]; +} + +export interface SleepPillarNight { + night: string; + asleepMinutes: number; + midpoint: number | null; +} + +export interface SleepPillarInput extends DomainReadState { + asOf: Date; + timezone: string; + ageYears: number | null; + nights: SleepPillarNight[]; + sources: string[]; +} + +export interface AdiposityReading { + type: "WAIST_CIRCUMFERENCE" | "WAIST_TO_HEIGHT"; + value: number; + unit: string; + at: Date; + source: string; +} + +export interface AdiposityPillarInput extends DomainReadState { + asOf: Date; + heightCm: number | null; + rows: AdiposityReading[]; +} + +export interface WellbeingAssessment { + instrument: "PHQ9" | "GAD7" | "WHO5"; + totalScore: number; + item9Flagged: boolean; + at: Date; +} + +export interface WellbeingPillarInput extends DomainReadState { + asOf: Date; + assessments: WellbeingAssessment[]; +} + +export interface FitnessReading { + value: number; + at: Date; + source: string; + /** True only when persisted provenance proves a measured CPET/test. */ + measured: boolean; +} + +export interface FitnessPillarInput extends DomainReadState { + asOf: Date; + ageYears: number | null; + sex: ProfileSex; + rows: FitnessReading[]; +} + +export interface LipidReading { + marker: string; + value: number; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + panel: string | null; + at: Date; + source: string; +} + +export interface LipidsPillarInput extends DomainReadState { + asOf: Date; + rows: LipidReading[]; +} + +export interface PillarInputs { + BLOOD_PRESSURE: BloodPressurePillarInput; + GLYCAEMIA: GlycaemiaPillarInput; + ACTIVITY: ActivityPillarInput; + SLEEP: SleepPillarInput; + ADIPOSITY: AdiposityPillarInput; + WELLBEING: WellbeingPillarInput; + FITNESS: FitnessPillarInput; + LIPIDS: LipidsPillarInput; +} diff --git a/src/lib/analytics/score/weight-goal.ts b/src/lib/analytics/score/weight-goal.ts new file mode 100644 index 000000000..b3c88ab09 --- /dev/null +++ b/src/lib/analytics/score/weight-goal.ts @@ -0,0 +1,96 @@ +import { + buildInsufficient, + buildOk, + deriveCoverage, +} from "@/lib/insights/derived/coverage"; +import type { + Derived, + DerivedProvenanceSource, +} from "@/lib/insights/derived/types"; + +import type { WeightGoalValue } from "./types"; +import { DAY_MS, provenance } from "./shared"; + +export interface WeightGoalReading { + value: number; + at: Date; + source: string; +} + +function distanceToBand( + value: number, + target: { min: number; max: number }, +): number { + return Math.max(0, target.min - value, value - target.max); +} + +export function computeWeightGoal(args: { + rows: WeightGoalReading[]; + target: { min: number; max: number } | null; + source: DerivedProvenanceSource; + asOf: Date; + readFailed?: boolean; +}): Derived { + const since = new Date(args.asOf.getTime() - 60 * DAY_MS); + const rowsInWindow = args.rows.filter( + (row) => row.at >= since && row.at <= args.asOf, + ); + const weightProvenance = provenance({ + inputs: ["WEIGHT", "personal weight target"], + source: args.source, + windowDays: 60, + asOf: args.asOf, + }); + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 2, + presentInputs: (rowsInWindow.length > 0 ? 1 : 0) + (args.target ? 1 : 0), + historyDays: rowsInWindow.length > 0 ? 1 : 0, + missing: [ + ...(rowsInWindow.length === 0 ? ["weight reading"] : []), + ...(!args.target ? ["personal weight target"] : []), + ], + fullHistoryDays: 1, + }); + if (args.readFailed) { + return buildInsufficient({ + coverage, + provenance: weightProvenance, + reason: "read_failed", + }); + } + if (!args.target || rowsInWindow.length === 0) { + return buildInsufficient({ + coverage, + provenance: weightProvenance, + reason: args.target ? "weight_not_tracked" : "no_personal_goal", + }); + } + + const rows = [...rowsInWindow].sort( + (a, b) => b.at.getTime() - a.at.getTime(), + ); + const latest = rows[0]; + const previous = rows.find( + (row) => row.at <= new Date(latest.at.getTime() - 7 * DAY_MS), + ); + const latestDistance = distanceToBand(latest.value, args.target); + const previousDistance = previous + ? distanceToBand(previous.value, args.target) + : null; + return buildOk({ + value: { + currentKg: latest.value, + target: args.target, + distanceKg: latestDistance, + deltaKg: + previousDistance == null + ? null + : Math.round((previousDistance - latestDistance) * 10) / 10, + asOf: latest.at.toISOString(), + source: latest.source, + }, + coverage, + confidence, + provenance: weightProvenance, + }); +} diff --git a/src/lib/analytics/score/wellbeing.ts b/src/lib/analytics/score/wellbeing.ts new file mode 100644 index 000000000..a8ebe6603 --- /dev/null +++ b/src/lib/analytics/score/wellbeing.ts @@ -0,0 +1,134 @@ +import type { Derived } from "@/lib/insights/derived/types"; + +import type { PillarValue, WellbeingPillarInput } from "./types"; +import { + clampScore, + coverage, + failedPillar, + insufficientPillar, + okPillar, + withinWindow, +} from "./shared"; + +export const WELLBEING_WINDOW_DAYS = 90; + +const INSTRUMENT: Record< + "PHQ9" | "GAD7" | "WHO5", + { + max: number; + low: number; + high: number; + source: string; + label: string; + itemStep: number; + } +> = { + PHQ9: { + max: 27, + low: 0, + high: 4, + source: "Kroenke 2001", + label: "PHQ-9 0–4", + itemStep: 1, + }, + GAD7: { + max: 21, + low: 0, + high: 4, + source: "Spitzer 2006", + label: "GAD-7 0–4", + itemStep: 1, + }, + WHO5: { + max: 100, + low: 52, + high: 100, + source: "WHO 1998", + label: "WHO-5 52–100", + // WHO-5 percentage totals move in four-point increments (raw 0–25 × 4). + itemStep: 4, + }, +}; + +export function computeWellbeingPillar( + input: WellbeingPillarInput, +): Derived { + if (input.readFailed) { + return failedPillar({ + id: "WELLBEING", + asOf: input.asOf, + windowDays: WELLBEING_WINDOW_DAYS, + }); + } + + const assessments = input.assessments + .filter((row) => withinWindow(row.at, input.asOf, WELLBEING_WINDOW_DAYS)) + .sort((a, b) => b.at.getTime() - a.at.getTime()); + const latest = assessments[0]; + if (!latest) { + return insufficientPillar({ + id: "WELLBEING", + source: input.source, + asOf: input.asOf, + windowDays: WELLBEING_WINDOW_DAYS, + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: ["completed WHO-5, PHQ-9 or GAD-7"], + reason: input.assessments.length > 0 ? "stale" : "not_tracked", + }); + } + + if (latest.instrument === "PHQ9" && latest.item9Flagged) { + return insufficientPillar({ + id: "WELLBEING", + source: input.source, + asOf: input.asOf, + windowDays: WELLBEING_WINDOW_DAYS, + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + missing: [], + reason: "crisis_signposting", + }); + } + + const spec = INSTRUMENT[latest.instrument]; + const score = + latest.instrument === "WHO5" + ? clampScore(latest.totalScore) + : clampScore((1 - latest.totalScore / spec.max) * 100); + const noiseFloor = Math.max(1, Math.round((spec.itemStep / spec.max) * 100)); + + return okPillar({ + id: "WELLBEING", + source: input.source, + asOf: input.asOf, + windowDays: WELLBEING_WINDOW_DAYS, + coverage: coverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + }), + value: { + score, + observed: { + value: latest.totalScore, + unit: "score", + label: `${latest.instrument} ${latest.totalScore}/${spec.max}`, + asOf: latest.at.toISOString(), + sources: ["COMPUTED"], + }, + reference: { + kind: "clinical-threshold", + low: spec.low, + high: spec.high, + label: spec.label, + source: spec.source, + }, + noiseFloor, + deltaEligible: true, + deltaIdentity: latest.instrument, + }, + }); +} diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index f2faab32b..fbe65fe56 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -11,6 +11,7 @@ import { invalidateUserDashboardWidgets, invalidateUserDashboardSnapshot, invalidateUserInsights, + invalidateUserHealthScore, invalidateUserMeasurements, invalidateUserMedications, invalidateUserMood, @@ -110,6 +111,36 @@ function snapshotCell(userId: string, locale = "en"): string { return `${dashboardSnapshotCacheKey(userId)}|${locale}`; } +describe("invalidateUserHealthScore", () => { + it("evicts analytics, dashboard snapshots, and derived batches for one user", async () => { + await cached(caches.analytics, `${USER_A}|default|en`, async () => ({})); + await cached(caches.analytics, snapshotCell(USER_A), async () => ({})); + await cached( + caches.insightsDerived, + `${USER_A}|batch|HEALTH_SCORE|en`, + async () => ({}), + ); + await cached(caches.analytics, `${USER_B}|default|en`, async () => ({})); + await cached( + caches.insightsDerived, + `${USER_B}|batch|HEALTH_SCORE|en`, + async () => ({}), + ); + + invalidateUserHealthScore(USER_A); + + expect(caches.analytics.get(`${USER_A}|default|en`)).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); + expect( + caches.insightsDerived.get(`${USER_A}|batch|HEALTH_SCORE|en`), + ).toBeNull(); + expect(caches.analytics.get(`${USER_B}|default|en`)).not.toBeNull(); + expect( + caches.insightsDerived.get(`${USER_B}|batch|HEALTH_SCORE|en`), + ).not.toBeNull(); + }); +}); + describe("invalidateUserMeasurements", () => { it("evicts analytics + achievements + workouts for the target user only", async () => { await primeAllCaches(); diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index b154bf598..b75193c13 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -185,6 +185,16 @@ export function invalidateUserMedications( caches.insightsTargets.deleteByPrefix(userId); } +/** + * Hard-evict every server cache that can carry the derived Health Score. + * Lab, assessment and score-notice mutations call this one bundle so the + * analytics report, dashboard snapshot and derived batch cannot disagree. + */ +export function invalidateUserHealthScore(userId: string): void { + caches.analytics.deleteByPrefix(`${userId}|`); + caches.insightsDerived.deleteByPrefix(`${userId}|`); +} + /** * v1.16.8 — invalidate every cache whose payload derives from the user's * PROFILE-level settings rather than from measurement / mood / medication diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index e60800816..89739af2b 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -24,6 +24,10 @@ import type { ArrivalKind } from "@/lib/arrivals/types"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; +import type { + ScoreDeltaReason, + ScorePillarId, +} from "@/lib/analytics/score/types"; import { MAX_PRIORITY_ACTIONS, type PriorityItem, @@ -97,6 +101,10 @@ export interface DailyDigestScore { value: number; band: string; delta: number | null; + /** Additive score identity; omitted by older cached digests. */ + deltaReason?: ScoreDeltaReason | null; + scoreVersion?: number; + composition?: ScorePillarId[]; } /** A broken integration, deterministically derived from `IntegrationStatus`. */ diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 5843cc774..7864b11a0 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -428,6 +428,9 @@ export async function loadDailyDigest( value: snapshot.healthScore.score, band: snapshot.healthScore.band, delta: snapshot.healthScore.delta, + deltaReason: snapshot.healthScore.deltaReason, + scoreVersion: snapshot.healthScore.scoreVersion, + composition: snapshot.healthScore.composition, } : null; diff --git a/src/lib/daily/priority-item-key.ts b/src/lib/daily/priority-item-key.ts index ba2d93419..9be276fc7 100644 --- a/src/lib/daily/priority-item-key.ts +++ b/src/lib/daily/priority-item-key.ts @@ -55,3 +55,8 @@ export function sameTimeBaselineItemKey( ): string { return `same_time_baseline:${localDayKey}:${type}`; } + +/** One persisted acknowledgement per Health Score algorithm version. */ +export function healthScoreAlgorithmItemKey(scoreVersion: number): string { + return `health_score_algorithm:${scoreVersion}`; +} diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts index d20f97db1..d29582a5c 100644 --- a/src/lib/daily/priority-item.ts +++ b/src/lib/daily/priority-item.ts @@ -81,9 +81,16 @@ export function isDismissibleKind( * builder (which stamps the key) and the dismiss route's Zod schema (which * rejects anything else structurally, before a lookup ever runs). */ +const DISMISSIBLE_NOTICE_PREFIXES = ["health_score_algorithm"] as const; + export function isDismissibleItemKey(itemKey: string): boolean { - return DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) => - itemKey.startsWith(`${kind}:`), + return ( + DISMISSIBLE_PRIORITY_ITEM_KINDS.some((kind) => + itemKey.startsWith(`${kind}:`), + ) || + DISMISSIBLE_NOTICE_PREFIXES.some((prefix) => + itemKey.startsWith(`${prefix}:`), + ) ); } diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 74897a21c..9aac226eb 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -22,7 +22,7 @@ const computeBpInTargetFastPath = vi.fn(); const getAssistantFlags = vi.fn(); const hasAnyConfiguredProvider = vi.fn(); const buildMedsTodayBlock = vi.fn(); -const computeUserHealthScoreFastPath = vi.fn(); +const computeUserHealthScoreMock = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), @@ -53,9 +53,57 @@ vi.mock("@/lib/ai/provider", () => ({ vi.mock("@/lib/dashboard/meds-today", () => ({ buildMedsTodayBlock: (...a: unknown[]) => buildMedsTodayBlock(...a), })); -vi.mock("@/lib/analytics/health-score-fast-path", () => ({ - computeUserHealthScoreFastPath: (...a: unknown[]) => - computeUserHealthScoreFastPath(...a), +vi.mock("@/lib/analytics/score/reader", () => ({ + computeUserHealthScore: async (...a: unknown[]) => { + const legacy = await computeUserHealthScoreMock(...a); + const provenance = { + inputs: [], + source: "none" as const, + windowDays: 0, + computedAt: "2026-07-28T12:00:00.000Z", + }; + const coverage = { + requiredInputs: 3, + presentInputs: legacy ? 3 : 0, + historyDays: legacy ? 28 : 0, + missing: legacy ? [] : ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + }; + const composite = legacy + ? { + status: "ok" as const, + value: { + score: legacy.score, + band: legacy.band, + bandSetter: "BLOOD_PRESSURE" as const, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] as const, + noiseFloor: 2, + scoreVersion: 2 as const, + }, + coverage, + confidence: { score: 100, band: "high" as const }, + provenance, + } + : { + status: "insufficient" as const, + coverage, + provenance, + reason: "three_domains_required", + }; + return { + composite, + pillars: [], + delta: legacy?.delta ?? null, + deltaReason: null, + scoreVersion: 2 as const, + weightGoal: { + status: "insufficient" as const, + coverage, + provenance, + reason: "no_personal_goal", + }, + algorithmNotice: null, + }; + }, })); // v1.18.0 — module gate. Default-mocked all-on so the existing envelope // tests keep their full tile set; the module-gating suite below passes @@ -182,7 +230,7 @@ beforeEach(() => { nextDueOverdue: false, nextDueMedicationName: null, }); - computeUserHealthScoreFastPath.mockResolvedValue(null); + computeUserHealthScoreMock.mockResolvedValue(null); resolveModuleMap.mockResolvedValue(moduleMap()); buildScoreRingsBlock.mockResolvedValue([]); }); @@ -301,7 +349,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { priorYear: { pct: 50 }, gradedScore: 84, }); - computeUserHealthScoreFastPath.mockResolvedValue({ + computeUserHealthScoreMock.mockResolvedValue({ score: 71, band: "yellow", delta: 3, @@ -319,16 +367,21 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { band: "yellow", delta: 3, restMode: null, + confidence: { score: 100, band: "high" }, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + deltaReason: null, + scoreVersion: 2, + bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null absent a // disagreeing readiness set / a returned metric (the fake derived // engines resolve nothing here). tension: null, returnToBand: null, }); - // v1.17 W1b — two runs (current + prior-week), identical to the analytics - // route, so the ring's delta reflects BP movement. - expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(2); - expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); + // Current, prior-week, and prior-two-week windows support both the weekly + // movement and the first-eligibility guard. + expect(computeBpInTargetFastPath).toHaveBeenCalledTimes(3); + expect(computeUserHealthScoreMock).toHaveBeenCalledTimes(1); }); it("a fresh account with zero coverage entries stays null", async () => { @@ -340,7 +393,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { expect(snap.extras).toBeNull(); expect(snap.healthScore).toBeNull(); expect(computeBpInTargetFastPath).not.toHaveBeenCalled(); - expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled(); + expect(computeUserHealthScoreMock).not.toHaveBeenCalled(); }); it("a gated type ABSENT from the map (never logged) keeps the phase warm", async () => { @@ -357,7 +410,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { priorYear: null, gradedScore: null, }); - computeUserHealthScoreFastPath.mockResolvedValue(null); + computeUserHealthScoreMock.mockResolvedValue(null); const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); @@ -423,10 +476,10 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); expect(snap.healthScore).toBeNull(); - expect(computeUserHealthScoreFastPath).not.toHaveBeenCalled(); + expect(computeUserHealthScoreMock).not.toHaveBeenCalled(); }); - it("serialises score + band + delta — and ONLY those — when warm", async () => { + it("serialises the additive score identity when warm", async () => { const coverageMap = new Map([["WEIGHT", true]]); probeRollupCoverage.mockResolvedValue(coverageMap); isFullyCovered.mockReturnValue(true); @@ -443,7 +496,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { priorYear: { pct: 50, pairs: 40 }, gradedScore: 88, }); - computeUserHealthScoreFastPath.mockResolvedValue({ + computeUserHealthScoreMock.mockResolvedValue({ score: 72, band: "yellow", delta: -12, @@ -460,6 +513,11 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { band: "yellow", delta: -12, restMode: null, + confidence: { score: 100, band: "high" }, + composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], + deltaReason: null, + scoreVersion: 2, + bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null here. tension: null, returnToBand: null, @@ -467,27 +525,17 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { // The fast path reuses the BP windows + coverage map already // computed for `extras` (no re-probe, graded score threaded). - expect(computeUserHealthScoreFastPath).toHaveBeenCalledTimes(1); - const arg = computeUserHealthScoreFastPath.mock.calls[0][0] as { + expect(computeUserHealthScoreMock).toHaveBeenCalledTimes(1); + const arg = computeUserHealthScoreMock.mock.calls[0][0] as { userId: string; - bpInTargetPct: number | null; - bpGradedScore: number | null; - weightTarget: { min: number; max: number } | null; - weightTargetSource: string; - moodEnabled: boolean; - coverage: unknown; + bpEnvelope: { last90Days?: { pct: number } | null } | null; + bpTargets: unknown; + profile: { thresholdsJson: unknown }; + modules: { mentalHealth: boolean }; }; expect(arg.userId).toBe("user-1"); - // v1.17 W1d — the BP pillar reads the 90-day window, identical to the - // analytics route, so dashboard ring and insights card score off one - // number. Previously this was last30Days (80). - expect(arg.bpInTargetPct).toBe(77); - expect(arg.bpGradedScore).toBe(88); - // v1.34 — no weight target on this fixture, so the pillar scores the bare - // trend. The BMI-22 band that used to be derived from `heightCm` is gone. - expect(arg.weightTarget).toBeNull(); - expect(arg.weightTargetSource).toBe("none"); - expect(arg.coverage).toBe(coverageMap); + expect(arg.bpEnvelope?.last90Days?.pct).toBe(77); + expect(arg.bpTargets).not.toBeNull(); }); it("is null when warm but no pillar is computable (fast path returns null)", async () => { @@ -502,7 +550,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { priorYear: null, gradedScore: null, }); - computeUserHealthScoreFastPath.mockResolvedValue(null); + computeUserHealthScoreMock.mockResolvedValue(null); const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); @@ -993,48 +1041,38 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { ); }); - it("mood disabled: tile hidden + mood block emptied", async () => { + it("mental assessments disabled: WELLBEING is omitted from the score", async () => { const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { - modules: () => Promise.resolve(moduleMap({ mood: false })), + modules: () => Promise.resolve(moduleMap({ mentalHealth: false })), }); - expect(widget(snap, "mood")!.visible).toBe(false); - expect(widget(snap, "mood")!.tileVisible).toBe(false); - expect(cat(snap, "mood")!.visible).toBe(false); - expect(snap.tiles.mood.entries).toEqual([]); - expect(snap.tiles.mood.summary).toBeNull(); - // Core tile unaffected. - expect(widget(snap, "weight")!.visible).toBe(true); - // v1.34 — the hero ring's health score drops the mood pillar too. Before - // this the snapshot never resolved the module, so a mood-off account was - // scored on a pillar the Insights card had already dropped. - const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as - { moodEnabled: boolean } | undefined; - expect(scoreCall?.moodEnabled).toBe(false); + // The independent mood tile is unaffected by the assessment-module gate. + expect(widget(snap, "mood")!.visible).toBe(true); + expect(snap.tiles.mood.entries.length).toBeGreaterThan(0); + const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as + { modules: { mentalHealth: boolean } } | undefined; + expect(scoreCall?.modules.mentalHealth).toBe(false); }); - it("mood enabled: the health score keeps its mood pillar", async () => { + it("mental assessments enabled: the score admits WELLBEING", async () => { await buildDashboardSnapshot(fakePrisma, baseUser(), { modules: () => Promise.resolve(moduleMap()), }); - const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as - { moodEnabled: boolean } | undefined; - expect(scoreCall?.moodEnabled).toBe(true); + const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as + { modules: { mentalHealth: boolean } } | undefined; + expect(scoreCall?.modules.mentalHealth).toBe(true); }); - it("an explicit weight target reaches the score and the bands", async () => { + it("an explicit weight target reaches personal context and dashboard bands", async () => { const snap = await buildDashboardSnapshot( fakePrisma, baseUser({ thresholdsJson: { WEIGHT: { min: 74, max: 78 } } }), { modules: () => Promise.resolve(moduleMap()) }, ); - const scoreCall = computeUserHealthScoreFastPath.mock.calls.at(-1)?.[0] as - | { - weightTarget: { min: number; max: number } | null; - weightTargetSource: string; - } - | undefined; - expect(scoreCall?.weightTarget).toEqual({ min: 74, max: 78 }); - expect(scoreCall?.weightTargetSource).toBe("user"); + const scoreCall = computeUserHealthScoreMock.mock.calls.at(-1)?.[0] as + { profile: { thresholdsJson: unknown } } | undefined; + expect(scoreCall?.profile.thresholdsJson).toEqual({ + WEIGHT: { min: 74, max: 78 }, + }); // The tile / chart band follows the same target. expect(snap.targetBands.weightRange?.greenMin).toBe(74); expect(snap.targetBands.weightRange?.greenMax).toBe(78); @@ -1145,7 +1183,7 @@ describe("buildDashboardSnapshot — ambient narrative (A4/A5/A6)", () => { priorMonth: { pct: 60 }, priorYear: { pct: 50 }, }); - computeUserHealthScoreFastPath.mockResolvedValue({ + computeUserHealthScoreMock.mockResolvedValue({ score: 71, band: "yellow", delta: 3, diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 3b62ce33c..b14bc7e47 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -575,7 +575,12 @@ describe("rung 6 — silence", () => { describe("rung 7 — scoreDrop", () => { function scoreSnapshot(delta: number | null): DashboardSnapshot { return baseSnapshot({ - healthScore: { score: 60, band: "yellow", delta }, + healthScore: { + score: 60, + band: "yellow", + delta, + deltaReason: null, + }, }); } @@ -709,7 +714,12 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => { }, mood: { summary: null, entries: [] }, }, - healthScore: { score: 40, band: "red", delta: -20 }, + healthScore: { + score: 40, + band: "red", + delta: -20, + deltaReason: null, + }, briefing: { paragraph: "Busy day.", keyFindings: [WATCH_FINDING], @@ -725,7 +735,12 @@ describe("rung 9 — allQuiet + full-ladder precedence", () => { it("scoreDrop outranks the briefing teaser", () => { const verdict = resolveDashboardVerdict( baseSnapshot({ - healthScore: { score: 50, band: "yellow", delta: -15 }, + healthScore: { + score: 50, + band: "yellow", + delta: -15, + deltaReason: null, + }, briefing: { paragraph: "Busy day.", keyFindings: [WATCH_FINDING], diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 4acd25431..44038892a 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -67,6 +67,7 @@ export async function readDashboardSnapshotCached( insightsCachedText: user.insightsCachedText, insightsCachedAt: user.insightsCachedAt, dashboardWidgetsJson: user.dashboardWidgetsJson, + sourcePriorityJson: user.sourcePriorityJson, thresholdsJson: user.thresholdsJson, }; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 9639bf65f..f45bde80a 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -43,7 +43,7 @@ import { type DataPoint, type DataSummary, } from "@/lib/analytics/trends"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -54,12 +54,8 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { - buildHealthScoreBpInputs, - buildHealthScoreWeightTarget, -} from "@/lib/analytics/health-score-inputs"; -import { + resolveEffectiveBpTargets, resolveWeightTargetOverride, - type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { @@ -81,15 +77,18 @@ import { buildMedsTodayBlock, type MedsTodayBlock, } from "@/lib/dashboard/meds-today"; -import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { computeUserHealthScore } from "@/lib/analytics/score/reader"; import { buildDashboardBands as buildTargetBands, type DashboardTargetBands, } from "@/lib/dashboard/bands"; import type { - HealthScoreBand, RestModeAnnotation, -} from "@/lib/analytics/health-score"; + ScoreBand, + ScoreDeltaReason, + ScorePillarId, +} from "@/lib/analytics/score/types"; +import type { DerivedConfidence } from "@/lib/insights/derived/types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { @@ -326,8 +325,13 @@ export interface DashboardLayoutCatalogueEntry { */ export interface DashboardSnapshotHealthScore { score: number; - band: HealthScoreBand; + band: ScoreBand; delta: number | null; + confidence?: DerivedConfidence; + composition?: ScorePillarId[]; + deltaReason?: ScoreDeltaReason | null; + scoreVersion?: number; + bandSetter?: ScorePillarId | null; /** * v1.18.1 — Rest Mode annotation. When an illness/condition episode is * active the dashboard frames the score ("you were unwell during this @@ -479,12 +483,11 @@ export interface SnapshotUserInput { insightsCachedText: string | null; insightsCachedAt: Date | null; dashboardWidgetsJson: unknown; + /** Raw source-priority settings already loaded with the authenticated user. */ + sourcePriorityJson?: unknown; /** - * v1.34 — the raw `User.thresholdsJson` blob. The hero ring's weight - * pillar and the dashboard's weight bands both grade against the user's - * OWN target when one is set, so the builder needs the overrides the - * `/targets` editor writes. Parsed at the point of use through - * `ThresholdOverridesJson`. + * Raw personal targets used by the unscored weight-goal context row and + * dashboard reference bands. */ thresholdsJson: unknown; } @@ -593,15 +596,13 @@ async function buildNutrientWaterBlock( * this entirely and emits `extras: null` + `healthScore: null` * (per-tile shimmer until the boot backfill converges). * - * The health score rides this warm phase because it reuses the BP - * windows already computed here (`gradedScore` + `last30Days.pct`) and - * the same coverage map, so the score's weight pillar also stays on - * the rollup branch. + * The Health Score rides this warm phase because it reuses the BP windows and + * coverage map. Weight remains in the warm gate for the unscored personal-goal + * row, which must not force a dense live fallback while the rollup backfill is + * pending. * - * v1.34 — takes the resolved module map. The hero ring used to score a - * mood pillar for accounts that had switched the mood module off, while - * the Insights card (which does resolve the module) dropped it: one - * score, two compositions, two surfaces. + * v1.34 — takes the resolved module map so a disabled mental-assessment module + * cannot leave WELLBEING in this composition while Insights omits it. */ async function buildExtras( prisma: PrismaClient, @@ -628,32 +629,79 @@ async function buildExtras( // analytics route uses. let bpEnvelope: BpInTargetEnvelope | null = null; let bpEnvelopePriorWeek: BpInTargetEnvelope | null = null; + let bpEnvelopePriorTwoWeeks: BpInTargetEnvelope | null = null; - const bpTargets = getBpTargets(user.dateOfBirth); + const bpTargets = resolveEffectiveBpTargets( + { + dateOfBirth: user.dateOfBirth, + gender: user.gender, + heightCm: user.heightCm ?? null, + }, + user.thresholdsJson, + ); + const clinicalBpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { // v1.17 W1b — two runs (current + prior-week), identical to the // analytics route, so the dashboard ring's week-over-week delta reflects // BP movement instead of zeroing it out. Both reuse the already-probed // coverage map and share the rollup/live branch decision. const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const [windows, windowsPriorWeek] = await Promise.all([ - computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now, - coverage, - userTz, - }), - computeBpInTargetFastPath({ - userId: user.id, - targets: bpTargets, - now: sevenDaysAgo, - coverage, - userTz, - }), - ]); - bpEnvelope = windows; - bpEnvelopePriorWeek = windowsPriorWeek; + const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const [windows, windowsPriorWeek, windowsPriorTwoWeeks] = await Promise.all( + [ + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: bpTargets, + now: fourteenDaysAgo, + coverage, + userTz, + }), + ], + ); + if (bpTargetsEqual(bpTargets, clinicalBpTargets)) { + bpEnvelope = windows; + bpEnvelopePriorWeek = windowsPriorWeek; + bpEnvelopePriorTwoWeeks = windowsPriorTwoWeeks; + } else if (clinicalBpTargets) { + [bpEnvelope, bpEnvelopePriorWeek, bpEnvelopePriorTwoWeeks] = + await Promise.all([ + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now: sevenDaysAgo, + coverage, + userTz, + }), + computeBpInTargetFastPath({ + userId: user.id, + targets: clinicalBpTargets, + now: fourteenDaysAgo, + coverage, + userTz, + }), + ]); + } // v1.17 W1d — the headline standardises on the trailing-90-day // window (labelled "· 90 T" in the tile), identical to the analytics // route, so the dashboard tile and the insights surface never narrate @@ -686,55 +734,63 @@ async function buildExtras( // ring and the insights card grade the pillar off identical inputs (same // 90-day window via W1d, same all-time fallback, same graded score, same // prior-week delta values). Closes the dashboard-vs-insights divergence. - const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); - // v1.34 — the weight yardstick, resolved through the same shared builder the - // analytics route uses. A target reaches the pillar only when the account - // set one; otherwise the pillar scores the bare trend on both surfaces. - const weightTargetInputs = buildHealthScoreWeightTarget( - { - heightCm: user.heightCm, - dateOfBirth: user.dateOfBirth, - gender: user.gender, - }, - (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, - ); + const scoreSourcePriority = user.sourcePriorityJson ?? null; const scoreResult = await time("healthScore", () => - computeUserHealthScoreFastPath({ + computeUserHealthScore({ + prisma, userId: user.id, - ...bpInputs, - ...weightTargetInputs, now, - coverage, - // v1.34 — the mood module gates the pillar here exactly as it does on - // `/api/analytics`. Before this the ring scored a pillar the account - // had switched off everywhere else. - moodEnabled: modules.mood !== false, + profile: { + dateOfBirth: user.dateOfBirth, + gender: user.gender, + heightCm: user.heightCm, + timezone: userTz, + sourcePriorityJson: scoreSourcePriority, + thresholdsJson: user.thresholdsJson, + }, + modules: { + glucose: modules.glucose !== false, + labs: modules.labs !== false, + sleep: modules.sleep !== false, + mentalHealth: modules.mentalHealth !== false, + }, + bpTargets: clinicalBpTargets, + bpEnvelope, + bpEnvelopePriorWeek, + bpEnvelopePriorTwoWeeks, }), ); // v1.18.1 — resolve the value-free Rest Mode annotation alongside the score // so the dashboard hero can frame (never penalise) the number, matching the // `/api/analytics` payload + iOS. Fail-soft inside `resolveRestMode`, and // only resolved when a score actually rendered. - const restMode: RestModeAnnotation | null = scoreResult - ? await (async () => { - const ctx = await resolveRestMode(user.id, now); - return ctx.active - ? { - active: true, - since: ctx.since, - episodeCount: ctx.episodeCount, - } - : null; - })() - : null; - const healthScore: DashboardSnapshotHealthScore | null = scoreResult - ? { - score: scoreResult.score, - band: scoreResult.band, - delta: scoreResult.delta, - restMode, - } - : null; + const restMode: RestModeAnnotation | null = + scoreResult.composite.status === "ok" + ? await (async () => { + const ctx = await resolveRestMode(user.id, now); + return ctx.active + ? { + active: true, + since: ctx.since, + episodeCount: ctx.episodeCount, + } + : null; + })() + : null; + const healthScore: DashboardSnapshotHealthScore | null = + scoreResult.composite.status === "ok" + ? { + score: scoreResult.composite.value.score, + band: scoreResult.composite.value.band, + delta: scoreResult.delta, + confidence: scoreResult.composite.confidence, + composition: scoreResult.composite.value.composition, + deltaReason: scoreResult.deltaReason, + scoreVersion: scoreResult.scoreVersion, + bandSetter: scoreResult.composite.value.bandSetter, + restMode, + } + : null; const glucoseSince = new Date( Date.now() - GLUCOSE_PANEL_WINDOW_DAYS * 24 * 60 * 60 * 1000, @@ -950,27 +1006,27 @@ function buildLayoutCatalogue( * DAY bucket — one fresh wearable reading whose async fold hadn't * landed yet zeroed the hero score, while the insights analytics * route (which calls the self-gating helpers unconditionally) kept - * showing a score for the same account. Mirrors the v1.4.38.8 - * per-type pattern already inside `computeBpInTargetFastPath` and - * `computeUserHealthScoreFastPath`: gate only on the types the thick - * phase actually reads through the rollup tier — WEIGHT (score weight - * pillar) + BLOOD_PRESSURE_SYS / _DIA (BP windows + BP pillar). + * showing a score for the same account. Gate only the dense types the thick + * phase might otherwise read live: blood-pressure pairs and activity slices. + * Sparse weight rows feed the unscored personal-goal disclosure and remain + * cheap; every other score input is naturally sparse. * * `!== false` rather than `=== true`: a type ABSENT from the map has * zero measurements, so the helpers' own live fallbacks are trivially * cheap (empty reads) and absence must not cold the phase. A type - * present-but-`false` has live rows without buckets — for the three - * gated types that is exactly the multi-second live fallback the - * snapshot must never wait on, so the phase stays `null` until the - * backfill converges. An empty map is a fresh account with no - * measurements at all — nothing to compute, stay `null`. + * present-but-`false` has live rows without buckets — for these dense types + * that is exactly the multi-second live fallback the snapshot must never + * wait on, so the phase stays `null` until the backfill converges. + * An empty map is a fresh account with no measurements at all — nothing to + * compute, so the thick phase stays `null`. */ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { if (coverage.size === 0) return false; return ( coverage.get("WEIGHT") !== false && coverage.get("BLOOD_PRESSURE_SYS") !== false && - coverage.get("BLOOD_PRESSURE_DIA") !== false + coverage.get("BLOOD_PRESSURE_DIA") !== false && + coverage.get("ACTIVITY_STEPS") !== false ); } diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index 94c3c0fb1..6ee7cb96e 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -250,6 +250,7 @@ export function resolveDashboardVerdict( if ( score !== null && score.delta !== null && + score.deltaReason === null && score.delta <= -SCORE_DROP_MIN_POINTS ) { return { diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts index 8264c1133..ab5e1f6db 100644 --- a/src/lib/documents/commit.ts +++ b/src/lib/documents/commit.ts @@ -18,6 +18,7 @@ */ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { decryptFactData } from "@/lib/documents/store"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; @@ -105,6 +106,7 @@ async function commitObservation( noteEncrypted: null, }, }); + invalidateUserHealthScore(userId); // v1.31.0 — the labs arm of the data-arrival spine. A "panel" has no // first-class entity in the schema (it is a nullable label plus a `takenAt`), diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index bec915336..267fc592c 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -60,6 +60,7 @@ describe("registry", () => { true, ); expect(getDerivedMetricMeta("TRAJECTORY")?.implemented).toBe(true); + expect(getDerivedMetricMeta("HEALTH_SCORE")?.implemented).toBe(true); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -77,6 +78,7 @@ describe("registry", () => { expect(DERIVED_METRIC_IDS).toContain("READINESS"); expect(DERIVED_METRIC_IDS).toContain("HRV_BALANCE"); expect(DERIVED_METRIC_IDS).toContain("BMI"); + expect(DERIVED_METRIC_IDS).toContain("HEALTH_SCORE"); expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE"); expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE"); expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE"); @@ -86,7 +88,7 @@ describe("registry", () => { expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND"); expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY"); expect(DERIVED_METRIC_IDS).toContain("SAME_TIME_BASELINE"); - expect(DERIVED_METRIC_IDS.length).toBe(17); + expect(DERIVED_METRIC_IDS.length).toBe(18); }); }); diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index b9ef6989c..7487914f6 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -36,6 +36,7 @@ import { type WellnessScoreType, } from "./wellness-scores"; import type { Derived } from "./types"; +import { computeHealthScoreDerived } from "@/lib/analytics/score/derived"; export interface DerivedComputeArgs { metric: DerivedMetricId; @@ -238,6 +239,10 @@ export async function computeDerivedMetric( now, }) as Promise>; } + case "HEALTH_SCORE": + return computeHealthScoreDerived(args.userId) as Promise< + Derived + >; case "RECOVERY_SCORE": case "STRESS_SCORE": case "STRAIN_SCORE": diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 772934844..8155ccb50 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -55,6 +55,8 @@ export type DerivedMetricId = | "STAIR_DESCENT_SPEED_BASELINE" /** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */ | "SIX_MINUTE_WALK_BAND" + /** Versioned cardiometabolic reference composite. */ + | "HEALTH_SCORE" /** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */ | "TRAJECTORY" /** v1.34.0: today's running total for a cumulative metric vs this person's own typical total at the same local hour. */ @@ -336,6 +338,24 @@ const REGISTRY: Record = { minInputs: 1, implemented: true, }, + HEALTH_SCORE: { + id: "HEALTH_SCORE", + displayName: "Cardiometabolic reference score", + archetype: "composite", + inputs: [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ], + minHistoryDays: 1, + minInputs: 3, + implemented: true, + }, SAME_TIME_BASELINE: { id: "SAME_TIME_BASELINE", displayName: "Same-time baseline", diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts index 39bcf6971..351a161d1 100644 --- a/src/lib/jobs/lab-biomarker-backfill.ts +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -25,6 +25,7 @@ * (the v1.4.37 dead-queue class). */ import { prisma } from "@/lib/db"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission"; import { annotate } from "@/lib/logging/context"; @@ -151,6 +152,7 @@ export async function runLabBiomarkerBackfillForUser( }); linked += count; } + if (linked > 0) invalidateUserHealthScore(userId); annotate({ action: { diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 803ac0084..6c160083e 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -968,18 +968,65 @@ export const dashboardSnapshotResponse = z .describe( "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), every active medication's current display-due candidate, and legacy scalar fields projected from candidate zero. `overdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band); `availableFrom` is the canonical cadence- and dose-window-derived attribution-band start. `dueCandidates` is optional so older cached snapshots remain valid. Medication ids let consumers deep-link straight to the relevant card.", ), - // Dashboard hero — health score (warm phase, nullable on a - // rollup-coverage miss). Score + band + delta only; the per-pillar - // component breakdown stays on the analytics route. + // Dashboard hero health score. The additive metadata lets clients suppress + // false change narratives when eligibility or the algorithm changes. healthScore: z .object({ - score: z.number().int(), + score: z.number().int().min(0).max(100), band: z.enum(["green", "yellow", "red"]), delta: z.number().nullable(), + confidence: derivedConfidence.optional(), + composition: z + .array( + z.enum([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ]), + ) + .optional(), + deltaReason: z + .enum([ + "algorithm_changed", + "composition_changed", + "first_eligibility_window", + "below_noise_floor", + "no_previous_window", + "no_current_score", + ]) + .nullable() + .optional(), + scoreVersion: z.number().int().positive().optional(), + bandSetter: z + .enum([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + "FITNESS", + "LIPIDS", + ]) + .nullable() + .optional(), + restMode: z + .object({ + active: z.boolean(), + since: z.string().nullable(), + episodeCount: z.number().int(), + }) + .nullable() + .optional(), }) .nullable() .describe( - "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", + "Derived cardiometabolic reference score summary. Null until at least three eligible domains include a measured physiological domain. Additive metadata names confidence, ordered composition, the worst-pillar band setter, score version, and why a delta was suppressed. Rest Mode annotates but never changes the score.", ), // v1.27.7 — user-selected hero score rings (max 3), resolved // server-side next to the health score. Additive; optional so diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 67826add9..2ea969fc0 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -219,22 +219,9 @@ describe("aggregateWmyBuckets", () => { }); /** - * v1.4.40 W-WMY-WIRE — granularity-routing parity across the v1.4.40 - * consumers (summaries-slice + health-score-fast-path). Both consumers - * now call `readBestGranularityRollups` for their long-window probes; - * this pins: - * - 90 d → DAY (every coarser floor too high — DAY is canonical - * for the trailing quarter regardless of consumer), - * - 365 d → MONTH (clears MONTH floor 181 d, short of YEAR floor - * 731 d) — the granularity health-score-fast-path's weight long- - * window read and summaries-slice's year-ago baseline both - * consume, - * - 1095 d → YEAR (clears the 731-day YEAR floor) — the v1.5 - * multi-year trend card target. - * - * The parity assertion ensures the routing contract is a single - * source of truth: any future floor adjustment forces both consumers - * to re-pin in lock-step rather than silently diverging. + * Granularity-routing boundaries for the shared WMY reader: + * 90 days resolves to DAY, 365 days to MONTH, and 1095 days to YEAR. + * A floor adjustment must update this single routing contract. */ describe("readBestGranularityRollups — cross-consumer routing parity", () => { it("pins the 90 / 365 / 1095 day routing targets the consumers depend on", async () => { diff --git a/src/lib/validations/daily.ts b/src/lib/validations/daily.ts index 5afc1c7a4..48a30fade 100644 --- a/src/lib/validations/daily.ts +++ b/src/lib/validations/daily.ts @@ -3,12 +3,10 @@ * (`POST /api/daily/digest/dismiss`). * * Lives outside the route file so the OpenAPI registry can import it without - * touching the route module. `itemKey` is namespaced `:...` - * (`isDismissibleItemKey` in `@/lib/daily/priority-item`) — the prefix alone - * proves the key names one of the OBSERVATIONAL kinds - * (`milestone` / `ecg_new_recording` / `tension_window`); anything else 422s - * before a lookup ever runs, so an actionable item can never be dismissed - * through this endpoint no matter what a client sends. + * touching the route module. `isDismissibleItemKey` accepts only approved + * observational item and versioned notice prefixes. Anything else 422s before + * a lookup runs, so an actionable item can never be dismissed through this + * endpoint. */ import { z } from "zod/v4"; diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 5ce8cdfeb..f0628c71d 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -36,6 +36,7 @@ import type { DataSummary } from "@/lib/analytics/trends"; import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import type { CorrelationResult } from "@/lib/insights/correlations"; +import type { HealthScoreReport } from "@/lib/analytics/score/types"; /** Shape consumed by every `/insights//page.tsx`. */ export interface SubPageAnalyticsData { @@ -133,48 +134,5 @@ export interface InsightsAnalyticsData { moodPulse: CorrelationResult; weightWeekday: CorrelationResult; } | null; - healthScore?: { - score: number; - band: "green" | "yellow" | "red"; - components: { - // v1.4.25 W8e — the optional `source`/`asOf` slots feed the - // provenance accordion. Older clients reading this payload - // happily ignore the extras (additive contract). - bp: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - weight: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - /** - * v1.34 — which yardstick graded this row. `"user"` when the account - * has set an explicit weight target (`target` then carries that band - * in kg), `"none"` when the pillar scored the bare trend against no - * target at all. The card renders one disclosure line off this pair; - * until it existed the score graded weight against a BMI-22 band - * nobody chose and no surface named. - */ - targetSource?: "user" | "none"; - target?: { min: number; max: number } | null; - }; - mood: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - compliance: { - value: number | null; - weight: number; - source?: "manual" | "withings" | "appleHealth" | "mixed" | "none"; - asOf?: string; - }; - }; - delta: number | null; - } | null; + healthScore?: HealthScoreReport | null; }