merge(insights): surface stored sleep/recovery/strain metrics and a recovery sub-page

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:40:27 +02:00
commit 168b69b334
14 files changed
+1087 -19

No files matched your search

+44 -3
View File
@@ -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",
+44 -3
View File
@@ -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",
+44 -3
View File
@@ -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",
+44 -3
View File
@@ -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",
+44 -3
View File
@@ -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",
+44 -3
View File
@@ -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",
+34
View File
@@ -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 `<RecoverySection>`, 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 (
<SubPageShell
title={t("insights.recovery.title")}
description={t("insights.recovery.description")}
explainerMetric="recoveryPage"
coachLaunch
>
<RecoverySection />
</SubPageShell>
);
}
+22 -1
View File
@@ -1,8 +1,10 @@
"use client";
import { Thermometer } from "lucide-react";
import { Thermometer, ThermometerSnowflake } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { ConnectedDeviceScoreTile } from "@/components/insights/device-score-tile";
/**
* v1.7.0 — `/insights/skin-temperature`.
@@ -11,8 +13,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*
* v1.17.1 — the Oura overnight body-temperature DEVIATION (a signed delta from
* the personal baseline, an illness / cycle signal) joins this metabolic page
* as a gated tile beneath the chart. It only mounts when the Oura series has
* data, so a non-Oura account renders the page byte-identically.
*/
export default function InsightsHauttemperaturPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="SKIN_TEMPERATURE"
@@ -27,6 +35,19 @@ export default function InsightsHauttemperaturPage() {
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
afterChart={
<ConnectedDeviceScoreTile
type="BODY_TEMPERATURE_DEVIATION"
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="#8be9fd"
unit="°C"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
sectionIcon={ThermometerSnowflake}
sectionSubtitle={t("insights.bodyTempDeviation.subtitle")}
/>
}
/>
);
}
+11
View File
@@ -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() {
*/}
<SleepRhythmSection enabled={!isEmpty} />
{/*
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.
*/}
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.17.1 — the WHOOP / Oura / Polar device-native score surfaces.
*
* `<SleepQualitySection>` (sleep page) and `<RecoverySection>`
* (`/insights/recovery`) surface metrics that were ingested end-to-end but
* never rendered. The contract under test is the DATA-GATING: a tile / section
* appears ONLY when its metric has stored readings, so a non-wearable account
* never sees an empty card. `<DeviceScoreTile>` carries the same gate plus a
* sparse-data `<LearningGate>` swap.
*/
// The mini chart is `next/dynamic`-imported; SSR renders the stub.
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="mini-chart-stub" />;
Stub.displayName = "MiniChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC" },
isAuthenticated: true,
}),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import { DeviceScoreTile } from "../device-score-tile";
import { SleepQualitySection } from "../sleep/sleep-quality-section";
import { RecoverySection } from "../recovery/recovery-section";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
function summary(over: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
...over,
};
}
function analyticsWith(summaries: Record<string, DataSummary>) {
return {
data: { summaries },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
};
}
beforeEach(() => {
analyticsMock.mockReset();
});
describe("<DeviceScoreTile> gating", () => {
it("renders nothing when the metric has no readings", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 0 })}
title="Sleep efficiency"
icon={() => null}
color="#50fa7b"
unit="%"
/>,
);
expect(html).toBe("");
});
it("shows the latest reading + sparkline once it has enough data", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 12, latest: 91, mean: 88 })}
title="Sleep efficiency"
icon={() => null}
color="#50fa7b"
unit="%"
/>,
);
expect(html).toContain('data-slot="device-score-tile"');
expect(html).toContain('data-slot="device-score-latest"');
expect(html).toContain("91");
expect(html).toContain('data-slot="mini-chart-stub"');
// No learning gate once past the threshold.
expect(html).not.toContain('data-slot="device-score-learning"');
});
it("swaps the sparkline for the LearningGate while the series is sparse", () => {
const html = render(
<DeviceScoreTile
type="DAY_STRAIN"
summary={summary({ count: 2, latest: 9, mean: 8 })}
title="Day strain"
icon={() => null}
color="#ffb86c"
/>,
);
expect(html).toContain('data-slot="device-score-learning"');
expect(html).not.toContain('data-slot="mini-chart-stub"');
});
});
describe("<SleepQualitySection> data-gating", () => {
it("collapses to nothing when no sleep-quality metric has data", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 30 }) }),
);
const html = render(<SleepQualitySection enabled />);
expect(html).not.toContain('data-slot="sleep-quality-section"');
});
it("renders only the metrics that have readings", () => {
analyticsMock.mockReturnValue(
analyticsWith({
SLEEP_EFFICIENCY: summary({ count: 20, latest: 92, mean: 90 }),
SLEEP_SCORE: summary({ count: 0 }),
}),
);
const html = render(<SleepQualitySection enabled />);
expect(html).toContain('data-slot="sleep-quality-section"');
expect(html).toContain('data-metric="SLEEP_EFFICIENCY"');
expect(html).not.toContain('data-metric="SLEEP_SCORE"');
});
it("renders nothing when disabled", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_EFFICIENCY: summary({ count: 20 }) }),
);
const html = render(<SleepQualitySection enabled={false} />);
expect(html).toBe("");
});
});
describe("<RecoverySection> data-gating", () => {
it("shows the calm empty note when there is no recovery data at all", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }),
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
it("renders the strain group and cross-links the recovery score when present", () => {
analyticsMock.mockReturnValue(
analyticsWith({
DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }),
RECOVERY_SCORE: summary({ count: 14, latest: 66, mean: 60 }),
ANS_CHARGE: summary({ count: 0 }),
}),
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-group-strain"');
expect(html).toContain('data-metric="DAY_STRAIN"');
expect(html).toContain('data-slot="recovery-score-link"');
// The recharge group (ANS charge only) has no data → hidden.
expect(html).not.toContain('data-slot="recovery-group-recharge"');
// Empty note must not show when at least one signal is present.
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
@@ -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 `<MetricStatusCard>`. 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
* `<LearningGate>` 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 `<LearningGate>` 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 (
<Card
data-slot="device-score-tile"
data-metric={type}
className={cn("gap-2 py-3 md:py-4", className)}
>
<CardContent className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate text-sm font-medium">{title}</span>
</div>
{latest != null ? (
<span
data-slot="device-score-latest"
className="text-foreground shrink-0 text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
</span>
) : null}
</span>
) : null}
</div>
{isLearning ? (
<LearningGate
compact
bodySlot="device-score-learning"
message={t("insights.deviceScore.learning")}
/>
) : (
<>
<div className="h-[120px] [--chart-height:96px]">
<HealthChartDynamicMini
types={[type]}
title={title}
colors={[color]}
unit={unit ?? ""}
mini
userTimezone={user?.timezone}
/>
</div>
{mean != null ? (
<p
data-slot="device-score-mean"
className="text-muted-foreground text-xs"
>
{t("insights.deviceScore.average", { value: fmt(mean) })}
{unit ? ` ${unit}` : ""}
</p>
) : null}
</>
)}
</CardContent>
</Card>
);
}
export interface ConnectedDeviceScoreTileProps
extends Omit<DeviceScoreTileProps, "summary"> {
/** 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 = (
<DeviceScoreTile type={type} summary={summary} {...tileProps} />
);
if (!sectionTitle || !sectionIcon) return tile;
return (
<section
data-slot="connected-device-score"
data-metric={type}
className="space-y-3"
>
<SectionHeading
icon={sectionIcon}
title={sectionTitle}
action={
sectionSubtitle ? (
<p className="text-muted-foreground text-xs">{sectionSubtitle}</p>
) : undefined
}
/>
{tile}
</section>
);
}
@@ -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 (`<DeviceScoreTile>` 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 (
<div
data-slot="recovery-empty"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.recovery.empty")}
</div>
);
}
return (
<div className="space-y-8" data-slot="recovery-section">
{hasRecoveryScore ? (
<Link
href="/insights/scores/recovery"
data-slot="recovery-score-link"
className="bg-card border-border hover:bg-accent/40 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors"
>
<span className="flex min-w-0 items-center gap-2">
<Heart
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">
{t("insights.recovery.scoreLink.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("insights.recovery.scoreLink.subtitle")}
</span>
</span>
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("insights.recovery.scoreLink.cta")}
</span>
</Link>
) : null}
{groups.map((group) => {
const present =
summaries == null
? []
: group.tiles.filter(
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
);
if (present.length === 0) return null;
return (
<section
key={group.key}
data-slot={`recovery-group-${group.key}`}
className="space-y-3"
>
<SectionHeading
icon={group.headingIcon}
title={group.title}
action={
<p className="text-muted-foreground text-xs">
{group.subtitle}
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries?.[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
fractionDigits={tile.fractionDigits}
/>
))}
</div>
</section>
);
})}
</div>
);
}
@@ -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 (`<DeviceScoreTile>` 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 (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
icon={Moon}
title={t("insights.sleepQuality.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.sleepQuality.subtitle")}
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
/>
))}
</div>
</section>
);
}
+12
View File
@@ -3,6 +3,7 @@ import {
Droplets,
Dumbbell,
FlaskConical,
HeartPulse,
Home,
Lightbulb,
MessagesSquare,
@@ -82,6 +83,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
icon: Dumbbell,
tourId: "nav-workouts",
},
// v1.17.1 — the Recovery surface gets a nav home alongside Workouts. It
// hosts the WHOOP / Polar device-native recovery + strain scores that were
// stored end-to-end but had no page. Like Workouts it is shown to every
// account (the page itself data-gates each block, so a non-wearable user
// lands on a calm empty note rather than a broken surface).
{
href: "/insights/recovery",
tKey: "nav.recovery",
icon: HeartPulse,
tourId: "nav-recovery",
},
{
href: "/insights",
tKey: "nav.insights",