From 794192c0891ea3d650b2d5ccab689fda461d1157 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:38:59 +0200
Subject: [PATCH 1/5] feat(insights): add a calm device-score tile for
vendor-native metrics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
charge, body-temperature deviation, …) is ingested end-to-end and lit in the
AI chart-token allowlist, but no page renders it. These scores sit outside the
metric-status registry by design — they are vendor-rolled-up values, not
signals the app re-derives — so they carry no assessment card.
DeviceScoreTile is the surface for them: a title, the latest reading, the
trailing-window mean, and a small sparkline, read straight from the stored
analytics summaries slice (server-authoritative, never recomputed). One neutral
card, never a status colour. When the series is still sparse the sparkline gives
way to the shared LearningGate so a two-night-old strap never paints a jagged
two-point line. The connected variant reads its own summary so it can drop onto
a page that already owns an analytics read.
Add the page-level copy in all six locales.
---
messages/de.json | 47 +++-
messages/en.json | 47 +++-
messages/es.json | 47 +++-
messages/fr.json | 47 +++-
messages/it.json | 47 +++-
messages/pl.json | 47 +++-
src/components/insights/device-score-tile.tsx | 210 ++++++++++++++++++
7 files changed, 474 insertions(+), 18 deletions(-)
create mode 100644 src/components/insights/device-score-tile.tsx
diff --git a/messages/de.json b/messages/de.json
index 504fbedc1..1efed7e4f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -249,7 +249,8 @@
"moodDescription": "Stimmung und Tags erfassen"
},
"cycle": "Zyklus",
- "labs": "Laborwerte"
+ "labs": "Laborwerte",
+ "recovery": "Erholung"
},
"auth": {
"login": "Anmelden",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. 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 — gestützt 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.",
"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, nicht an einem fremden Maßstab.",
- "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 ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen."
+ "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 ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
+ "recoveryPageTitle": "Erholung",
+ "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm."
},
"stats": {
"label": "Zusammenfassende Statistik",
@@ -3583,7 +3586,45 @@
"customize": "Insights anpassen",
"regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
"regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.",
- "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
+ "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.",
+ "deviceScore": {
+ "learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.",
+ "average": "30-Tage-Durchschnitt {value}",
+ "unitScore": "Punkte",
+ "unitMinutes": "Min.",
+ "unitBpm": "bpm",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Schlafqualität",
+ "subtitle": "Nächtliche Werte deines Wearables"
+ },
+ "recovery": {
+ "title": "Erholung",
+ "description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
+ "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
+ "recharge": {
+ "title": "Recharge",
+ "subtitle": "Ladung des autonomen Nervensystems"
+ },
+ "strain": {
+ "title": "Belastung & Load",
+ "subtitle": "Tägliche kardiovaskuläre Belastung"
+ },
+ "cardio": {
+ "title": "Herz & Energie pro Tag",
+ "subtitle": "Herz- und Energiewerte des ganzen Tages"
+ },
+ "scoreLink": {
+ "title": "Erholungs-Score",
+ "subtitle": "Sieh dir an, wie sich der nächtliche Score zusammensetzt",
+ "cta": "Ansehen"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Körpertemperatur-Abweichung",
+ "subtitle": "Nächtliche Abweichung von deiner Basislinie"
+ }
},
"thresholds": {
"defaultLabel": "Default",
diff --git a/messages/en.json b/messages/en.json
index 8477957ff..e8c5a0052 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -249,7 +249,8 @@
"moodDescription": "Log your mood and tags"
},
"cycle": "Cycle",
- "labs": "Labs"
+ "labs": "Labs",
+ "recovery": "Recovery"
},
"auth": {
"login": "Sign in",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. 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, 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.",
"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, not someone else's baseline.",
- "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 calm, well-regulated state; a higher one is an invitation to build in recovery."
+ "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 calm, well-regulated state; a higher one is an invitation to build in recovery.",
+ "recoveryPageTitle": "Recovery",
+ "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm."
},
"stats": {
"label": "Summary statistics",
@@ -3583,7 +3586,45 @@
"customize": "Customize Insights",
"regenerateRateLimited": "Refresh limit reached — please try again later.",
"regenerateUnavailable": "No fresh analysis arrived — please try again later.",
- "regenerateError": "Still refreshing — please try again in a moment."
+ "regenerateError": "Still refreshing — please try again in a moment.",
+ "deviceScore": {
+ "learning": "Still learning your baseline — a few more nights will fill in the trend.",
+ "average": "30-day average {value}",
+ "unitScore": "score",
+ "unitMinutes": "min",
+ "unitBpm": "bpm",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Sleep quality",
+ "subtitle": "Nightly scores from your wearable"
+ },
+ "recovery": {
+ "title": "Recovery",
+ "description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
+ "empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
+ "recharge": {
+ "title": "Recharge",
+ "subtitle": "Autonomic-nervous-system charge"
+ },
+ "strain": {
+ "title": "Strain & load",
+ "subtitle": "Daily cardiovascular load"
+ },
+ "cardio": {
+ "title": "Daily heart & energy",
+ "subtitle": "Whole-day cardiac and energy totals"
+ },
+ "scoreLink": {
+ "title": "Recovery score",
+ "subtitle": "See how the nightly score breaks down",
+ "cta": "View"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Body temperature deviation",
+ "subtitle": "Overnight delta from your baseline"
+ }
},
"thresholds": {
"defaultLabel": "Default",
diff --git a/messages/es.json b/messages/es.json
index 4ac12939a..d0313d494 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -249,7 +249,8 @@
"moodDescription": "Registrar estado de ánimo y etiquetas"
},
"cycle": "Ciclo",
- "labs": "Análisis"
+ "labs": "Análisis",
+ "recovery": "Recuperación"
},
"auth": {
"login": "Iniciar sesión",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. 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, 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.",
"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, no frente a un estándar ajeno.",
- "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 calmado y bien regulado; una más alta es una invitación a incorporar recuperación."
+ "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 calmado y bien regulado; una más alta es una invitación a incorporar recuperación.",
+ "recoveryPageTitle": "Recuperación",
+ "recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma."
},
"stats": {
"label": "Estadísticas resumidas",
@@ -3583,7 +3586,45 @@
"customize": "Personalizar Análisis",
"regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
"regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.",
- "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
+ "regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento.",
+ "deviceScore": {
+ "learning": "Aún estamos aprendiendo tu línea base: unas noches más completarán la tendencia.",
+ "average": "Media de 30 días {value}",
+ "unitScore": "puntos",
+ "unitMinutes": "min",
+ "unitBpm": "ppm",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Calidad del sueño",
+ "subtitle": "Puntuaciones nocturnas de tu wearable"
+ },
+ "recovery": {
+ "title": "Recuperación",
+ "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
+ "empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
+ "recharge": {
+ "title": "Recarga",
+ "subtitle": "Carga del sistema nervioso autónomo"
+ },
+ "strain": {
+ "title": "Esfuerzo y carga",
+ "subtitle": "Carga cardiovascular diaria"
+ },
+ "cardio": {
+ "title": "Corazón y energía del día",
+ "subtitle": "Totales cardiacos y de energía de todo el día"
+ },
+ "scoreLink": {
+ "title": "Puntuación de recuperación",
+ "subtitle": "Mira cómo se descompone la puntuación nocturna",
+ "cta": "Ver"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Desviación de la temperatura corporal",
+ "subtitle": "Variación nocturna respecto a tu línea base"
+ }
},
"thresholds": {
"defaultLabel": "Por defecto",
diff --git a/messages/fr.json b/messages/fr.json
index a5d6fc975..08225d96e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -249,7 +249,8 @@
"moodDescription": "Enregistrer l'humeur et les tags"
},
"cycle": "Cycle",
- "labs": "Analyses"
+ "labs": "Analyses",
+ "recovery": "Récupération"
},
"auth": {
"login": "Se connecter",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. 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, à 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.",
"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, pas à un standard extérieur.",
- "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 calme et bien régulé ; un score plus élevé invite à prévoir de la récupération."
+ "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 calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.",
+ "recoveryPageTitle": "Récupération",
+ "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme."
},
"stats": {
"label": "Statistiques récapitulatives",
@@ -3583,7 +3586,45 @@
"customize": "Personnaliser les Analyses",
"regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
"regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.",
- "regenerateError": "Actualisation en cours — réessayez dans un instant."
+ "regenerateError": "Actualisation en cours — réessayez dans un instant.",
+ "deviceScore": {
+ "learning": "Nous apprenons encore votre référence : quelques nuits de plus compléteront la tendance.",
+ "average": "Moyenne sur 30 jours {value}",
+ "unitScore": "points",
+ "unitMinutes": "min",
+ "unitBpm": "bpm",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Qualité du sommeil",
+ "subtitle": "Scores nocturnes de votre wearable"
+ },
+ "recovery": {
+ "title": "Récupération",
+ "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
+ "empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
+ "recharge": {
+ "title": "Recharge",
+ "subtitle": "Charge du système nerveux autonome"
+ },
+ "strain": {
+ "title": "Effort et charge",
+ "subtitle": "Charge cardiovasculaire quotidienne"
+ },
+ "cardio": {
+ "title": "Cœur et énergie du jour",
+ "subtitle": "Totaux cardiaques et énergétiques de la journée"
+ },
+ "scoreLink": {
+ "title": "Score de récupération",
+ "subtitle": "Voyez comment se décompose le score nocturne",
+ "cta": "Voir"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Écart de température corporelle",
+ "subtitle": "Écart nocturne par rapport à votre référence"
+ }
},
"thresholds": {
"defaultLabel": "Par défaut",
diff --git a/messages/it.json b/messages/it.json
index 15cc4ed2e..bf88eeae6 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -249,7 +249,8 @@
"moodDescription": "Registra umore e tag"
},
"cycle": "Ciclo",
- "labs": "Esami"
+ "labs": "Esami",
+ "recovery": "Recupero"
},
"auth": {
"login": "Accedi",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. 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, 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.",
"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, non rispetto a uno standard esterno.",
- "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 calmo e ben regolato; uno più alto è un invito a inserire del recupero."
+ "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 calmo e ben regolato; uno più alto è un invito a inserire del recupero.",
+ "recoveryPageTitle": "Recupero",
+ "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme."
},
"stats": {
"label": "Statistiche riepilogative",
@@ -3583,7 +3586,45 @@
"customize": "Personalizza gli Approfondimenti",
"regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
"regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.",
- "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
+ "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.",
+ "deviceScore": {
+ "learning": "Stiamo ancora imparando la tua linea di base: qualche notte in più completerà l'andamento.",
+ "average": "Media a 30 giorni {value}",
+ "unitScore": "punti",
+ "unitMinutes": "min",
+ "unitBpm": "bpm",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Qualità del sonno",
+ "subtitle": "Punteggi notturni del tuo wearable"
+ },
+ "recovery": {
+ "title": "Recupero",
+ "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
+ "empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
+ "recharge": {
+ "title": "Ricarica",
+ "subtitle": "Carica del sistema nervoso autonomo"
+ },
+ "strain": {
+ "title": "Sforzo e carico",
+ "subtitle": "Carico cardiovascolare giornaliero"
+ },
+ "cardio": {
+ "title": "Cuore ed energia del giorno",
+ "subtitle": "Totali cardiaci ed energetici dell'intera giornata"
+ },
+ "scoreLink": {
+ "title": "Punteggio di recupero",
+ "subtitle": "Vedi come si scompone il punteggio notturno",
+ "cta": "Apri"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Deviazione della temperatura corporea",
+ "subtitle": "Scostamento notturno dalla tua linea di base"
+ }
},
"thresholds": {
"defaultLabel": "Predefinito",
diff --git a/messages/pl.json b/messages/pl.json
index 5b865a424..5f24789c1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -249,7 +249,8 @@
"moodDescription": "Zapisz nastrój i tagi"
},
"cycle": "Cykl",
- "labs": "Wyniki badań"
+ "labs": "Wyniki badań",
+ "recovery": "Regeneracja"
},
"auth": {
"login": "Zaloguj się",
@@ -2633,7 +2634,9 @@
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — 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.",
"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, a nie cudzej miary.",
- "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 spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację."
+ "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 spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.",
+ "recoveryPageTitle": "Regeneracja",
+ "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm."
},
"stats": {
"label": "Statystyki podsumowujące",
@@ -3583,7 +3586,45 @@
"customize": "Dostosuj Analizy",
"regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
"regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.",
- "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
+ "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.",
+ "deviceScore": {
+ "learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.",
+ "average": "Średnia z 30 dni {value}",
+ "unitScore": "pkt",
+ "unitMinutes": "min",
+ "unitBpm": "ud./min",
+ "unitKj": "kJ"
+ },
+ "sleepQuality": {
+ "title": "Jakość snu",
+ "subtitle": "Nocne wyniki z Twojego urządzenia"
+ },
+ "recovery": {
+ "title": "Regeneracja",
+ "description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
+ "empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
+ "recharge": {
+ "title": "Recharge",
+ "subtitle": "Ładowanie autonomicznego układu nerwowego"
+ },
+ "strain": {
+ "title": "Obciążenie i load",
+ "subtitle": "Dzienne obciążenie sercowo-naczyniowe"
+ },
+ "cardio": {
+ "title": "Serce i energia w ciągu dnia",
+ "subtitle": "Całodobowe sumy sercowe i energetyczne"
+ },
+ "scoreLink": {
+ "title": "Wynik regeneracji",
+ "subtitle": "Zobacz, jak rozkłada się nocny wynik",
+ "cta": "Zobacz"
+ }
+ },
+ "bodyTempDeviation": {
+ "title": "Odchylenie temperatury ciała",
+ "subtitle": "Nocne odchylenie od Twojej linii bazowej"
+ }
},
"thresholds": {
"defaultLabel": "Domyślne",
diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx
new file mode 100644
index 000000000..69af38d43
--- /dev/null
+++ b/src/components/insights/device-score-tile.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import type { ComponentType } from "react";
+
+import { useAuth } from "@/hooks/use-auth";
+import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
+import { useTranslations } from "@/lib/i18n/context";
+import type { DataSummary } from "@/lib/analytics/trends";
+import { cn } from "@/lib/utils";
+import { Card, CardContent } from "@/components/ui/card";
+import { LearningGate } from "@/components/ui/learning-gate";
+import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
+import { SectionHeading } from "@/components/insights/section-heading";
+
+/**
+ * v1.17.1 — calm, data-gated readout for a device-native score that has no
+ * generic assessment registry entry.
+ *
+ * The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
+ * charge, …) is ingested end-to-end and lit in the AI chart-token allowlist,
+ * but no page renders it. These metrics are deliberately OUTSIDE the
+ * `metric-status-registry` (they're vendor-rolled-up scores, not signals the
+ * app re-derives), so they carry no ``. This tile is the
+ * surface for them: a title, the latest reading, the trailing-window mean, and
+ * a small sparkline — server-authoritative throughout (it reads the stored
+ * `summaries[type]` slice; it never recomputes).
+ *
+ * Calm posture (per the maintainer's standing card rules): one neutral card,
+ * never an alarming colour, never a green-when-good / red-when-bad tint. The
+ * caller passes the single chart line colour; the card chrome stays neutral.
+ *
+ * Data-gating is the caller's job: mount this tile only when
+ * `summary.count > 0` (the parent section already reads the shared analytics
+ * payload, so this avoids a second fetch and keeps the empty surface clean for
+ * non-wearable users). When the series is present but still sparse
+ * (`count < learningThreshold`), the tile swaps the sparkline for a
+ * `` so a two-night-old strap never paints a jagged
+ * two-point line that reads as a trend.
+ */
+
+export interface DeviceScoreTileProps {
+ /** The MeasurementType backing the readout + sparkline. */
+ type: string;
+ /** The stored summary slice for `type` (count / latest / mean). */
+ summary: DataSummary | null | undefined;
+ /** Localized tile title (e.g. the `measurements.type*` label). */
+ title: string;
+ /** Leading glyph. */
+ icon: ComponentType<{ className?: string }>;
+ /** Single chart-line colour. Never used for card chrome. */
+ color: string;
+ /** Unit suffix on the readout + chart (e.g. "%", "bpm", "kJ", "score"). */
+ unit?: string;
+ /** Optional decimal precision for the readout. Defaults to 0. */
+ fractionDigits?: number;
+ /**
+ * Below this many readings the tile shows the `` instead of a
+ * sparkline. Defaults to 3 — enough points for a line to read honestly.
+ */
+ learningThreshold?: number;
+ className?: string;
+}
+
+export function DeviceScoreTile({
+ type,
+ summary,
+ title,
+ icon: Icon,
+ color,
+ unit,
+ fractionDigits = 0,
+ learningThreshold = 3,
+ className,
+}: DeviceScoreTileProps) {
+ const { t } = useTranslations();
+ const { user } = useAuth();
+
+ const count = summary?.count ?? 0;
+ if (count === 0) return null;
+
+ const latest = summary?.latest ?? null;
+ const mean = summary?.mean ?? null;
+ const isLearning = count < learningThreshold;
+
+ const fmt = (value: number) =>
+ value.toLocaleString(undefined, {
+ minimumFractionDigits: fractionDigits,
+ maximumFractionDigits: fractionDigits,
+ });
+
+ return (
+
+
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
+
+export interface ConnectedDeviceScoreTileProps
+ extends Omit {
+ /** Optional section heading rendered above the tile when it has data. */
+ sectionTitle?: string;
+ /** Optional Lucide glyph for the section heading. */
+ sectionIcon?: ComponentType<{ className?: string }>;
+ /** Optional section subtitle pinned to the heading's right edge. */
+ sectionSubtitle?: string;
+}
+
+/**
+ * v1.17.1 — a {@link DeviceScoreTile} that reads its own summary from the
+ * shared `["analytics", "summaries"]` slice, so it can be dropped onto a page
+ * that uses the `HealthKitMetricPage` scaffold (which owns its own analytics
+ * read internally) without threading a summary prop down. Reuses the same
+ * cache the host page already populated — no extra round-trip. Returns null
+ * until the data lands and when the metric has no readings, so a host page
+ * stays byte-identical for users without that device signal.
+ */
+export function ConnectedDeviceScoreTile({
+ type,
+ sectionTitle,
+ sectionIcon,
+ sectionSubtitle,
+ ...tileProps
+}: ConnectedDeviceScoreTileProps) {
+ const { data } = useInsightsAnalytics("SLEEP_DURATION");
+ const summary = data?.summaries?.[type];
+
+ if ((summary?.count ?? 0) === 0) return null;
+
+ const tile = (
+
+ );
+
+ if (!sectionTitle || !sectionIcon) return tile;
+
+ return (
+
+ {sectionSubtitle}
+ ) : undefined
+ }
+ />
+ {tile}
+
+ );
+}
From e063f79353371a63a96d3d1b30d9323fac82b4b1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:39:07 +0200
Subject: [PATCH 2/5] feat(sleep): surface the nightly sleep-quality scores on
the Sleep page
WHOOP and Oura write sleep efficiency, performance, consistency, need, the
disturbance count, and the Oura sleep score every night; until now none of them
rendered anywhere. Add a Sleep quality block after the rhythm section that
surfaces all six through the device-score tile.
Each tile is data-gated, so a non-wearable account never sees an empty card and
the whole block collapses metric-by-metric down to nothing when no source
writes them. It reads the same analytics summaries slice the page already
fetched for its duration gate, so it costs no extra round-trip.
---
src/app/insights/sleep/page.tsx | 11 ++
.../insights/sleep/sleep-quality-section.tsx | 114 ++++++++++++++++++
2 files changed, 125 insertions(+)
create mode 100644 src/components/insights/sleep/sleep-quality-section.tsx
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index 1cac47cd7..8b2845275 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -13,6 +13,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
+import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -89,6 +90,16 @@ export default function InsightsSchlafPage() {
*/}
+ {/*
+ v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
+ sleep-quality scores (efficiency / performance / consistency / need /
+ disturbance count + the Oura sleep score) that were ingested but never
+ rendered. Each tile is data-gated, so the block is invisible for
+ non-wearable users and collapses metric-by-metric. Server-authoritative
+ — the tiles render stored values.
+ */}
+
+
{/*
diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx
new file mode 100644
index 000000000..0b6a817ed
--- /dev/null
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
+
+import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
+import { useTranslations } from "@/lib/i18n/context";
+import { SectionHeading } from "@/components/insights/section-heading";
+import { DeviceScoreTile } from "@/components/insights/device-score-tile";
+
+/**
+ * v1.17.1 — "Sleep quality" block on `/insights/sleep`.
+ *
+ * Surfaces the WHOOP / Oura nightly sleep-quality scores that were ingested
+ * end-to-end but rendered nowhere: efficiency, performance, consistency, need,
+ * disturbance count, and the Oura headline sleep score. Each tile is gated on
+ * the metric having data (`` returns null at count 0), so a
+ * non-wearable user never sees an empty card and the whole section collapses
+ * when none of the six has readings.
+ *
+ * Reads the shared `["analytics", "summaries"]` slice — the same cache the
+ * page's duration gate already populated, so this adds no network round-trip.
+ * Server-authoritative: the tiles render the stored values, never a recompute.
+ */
+export function SleepQualitySection({ enabled }: { enabled: boolean }) {
+ const { t } = useTranslations();
+ const { data } = useInsightsAnalytics("SLEEP_DURATION");
+
+ if (!enabled) return null;
+
+ const summaries = data?.summaries;
+ if (!summaries) return null;
+
+ const tiles: Array<{
+ type: string;
+ title: string;
+ icon: typeof Moon;
+ color: string;
+ unit?: string;
+ }> = [
+ {
+ type: "SLEEP_SCORE",
+ title: t("measurements.typeSleepScore"),
+ icon: Moon,
+ color: "#bd93f9",
+ unit: t("insights.deviceScore.unitScore"),
+ },
+ {
+ type: "SLEEP_PERFORMANCE",
+ title: t("measurements.typeSleepPerformance"),
+ icon: Target,
+ color: "#8be9fd",
+ unit: "%",
+ },
+ {
+ type: "SLEEP_EFFICIENCY",
+ title: t("measurements.typeSleepEfficiency"),
+ icon: Gauge,
+ color: "#50fa7b",
+ unit: "%",
+ },
+ {
+ type: "SLEEP_CONSISTENCY",
+ title: t("measurements.typeSleepConsistency"),
+ icon: Repeat,
+ color: "#f1fa8c",
+ unit: "%",
+ },
+ {
+ type: "SLEEP_NEED",
+ title: t("measurements.typeSleepNeed"),
+ icon: Waves,
+ color: "#ffb86c",
+ unit: t("insights.deviceScore.unitMinutes"),
+ },
+ {
+ type: "SLEEP_DISTURBANCE_COUNT",
+ title: t("measurements.typeSleepDisturbanceCount"),
+ icon: Activity,
+ color: "#ff79c6",
+ },
+ ];
+
+ const present = tiles.filter(
+ (tile) => (summaries[tile.type]?.count ?? 0) > 0,
+ );
+ if (present.length === 0) return null;
+
+ return (
+
+
+ {t("insights.sleepQuality.subtitle")}
+
+ }
+ />
+
+ {present.map((tile) => (
+
+ ))}
+
+
+ );
+}
From 866f3dc0e2a24d86859362f1687ea3cc60a1a29e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:39:18 +0200
Subject: [PATCH 3/5] feat(insights): add a Recovery sub-page for the
device-native strain scores
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WHOOP and Polar record day strain, workout strain, ANS charge, cardio load, and
the day's whole-cycle average / max heart rate and kilojoule energy expenditure
— all stored end-to-end with no render host. The composite recovery score had
only its anatomy detail at /insights/scores/recovery; the raw signals around it
appeared nowhere.
Add /insights/recovery as the home for them, grouped into recharge, strain &
load, and the day's heart & energy. Each block is data-gated, so an account
without a strap or ring lands on a calm empty note rather than a broken surface.
When a recovery score is present the page cross-links to its anatomy view rather
than duplicating the ring. Server-authoritative throughout.
Give the page a single nav home alongside Workouts in the unified destination
model so it is discoverable on both the sidebar and the mobile More hub.
---
src/app/insights/recovery/page.tsx | 34 +++
.../insights/recovery/recovery-section.tsx | 226 ++++++++++++++++++
src/components/layout/nav-model.ts | 12 +
3 files changed, 272 insertions(+)
create mode 100644 src/app/insights/recovery/page.tsx
create mode 100644 src/components/insights/recovery/recovery-section.tsx
diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx
new file mode 100644
index 000000000..31125cec1
--- /dev/null
+++ b/src/app/insights/recovery/page.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { SubPageShell } from "@/components/insights/sub-page-shell";
+import { RecoverySection } from "@/components/insights/recovery/recovery-section";
+
+/**
+ * v1.17.1 — `/insights/recovery`.
+ *
+ * The render host for the WHOOP / Polar device-native recovery + strain scores
+ * that were stored end-to-end but had no page: day strain, workout strain, ANS
+ * charge, cardio load, and the day's whole-cycle average / max heart rate and
+ * kilojoule energy expenditure. The composite RECOVERY_SCORE keeps its anatomy
+ * detail at `/insights/scores/recovery`; this page cross-links to it and
+ * surrounds it with the raw signals it never surfaced.
+ *
+ * Every block is data-gated inside ``, so an account without a
+ * strap / ring sees only the calm empty note. Server-authoritative: the tiles
+ * render stored values, never a recompute.
+ */
+export default function InsightsRecoveryPage() {
+ const { t } = useTranslations();
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx
new file mode 100644
index 000000000..4f423cc45
--- /dev/null
+++ b/src/components/insights/recovery/recovery-section.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import Link from "next/link";
+import {
+ Activity,
+ Battery,
+ Flame,
+ Gauge,
+ Heart,
+ HeartPulse,
+ TrendingUp,
+} from "lucide-react";
+
+import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
+import { useTranslations } from "@/lib/i18n/context";
+import { SectionHeading } from "@/components/insights/section-heading";
+import { DeviceScoreTile } from "@/components/insights/device-score-tile";
+
+interface TileSpec {
+ type: string;
+ title: string;
+ icon: typeof Activity;
+ color: string;
+ unit?: string;
+ fractionDigits?: number;
+}
+
+interface TileGroup {
+ key: string;
+ headingIcon: typeof Activity;
+ title: string;
+ subtitle: string;
+ tiles: TileSpec[];
+}
+
+/**
+ * v1.17.1 — body of the `/insights/recovery` sub-page.
+ *
+ * The home for the WHOOP / Polar device-native recovery + strain scores that
+ * were ingested but had no render host: day strain, workout strain, ANS charge,
+ * cardio load, plus the day's whole-cycle average / max heart rate and the
+ * kilojoule energy-expenditure figure. The composite RECOVERY_SCORE keeps its
+ * own anatomy view at `/insights/scores/recovery`; this page cross-links to it
+ * rather than duplicating the ring, and surrounds it with the raw device
+ * signals it never showed.
+ *
+ * Each tile is data-gated (`` returns null at count 0) and
+ * each group hides when none of its tiles has data, so a non-wearable account
+ * sees only the calm empty state below. Reads the shared analytics summaries
+ * slice (no extra round-trip); server-authoritative throughout.
+ */
+export function RecoverySection() {
+ const { t } = useTranslations();
+ const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
+
+ const summaries = data?.summaries;
+
+ const groups: TileGroup[] = [
+ {
+ key: "recharge",
+ headingIcon: Battery,
+ title: t("insights.recovery.recharge.title"),
+ subtitle: t("insights.recovery.recharge.subtitle"),
+ tiles: [
+ {
+ type: "ANS_CHARGE",
+ title: t("measurements.typeAnsCharge"),
+ icon: Battery,
+ color: "#50fa7b",
+ unit: t("insights.deviceScore.unitScore"),
+ fractionDigits: 1,
+ },
+ ],
+ },
+ {
+ key: "strain",
+ headingIcon: Gauge,
+ title: t("insights.recovery.strain.title"),
+ subtitle: t("insights.recovery.strain.subtitle"),
+ tiles: [
+ {
+ type: "DAY_STRAIN",
+ title: t("measurements.typeDayStrain"),
+ icon: Gauge,
+ color: "#ffb86c",
+ unit: t("insights.deviceScore.unitScore"),
+ fractionDigits: 1,
+ },
+ {
+ type: "WORKOUT_STRAIN",
+ title: t("measurements.typeWorkoutStrain"),
+ icon: Activity,
+ color: "#ff79c6",
+ unit: t("insights.deviceScore.unitScore"),
+ fractionDigits: 1,
+ },
+ {
+ type: "CARDIO_LOAD",
+ title: t("measurements.typeCardioLoad"),
+ icon: TrendingUp,
+ color: "#bd93f9",
+ unit: t("insights.deviceScore.unitScore"),
+ },
+ ],
+ },
+ {
+ key: "cardio",
+ headingIcon: HeartPulse,
+ title: t("insights.recovery.cardio.title"),
+ subtitle: t("insights.recovery.cardio.subtitle"),
+ tiles: [
+ {
+ type: "AVERAGE_HEART_RATE",
+ title: t("measurements.typeAverageHeartRate"),
+ icon: Heart,
+ color: "#8be9fd",
+ unit: t("insights.deviceScore.unitBpm"),
+ },
+ {
+ type: "MAX_HEART_RATE",
+ title: t("measurements.typeMaxHeartRate"),
+ icon: HeartPulse,
+ color: "#ff5555",
+ unit: t("insights.deviceScore.unitBpm"),
+ },
+ {
+ type: "ENERGY_EXPENDITURE_KJ",
+ title: t("measurements.typeEnergyExpenditureKj"),
+ icon: Flame,
+ color: "#f1fa8c",
+ unit: t("insights.deviceScore.unitKj"),
+ },
+ ],
+ },
+ ];
+
+ const hasAny =
+ summaries != null &&
+ groups.some((group) =>
+ group.tiles.some((tile) => (summaries[tile.type]?.count ?? 0) > 0),
+ );
+
+ const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
+
+ if (!isLoading && summaries != null && !hasAny && !hasRecoveryScore) {
+ return (
+