mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(analytics): replace health score with reference composite
This commit is contained in:
1 parent
666a85d311
commit
c91558f18f
87 files changed
+6033
-5868
No files matched your search
+78
-30
@@ -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": {
|
||||
|
||||
+78
-30
@@ -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": {
|
||||
|
||||
+78
-30
@@ -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": {
|
||||
|
||||
+78
-30
@@ -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": {
|
||||
|
||||
+78
-30
@@ -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": {
|
||||
|
||||
+78
-30
@@ -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": {
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(computeUserHealthScore as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(
|
||||
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hypotheses: [] });
|
||||
|
||||
+111
-89
@@ -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<string, number> = {};
|
||||
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`.
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Response> {
|
||||
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 } });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response> {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,7 +52,14 @@ function digest(over: Partial<DailyDigest> = {}): 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("<TodayHero>", () => {
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("does not present a suppressed algorithm jump as score movement", () => {
|
||||
const html = render(
|
||||
<TodayHero
|
||||
digest={digest({
|
||||
score: {
|
||||
value: 82,
|
||||
band: "green",
|
||||
delta: 20,
|
||||
deltaReason: "algorithm_changed",
|
||||
scoreVersion: 2,
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-delta"');
|
||||
});
|
||||
|
||||
it("wires each PriorityItem action to its existing destination via href", () => {
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
// Every S1 rail action carries an href, so PriorityCard renders it as a
|
||||
|
||||
@@ -229,7 +229,9 @@ export function TodayHero({
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{digest.score && digest.score.delta !== null ? (
|
||||
{digest.score &&
|
||||
digest.score.delta !== null &&
|
||||
digest.score.deltaReason === null ? (
|
||||
<span
|
||||
data-slot="today-hero-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
type HealthScoreCardProps,
|
||||
} from "../health-score-card";
|
||||
|
||||
/**
|
||||
* v1.4.25 W8e — Health-Score provenance accordion.
|
||||
*
|
||||
* The card now grows a tap-to-expand accordion under the four
|
||||
* component sub-bars. The accordion surfaces:
|
||||
* - per-component value-bar + numeric value
|
||||
* - effective weight share (second, narrower bar)
|
||||
* - source pill (manual / withings / appleHealth / mixed / none)
|
||||
* - a "mixed sources" `role="status"` banner when at least one row
|
||||
* has `source === "mixed"`
|
||||
* - a "Provisional" badge in the header when < 50% of inputs are
|
||||
* present
|
||||
*
|
||||
* SSR is the test convention in this project (no `@testing-library/react`
|
||||
* dependency). The card exposes `initiallyExpanded` so the markup of
|
||||
* the expanded panel renders inside `renderToStaticMarkup`. Interactive
|
||||
* behaviour (click toggles `aria-expanded`) is verified at the contract
|
||||
* level: collapsed state pins `aria-expanded="false"`, expanded state
|
||||
* (via `initiallyExpanded`) pins `aria-expanded="true"` + renders the
|
||||
* panel.
|
||||
*/
|
||||
|
||||
const componentsAllPresent: HealthScoreCardProps["components"] = {
|
||||
bp: {
|
||||
value: 82,
|
||||
weight: 0.3,
|
||||
source: "withings",
|
||||
asOf: "2026-05-14T08:00:00.000Z",
|
||||
},
|
||||
weight: {
|
||||
value: 75,
|
||||
weight: 0.2,
|
||||
source: "manual",
|
||||
asOf: "2026-05-13T18:00:00.000Z",
|
||||
},
|
||||
mood: {
|
||||
value: 90,
|
||||
weight: 0.2,
|
||||
source: "manual",
|
||||
asOf: "2026-05-14T07:00:00.000Z",
|
||||
},
|
||||
compliance: {
|
||||
value: 95,
|
||||
weight: 0.3,
|
||||
source: "manual",
|
||||
asOf: "2026-05-14T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
|
||||
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreCard> — provenance accordion (W8e)", () => {
|
||||
it("renders the toggle button with aria-expanded=false when collapsed", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
`<section[^>]*\\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(
|
||||
<HealthScoreCard
|
||||
score={60}
|
||||
band="yellow"
|
||||
components={components}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
"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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={components}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={70}
|
||||
band="yellow"
|
||||
components={components}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={components}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={75}
|
||||
band="green"
|
||||
components={components}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={70}
|
||||
band="yellow"
|
||||
components={components}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={componentsAllPresent}
|
||||
delta={null}
|
||||
initiallyExpanded
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card-provenance-footnote"');
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<HealthScoreCard report={report} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function report(overrides: Partial<HealthScoreReport> = {}): 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("<HealthScoreCard>", () => {
|
||||
it("renders the slot wrapper + band data attribute (green)", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={62}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={32}
|
||||
band="red"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={5}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-7}
|
||||
/>,
|
||||
);
|
||||
// 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 `<span>` 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(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
// The aria-describedby attribute lands on the delta span.
|
||||
const deltaSpan = html.match(
|
||||
/<span\s+aria-describedby="([^"]+)"[^>]*>[^<]*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(
|
||||
`<span[^>]*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(
|
||||
<HealthScoreCard
|
||||
score={64}
|
||||
band="yellow"
|
||||
components={baseComponents}
|
||||
delta={-3}
|
||||
/>,
|
||||
);
|
||||
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 <p> closes before the explainer body opens —
|
||||
// the caption is a sibling below, never a flex neighbour.
|
||||
expect(html).toMatch(
|
||||
/vs last week[^<]*<\/span><\/p><span[^>]*data-slot="health-score-delta-explainer-body"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders four component rows with their values", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={50}
|
||||
band="yellow"
|
||||
components={{
|
||||
...baseComponents,
|
||||
mood: { value: null, weight: 0 },
|
||||
}}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// 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"[^>]*>([^<]+)</,
|
||||
);
|
||||
expect(moodSliceMatch).not.toBeNull();
|
||||
expect(moodSliceMatch?.[1]).toBe("—");
|
||||
});
|
||||
|
||||
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
});
|
||||
|
||||
it("renders German strings", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={5}
|
||||
/>,
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
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(
|
||||
/<span[^>]*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(
|
||||
<HealthScoreCard
|
||||
score={120}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const cardOpen = html.match(/<div[^>]*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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
// The inner column lives directly after the card wrapper. Match
|
||||
// the first <div> that opens after the wrapper and assert the
|
||||
// grid + grid-rows-[...1fr...] contract.
|
||||
const innerColumn = html.match(
|
||||
/<div[^>]*data-slot="health-score-card"[^>]*>\s*(?:<!--[\s\S]*?-->\s*)?<div([^>]*)>/,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
score={86}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
const progressMatch = html.match(
|
||||
/<div[^>]*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("<HealthScoreCard> — weight yardstick disclosure", () => {
|
||||
it("names the user's own target band", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "user", targetLabel: "64-70 kg" }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*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(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
const line = html.match(
|
||||
/<p[^>]*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(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={baseComponents}
|
||||
delta={null}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
});
|
||||
|
||||
it("stays quiet when the weight pillar itself has no value", () => {
|
||||
const html = ssr(
|
||||
<HealthScoreCard
|
||||
score={80}
|
||||
band="green"
|
||||
components={{
|
||||
...baseComponents,
|
||||
weight: { value: null, weight: 0 },
|
||||
}}
|
||||
delta={null}
|
||||
weightYardstick={{ source: "none", targetLabel: null }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("health-score-card-weight-yardstick");
|
||||
expect(patched).toMatchObject({
|
||||
healthScore: {
|
||||
algorithmNotice: {
|
||||
itemKey: "health_score_algorithm:2",
|
||||
dismissed: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span data-slot="probe-body">
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
describe("<HealthScoreDeltaExplainer>", () => {
|
||||
it("renders the read inline as a muted caption (no trigger glyph)", () => {
|
||||
const html = ssr(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
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(
|
||||
<HealthScoreDeltaExplainer delta={-3} bodyId="parent-id-x" />,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*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(<HealthScoreDeltaExplainer delta={-3} />);
|
||||
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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*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(
|
||||
<I18nProvider initialLocale="de">
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const body = html.match(
|
||||
/<span[^>]*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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<BodyProbe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("<HeroStrip>", () => {
|
||||
it("renders the slot wrapper + Dracula gradient utility", () => {
|
||||
@@ -250,17 +298,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
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).toMatch(/data-slot="health-score-card"/);
|
||||
@@ -277,17 +315,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
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).not.toContain('data-slot="health-score-card-ask-coach"');
|
||||
@@ -299,17 +327,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
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: null,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
// The wrapper picks up the `lg:flex-row` modifier when the score
|
||||
@@ -329,17 +347,7 @@ describe("<HeroStrip>", () => {
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
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: null,
|
||||
}}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("md:items-stretch");
|
||||
@@ -395,17 +403,7 @@ describe("<HeroStrip>", () => {
|
||||
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"');
|
||||
|
||||
@@ -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({
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is
|
||||
never clipped top/bottom by the hero container. */}
|
||||
<div className="py-2">
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
hue={insufficient ? undefined : hue}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
{!insufficient || showProvisionalRing ? (
|
||||
<div className="py-2">
|
||||
<ScoreRing
|
||||
score={effectiveScore}
|
||||
band={band}
|
||||
hue={insufficient ? undefined : hue}
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{caption && !insufficient ? (
|
||||
<p
|
||||
data-slot="score-anatomy-caption"
|
||||
@@ -174,7 +179,8 @@ export function ScoreAnatomyView({
|
||||
insufficientNote ?? t("insights.derived.anatomy.insufficient")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : null}
|
||||
{contributors.length > 0 ? (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t("insights.derived.anatomy.contributorsLabel")}
|
||||
@@ -185,7 +191,7 @@ export function ScoreAnatomyView({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* The plain-language method caption closes the card, in normal flow.
|
||||
`ProvenanceExplainer` (v1.22) is no longer a top-right ⓘ glyph — it
|
||||
|
||||
@@ -188,4 +188,11 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 `<span>` 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 (
|
||||
<span
|
||||
id={resolvedBodyId}
|
||||
data-slot="health-score-delta-explainer-body"
|
||||
className={cn("text-muted-foreground text-xs leading-snug", className)}
|
||||
>
|
||||
{t("insights.healthScore.deltaExplainer.body")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
<HealthScoreCard
|
||||
score={healthScore.score}
|
||||
band={healthScore.band}
|
||||
components={healthScore.components}
|
||||
delta={healthScore.delta}
|
||||
moodEnabled={moodEnabled}
|
||||
restModeActive={healthScore.restMode?.active ?? false}
|
||||
report={healthScore}
|
||||
tension={tensionProp}
|
||||
returnToBand={returnToBandProp}
|
||||
weightYardstick={weightYardstick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<QueryClientProvider>`.
|
||||
*
|
||||
* A long tail of legacy presentational components (`<HeroStrip>`,
|
||||
* `<SuggestedPrompts>`, `<LayoutCoachFab>`, `<InsightStatusCard>`,
|
||||
* `<HealthScoreDeltaExplainer>`, …) ships with unit tests that render
|
||||
* the component in isolation without wrapping it in
|
||||
* Several presentational components (`<HeroStrip>`, `<SuggestedPrompts>`,
|
||||
* `<LayoutCoachFab>`, and `<InsightStatusCard>`) ship with unit tests that
|
||||
* render the component in isolation without wrapping it in
|
||||
* `<QueryClientProvider>`. 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);
|
||||
}
|
||||
@@ -70,9 +70,8 @@ async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
|
||||
* 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 (`<InsightStatusCard>`, `<HealthScoreDeltaExplainer>`)
|
||||
* whose unit tests render the component without a
|
||||
* The hook is referenced from presentational components such as
|
||||
* `<InsightStatusCard>` whose unit tests render without a
|
||||
* `<QueryClientProvider>`. 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
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>): 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 });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
const ROLLUP_FIND_MANY = prisma.measurementRollup
|
||||
.findMany as unknown as ReturnType<typeof vi.fn>;
|
||||
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<typeof vi.fn>;
|
||||
const PROBE = probeRollupCoverage as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
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<number, { sum: number; count: number }>();
|
||||
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<string, boolean>([["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<string, boolean>([["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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<BpPairPoint>;
|
||||
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<BpPairPoint>;
|
||||
target: BpTargets;
|
||||
now: Date;
|
||||
}): number | null {
|
||||
const representative = representativeBpFromSeries(input);
|
||||
return representative
|
||||
? gradeBpScore({ ...representative, target: input.target })
|
||||
: null;
|
||||
}
|
||||
@@ -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<Date | null>(
|
||||
(latest, pair) => (!latest || pair.at > latest ? pair.at : latest),
|
||||
null,
|
||||
),
|
||||
allTime: windows.allTime,
|
||||
priorMonth: windows.priorMonth,
|
||||
priorYear: windows.priorYear,
|
||||
path: "live",
|
||||
rowCount,
|
||||
gradedScore,
|
||||
representative,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>)[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.
|
||||
|
||||
@@ -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<MeasurementSource>,
|
||||
): ReadonlyArray<ContributingSource> {
|
||||
const seen = new Set<ContributingSource>();
|
||||
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<HealthScoreResult | null> {
|
||||
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<ContributingSource>;
|
||||
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<string, typeof intakeEvents>();
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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<ContributingSource>;
|
||||
asOfBp?: string | null;
|
||||
/** Sources that contributed to the weight series. */
|
||||
weightSources?: ReadonlyArray<ContributingSource>;
|
||||
asOfWeight?: string | null;
|
||||
/** Sources that contributed to the mood entries. */
|
||||
moodSources?: ReadonlyArray<ContributingSource>;
|
||||
asOfMood?: string | null;
|
||||
/** Sources that contributed to medication compliance. */
|
||||
complianceSources?: ReadonlyArray<ContributingSource>;
|
||||
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<ContributingSource> | 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<ContributingSource> | null;
|
||||
asOf: string | null;
|
||||
}
|
||||
>,
|
||||
windowEndAt: string,
|
||||
weightYardstick: {
|
||||
source: WeightTargetSource;
|
||||
target: { min: number; max: number } | null;
|
||||
},
|
||||
): HealthScoreResult["components"] {
|
||||
const present: Array<keyof typeof BASE_WEIGHTS> = [];
|
||||
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 <today>" 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();
|
||||
}
|
||||
@@ -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<ScorePillarId, ScorePillarResult["domain"]> = {
|
||||
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<PillarValue> = 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<ScorePillarId> = new Set([
|
||||
"BLOOD_PRESSURE",
|
||||
"GLYCAEMIA",
|
||||
"SLEEP",
|
||||
"ADIPOSITY",
|
||||
"FITNESS",
|
||||
"LIPIDS",
|
||||
]);
|
||||
|
||||
const BAND_RANK: Record<ScoreBand, number> = {
|
||||
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<CompositeValue> {
|
||||
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<CompositeValue>,
|
||||
right: Derived<CompositeValue>,
|
||||
): 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<CompositeValue>,
|
||||
other: Derived<CompositeValue>,
|
||||
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<CompositeValue>,
|
||||
previous: Derived<CompositeValue>,
|
||||
previousPrevious: Derived<CompositeValue>,
|
||||
asOf: Date,
|
||||
currentPillars: ScorePillarResult[] = [],
|
||||
previousPillars: ScorePillarResult[] = [],
|
||||
): Pick<HealthScoreReport, "delta" | "deltaReason"> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<Derived<CompositeValue>> {
|
||||
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;
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<WeightGoalValue>;
|
||||
delta?: number | null;
|
||||
deltaReason?: HealthScoreReport["deltaReason"];
|
||||
}
|
||||
|
||||
export function computeHealthScore(
|
||||
input: ComputeHealthScoreInput,
|
||||
): HealthScoreReport {
|
||||
const byId: Record<ScorePillarId, ScorePillarResult> = {
|
||||
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";
|
||||
@@ -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<PillarValue> {
|
||||
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}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<T> = { 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<T>(
|
||||
promise: Promise<T>,
|
||||
fallback: T,
|
||||
failure?:
|
||||
ScorePillarId | "WEIGHT_GOAL" | "ALGORITHM_NOTICE" | ScorePillarId[],
|
||||
): Promise<Settled<T>> {
|
||||
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<MeasurementRow[]> {
|
||||
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<MeasurementRow[]> {
|
||||
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<ActivityRead> {
|
||||
// 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<string, true> = {
|
||||
hba1c: true,
|
||||
hemoglobina1c: true,
|
||||
haemoglobina1c: true,
|
||||
glycatedhemoglobin: true,
|
||||
glycatedhaemoglobin: true,
|
||||
};
|
||||
|
||||
const LIPID_NAMES: Record<string, true> = {
|
||||
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<T extends { source: MeasurementSource }>(
|
||||
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<string, unknown>;
|
||||
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<MeasurementRow[]>;
|
||||
}): { 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<MeasurementRow[]>;
|
||||
activity: Settled<ActivityRead>;
|
||||
sleep: Settled<MeasurementRow[]>;
|
||||
adiposity: Settled<MeasurementRow[]>;
|
||||
fitness: Settled<MeasurementRow[]>;
|
||||
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<HealthScoreReport> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
const { coverage } = deriveCoverage({
|
||||
requiredInputs: args.requiredInputs,
|
||||
presentInputs: args.presentInputs,
|
||||
historyDays: args.historyDays,
|
||||
missing: args.missing,
|
||||
fullHistoryDays: args.windowDays,
|
||||
});
|
||||
return buildInsufficient<PillarValue>({
|
||||
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<PillarValue> {
|
||||
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<PillarValue> {
|
||||
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;
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<PillarValue>;
|
||||
}
|
||||
|
||||
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<CompositeValue>;
|
||||
pillars: ScorePillarResult[];
|
||||
delta: number | null;
|
||||
deltaReason: ScoreDeltaReason | null;
|
||||
scoreVersion: typeof SCORE_VERSION;
|
||||
weightGoal: Derived<WeightGoalValue>;
|
||||
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;
|
||||
}
|
||||
@@ -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<WeightGoalValue> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<PillarValue> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
+31
@@ -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();
|
||||
|
||||
Vendored
+10
@@ -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
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}:`),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function readDashboardSnapshotCached(
|
||||
insightsCachedText: user.insightsCachedText,
|
||||
insightsCachedAt: user.insightsCachedAt,
|
||||
dashboardWidgetsJson: user.dashboardWidgetsJson,
|
||||
sourcePriorityJson: user.sourcePriorityJson,
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
};
|
||||
|
||||
|
||||
+149
-93
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ export function resolveDashboardVerdict(
|
||||
if (
|
||||
score !== null &&
|
||||
score.delta !== null &&
|
||||
score.deltaReason === null &&
|
||||
score.delta <= -SCORE_DROP_MIN_POINTS
|
||||
) {
|
||||
return {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Derived<unknown>>;
|
||||
}
|
||||
case "HEALTH_SCORE":
|
||||
return computeHealthScoreDerived(args.userId) as Promise<
|
||||
Derived<unknown>
|
||||
>;
|
||||
case "RECOVERY_SCORE":
|
||||
case "STRESS_SCORE":
|
||||
case "STRAIN_SCORE":
|
||||
|
||||
@@ -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<DerivedMetricId, DerivedMetricMeta> = {
|
||||
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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 `<kind>:...`
|
||||
* (`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";
|
||||
|
||||
|
||||
+2
-44
@@ -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/<metric>/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;
|
||||
}
|
||||
Reference in new issue
Block a user