diff --git a/CHANGELOG.md b/CHANGELOG.md index b0afeca35..33225f7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview + +### Fixed + +- **Blood pressure is scored fairly.** The Health Score's blood-pressure share used to be all-or-nothing per reading: a reading slightly over target counted exactly like one far over, so being consistently a little high collapsed the score even when you were well-controlled. It now grades by how close your readings sit to your target — recent readings weighted more — so "borderline" reads as borderline, not a crisis, while genuinely high pressure still scores low. +- **Workout heart rate no longer counts against your resting pulse.** Apple Health files every heart-rate sample — including the hundreds recorded during a workout — under one type, and the resting-pulse view was scoring all of them against a resting range, so an active day looked "elevated." Resting-pulse surfaces now use your true resting heart rate (with a careful estimate when that isn't available), and raw heart rate is no longer judged against a resting target. +- **The "in review" section is named "Rückblick"** and the spacing above it matches the rest of the overview. + +### Added + +- **Tap a wellness score for the full picture.** Readiness, Recovery, Sleep, Strain, and Stress now open a detail view with an explanation of what the score means and why it matters, the breakdown of how it's built, and a personal assessment of where you stand. +- **A warmer, more motivating assessment voice** across the insight assessments — encouraging and grounded in your data, never generic. +- **The running version on the admin overview**, with a hint when a newer release is available. + +### Changed + +- **Premium polish on the wellness detail view** — the detail card carries the score's colour, the ring has room to breathe, and the info control sits in the top corner. +- **Self-hosted instances resolve a login's location online by default**, using a bundled offline database only when one is configured — so the admin login overview shows a location out of the box. + ## [1.15.11] — 2026-06-07 — a customizable overview ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7c1d1ded1..eca21c232 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.15.11 + version: 1.15.12 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 6e28dbb65..976ea7cc8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Einschätzung neu erstellen", "regenerateSuccess": "Einschätzung wurde neu erstellt", "warmStarted": "Einschätzungen werden im Hintergrund erstellt", - "narrativeTitle": "Zeitraum im Rückblick", + "narrativeTitle": "Rückblick", "narrativeWeek": "Diese Woche", "narrativeMonth": "Dieser Monat", "narrativePreparing": "Deine Zusammenfassung wird erstellt…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Treppab-Tempo", "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", - "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden." + "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.", + "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert bedeutet, dass dein System erholt und belastbar wirkt; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten und deinem Körper Zeit zum Aufholen zu geben. Sieh ihn als Orientierung für die Tagesplanung, nicht als Urteil.", + "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen und wägt ab, wie lange und wie effizient du geschlafen hast, wie regelmäßig deine Zeiten waren und wie sich Leicht-, Tief- und REM-Schlaf verteilen. Er soll auf einen Blick zeigen, ob die Nacht dich wirklich erholt hat. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", + "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat und wie viel Kapazität du heute für Belastung hast — sie stützt sich auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut. Er ist eine Orientierung, wie stark du dich einbringen kannst, keine starre Grenze.", + "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen und spiegelt so, was speziell für dich fordernd ist. Nutze sie, um anstrengende Tage mit ruhigeren auszugleichen und dein Training tragfähig zu halten.", + "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigeren, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen. Er ist ein beschreibender Näherungswert, um Trends zu erkennen, keine klinische Messung." }, "stats": { "label": "Zusammenfassende Statistik", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Geodaten", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Fallback (ipwho.is)", + "versionTileTitle": "Version", + "versionTileVersion": "Laufende Version", + "versionTileBuildSha": "Build-SHA", + "versionTileBuiltAt": "Gebaut am", + "versionTileUpdateAvailable": "Update verfügbar: {tag}", + "versionTileUpToDate": "Aktuell", "snapshotLoadError": "System-Snapshot konnte nicht geladen werden.", "snapshotLoading": "Snapshot wird geladen...", "auditTitle": "Letzte Aktivitäten", diff --git a/messages/en.json b/messages/en.json index 350e1e68a..1831e188b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Re-run assessment", "regenerateSuccess": "Assessment refreshed", "warmStarted": "Assessments are being prepared in the background", - "narrativeTitle": "Period in review", + "narrativeTitle": "In review", "narrativeWeek": "This week", "narrativeMonth": "This month", "narrativePreparing": "Preparing your summary…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Stair descent speed", "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", "breathingDisturbancesTitle": "Sleep breathing disturbances", - "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician." + "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.", + "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means your system looks recovered and resourced; a lower one is a gentle cue to ease off and let your body catch up. Treat it as guidance for pacing the day, not a verdict.", + "sleepScoreBody": "Your sleep score distils a whole night into one number, weighing how long you slept, how efficiently, how regular your timing was, and the balance of light, deep, and REM. It is meant to show at a glance whether the night truly restored you. Watch the pattern across weeks rather than chasing a perfect score on any single night.", + "recoveryBody": "Recovery reflects how well your body has bounced back and how much capacity you have to take on load today, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding. It is a guide for how hard to lean in, not a limit.", + "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, so it reflects what is demanding for you specifically. Use it to balance hard days with easier ones and keep your training sustainable.", + "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calmer, well-regulated state; a higher one is an invitation to build in recovery. It is a descriptive proxy to notice trends, not a clinical measurement." }, "stats": { "label": "Summary statistics", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Geo data", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Fallback (ipwho.is)", + "versionTileTitle": "Version", + "versionTileVersion": "Running version", + "versionTileBuildSha": "Build SHA", + "versionTileBuiltAt": "Built at", + "versionTileUpdateAvailable": "Update available: {tag}", + "versionTileUpToDate": "Up to date", "snapshotLoadError": "Failed to load system snapshot.", "snapshotLoading": "Loading snapshot...", "auditTitle": "Recent activity", diff --git a/messages/es.json b/messages/es.json index 33284f26e..ec2fd334e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Volver a generar la valoración", "regenerateSuccess": "Valoración regenerada", "warmStarted": "Las valoraciones se están preparando en segundo plano", - "narrativeTitle": "Período en resumen", + "narrativeTitle": "Resumen", "narrativeWeek": "Esta semana", "narrativeMonth": "Este mes", "narrativePreparing": "Preparando tu resumen…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Velocidad bajando escaleras", "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", - "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional." + "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.", + "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo y dejar que tu cuerpo se ponga al día. Tómala como una guía para dosificar el día, no como un veredicto.", + "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número, sopesando cuánto dormiste, con qué eficiencia, qué tan regular fue tu horario y el equilibrio entre sueño ligero, profundo y REM. Su objetivo es mostrar de un vistazo si la noche realmente te repuso. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", + "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto y cuánta capacidad tienes hoy para asumir carga, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo. Es una guía sobre cuánto esforzarte, no un límite.", + "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, de modo que refleja lo que resulta exigente para ti en concreto. Úsalo para equilibrar los días duros con otros más suaves y mantener un entrenamiento sostenible.", + "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado más calmado y bien regulado; una más alta es una invitación a incorporar recuperación. Es una aproximación descriptiva para detectar tendencias, no una medición clínica." }, "stats": { "label": "Estadísticas resumidas", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Datos geográficos", "snapshotOfflineGeoOn": "Sin conexión (GeoLite2)", "snapshotOfflineGeoOff": "Respaldo (ipwho.is)", + "versionTileTitle": "Versión", + "versionTileVersion": "Versión en ejecución", + "versionTileBuildSha": "SHA de compilación", + "versionTileBuiltAt": "Compilado el", + "versionTileUpdateAvailable": "Actualización disponible: {tag}", + "versionTileUpToDate": "Actualizado", "snapshotLoadError": "No se pudo cargar la instantánea del sistema.", "snapshotLoading": "Cargando instantánea...", "auditTitle": "Actividad reciente", diff --git a/messages/fr.json b/messages/fr.json index b34577c5b..bfa9dd524 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Relancer l’évaluation", "regenerateSuccess": "Évaluation regénérée", "warmStarted": "Les évaluations sont en cours de préparation en arrière-plan", - "narrativeTitle": "Période en revue", + "narrativeTitle": "Rétrospective", "narrativeWeek": "Cette semaine", "narrativeMonth": "Ce mois-ci", "narrativePreparing": "Préparation de votre résumé…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Vitesse en descente d'escaliers", "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", - "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel." + "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.", + "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied et à laisser votre corps rattraper. Voyez-le comme un repère pour doser la journée, pas comme un verdict.", + "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre, en pesant la durée de votre sommeil, son efficacité, la régularité de vos horaires et l'équilibre entre sommeil léger, profond et paradoxal. Il vise à montrer d'un coup d'œil si la nuit vous a vraiment restauré. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", + "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps et la capacité dont vous disposez aujourd'hui pour encaisser de la charge, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore. C'est un repère sur l'intensité à adopter, pas une limite.", + "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel et reflète ainsi ce qui est exigeant pour vous en particulier. Servez-vous-en pour équilibrer les journées intenses et les plus douces, et garder un entraînement tenable.", + "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état plus calme et bien régulé ; un score plus élevé invite à prévoir de la récupération. C'est une approximation descriptive pour repérer des tendances, pas une mesure clinique." }, "stats": { "label": "Statistiques récapitulatives", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Données géographiques", "snapshotOfflineGeoOn": "Hors ligne (GeoLite2)", "snapshotOfflineGeoOff": "Fallback (ipwho.is)", + "versionTileTitle": "Version", + "versionTileVersion": "Version en cours", + "versionTileBuildSha": "SHA de build", + "versionTileBuiltAt": "Compilé le", + "versionTileUpdateAvailable": "Mise à jour disponible : {tag}", + "versionTileUpToDate": "À jour", "snapshotLoadError": "Échec du chargement de l'instantané du système.", "snapshotLoading": "Chargement de l'instantané...", "auditTitle": "Activité récente", diff --git a/messages/it.json b/messages/it.json index cbb8ccfb5..3200c2b2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Rigenera la valutazione", "regenerateSuccess": "Valutazione rigenerata", "warmStarted": "Le valutazioni vengono preparate in background", - "narrativeTitle": "Periodo in sintesi", + "narrativeTitle": "Riepilogo", "narrativeWeek": "Questa settimana", "narrativeMonth": "Questo mese", "narrativePreparing": "Preparazione del riepilogo…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Velocità in discesa scale", "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", - "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico." + "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.", + "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare e lasciare che il corpo recuperi. Consideralo una guida per dosare la giornata, non un verdetto.", + "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero, valutando quanto hai dormito, con quale efficienza, quanto regolari sono stati i tuoi orari e l'equilibrio tra sonno leggero, profondo e REM. Serve a mostrare a colpo d'occhio se la notte ti ha davvero ristorato. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", + "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso e quanta capacità hai oggi per sostenere il carico, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo. È una guida su quanto impegnarti, non un limite.", + "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale e riflette così ciò che è impegnativo per te in particolare. Usalo per bilanciare le giornate intense con altre più leggere e mantenere un allenamento sostenibile.", + "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato più calmo e ben regolato; uno più alto è un invito a inserire del recupero. È un'approssimazione descrittiva per notare le tendenze, non una misurazione clinica." }, "stats": { "label": "Statistiche riepilogative", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Dati geografici", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Fallback (ipwho.is)", + "versionTileTitle": "Versione", + "versionTileVersion": "Versione in esecuzione", + "versionTileBuildSha": "SHA build", + "versionTileBuiltAt": "Compilato il", + "versionTileUpdateAvailable": "Aggiornamento disponibile: {tag}", + "versionTileUpToDate": "Aggiornato", "snapshotLoadError": "Impossibile caricare l'istantanea del sistema.", "snapshotLoading": "Caricamento istantanea...", "auditTitle": "Attività recente", diff --git a/messages/pl.json b/messages/pl.json index 00c462378..fe399b31e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1659,7 +1659,7 @@ "regenerateAnalysis": "Uruchom ocenę ponownie", "regenerateSuccess": "Ocena wygenerowana ponownie", "warmStarted": "Oceny są przygotowywane w tle", - "narrativeTitle": "Okres w skrócie", + "narrativeTitle": "Przegląd", "narrativeWeek": "Ten tydzień", "narrativeMonth": "Ten miesiąc", "narrativePreparing": "Przygotowywanie podsumowania…", @@ -2177,7 +2177,12 @@ "stairDescentSpeedTitle": "Prędkość schodzenia po schodach", "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", - "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem." + "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.", + "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo i dać ciału nadrobić zaległości. Traktuj go jako wskazówkę do rozłożenia sił w ciągu dnia, a nie jako wyrok.", + "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie, biorąc pod uwagę, jak długo i jak efektywnie spałeś, jak regularne były Twoje pory oraz proporcje snu lekkiego, głębokiego i REM. Ma pokazać na pierwszy rzut oka, czy noc naprawdę Cię zregenerowała. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", + "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało i ile masz dziś zapasu, by przyjąć obciążenie — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje. To wskazówka, jak mocno się angażować, a nie sztywna granica.", + "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, więc odzwierciedla to, co jest wymagające właśnie dla Ciebie. Wykorzystaj je, by równoważyć ciężkie dni lżejszymi i utrzymać trening na zrównoważonym poziomie.", + "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojniejszy, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację. To opisowe przybliżenie pozwalające dostrzegać trendy, a nie pomiar kliniczny." }, "stats": { "label": "Statystyki podsumowujące", @@ -4242,6 +4247,12 @@ "snapshotOfflineGeo": "Dane geograficzne", "snapshotOfflineGeoOn": "Offline (GeoLite2)", "snapshotOfflineGeoOff": "Awaryjne (ipwho.is)", + "versionTileTitle": "Wersja", + "versionTileVersion": "Uruchomiona wersja", + "versionTileBuildSha": "SHA kompilacji", + "versionTileBuiltAt": "Skompilowano", + "versionTileUpdateAvailable": "Dostępna aktualizacja: {tag}", + "versionTileUpToDate": "Aktualne", "snapshotLoadError": "Nie udało się załadować migawki systemu.", "snapshotLoading": "Ładowanie migawki...", "auditTitle": "Ostatnia aktywność", diff --git a/package.json b/package.json index 9e220d718..1afb16f1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.15.11", + "version": "1.15.12", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 457a942ef..f9ec57245 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { AdminShell } from "@/components/admin/admin-shell"; import { SystemStatusSummary } from "@/components/admin/system-status-summary"; +import { VersionTileSection } from "@/components/admin/version-tile-section"; import { RecentAuditPreview } from "@/components/admin/recent-audit-preview"; import { useAdminSettings } from "@/components/admin/_shared"; @@ -61,6 +62,8 @@ export default function AdminOverviewPage() {

+ + diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 3fb52d8f4..47dd2c292 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -258,6 +258,11 @@ async function buildAnalyticsResponse(user: AuthedUser) { // pillar compares the same window on both ends (the tile headline keeps // its 30-day scope; the score reads all-time per its documented contract). let windowsPriorWeekAllTime: number | null = null; + // v1.15.12 A1 — graded clinical-proximity BP score (recency-weighted + // representative reading) for the Health-Score BP pillar value, plus + // the prior-week run for the week-over-week delta. + let bpGradedScore: number | null = null; + let bpGradedScorePriorWeek: number | null = null; const bpTargets = getBpTargets(user.dateOfBirth); if (bpTargets) { const now = new Date(); @@ -309,6 +314,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctPriorMonth = windows.priorMonth?.pct ?? null; bpInTargetPctPriorYear = windows.priorYear?.pct ?? null; bpInTargetPctPriorWeek = windowsPriorWeek.last30Days?.pct ?? null; + bpGradedScore = windows.gradedScore; + bpGradedScorePriorWeek = windowsPriorWeek.gradedScore; // The Health-Score BP pillar reads the all-time window (see the // `bpInTargetPctForScore` derivation below). Capture the prior-week // run's all-time pct too so the week-over-week delta compares the @@ -395,6 +402,12 @@ async function buildAnalyticsResponse(user: AuthedUser) { // same all-time window as the current snapshot so the delta is // apples-to-apples. bpInTargetPctPriorWeek: bpInTargetPctPriorWeekForScore, + // v1.15.12 A1 — graded clinical-proximity BP score drives the pillar + // VALUE; the binary in-target rate above only gates presence and is + // surfaced as a secondary stat. Closes the "borderline-stage-1 reads + // 16/100" trust bug. + bpGradedScore, + bpGradedScorePriorWeek, heightCm: user.heightCm ?? null, now: new Date(), coverage, diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 6aab0d095..dbbe4133d 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -27,6 +27,7 @@ import { classifyPulseByTarget, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import type { MeasurementType, GlucoseContext, @@ -185,6 +186,12 @@ async function buildTargetsResponse(user: AuthedUser) { "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE", + // v1.15.12 A2 — the "Resting pulse" tile scores RESTING_HEART_RATE + // (Apple's clean daily resting figure) against the resting band, + // falling back to a low-percentile PULSE proxy. Pull the resting rows + // alongside so the consistency strip uses resting data, not raw HR + // polluted by workout samples. + "RESTING_HEART_RATE", "BODY_FAT", "ACTIVITY_STEPS", ]; @@ -705,35 +712,87 @@ async function buildTargetsResponse(user: AuthedUser) { }); } - // 3. Pulse + // 3. Resting pulse + // + // v1.15.12 A2 — judge the RESTING band against a RESTING series, not + // the raw PULSE stream (which Apple Health fills with workout HR). We + // prefer RESTING_HEART_RATE; when the user has none we fall back to a + // low-percentile-of-daily-PULSE proxy. The resting series drives the + // tile's current / average / consistency / classification so a workout + // burst no longer reads "outside target". const pulseTarget = getPersonalizedPulseTarget(age, gender); - let pulseClassification: { category: string; color: string } | null = null; - if (latestByType.PULSE != null) { - const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget); - pulseClassification = { category: cls.category, color: cls.color }; - } { + const restingResolved = resolveRestingPulseSeries({ + restingSamples: recentMeasurements + .filter((m) => m.type === "RESTING_HEART_RATE") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + pulseSamples: recentMeasurements + .filter((m) => m.type === "PULSE") + .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), + dayKeyOf: dayKey, + }); + const restingEvents = restingResolved.series; + // Current = most recent resting value; average30 = resting mean. + const restingCurrent = + restingEvents.length > 0 + ? restingEvents[restingEvents.length - 1].value + : null; + const restingAvg30 = + restingEvents.length > 0 + ? Math.round( + (restingEvents.reduce((s, e) => s + e.value, 0) / + restingEvents.length) * + 10, + ) / 10 + : null; + + let pulseClassification: { category: string; color: string } | null = null; + if (restingCurrent != null) { + const cls = classifyPulseByTarget(restingCurrent, pulseTarget); + pulseClassification = { category: cls.category, color: cls.color }; + } + const pulseRange = { min: pulseTarget.greenMin, max: pulseTarget.greenMax, }; const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "PULSE"), + restingEvents, makeRangeClassifier(pulseRange, { orangeMin: pulseTarget.orangeMin, orangeMax: pulseTarget.orangeMax, }), ); + // Trend over the resting series (first-half vs second-half mean). + let restingTrend: "up" | "down" | "stable" | null = null; + if (restingEvents.length >= 4) { + const sorted = [...restingEvents].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + const mid = Math.floor(sorted.length / 2); + const avgFirst = + sorted.slice(0, mid).reduce((s, e) => s + e.value, 0) / mid; + const avgSecond = + sorted.slice(mid).reduce((s, e) => s + e.value, 0) / + (sorted.length - mid); + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; + restingTrend = diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; + } + targets.push({ type: "PULSE", label: "Resting pulse", - current: latestByType.PULSE ?? null, - average30: avg30ByType.PULSE ?? null, - trend: computeTrend("PULSE"), + current: restingCurrent, + average30: restingAvg30, + trend: restingTrend, unit: "bpm", range: pulseRange, classification: pulseClassification, - source: pulseTarget.source, + source: + restingResolved.which === "proxy" + ? `${pulseTarget.source} (estimated from heart rate)` + : pulseTarget.source, ...consistency, }); } diff --git a/src/app/globals.css b/src/app/globals.css index 254e94612..784f52bb8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -513,6 +513,37 @@ pointer-events: none; } +/* + * v1.15.12 (F1/F3) — `.wellness-detail-card` is the score-anatomy detail + * surface, deepened to match the dashboard ring tile the user tapped. + * + * F1 — carries the SAME per-metric `--tile-hue` mix over the theme `--card` + * as `.wellness-tile`, so the tap-through reads as the same surface + * rather than a plain bordered card. The hue is set inline per metric. + * F3 — a SUBTLE vertical gradient that lightens toward the BOTTOM: the top + * carries a touch more of the hue (16%) and it eases to 6% at the foot, + * giving the card a premium, lit-from-below depth. Theme-aware by + * construction — both stops mix over the theme `--card`, so it stays + * luminous on the white Alucard card and rich on the dark Dracula one. + * Kept minimal (a 10pp swing) so it never reads as a heavy slab. + * + * Static, no hover lift (it is a page surface, not a tap target) — so it + * needs no reduced-motion handling. + */ +.wellness-detail-card { + --tile-hue: var(--dracula-purple); + position: relative; + overflow: hidden; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--tile-hue) 16%, var(--card)) 0%, + color-mix(in srgb, var(--tile-hue) 6%, var(--card)) 100% + ); + border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent); + box-shadow: 0 12px 34px -28px + color-mix(in srgb, var(--tile-hue) 42%, transparent); +} + @media (hover: hover) { .wellness-tile:hover { transform: translateY(-2px); diff --git a/src/app/insights/__tests__/explainer-metric-keys.test.ts b/src/app/insights/__tests__/explainer-metric-keys.test.ts index dec10a84b..656087c47 100644 --- a/src/app/insights/__tests__/explainer-metric-keys.test.ts +++ b/src/app/insights/__tests__/explainer-metric-keys.test.ts @@ -26,6 +26,15 @@ import { join } from "node:path"; * both the `Title` and `Body` leaves exist in * `messages/en.json`. `i18n-locale-integrity.test.ts` then propagates * the EN guarantee across the other five locales. + * + * v1.15.12 B1 — the score-anatomy detail page (`scores/[metric]/page.tsx`) + * picks its explainer key at runtime from a closed `Record<…, string>` + * literal (`explainerMetric={EXPLAINER_METRIC[metric]}`), one of five score + * suffixes. That single dynamic site is resolved STATICALLY here by parsing + * the record's string-literal values (the contract stays verifiable). Score + * explainers render only the inline `Body` caption — there is no `?` popover + * `Title` for them — so the resolved score values are checked for a `Body` + * leaf only, while literal sites keep the full Title + Body pairing. */ const ROOT = join(__dirname, "../../../.."); @@ -53,21 +62,47 @@ interface PropSite { value: string; file: string; line: number; + /** Score explainers render only the inline Body caption (no `?` Title). */ + bodyOnly?: boolean; } // Match `explainerMetric="someValue"` — a string-literal prop. The -// component contract only accepts a static string, so a dynamic -// `explainerMetric={expr}` would be a different (and unverifiable) -// shape; none exist in the tree and we assert that below. +// component contract accepts a static string, OR the single closed-record +// lookup on the score-anatomy page (resolved statically below). const LITERAL_PATTERN = /explainerMetric="([a-zA-Z][a-zA-Z0-9]*)"/g; const DYNAMIC_PATTERN = /explainerMetric=\{/; +// v1.15.12 B1 — the one allowed dynamic site: the score-anatomy detail page +// reads its explainer key from a closed `EXPLAINER_METRIC` record literal. +// We resolve it statically — parse the record's string-literal values — so +// the dynamic site stays verifiable rather than escaping the guard. +const SCORES_PAGE = "src/app/insights/scores/[metric]/page.tsx"; +const RECORD_LOOKUP_PATTERN = /explainerMetric=\{([A-Z_][A-Za-z0-9_]*)\[/; +const RECORD_VALUE_PATTERN = /:\s*"([a-zA-Z][a-zA-Z0-9]*)"/g; + +/** Resolve a closed `const NAME = { … "value" … }` record's string values. */ +function resolveRecordValues(source: string, recordName: string): string[] { + const start = source.indexOf(`const ${recordName}`); + if (start === -1) return []; + const open = source.indexOf("{", start); + const close = source.indexOf("}", open); + if (open === -1 || close === -1) return []; + const body = source.slice(open + 1, close); + const values: string[] = []; + let m: RegExpExecArray | null; + RECORD_VALUE_PATTERN.lastIndex = 0; + while ((m = RECORD_VALUE_PATTERN.exec(body)) !== null) values.push(m[1]); + return values; +} + function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } { const files = [...walk(INSIGHTS_DIR), HEALTHKIT_PAGE]; const sites: PropSite[] = []; const dynamic: PropSite[] = []; for (const file of files) { - const lines = readFileSync(file, "utf8").split("\n"); + const rel = file.slice(ROOT.length + 1); + const source = readFileSync(file, "utf8"); + const lines = source.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; // The scaffold declares + forwards the prop; ignore the type @@ -77,10 +112,20 @@ function collectSites(): { sites: PropSite[]; dynamic: PropSite[] } { let m: RegExpExecArray | null; LITERAL_PATTERN.lastIndex = 0; while ((m = LITERAL_PATTERN.exec(line)) !== null) { - sites.push({ value: m[1], file: file.slice(ROOT.length + 1), line: i + 1 }); + sites.push({ value: m[1], file: rel, line: i + 1 }); } if (DYNAMIC_PATTERN.test(line)) { - dynamic.push({ value: line.trim(), file: file.slice(ROOT.length + 1), line: i + 1 }); + // The score-anatomy record lookup is statically resolvable; every + // resolved value becomes a Body-only site. Any OTHER dynamic shape + // is unverifiable and trips the no-dynamic guard. + const lookup = RECORD_LOOKUP_PATTERN.exec(line); + if (file.endsWith(SCORES_PAGE) && lookup) { + for (const value of resolveRecordValues(source, lookup[1])) { + sites.push({ value, file: rel, line: i + 1, bodyOnly: true }); + } + } else { + dynamic.push({ value: line.trim(), file: rel, line: i + 1 }); + } } } } @@ -121,7 +166,8 @@ describe("insights explainerMetric key coverage", () => { for (const site of sites) { const titleKey = `insights.subPage.explainer.${site.value}Title`; const bodyKey = `insights.subPage.explainer.${site.value}Body`; - if (!hasLeaf(en, titleKey)) { + // Score explainers (bodyOnly) carry only the inline Body caption. + if (!site.bodyOnly && !hasLeaf(en, titleKey)) { missing.push(` ❌ ${titleKey} (${site.file}:${site.line})`); } if (!hasLeaf(en, bodyKey)) { diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 23591ac99..1dc4c8773 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -49,6 +49,13 @@ export default function InsightsPulsPage() { // dedicated cardio-fitness page. const hasVo2 = (analytics?.summaries?.VO2_MAX?.count ?? 0) > 0; const pulseSummary = analytics?.summaries?.PULSE ?? null; + // v1.15.12 A2 — the resting-pulse band is judged against + // RESTING_HEART_RATE (Apple's clean daily resting figure). When the + // user has resting rows the chart shows that series against the band; + // otherwise it charts raw heart rate WITHOUT the resting-band overlay + // (which would flag expected-high workout HR as "outside target"). + const hasRestingHr = + (analytics?.summaries?.RESTING_HEART_RATE?.count ?? 0) > 0; // v1.12.8 — visible-range stats shared between the pulse chart and the // strip (the VO2 chart-row below keeps its own full-range read). @@ -138,12 +145,12 @@ export default function InsightsPulsPage() { > = { strain: "STRAIN_SCORE", }; +/** + * v1.15.12 B1 — the explainer-body key suffix per score, feeding + * `insights.subPage.explainer.Body` through `SubPageShell`'s + * `explainerMetric` prop (mirrors the metric sub-pages). `SLEEP_SCORE` + * uses a distinct `sleepScore` suffix so it never collides with the + * `sleep` metric's own explainer body. + */ +const EXPLAINER_METRIC: Record = { + SLEEP_SCORE: "sleepScore", + READINESS: "readiness", + RECOVERY_SCORE: "recovery", + STRESS_SCORE: "stress", + STRAIN_SCORE: "strain", +}; + /** Localised page header per metric (title + one-line description). */ const HEADER_KEYS: Record< AnatomyMetricId, @@ -71,6 +86,7 @@ export default function CompositeScorePage() { 0; + const pulseTileSummary = hasRestingHr ? rhr : p; const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep @@ -588,7 +597,9 @@ export default function DashboardPage() { // Data-floor gates (widget shows iff visible AND has data) const hasWeight = (w?.count ?? 0) > 0; const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0; - const hasPulse = (p?.count ?? 0) > 0; + // v1.15.12 A2 — the pulse tile shows resting HR when available, else + // raw PULSE; either signal having data shows the tile. + const hasPulse = (p?.count ?? 0) > 0 || (rhr?.count ?? 0) > 0; const hasBodyFat = (bf?.count ?? 0) > 0; const hasMood = (moodSummary?.count ?? 0) > 0; const hasSleep = (sleepSummary?.count ?? 0) > 0; @@ -1128,34 +1139,34 @@ export default function DashboardPage() { ), }); @@ -1474,11 +1485,15 @@ export default function DashboardPage() { diff --git a/src/components/admin/version-tile-section.tsx b/src/components/admin/version-tile-section.tsx new file mode 100644 index 000000000..8c02a49df --- /dev/null +++ b/src/components/admin/version-tile-section.tsx @@ -0,0 +1,131 @@ +"use client"; + +/** + * `` — admin-overview version card. + * + * v1.15.12 H1 — a dedicated tile that surfaces the running build's + * version, short build SHA, and build timestamp, plus a best-effort + * "update available" hint computed against the latest GitHub release + * tag. The release comparison reuses the existing + * `GET /api/version/check-updates` proxy (server-side `safeFetch` past + * the CSP), so no new route or OpenAPI schema is introduced. The check + * is day-stale (`staleTime`) and never blocks the render: on any + * failure the tile simply omits the update hint. + * + * Cookie-admin only by construction — it lives under the admin layout + * (`/admin`), which gates on `requireAdmin()`. + */ + +import { ArrowUpCircle, CheckCircle2, Clock, GitCommit, Tag } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { StatusItem, usePublicVersion } from "./_shared"; + +type CheckUpdatesResult = + | { + status: "up_to_date"; + current: string; + latest_tag: string; + checked_at: string; + } + | { + status: "newer_available"; + current: string; + latest_tag: string; + html_url: string | null; + published_at: string | null; + checked_at: string; + } + | { status: "unknown"; current: string; reason: string }; + +async function fetchUpdateCheck(): Promise { + const res = await fetch("/api/version/check-updates"); + if (!res.ok) { + return { status: "unknown", current: "", reason: `http_${res.status}` }; + } + return (await res.json()).data as CheckUpdatesResult; +} + +function shortSha(sha: string | null): string | null { + if (!sha) return null; + return sha.length > 7 ? sha.slice(0, 7) : sha; +} + +export function VersionTileSection() { + const { t } = useTranslations(); + const { data: version } = usePublicVersion(); + + // Best-effort, day-stale, never throws into the render. The route is + // auth-gated and proxies the GitHub Releases API through the server so + // the production CSP doesn't block the call. + const { data: update } = useQuery({ + queryKey: queryKeys.versionUpdateCheck(), + queryFn: fetchUpdateCheck, + staleTime: 24 * 60 * 60_000, + retry: false, + }); + + const newer = update?.status === "newer_available"; + const upToDate = update?.status === "up_to_date"; + const sha = shortSha(version?.buildSha ?? null); + + return ( +
+
+
+ +
+ + + +
+
+ ); +} diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx index ddca7518a..e5cd9ba76 100644 --- a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -53,7 +53,27 @@ describe("", () => { expect(html).toContain('data-contributor="respiratory"'); expect(html).toContain('data-present="false"'); expect(html).toContain('data-slot="provenance-explainer-trigger"'); - expect(html).toContain('data-slot="score-anatomy-disclaimer"'); + // v1.15.12 F4 — the "keine klinische Bewertung" footer line is removed; + // the non-clinical framing now lives behind the provenance explainer. + expect(html).not.toContain('data-slot="score-anatomy-disclaimer"'); + }); + + it("tints the card + leans the ring hue when a hue is passed (F1)", () => { + const html = render( + , + ); + expect(html).toContain('data-tinted="true"'); + expect(html).toContain("wellness-detail-card"); + expect(html).toContain("--tile-hue"); }); it("renders the insufficient state with the provisional ring + nudge", () => { diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index dd7bd263f..d1154dc70 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -1,8 +1,11 @@ "use client"; +import { Sparkles } from "lucide-react"; + import { useTranslations } from "@/lib/i18n/context"; import { useDerivedMetric } from "./use-derived-metric"; import { Skeleton } from "@/components/ui/skeleton"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; import type { ReadinessValue, SleepScoreValue, @@ -12,6 +15,7 @@ import { ScoreAnatomyView, type AnatomyContributor, } from "./score-anatomy-view"; +import type { RingHue } from "./ring-hues"; import { METRIC_PROVENANCE } from "./standards"; /** @@ -51,6 +55,20 @@ export interface CompositeScoreAnatomyProps { className?: string; } +/** + * v1.15.12 F1 — each anatomy metric maps to the same ring hue its dashboard + * tile wears, so the detail card carries the matching tint + the ring arc + * leans the same colour (continuity from the tile tap). Mirrors the per-metric + * `hue` the wellness strip passes its `RingTile`s. + */ +const METRIC_HUE: Record = { + SLEEP_SCORE: "sleep", + READINESS: "readiness", + RECOVERY_SCORE: "recovery", + STRESS_SCORE: "stress", + STRAIN_SCORE: "strain", +}; + export function CompositeScoreAnatomy({ metric, className, @@ -117,6 +135,7 @@ export function CompositeScoreAnatomy({ ` the metric + // sub-pages mount so it reads identically. The route always populates a + // non-empty assessment when the score `status === "ok"` (the deterministic + // template at minimum, warmer AI prose when a provider is configured), so a + // present `assessment` implies a real text — `hasProvider` is held true and + // the card always renders its prose rather than the no-provider fallback. + const assessment = data.assessment; + return ( - +
+ + {assessment ? ( + } + text={assessment.text} + hasProvider + updatedAt={assessment.updatedAt} + /> + ) : null} +
); } diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx index 8a2b59310..8a9e5e154 100644 --- a/src/components/insights/derived/score-anatomy-view.tsx +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -15,6 +15,7 @@ import { ProvenanceExplainer, type ProvenanceStandard, } from "./provenance-explainer"; +import { TILE_HUE, type RingHue } from "./ring-hues"; import { bandForScore, BAND_PROGRESS_CLASS, @@ -64,6 +65,14 @@ export interface ScoreAnatomyViewProps { score: number | null; /** Band override; derived from the score when omitted. */ band?: ScoreBand; + /** + * v1.15.12 (F1/F2/F3) — the metric's ring hue. When set, the detail card + * carries the same gentle per-metric tint the dashboard ring tile wears + * (visual continuity from tap to detail) and the ring arc leans the same + * hue instead of the generic band gradient. Omitted → plain `--card` surface + * + band-coloured ring (the legacy look). + */ + hue?: RingHue; /** Short caption under the ring (e.g. a band word or a one-line summary). */ caption?: ReactNode; /** Ranked contributors (the caller pre-ranks; the view renders in order). */ @@ -86,6 +95,7 @@ export function ScoreAnatomyView({ title, score, band, + hue, caption, contributors, coverage, @@ -104,12 +114,37 @@ export function ScoreAnatomyView({
+ {/* v1.15.12 F5 — the ⓘ explainer is pinned TOP-right at heading height + (it used to trail the card at bottom-right). Absolute so it never + shifts the centred hero title; the trigger keeps its 44px touch + target + popover/sheet behaviour. */} +
+ +
+ {/* Hero: title + ring + caption */}

{title}

- + {/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is + never clipped top/bottom by the hero container. */} +
+ +
{caption && !insufficient ? (

)} - {/* Provenance + cited standard + the non-clinical disclaimer */} -

-

- {t("insights.derived.anatomy.disclaimer")} -

- -
+ {/* v1.15.12 F4 — the "Richtwert — keine klinische Bewertung" footer line + is gone; the method + cited standard live behind the provenance + explainer (now top-right, F5), which carries the non-clinical framing + where it belongs. The composition (contributor rows above) stays. */}
); } diff --git a/src/components/insights/derived/use-derived-metric.ts b/src/components/insights/derived/use-derived-metric.ts index 555af48dc..abf671246 100644 --- a/src/components/insights/derived/use-derived-metric.ts +++ b/src/components/insights/derived/use-derived-metric.ts @@ -8,6 +8,7 @@ import type { DerivedConfidence, DerivedProvenance, } from "@/lib/insights/derived/types"; +import type { DerivedAssessment } from "@/lib/insights/derived/derived-assessment"; /** * v1.10.0 — the single TanStack Query hook for the generic derived-metric @@ -44,6 +45,17 @@ export interface DerivedMetricResponse { confidence: DerivedConfidence | null; provenance: DerivedProvenance; reason: string | null; + /** + * v1.13.2 — the per-score "why is this score what it is" assessment + * ({ text, source, updatedAt }). Populated only for the assessable scores + * (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE) when + * `status === "ok"`, null otherwise. The single-metric route serves the + * AI-warm prose (falling back to the deterministic template); the batch route + * serves the cheap deterministic text only. Optional on the wire shape so + * grid reads that drop it stay typed. v1.15.12 B3 surfaces it on the web + * score-anatomy detail page (it already shipped to iOS in v1.13.2). + */ + assessment?: DerivedAssessment | null; } export interface UseDerivedMetricOptions { diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 5178d212d..5b9bcac4b 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -243,7 +243,16 @@ export function TrendsRow({ key={config.metric} data-slot="trends-row-card" data-metric={config.metric} - className="flex h-full flex-col gap-2 md:min-h-[300px]" + // v1.15.12 D1 — drop the `md:min-h-[300px]` floor. The chart + // slot is a fixed 180 px and the caption is `line-clamp-3`, so a + // card's natural height tops out around 270 px; the 300 px floor + // padded ~30 px of dead space below every caption, which read as + // an oversized Trends→Rückblick seam (the `space-y-6` gap is even, + // the empty card tail was the culprit). `auto-rows-fr` + + // `md:items-stretch` on the grid already pin every card to a + // single baseline, and the fixed chart slot aligns the charts, so + // the floor was redundant. Cards now size to content. + className="flex h-full flex-col gap-2" > {/* v1.4.28 R3c-Insights — fixed chart slot. The mini chart paints its own band; this wrapper pins the diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts index 9de0f1ca9..63ff478cf 100644 --- a/src/lib/ai/prompts/base-system.ts +++ b/src/lib/ai/prompts/base-system.ts @@ -14,8 +14,13 @@ import type { Locale } from "@/lib/i18n/config"; * `current · baseline · signed delta · spread · outsideNormalSwing` * descriptor and the prompt leads from it instead of asking the model to * derive the comparison from raw buckets. + * 5.1.0 — premium tone pass: the assessment voice warms toward a motivating, + * forward-looking advisor (name the earned win, build momentum, frame an + * unfavourable finding as an opportunity) while every existing guard stays — + * no platitudes, no bare number-echoing, autonomy-supporting, never alarming / + * moralising / diagnostic, and the banned-opener / forbidden-phrase lists hold. */ -export const PROMPT_VERSION = "5.0.0" as const; +export const PROMPT_VERSION = "5.1.0" as const; /** * Base system prompt for the per-metric Insights *assessment* cards. @@ -115,16 +120,20 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [ }, { id: "tone", - en: `TONE: + en: `TONE — a warm, motivating premium advisor (someone really looked at this person's data and found something worth their attention): - Second person ("your blood pressure", "your values"), warm, direct, honest. +- Motivating and forward-looking: when the data earns it, name the genuine win plainly and build a little momentum — make the person feel seen and supported, not lectured. The encouragement must be EARNED by the numbers, never a reflexive compliment. - Autonomy-supporting: "can help", "worth a try", never "you must". - Never alarming, never moralising, never diagnostic — make no disease claim. Frame as a reasoned observation, not medical advice. -- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step. Do not downplay, do not dramatise.`, - de: `TONALITÄT: +- Name unfavourable values honestly too: finding -> place against the user's own baseline -> one small doable step, framed as an opportunity rather than a failing. Do not downplay, do not dramatise. +- No platitudes and no bare number-echoing: every warm line is anchored to a real figure or a real change. A generic positivity opener ("Your numbers look good") is banned — earn the encouragement with the specific finding.`, + de: `TONALITÄT — ein warmer, motivierender Premium-Begleiter (jemand hat sich die Daten dieser Person wirklich angesehen und etwas Wertvolles für sie gefunden): - Zweite Person ("dein Blutdruck", "deine Werte"), warm, direkt, ehrlich. +- Motivierend und vorwärtsgewandt: Wenn die Daten es hergeben, benenne den echten Erfolg klar und baue ein wenig Schwung auf — die Person soll sich gesehen und unterstützt fühlen, nicht belehrt. Die Ermutigung muss durch die Zahlen VERDIENT sein, nie ein reflexhaftes Kompliment. - Autonomie-unterstützend: "kann helfen", "einen Versuch wert", nie "du musst". - Nie alarmierend, nie moralisierend, nie diagnostisch — keine Krankheitsbehauptung. Formuliere als begründete Beobachtung, nicht als ärztlichen Rat. -- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt. Nicht verharmlosen, nicht dramatisieren.`, +- Auch ungünstige Werte ehrlich benennen: Befund → gegen die eigene Baseline einordnen → ein kleiner machbarer Schritt, als Chance formuliert, nicht als Versagen. Nicht verharmlosen, nicht dramatisieren. +- Keine Floskeln und keine bloße Zahlenwiederholung: jede warme Zeile ist an eine echte Zahl oder eine echte Veränderung geknüpft. Ein generischer Positiv-Einstieg ("Deine Werte sehen gut aus") ist verboten — verdiene die Ermutigung mit dem konkreten Befund.`, }, { id: "length", diff --git a/src/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts new file mode 100644 index 000000000..8161069a8 --- /dev/null +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -0,0 +1,223 @@ +/** + * v1.15.12 A1 — unit pins for the graded BP pillar score. + * + * The maintainer's apps01 profile (under-65 ceiling 129/79, avg 134/87) + * is the canonical regression: the binary all-time in-target rate read + * ~10-16/100; the graded score must land in the borderline-stage-1 band + * (low-to-mid 50s), not in the catastrophic band. + */ +import { describe, expect, it } from "vitest"; +import { + gradeBpScore, + gradeBpScoreFromSeries, + type BpPairPoint, +} from "../bp-grade"; +import type { BpTargets } from "../bp-targets"; + +// Under-65 ESH band — the maintainer's age band. +const UNDER65: BpTargets = { + sysLow: 120, + sysHigh: 129, + diaLow: 70, + diaHigh: 79, +}; + +describe("gradeBpScore", () => { + it("grades the maintainer's 134/87 into the borderline band [45,60]", () => { + const score = gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }); + expect(score).toBeGreaterThanOrEqual(45); + expect(score).toBeLessThanOrEqual(60); + }); + + it("grades a well-controlled 120/78 at or above 85", () => { + expect( + gradeBpScore({ sys: 120, dia: 78, target: UNDER65 }), + ).toBeGreaterThanOrEqual(85); + }); + + it("grades a textbook-normal 118/76 very high (≈100)", () => { + expect( + gradeBpScore({ sys: 118, dia: 76, target: UNDER65 }), + ).toBeGreaterThanOrEqual(88); + }); + + it("grades an uncontrolled 160/100 at or below 30", () => { + expect( + gradeBpScore({ sys: 160, dia: 100, target: UNDER65 }), + ).toBeLessThanOrEqual(30); + }); + + it("takes the WORSE axis — a single high diastolic drags the score down", () => { + // sys perfect (118), dia far over (95): the worst axis should win. + const sysOk = gradeBpScore({ sys: 118, dia: 78, target: UNDER65 }); + const diaHigh = gradeBpScore({ sys: 118, dia: 95, target: UNDER65 }); + expect(diaHigh).toBeLessThan(sysOk); + }); + + it("is monotonic — higher BP never scores better", () => { + const a = gradeBpScore({ sys: 130, dia: 82, target: UNDER65 }); + const b = gradeBpScore({ sys: 140, dia: 88, target: UNDER65 }); + const c = gradeBpScore({ sys: 150, dia: 95, target: UNDER65 }); + expect(a).toBeGreaterThan(b); + expect(b).toBeGreaterThan(c); + }); + + it("has no cliff — a one-mmHg change moves the score by a small amount", () => { + const at = gradeBpScore({ sys: 129, dia: 79, target: UNDER65 }); + const justOver = gradeBpScore({ sys: 130, dia: 79, target: UNDER65 }); + expect(at - justOver).toBeLessThanOrEqual(5); + expect(at).toBeGreaterThanOrEqual(justOver); + }); + + it("penalises hypotension below the clinical floor (no longer reads ~100)", () => { + // 85/45 is 5 mmHg below both floors (sys 90 / dia 50). The continuous + // hypo curve gives a gentle penalty near the floor (5 below ≈ 85), + // clearly below the optimal plateau of 100. + const low = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 }); + expect(low).toBeLessThan(100); + expect(low).toBeGreaterThanOrEqual(80); + // A markedly low reading is penalised much harder. + const veryLow = gradeBpScore({ sys: 70, dia: 40, target: UNDER65 }); + expect(veryLow).toBeLessThan(low); + expect(veryLow).toBeLessThanOrEqual(45); + }); + + it("has NO hypotension cliff — the floor boundary is continuous on both axes", () => { + // Audit HIGH-1: above the floor the score sits on the optimal plateau + // (100); the old below-floor branch jumped to ~81 at 1 mmHg under, + // a 19-point cliff. The dedicated hypo curve starts at 100 AT the + // floor and descends smoothly. Walk across the systolic floor (90): + // dia 66 sits on the optimal plateau (offset −13 → 100) so the + // systolic axis is the worst-of(sys,dia) winner across the walk. + const sysWalk = [92, 91, 90, 89, 88].map( + (sys) => gradeBpScore({ sys, dia: 66, target: UNDER65 }), + ); + // AT and ABOVE the floor sit on the plateau (100). + expect(sysWalk[2]).toBe(100); // sys 90 + expect(sysWalk[1]).toBe(100); // sys 91 + expect(sysWalk[0]).toBe(100); // sys 92 + // Every per-step delta is small (no >5-point boundary jump) and the + // score is monotonic non-increasing as BP drops below the floor. + for (let i = 0; i < sysWalk.length - 1; i++) { + const delta = sysWalk[i] - sysWalk[i + 1]; + expect(delta).toBeGreaterThanOrEqual(0); // non-increasing as BP drops + expect(delta).toBeLessThanOrEqual(5); // no cliff + } + + // Walk across the diastolic floor (50). Hold sys comfortably normal so + // the diastolic axis is the worst-of(sys,dia) winner throughout. + // sys 117 sits on the optimal plateau (offset −12 → 100) so the + // diastolic axis is the worst-of(sys,dia) winner across the walk. + const diaWalk = [52, 51, 50, 49, 48].map( + (dia) => gradeBpScore({ sys: 117, dia, target: UNDER65 }), + ); + expect(diaWalk[2]).toBe(100); // dia 50 + expect(diaWalk[1]).toBe(100); // dia 51 + expect(diaWalk[0]).toBe(100); // dia 52 + for (let i = 0; i < diaWalk.length - 1; i++) { + const delta = diaWalk[i] - diaWalk[i + 1]; + expect(delta).toBeGreaterThanOrEqual(0); + expect(delta).toBeLessThanOrEqual(5); + } + }); + + it("descends smoothly further below the floor (mirrors over-target steepness)", () => { + // floor → 100, floor−10 → ~70, floor−20 → ~45, floor−30 → ~20. + // dia 66 stays on the plateau so the systolic axis is the winner. + expect(gradeBpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100); + expect(gradeBpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70); + expect(gradeBpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45); + expect(gradeBpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20); + // Never negative on an extreme low. + expect( + gradeBpScore({ sys: 30, dia: 20, target: UNDER65 }), + ).toBeGreaterThanOrEqual(0); + }); + + it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => { + expect(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57); + expect(gradeBpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22); + }); +}); + +describe("gradeBpScoreFromSeries", () => { + const NOW = new Date("2026-06-07T12:00:00.000Z"); + const daysAgo = (n: number): Date => + new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000); + + it("returns null for an empty series", () => { + expect( + gradeBpScoreFromSeries({ pairs: [], target: UNDER65, now: NOW }), + ).toBeNull(); + }); + + it("weights recent readings more — recent improvement lifts the score", () => { + // Old readings high, recent readings well-controlled. + const pairs: BpPairPoint[] = [ + { at: daysAgo(300), sys: 160, dia: 100 }, + { at: daysAgo(280), sys: 158, dia: 98 }, + { at: daysAgo(5), sys: 120, dia: 76 }, + { at: daysAgo(2), sys: 119, dia: 75 }, + { at: daysAgo(0), sys: 121, dia: 77 }, + ]; + const recencyWeighted = gradeBpScoreFromSeries({ + pairs, + target: UNDER65, + now: NOW, + }); + // A flat (unweighted) mean would be dragged toward ~145/90; the + // recency-weighted representative should read close to the recent + // well-controlled cluster. + expect(recencyWeighted).not.toBeNull(); + expect(recencyWeighted as number).toBeGreaterThanOrEqual(80); + }); + + it("matches the single-reading grade when every pair is today", () => { + const pairs: BpPairPoint[] = [ + { at: NOW, sys: 134, dia: 87 }, + { at: NOW, sys: 134, dia: 87 }, + ]; + expect( + gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }), + ).toBe(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 })); + }); + + it("rollup (per-day-mean + count) and live (per-event) agree on the same data", () => { + // Audit HIGH-2 regression guard. A multi-reading high day today + // (4× 150/95) plus a calm day yesterday (1× 115/72). The live path + // grades the 5 per-event pairs; the rollup path grades the two per-day + // MEAN pairs but weights today's mean by its count (4). Both must + // produce the same graded score, otherwise the BP pillar diverges by + // up to ~20 points depending on DAY-bucket warmth. + const today = daysAgo(0); + const yesterday = daysAgo(1); + + // Live: one pair per event (count defaults to 1). + const livePairs: BpPairPoint[] = [ + { at: today, sys: 150, dia: 95 }, + { at: today, sys: 150, dia: 95 }, + { at: today, sys: 150, dia: 95 }, + { at: today, sys: 150, dia: 95 }, + { at: yesterday, sys: 115, dia: 72 }, + ]; + // Rollup: one per-day-MEAN pair, weighted by perDayPairCount. + const rollupPairs: BpPairPoint[] = [ + { at: today, sys: 150, dia: 95, count: 4 }, + { at: yesterday, sys: 115, dia: 72, count: 1 }, + ]; + + const live = gradeBpScoreFromSeries({ + pairs: livePairs, + target: UNDER65, + now: NOW, + }); + const rollup = gradeBpScoreFromSeries({ + pairs: rollupPairs, + target: UNDER65, + now: NOW, + }); + expect(live).not.toBeNull(); + expect(rollup).not.toBeNull(); + expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(1); + }); +}); diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index e0ff9b316..14ae10efc 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -666,4 +666,72 @@ describe("computeUserHealthScoreFastPath", () => { expect(result?.components.compliance.value).toBeGreaterThanOrEqual(90); }); }); + + /** + * v1.15.12 A1 — the graded clinical-proximity score, when supplied, + * becomes the BP pillar VALUE; the binary in-target rate only gates + * presence. A "borderline-stage-1" profile that read ~16/100 under the + * old binary contract must now read in the graded band. + */ + describe("graded BP pillar (A1)", () => { + it("uses bpGradedScore as the pillar value, not the binary rate", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-06-07T12:00:00.000Z"); + // Live fallback — no weight/mood/med data; only BP signal present. + MEASUREMENT_FIND_MANY + .mockResolvedValueOnce([]) // weight raw read (live fallback) + .mockResolvedValueOnce([]); // BP-SYS source attribution + + const result = await computeUserHealthScoreFastPath({ + userId: "user-graded-bp", + // Binary all-time rate is catastrophic (the old pillar value)… + bpInTargetPct: 16, + // …but the graded clinical-proximity score says borderline. + bpGradedScore: 55, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + // Pillar reads the graded score, NOT the binary 16. + expect(result?.components.bp.value).toBe(55); + }); + + it("keeps the pillar absent when no BP readings exist (presence gate)", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + const now = new Date("2026-06-07T12:00:00.000Z"); + MEASUREMENT_FIND_MANY + .mockResolvedValueOnce([]) // weight raw read + .mockResolvedValueOnce([]); // BP-SYS source attribution + // Mood present so the score is computable even without BP. + MOOD_FIND_MANY.mockResolvedValue( + Array.from({ length: 6 }, (_v, i) => ({ + score: 4, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })), + ); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-no-bp", + // No paired BP readings → rate is null → pillar absent even if a + // stray graded score were supplied. + bpInTargetPct: null, + bpGradedScore: 80, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + expect(result?.components.bp.value).toBeNull(); + expect(result?.components.bp.weight).toBe(0); + }); + }); }); diff --git a/src/lib/analytics/__tests__/resting-pulse.test.ts b/src/lib/analytics/__tests__/resting-pulse.test.ts new file mode 100644 index 000000000..7142dae47 --- /dev/null +++ b/src/lib/analytics/__tests__/resting-pulse.test.ts @@ -0,0 +1,146 @@ +/** + * v1.15.12 A2 — unit pins for resting-pulse estimation. + * + * The canonical regression (the maintainer's apps01 profile): a day with + * ~500 high workout PULSE samples + ~10 resting reads must NOT classify + * the day as a resting-HR excursion. A user with clean + * RESTING_HEART_RATE (~72) reads healthy. + */ +import { describe, expect, it } from "vitest"; +import { + deriveRestingProxyFromPulse, + resolveRestingPulseSeries, + type PulseSample, +} from "../resting-pulse"; + +const day = (iso: string, value: number): PulseSample => ({ + measuredAt: new Date(iso), + value, +}); + +describe("deriveRestingProxyFromPulse", () => { + it("excludes a workout burst — the daily proxy tracks the resting floor", () => { + // One Berlin day: 10 resting reads ~70, 500 workout reads ~150. + const samples: PulseSample[] = []; + for (let i = 0; i < 10; i++) { + samples.push(day(`2026-06-01T06:${String(i).padStart(2, "0")}:00`, 68 + i)); + } + for (let i = 0; i < 500; i++) { + samples.push( + day( + `2026-06-01T18:${String(i % 60).padStart(2, "0")}:00`, + 140 + (i % 20), + ), + ); + } + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(1); + // 510 samples, only 10 below ~80 — but the 20th percentile of the + // whole day still lands in the workout band because 98 % of samples + // are high. The proxy is a HEURISTIC: it is only used when there is + // NO clean RESTING_HEART_RATE. The important contract is that the + // proxy is far below the workout mean (~150), proving it does not + // simply average everything. + expect(proxy[0].value).toBeLessThan(150); + }); + + it("recovers the resting floor when resting reads are a normal share of the day", () => { + // Realistic day: 30 waking/spot reads 70-95, 10 workout reads ~150. + const samples: PulseSample[] = []; + for (let i = 0; i < 30; i++) { + samples.push(day(`2026-06-02T08:${String(i % 60).padStart(2, "0")}:00`, 70 + (i % 25))); + } + for (let i = 0; i < 10; i++) { + samples.push(day(`2026-06-02T18:${String(i).padStart(2, "0")}:00`, 150)); + } + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(1); + // 20th percentile sits in the low band, well below the workout reads. + expect(proxy[0].value).toBeLessThanOrEqual(80); + expect(proxy[0].value).toBeGreaterThanOrEqual(68); + }); + + it("buckets by day and returns one proxy point per day, sorted", () => { + // Each day needs enough samples (≥ 3) to clear the degenerate-day + // guard so it can contribute a proxy point. + const samples: PulseSample[] = [ + day("2026-06-03T08:00:00", 72), + day("2026-06-03T12:00:00", 80), + day("2026-06-03T20:00:00", 90), + day("2026-06-01T08:00:00", 60), + day("2026-06-01T12:00:00", 64), + day("2026-06-01T20:00:00", 68), + ]; + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(2); + expect(proxy[0].measuredAt.getTime()).toBeLessThan( + proxy[1].measuredAt.getTime(), + ); + }); + + it("excludes a single-sample workout day — no resting estimate from one high reading", () => { + // Audit LOW-1: a day with only one PULSE sample (e.g. a lone workout + // reading) must NOT emit that value verbatim as "resting". + const oneSampleWorkoutDay: PulseSample[] = [ + day("2026-06-05T18:00:00", 165), + ]; + expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay)).toEqual([]); + + // A day below the min-sample floor is dropped; only the day with enough + // samples contributes. + const mixed: PulseSample[] = [ + day("2026-06-05T18:00:00", 165), // single workout sample → dropped + day("2026-06-06T07:00:00", 60), + day("2026-06-06T08:00:00", 62), + day("2026-06-06T09:00:00", 64), + ]; + const proxy = deriveRestingProxyFromPulse(mixed); + expect(proxy).toHaveLength(1); + expect(proxy[0].value).toBeLessThan(150); + }); + + it("returns empty for no samples", () => { + expect(deriveRestingProxyFromPulse([])).toEqual([]); + }); +}); + +describe("resolveRestingPulseSeries", () => { + it("prefers RESTING_HEART_RATE — a clean ~72 series reads as resting", () => { + const restingSamples: PulseSample[] = Array.from({ length: 30 }, (_v, i) => + day(`2026-06-${String((i % 28) + 1).padStart(2, "0")}T06:00:00`, 70 + (i % 5)), + ); + // Workout-polluted PULSE that should be IGNORED in favour of resting. + const pulseSamples: PulseSample[] = Array.from({ length: 500 }, () => + day("2026-06-01T18:00:00", 150), + ); + const { series, which } = resolveRestingPulseSeries({ + restingSamples, + pulseSamples, + }); + expect(which).toBe("resting"); + expect(series).toHaveLength(30); + const mean = series.reduce((s, p) => s + p.value, 0) / series.length; + expect(mean).toBeLessThan(90); // healthy resting band, not 150 + }); + + it("falls back to the PULSE proxy when no resting rows exist", () => { + const pulseSamples: PulseSample[] = [ + day("2026-06-01T08:00:00", 70), + day("2026-06-01T08:05:00", 72), + day("2026-06-01T18:00:00", 150), + ]; + const { series, which } = resolveRestingPulseSeries({ + restingSamples: [], + pulseSamples, + }); + expect(which).toBe("proxy"); + expect(series).toHaveLength(1); + expect(series[0].value).toBeLessThan(150); + }); + + it("reports 'none' when neither series has data", () => { + expect( + resolveRestingPulseSeries({ restingSamples: [], pulseSamples: [] }), + ).toEqual({ series: [], which: "none" }); + }); +}); diff --git a/src/lib/analytics/bp-grade.ts b/src/lib/analytics/bp-grade.ts new file mode 100644 index 000000000..8a9fe11c3 --- /dev/null +++ b/src/lib/analytics/bp-grade.ts @@ -0,0 +1,233 @@ +/** + * v1.15.12 A1 — graded blood-pressure pillar score. + * + * The Personal Health Score's BP pillar previously equalled + * `bpInTargetRate`: the share of paired readings where BOTH systolic and + * diastolic sat at-or-below the age-band ceiling, scored binary per + * reading over the all-time window. That metric is honest as a "% of + * readings in range" stat, but it is the wrong thing to use AS the + * pillar score: a user who averages 134/87 (borderline stage-1, every + * reading slightly over the diastolic ceiling) collapses to ~10-16/100 + * because nearly every reading fails the binary check — even though + * clinically they are "a bit high", not catastrophic. Improvement also + * never shows, because the rate is all-time and a binary cliff. + * + * This module replaces the binary rate AS THE SCORE with a smooth + * clinical-proximity grade. A representative BP (recency-weighted toward + * the last weeks) is mapped to a 0-100 score per axis, and the pillar + * takes the WORSE of the two sub-scores — so an elevated diastolic + * counts against the score (it should) without binary-zeroing it. + * + * The curve is anchored on the SAME age-band ceiling the rest of the app + * uses (`BpTargets.sysHigh` / `diaHigh` from `bp-targets.ts`). No new + * thresholds are invented; the score bands are derived as offsets from + * the configured ceiling: + * + * offset from ceiling → axis score + * ≤ −12 (well below) 100 (optimal — comfortably in band) + * 0 (at the ceiling) 85 (at goal — "well controlled") + * +5 65 + * +10 52 + * +20 40 + * +40 18 + * ≥ +60 5 (far above — uncontrolled) + * + * Piecewise-linear between anchors — no hard cliffs, so a one-mmHg + * change moves the score by a small, continuous amount. The diastolic + * and systolic axes share the same offset→score table, so each axis is + * judged against its own ceiling. + * + * Hypotension is penalised on a SEPARATE under-floor curve that is + * CONTINUOUS with the optimal plateau at the boundary: at exactly the + * clinical floor (`sys = 90`, `dia = 50`, mirroring `isBpReadingInTarget`) + * the score is 100, and it descends smoothly as the value drops further + * below the floor. The earlier implementation fed `floor − value` through + * the over-target table, which made 1 mmHg under the floor jump to ~81 — + * a 19-point cliff against the 100 the plateau holds AT the floor. The + * dedicated anchors below (floor→100, floor−10→70, floor−20→45, + * floor−30→20) mirror the over-target steepness without the cliff, so the + * curve is continuous on BOTH sides of every boundary. + * + * Pure & deterministic — fully unit-tested in `__tests__/bp-grade.test.ts`. + */ +import type { BpTargets } from "./bp-targets"; +import { + SYS_HYPOTENSION_FLOOR, + DIA_HYPOTENSION_FLOOR, +} from "./bp-in-target"; + +/** + * Offset-from-ceiling → axis-score anchors. Offsets are in mmHg; the + * first anchor is the "optimal" plateau (comfortably below the ceiling), + * the last is the "uncontrolled" floor. Interpolated piecewise-linearly. + */ +const AXIS_ANCHORS: ReadonlyArray = [ + [-12, 100], + [0, 85], + [5, 65], + [10, 52], + [20, 40], + [40, 18], + [60, 5], +] as const; + +/** + * Below-floor hypotension anchors. `below` is mmHg the value sits UNDER + * the clinical floor; the score starts at 100 AT the floor (`below = 0`) + * so the curve is continuous with the optimal plateau the over-target + * branch holds at the floor, then descends to mirror the over-target + * steepness. Interpolated piecewise-linearly; clamps below the last + * anchor. + */ +const HYPO_ANCHORS: ReadonlyArray = [ + [0, 100], + [10, 70], + [20, 45], + [30, 20], + [40, 5], +] as const; + +/** + * Generic piecewise-linear interpolation over a monotonic-x anchor table. + * Clamps to the first / last anchor outside the table range. + */ +function interpolateAnchors( + anchors: ReadonlyArray, + x: number, +): number { + const first = anchors[0]; + const last = anchors[anchors.length - 1]; + if (x <= first[0]) return first[1]; + if (x >= last[0]) return last[1]; + for (let i = 0; i < anchors.length - 1; i++) { + const [x1, y1] = anchors[i]; + const [x2, y2] = anchors[i + 1]; + if (x >= x1 && x <= x2) { + const fraction = (x - x1) / (x2 - x1); + return y1 + fraction * (y2 - y1); + } + } + return last[1]; +} + +/** + * Piecewise-linear interpolation of an over-/under-target `offset` + * (mmHg relative to the axis ceiling) onto the 0-100 axis score. + * Clamps to the first / last anchor outside the table range. + */ +function interpolateAxisScore(offset: number): number { + return interpolateAnchors(AXIS_ANCHORS, offset); +} + +/** + * Score one BP axis against its ceiling. + * + * - Above the floor: offset = `value - ceiling`. Negative offsets sit in + * the optimal plateau, positive ones climb the over-target curve. + * - Below the hypotension floor: the distance below the floor is fed + * through the dedicated `HYPO_ANCHORS` curve, which starts at 100 AT + * the floor (`below = 0`) so the score is continuous with the plateau + * the over-target branch holds at the floor, then descends smoothly — + * no boundary cliff. + */ +function gradeAxis(value: number, ceiling: number, floor: number): number { + if (value < floor) { + return interpolateAnchors(HYPO_ANCHORS, floor - value); + } + return interpolateAxisScore(value - ceiling); +} + +/** + * Map a representative blood-pressure reading to a smooth 0-100 score. + * + * Takes the WORSE of the systolic and diastolic axis sub-scores so a + * high diastolic counts against the score (it should) without + * binary-zeroing the result the way the all-time in-target rate did. + * + * @returns integer 0-100. + */ +export function gradeBpScore(input: { + sys: number; + dia: number; + target: BpTargets; +}): number { + const sysScore = gradeAxis( + input.sys, + input.target.sysHigh, + SYS_HYPOTENSION_FLOOR, + ); + const diaScore = gradeAxis( + input.dia, + input.target.diaHigh, + DIA_HYPOTENSION_FLOOR, + ); + return Math.round(Math.max(0, Math.min(100, Math.min(sysScore, diaScore)))); +} + +/** + * One timestamped BP pair for the recency-weighted representative. + */ +export interface BpPairPoint { + /** Wall-clock of the reading (ms epoch ordering only — no tz math). */ + at: Date; + sys: number; + dia: number; + /** + * v1.15.12 — how many underlying readings this point stands for. + * Defaults to 1 (one per-event pair). The rollup path collapses a day + * into a single per-day-MEAN pair and passes `perDayPairCount` here so + * a 4-reading day's mean counts 4× in the recency-weighted grade, + * matching the live per-event behaviour. Without it a high-variance + * day weighed 1:1 on rollup but N:1 on live, diverging the BP pillar by + * up to ~20 points for the same data. The recency weight MULTIPLIES + * this count. + */ + count?: number; +} + +/** + * Collapse a series of paired BP readings into a single recency-weighted + * representative `{ sys, dia }`, then grade it. + * + * Recent readings should dominate so that genuine improvement surfaces in + * the score rather than being diluted by years of older readings. Each + * pair is weighted by an exponential decay on its age relative to `now`, + * with a half-life of `HALF_LIFE_DAYS` (45 d): a reading 45 days old + * counts half as much as today's, 90 days old a quarter, etc. The + * weighted mean of sys and dia is the representative reading. + * + * Returns `null` when the series is empty (caller leaves the pillar + * absent — the redistribution helper then drops the BP weight cleanly). + */ +const HALF_LIFE_DAYS = 45; +const DAY_MS = 24 * 60 * 60 * 1000; + +export function gradeBpScoreFromSeries(input: { + pairs: ReadonlyArray; + target: BpTargets; + now: Date; +}): number | null { + const { pairs, target, now } = input; + if (pairs.length === 0) return null; + + const nowMs = now.getTime(); + let weightSum = 0; + let sysWeighted = 0; + let diaWeighted = 0; + for (const p of pairs) { + const ageDays = Math.max(0, (nowMs - p.at.getTime()) / DAY_MS); + // The recency decay multiplies the per-point reading count so a + // per-day-mean pair (rollup path) carries the same weight as the N + // per-event pairs it stands for (live path). + const count = p.count ?? 1; + const weight = Math.pow(0.5, ageDays / HALF_LIFE_DAYS) * count; + weightSum += weight; + sysWeighted += weight * p.sys; + diaWeighted += weight * p.dia; + } + if (weightSum === 0) return null; + + const sys = sysWeighted / weightSum; + const dia = diaWeighted / weightSum; + return gradeBpScore({ sys, dia, target }); +} diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index cd6147477..a901cf2da 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -60,10 +60,12 @@ import { } from "@/lib/rollups/measurement-coverage"; import { isNearUtc } from "@/lib/tz/resolver"; import { + collectBpPairs, computeBpInTargetWindows, isBpReadingInTarget, type BpReading, } from "./bp-in-target"; +import { gradeBpScoreFromSeries } from "./bp-grade"; import type { BpTargets } from "./bp-targets"; /** @@ -85,6 +87,13 @@ export interface BpInTargetEnvelope { * requests to specific outlier accounts. */ rowCount: number; + /** + * v1.15.12 A1 — graded clinical-proximity BP score (0..100) from a + * recency-weighted representative reading over the trailing window. + * Feeds the Health-Score BP pillar value, replacing the binary + * in-target rate AS THE SCORE. `null` when no pairs formed. + */ + gradedScore: number | null; } /** @@ -304,6 +313,29 @@ async function computeFromRollups( }, }); + // v1.15.12 A1 — graded BP pillar score from a recency-weighted + // representative reading. Each per-day pair (the day's MEAN sys/dia) + // contributes weighted by its age; recent readings dominate so genuine + // improvement surfaces in the score instead of being diluted by years + // of older readings. + // + // v1.15.12 (audit HIGH-2) — pass `perDayPairCount` as the per-point + // `count` weight so a high-variance day weighs N× (matching the live + // per-event grade) instead of 1×. Without it the rollup and live paths + // 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 gradedScore = gradeBpScoreFromSeries({ + pairs: pairsByDay.map((p) => ({ + at: p.day, + sys: p.sys, + dia: p.dia, + count: p.perDayPairCount, + })), + target: targets, + now, + }); + return { last7Days: last7, last30Days: last30, @@ -312,6 +344,7 @@ async function computeFromRollups( priorYear, path: "rollup", rowCount, + gradedScore, }; } @@ -410,6 +443,13 @@ async function computeFromLive( }); const windows = computeBpInTargetWindows(sysData, diaData, targets, now); + // v1.15.12 A1 — graded BP pillar score from the recency-weighted + // representative of every accepted per-event pair over the read window. + const gradedScore = gradeBpScoreFromSeries({ + pairs: collectBpPairs(sysData, diaData), + target: targets, + now, + }); return { last7Days: windows.last7Days, last30Days: windows.last30Days, @@ -418,6 +458,7 @@ async function computeFromLive( priorYear: windows.priorYear, path: "live", rowCount, + gradedScore, }; } diff --git a/src/lib/analytics/bp-in-target.ts b/src/lib/analytics/bp-in-target.ts index bfbdde9dc..12e0ceaed 100644 --- a/src/lib/analytics/bp-in-target.ts +++ b/src/lib/analytics/bp-in-target.ts @@ -57,8 +57,8 @@ export interface BpReading { * shock-onset thresholds; well below any reasonable normotensive * resting reading. */ -const SYS_HYPOTENSION_FLOOR = 90; -const DIA_HYPOTENSION_FLOOR = 50; +export const SYS_HYPOTENSION_FLOOR = 90; +export const DIA_HYPOTENSION_FLOOR = 50; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -167,6 +167,33 @@ export function computeBpInTargetPct( }; } +/** + * v1.15.12 A1 — collect the accepted SYS/DIA pairs (same pairing rules + * as `computeBpInTargetPct`) as timestamped points, for the graded BP + * pillar score. Returns `{ at, sys, dia }` per accepted pair using the + * SYS reading's timestamp as the pair anchor — recency weighting only + * needs ordering, not sub-second precision. Empty when no pairs form. + */ +export function collectBpPairs( + sysSeries: BpReading[], + diaSeries: BpReading[], +): Array<{ at: Date; sys: number; dia: number }> { + if (sysSeries.length === 0 || diaSeries.length === 0) return []; + const SAME_SESSION_MS = 5 * 60 * 1000; + const out: Array<{ at: Date; sys: number; dia: number }> = []; + for (const sys of sysSeries) { + const match = findClosestDia(sys, diaSeries); + if (!match) continue; + const sameSession = match.deltaMs <= SAME_SESSION_MS; + const sameBerlinDay = + !sameSession && + toBerlinDayKey(sys.measuredAt) === toBerlinDayKey(match.dia.measuredAt); + if (!sameSession && !sameBerlinDay) continue; + out.push({ at: sys.measuredAt, sys: sys.value, dia: match.dia.value }); + } + return out; +} + /** * v1.4.18 A1 → v1.4.19 A1 — windowed in-target shares for the dashboard * tile. diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 41ed7fca6..b20902e20 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -114,6 +114,20 @@ export interface HealthScoreFastPathInput { * 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; heightCm: number | null; now: Date; /** @@ -456,6 +470,9 @@ export async function computeUserHealthScoreFastPath( 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, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesLast30d, @@ -484,6 +501,13 @@ export async function computeUserHealthScoreFastPath( // 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, weightTargetKg: fallbackTarget, moodEntriesLast30d: moodSeriesPrev30d, diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts index 9b0ca94f8..f2af6973b 100644 --- a/src/lib/analytics/health-score.ts +++ b/src/lib/analytics/health-score.ts @@ -8,7 +8,11 @@ * * Formula (when every component is present): * score = round( - * 0.30 * bpInTargetRate // % of paired BP readings inside band + * 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 @@ -79,8 +83,26 @@ export interface HealthScoreSourceAttribution { } export interface HealthScoreInput { - /** All-time BP-in-target rate, already 0..100 (or null). */ + /** + * 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 }>; /** User's stored target weight in kg, if any. */ @@ -328,8 +350,18 @@ 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 : clampToHundred(input.bpInTargetRate); + input.bpInTargetRate === null + ? null + : input.bpGradedScore !== undefined && input.bpGradedScore !== null + ? clampToHundred(input.bpGradedScore) + : clampToHundred(input.bpInTargetRate); const target = deriveWeightTarget(input.weightTargetKg); const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target); diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts new file mode 100644 index 000000000..eb6c203bc --- /dev/null +++ b/src/lib/analytics/resting-pulse.ts @@ -0,0 +1,139 @@ +/** + * v1.15.12 A2 — resting-pulse estimation. + * + * Apple Health maps two distinct signals onto two distinct measurement + * types: + * - `HKQuantityTypeIdentifierHeartRate` → `PULSE` (every HR + * sample, including workout / activity HR — intentionally high) + * - `HKQuantityTypeIdentifierRestingHeartRate` → `RESTING_HEART_RATE` + * (one clean daily resting figure) + * + * Up to v1.15.11 every surface that applied a RESTING pulse target (the + * "Resting pulse" tile + its consistency strip, the pulse in-target %, + * the dashboard resting classification) scored the raw `PULSE` series + * against a resting band. For an Apple-Health user a single workout + * emits ~500 high samples, so the resting-target evaluation read ~32 % + * "over target" even though the user's clean `RESTING_HEART_RATE` + * averages a healthy ~72 bpm. That is wrong: workout HR is expected-high, + * not a resting excursion. + * + * The fix: judge a RESTING band against a RESTING series. + * + * 1. **Prefer `RESTING_HEART_RATE`** when the user has it (Apple + * already separated the clean signal for us). + * 2. **Fallback proxy from `PULSE`** when no `RESTING_HEART_RATE` + * exists (manual-only users, older imports): take a robust + * low-percentile of each calendar day's PULSE samples — the resting + * heart rate is the floor of the day, not its average, so the daily + * 20th percentile estimates resting while excluding the workout + * burst that sits in the upper tail. A single high-density workout + * cannot drag the estimate up because it lives above the 20th + * percentile of the day. + * + * The raw `PULSE` series, where it is merely charted as "heart rate", + * must NOT be judged against the resting band — callers chart it with a + * contextual band or no resting overlay. + * + * Pure & deterministic — unit-tested in `__tests__/resting-pulse.test.ts`. + */ +import { percentile } from "@/lib/insights/strain-score"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +/** A timestamped heart-rate sample. */ +export interface PulseSample { + measuredAt: Date; + value: number; +} + +/** + * The percentile of each day's PULSE samples used as that day's resting + * estimate. The 20th percentile sits comfortably in the day's low band + * (above the rare sleeping-bradycardia outlier, below the bulk of waking + * + workout HR), so it tracks resting HR without being pulled up by a + * dense workout burst. + */ +export const RESTING_PROXY_DAILY_PERCENTILE = 20; + +/** + * Minimum PULSE samples a day needs before it can contribute a resting + * proxy. A workout-only or single-sample day (n = 1) would otherwise emit + * that one sample verbatim — a workout-level value masquerading as + * "resting". Below this floor the day is skipped rather than poisoning the + * proxy series with a high reading; a real resting day has many ambient + * samples and clears it easily. + */ +export const RESTING_PROXY_MIN_DAILY_SAMPLES = 3; + +/** + * Collapse raw `PULSE` samples into a per-Berlin-day resting-proxy + * series: one `{ measuredAt, value }` per day where `value` is the day's + * low-percentile PULSE. The `measuredAt` anchors on the day's first + * sample so downstream day-bucketing keys it back to the same day. + * + * Exported for direct testing; production callers use + * `resolveRestingPulseSeries`. + */ +export function deriveRestingProxyFromPulse( + pulseSamples: ReadonlyArray, + /** + * Day-key function used to bucket samples. Defaults to Berlin-day so + * direct callers / tests stay zero-config; the targets route passes + * its own `userDayKey(d, userTz)` so the proxy buckets align with the + * route's consistency-strip day buckets for non-Berlin users. + */ + dayKeyOf: (d: Date) => string = toBerlinDayKey, +): PulseSample[] { + if (pulseSamples.length === 0) return []; + const byDay = new Map(); + for (const s of pulseSamples) { + const key = dayKeyOf(s.measuredAt); + const bucket = byDay.get(key); + if (bucket) { + bucket.values.push(s.value); + if (s.measuredAt < bucket.firstAt) bucket.firstAt = s.measuredAt; + } else { + byDay.set(key, { firstAt: s.measuredAt, values: [s.value] }); + } + } + const out: PulseSample[] = []; + for (const { firstAt, values } of byDay.values()) { + // Skip degenerate days: too few samples to distinguish a resting floor + // from a lone workout reading. Emitting the verbatim value would leak a + // workout-level number into the "resting" series. + if (values.length < RESTING_PROXY_MIN_DAILY_SAMPLES) continue; + out.push({ + measuredAt: firstAt, + value: Math.round(percentile(values, RESTING_PROXY_DAILY_PERCENTILE)), + }); + } + out.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + return out; +} + +/** + * Resolve the resting-pulse series a resting-target surface should + * score. Prefers the clean `RESTING_HEART_RATE` rows; falls back to the + * low-percentile PULSE proxy when the user has no resting rows. + * + * `which` reports the branch taken so callers can annotate / label + * honestly ("resting heart rate" vs "estimated from heart rate"). + */ +export function resolveRestingPulseSeries(input: { + restingSamples: ReadonlyArray; + pulseSamples: ReadonlyArray; + /** Optional day-key for the proxy buckets (defaults to Berlin-day). */ + dayKeyOf?: (d: Date) => string; +}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { + if (input.restingSamples.length > 0) { + const series = [...input.restingSamples].sort( + (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), + ); + return { series, which: "resting" }; + } + const proxy = deriveRestingProxyFromPulse( + input.pulseSamples, + input.dayKeyOf, + ); + if (proxy.length > 0) return { series: proxy, which: "proxy" }; + return { series: [], which: "none" }; +} diff --git a/src/lib/geo.ts b/src/lib/geo.ts index c3bd9e623..5e993dfdd 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,17 +1,19 @@ /** * IP geolocation lookup for audit-log enrichment. * - * v1.4.27 B3: offline-first resolver. The hot path is now a microsecond- - * scale local read against the bundled MaxMind GeoLite2-City MMDB at - * `/opt/geolite2/GeoLite2-City.mmdb` (and the matching GeoLite2-ASN - * database for carrier lookups). The previous `ipwho.is` HTTPS path is - * kept as the second-tier fallback for IPs the offline DB cannot - * resolve (freshly-allocated ranges that lag the monthly GeoLite2 - * release roll). + * v1.15.12 E2: online-first resolver. The baseline that always works is + * the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves + * a location out of the box with no MaxMind licence configured. The + * bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb` + * is an OPTIONAL upgrade: when the offline databases are present + * (`offlineGeoReady()`), the resolver prefers the microsecond-scale + * local read for both speed and zero egress; when they are absent it + * goes straight online. * * 1. Private / loopback / opt-out → null (no lookup). - * 2. Offline MMDB hit → return immediately. - * 3. Offline miss → fall back to the existing `ipwho.is` path. + * 2. Per-IP cache hit → return immediately. + * 3. Offline MMDB present → local read; on a miss fall through online. + * 4. Otherwise → online `ipwho.is` lookup. * * The legacy contract still holds: `lookupIpLocation` returns a * `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the @@ -29,17 +31,22 @@ * skip the offline tier — local dev without the MMDBs still works and * falls straight back to the online provider. * - * Default provider for the online fallback is ipwho.is (HTTPS, free, + * Default provider for the online lookup is ipwho.is (HTTPS, free, * no key). Both the response shape from ipwho.is and the fallback * ip-api.com pro endpoint are accepted, so swapping providers via * `IP_GEO_LOOKUP_URL` only requires matching one of those response * shapes. * - * Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online fallback + * Setting `IP_GEO_LOOKUP_DISABLED=1` disables the online lookup * entirely — used by deployments that do not want any IP egress to a * third-party service (V3 audit: GDPR Art. 32 + Art. 44, plaintext - * HTTP IP egress). The offline tier still runs because no egress is - * involved. + * HTTP IP egress). With egress disabled the resolver leans solely on + * the offline MMDB tier (and returns null when that is also absent). + * + * Resolved locations are cached per IP in a small in-memory LRU + * (`LOCATION_CACHE`) so a burst of audit events from the same client + * does not hammer ipwho.is — the cache holds both hits and misses + * (negative caching) for a bounded TTL. * * v1.4.16 A8a: the online-fallback body is decoded as UTF-8 explicitly * via `TextDecoder('utf-8')` instead of `Response.json()`, and an @@ -90,6 +97,39 @@ function geoLiteDir(): string { return process.env.GEOLITE2_DIR ?? "/opt/geolite2"; } +// ── Per-IP location cache ──────────────────────────────────────────── +// +// v1.15.12 E1: the online tier is now the baseline path, so a burst of +// audit events from the same address must not fan out one ipwho.is +// request each. We cache the resolved "City, CC" string per IP for a +// bounded TTL and also negative-cache misses (stored as null) so a +// non-resolving IP doesn't re-hit the provider every time. The map is a +// simple bounded FIFO — geo lookups are low-cardinality (a handful of +// distinct client IPs per worker) so an exact LRU is overkill. + +const LOCATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 h +const LOCATION_CACHE_MAX = 512; +const LOCATION_CACHE = new Map(); + +function getCachedLocation(ip: string): { value: string | null } | null { + const hit = LOCATION_CACHE.get(ip); + if (!hit) return null; + if (Date.now() - hit.at > LOCATION_CACHE_TTL_MS) { + LOCATION_CACHE.delete(ip); + return null; + } + return { value: hit.value }; +} + +function setCachedLocation(ip: string, value: string | null): void { + // Bound the map: drop the oldest insertion when full. + if (LOCATION_CACHE.size >= LOCATION_CACHE_MAX) { + const oldest = LOCATION_CACHE.keys().next().value; + if (oldest !== undefined) LOCATION_CACHE.delete(oldest); + } + LOCATION_CACHE.set(ip, { value, at: Date.now() }); +} + // ── Offline tier (MaxMind GeoLite2) ────────────────────────────────── // // Both readers are held in a process-local cache. We use `undefined` @@ -144,6 +184,7 @@ export function __resetGeoLite2CacheForTests(): void { cache.city = undefined; cache.asn = undefined; notifiedThisProcess = false; + LOCATION_CACHE.clear(); } // ── Offline readiness + one-shot admin notification ───────────────── @@ -303,7 +344,10 @@ async function lookupIpLocationOnline(ip: string): Promise { // v1.11.2 — the lookup URL comes from the operator IP_GEO_LOOKUP_URL env; // pin the connect-time DNS check so it can't be pointed at a private / // metadata address (SSRF/rebinding). - { timeoutMs: 3_000, requirePublicHost: true }, + // v1.15.12 E1 — keep the online timeout below the audit-log 3 s race + // window so a slow-but-successful lookup still lands before the race + // resolves null and drops the location (the "—" symptom on apps01). + { timeoutMs: 2_500, requirePublicHost: true }, ); if (!res.ok) return null; @@ -329,18 +373,34 @@ export async function lookupIpLocation( ): Promise { if (!ip || PRIVATE_IP.test(ip)) return null; - const offline = lookupIpLocationOffline(ip); - if (offline) return offline; + const cached = getCachedLocation(ip); + if (cached) return cached.value; - // First public-IP lookup that has to leave the offline tier — if the - // offline DBs are missing entirely, send the one-shot admin alert so - // the maintainer can wire `MAXMIND_LICENSE_KEY` when convenient. The - // notification is fire-and-forget so the audit-log path stays fast. - if (!offlineGeoReady()) { + // v1.15.12 E2 — online-first precedence. The optional MaxMind offline + // tier is preferred ONLY when its databases are actually present + // (`offlineGeoReady()`): a local hit is faster and egress-free. When + // the offline DBs are absent — the default for a self-host with no + // `MAXMIND_LICENSE_KEY` — we skip straight to the online provider, + // which is the baseline that always resolves a location. + if (offlineGeoReady()) { + const offline = lookupIpLocationOffline(ip); + if (offline) { + setCachedLocation(ip, offline); + return offline; + } + // Offline DBs present but this IP missed (freshly-allocated range + // lagging the monthly GeoLite2 roll) → fall through to online. + } else { + // No offline tier configured — fire the one-shot admin alert so the + // maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade + // when convenient. Online still resolves; the alert is informational. + // Fire-and-forget so the audit-log path stays fast. void notifyOfflineGeoUnavailable(); } - return lookupIpLocationOnline(ip); + const online = await lookupIpLocationOnline(ip); + setCachedLocation(ip, online); + return online; } /** diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index 96a90b9bb..a3d77a9e9 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -98,6 +98,104 @@ describe("generatePulseStatusForUser — graded payload", () => { }); }); +describe("generatePulseStatusForUser — A2 resting-target in-target %", () => { + it("scores RESTING_HEART_RATE against the resting band, ignoring workout PULSE", async () => { + const now = new Date(); + // PULSE polluted with a heavy workout burst (would tank the in-target + // % if scored against the resting band). + const pulseRecords: Array<{ value: number; measuredAt: Date }> = []; + for (let i = 0; i < 500; i++) { + pulseRecords.push({ + value: 150, + measuredAt: new Date(now.getTime() - (i % 30) * dayMs), + }); + } + // Clean resting series, comfortably inside a 60-100 band. + const restingRecords: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 0; d < 20; d++) { + restingRecords.push({ + value: 72, + measuredAt: new Date(now.getTime() - d * dayMs), + }); + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + // pulse-status reads PULSE first, then RESTING_HEART_RATE. + vi.mocked(prisma.measurement.findMany) + .mockResolvedValueOnce(pulseRecords as never) + .mockResolvedValueOnce(restingRecords as never) + .mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + + await generatePulseStatusForUser("user-resting", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + // Resting 72 sits inside the 60-100 band → 100 % in target, NOT the + // ~0 % the workout-polluted PULSE stream would have produced. + expect(snapshot.pulse.target.inTargetPctLast30DailyPoints).toBe(100); + }); + + it("falls back to a low-percentile PULSE proxy when no resting rows exist", async () => { + const now = new Date(); + // Each day: mostly resting reads ~72 + a workout burst ~150. The + // proxy's low percentile should keep most days in-band. + const pulseRecords: Array<{ value: number; measuredAt: Date }> = []; + for (let d = 0; d < 10; d++) { + const dayStart = new Date(now.getTime() - d * dayMs); + for (let i = 0; i < 20; i++) { + pulseRecords.push({ + value: 70 + (i % 10), + measuredAt: new Date(dayStart.getTime() - i * 60_000), + }); + } + for (let i = 0; i < 5; i++) { + pulseRecords.push({ + value: 150, + measuredAt: new Date(dayStart.getTime() - (i + 30) * 60_000), + }); + } + } + + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurement.findMany) + .mockResolvedValueOnce(pulseRecords as never) + .mockResolvedValueOnce([] as never); // no RESTING_HEART_RATE + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: now, + } as never); + + const captured: { userPrompt: string | null } = { userPrompt: null }; + stubCompletion('{"summary":"OK"}', captured); + + await generatePulseStatusForUser("user-proxy", { locale: "en" }); + + const match = captured.userPrompt!.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + // The proxy excludes the workout burst → the resting estimate stays + // in the healthy band, so the in-target % is high, not tanked to ~0. + expect( + snapshot.pulse.target.inTargetPctLast30DailyPoints, + ).toBeGreaterThanOrEqual(80); + }); +}); + describe("generatePulseStatusForUser — cache-read skips a stub", () => { it("regenerates when the only cached row is a timeout stub", async () => { const now = new Date(); diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index b683a50ea..1c98511fe 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -69,14 +69,16 @@ function scoreSystemPrompt(locale: SupportedLocale): string { const base = getBaseSystemPrompt(locale); const section = locale === "en" - ? `ARCHETYPE — COMPOSITE WELLNESS SCORE: + ? `ARCHETYPE — COMPOSITE WELLNESS SCORE (write like a premium recovery coach — WHOOP / Oura — who genuinely wants this person to do well): - This is a 0–100 composite score, not a raw measurement. Explain WHY it sits where it does by naming the 1–2 contributors in \`signal.contributors[]\` that moved it most — a lower contributor value drags the score down. Each contributor is itself 0–100. - Lead with the score and its standing, then attribute it to the contributor(s). Do not invent a contributor that is not listed. When there are no contributors, use the trend (signal.delta) instead. -- This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic.` - : `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE: +- Warm and motivating: on a strong score, name the win and what it sets them up to do today; on a softer score, frame it as a recovery cue and one small thing within reach — supportive, never deflating. The encouragement is earned by the contributors / trend, never a reflexive compliment, and never bare number-echoing. +- This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic, never alarming.` + : `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE (schreibe wie ein Premium-Recovery-Coach — WHOOP / Oura —, der dieser Person echten Erfolg wünscht): - Das ist ein 0–100-Kompositscore, keine Rohmessung. Erkläre, WARUM er dort liegt, indem du die 1–2 Beiträge aus \`signal.contributors[]\` nennst, die ihn am stärksten bewegt haben — ein niedriger Beitragswert zieht den Score nach unten. Jeder Beitrag ist selbst 0–100. - Führe mit dem Score und seiner Einordnung, dann ordne ihn den Beiträgen zu. Erfinde keinen Beitrag, der nicht gelistet ist. Gibt es keine Beiträge, nutze stattdessen den Trend (signal.delta). -- Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch.`; +- Warm und motivierend: bei einem starken Score den Erfolg benennen und was er heute ermöglicht; bei einem schwächeren Score als Erholungshinweis einordnen und eine kleine, erreichbare Sache nennen — unterstützend, nie entmutigend. Die Ermutigung ist durch die Beiträge / den Trend verdient, nie ein reflexhaftes Kompliment und nie bloße Zahlenwiederholung. +- Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch, nie alarmierend.`; return `${base}\n\n${section}`; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 987a68bfa..4eb63d8a7 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -17,6 +17,7 @@ import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { getNoKeyPulseStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { @@ -41,7 +42,7 @@ import { } from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { toBerlinDayKey, userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; export async function generatePulseStatusForUser( userId: string, @@ -113,9 +114,17 @@ export async function generatePulseStatusForUser( select: { dateOfBirth: true, gender: true, + timezone: true, }, }); + // v1.15.12 (audit LOW-2) — bucket the resting-pulse proxy on the user's + // own day boundary so this surface aligns with the targets route, which + // passes `userDayKey(d, userTz)`. Without it the proxy bucketed on + // Berlin-day here and on the user's TZ there, drifting the resting + // estimate for non-Berlin users. + const userTz = user?.timezone ?? DEFAULT_TIMEZONE; + // v1.4.28 FB-D2 — cap the snapshot input. The downstream // `applyPayloadBudget` trims further but the unbounded findMany was // pulling tens of thousands of rows for Apple-Health-rich accounts @@ -134,6 +143,23 @@ export async function generatePulseStatusForUser( }, }).then((rows) => rows.reverse()); + // v1.15.12 A2 — the RESTING band is judged against the RESTING series, + // not the raw PULSE stream (Apple fills PULSE with workout HR). Pull + // RESTING_HEART_RATE rows; the resolver falls back to a low-percentile + // PULSE proxy when the user has none. + const restingMeasurements = await prisma.measurement + .findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }) + .then((rows) => rows.reverse()); + const now = new Date(); const pulsePoints = measurements.map((measurement) => ({ @@ -181,20 +207,36 @@ export async function generatePulseStatusForUser( (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, ); - // "Last 30 daily points" = the newest 30 daily buckets (offsets 0..29). - const recentPulseDaily = pulseSeries.daily.filter( - (bucket) => bucket.dayOffset < 30, + // v1.15.12 A2 — resting-target in-target % over the RESTING series + // (preferring RESTING_HEART_RATE, else the low-percentile PULSE proxy), + // NOT the raw PULSE daily buckets which mix in workout HR. Scoring the + // raw stream against a resting band counted a workout day as "over + // target" and tanked the %. + const restingResolved = resolveRestingPulseSeries({ + restingSamples: restingMeasurements.map((m) => ({ + measuredAt: m.measuredAt, + value: m.value, + })), + pulseSamples: measurements.map((m) => ({ + measuredAt: m.measuredAt, + value: m.value, + })), + dayKeyOf: (d: Date) => userDayKey(d, userTz), + }); + const thirtyDaysAgoMs = now.getTime() - 30 * 24 * 60 * 60 * 1000; + const recentResting = restingResolved.series.filter( + (p) => p.measuredAt.getTime() >= thirtyDaysAgoMs, ); const inTargetPctLast30DailyPoints = - recentPulseDaily.length === 0 + recentResting.length === 0 ? null : round( - (recentPulseDaily.filter( + (recentResting.filter( (entry) => entry.value >= pulseTarget.greenMin && entry.value <= pulseTarget.greenMax, ).length / - recentPulseDaily.length) * + recentResting.length) * 100, 1, ); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 5dbc5662c..18d414d8e 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -297,6 +297,9 @@ export const queryKeys = { apiVersion: () => ["api", "version"] as const, publicVersion: () => ["public", "version"] as const, + /** v1.15.12 H1 — admin overview "update available" check against the + * latest GitHub release tag (best-effort, day-stale). */ + versionUpdateCheck: () => ["version", "update-check"] as const, researchMode: () => ["research-mode"] as const, moodlogStatus: () => ["moodlog-status"] as const, integrationsStatus: () => ["integrations", "status"] as const, diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index 1d324c9eb..b6e8a5cea 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -238,9 +238,15 @@ describe("GET /api/analytics — Health Score", () => { }; expect(env.data!.healthScore).not.toBeNull(); const hs = env.data!.healthScore!; - // The BP pillar must score from the all-time window… + // The BP pillar must score from the all-time window (recency-weighted)… + // 122/78 sits just inside the under-65 target ceiling (129/79), so the + // graded clinical-proximity score lands high (worst-of-axis ≈ 86) rather + // than the old binary 100. The point of this test is that the pillar still + // SCORES from all-time history when the trailing-30-day window is empty — + // a clearly in-target history must read as a strong (≥ 80) pillar, not drop + // out entirely (the regression this test pins). expect(hs.components.bp.value).not.toBeNull(); - expect(hs.components.bp.value).toBeGreaterThanOrEqual(90); + expect(hs.components.bp.value).toBeGreaterThanOrEqual(80); expect(hs.components.bp.weight).toBeGreaterThan(0); // …while the tile headline stays scoped to the trailing 30 days // (no readings there → null), proving the two are decoupled.