diff --git a/messages/de.json b/messages/de.json index 465d77959..cc51cea47 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2029,7 +2029,8 @@ "distance": "Geh- und Laufstrecke der letzten 30 Tage.", "vo2_max": "VO₂max der letzten 30 Tage.", "body_temp": "Körpertemperatur der letzten 30 Tage." - } + }, + "viewDetail": "{metric} im Detail" }, "vo2Max": { "statLatest": "Aktuell", @@ -2547,7 +2548,8 @@ "asleepTotal": "Geschlafen {value}", "inBedTotal": "Im Bett {value}", "awakenings": "{count} Wachphasen", - "source": "Quelle: {source}" + "source": "Quelle: {source}", + "estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf." }, "stages": { "deep": "Tief", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Chronotyp", "learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.", - "midSleepCaption": "Schlafmitte an freien Tagen gegen {clock}", + "midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.", "advancedToggle": "Mehr Details", "socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag", "socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.", @@ -2587,7 +2589,8 @@ "intermediate": "Mischtyp", "late": "Abendtyp", "extreme_late": "Ausgeprägter Abendtyp" - } + }, + "cornerLabel": "Chronotyp" }, "rhythm": { "loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Schlafqualität", - "subtitle": "Nächtliche Werte deines Wearables" + "subtitle": "Nächtliche Werte deines Wearables", + "assessment": { + "metric": { + "SLEEP_SCORE": "dein Gesamt-Schlafscore", + "SLEEP_EFFICIENCY": "die Schlafeffizienz", + "SLEEP_PERFORMANCE": "die Schlafleistung", + "SLEEP_CONSISTENCY": "die Schlafkonstanz" + }, + "grade": { + "excellent": "ausgezeichnet", + "good": "solide", + "fair": "durchschnittlich", + "low": "eher niedrig" + }, + "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.", + "closing": { + "excellent": "Behalte die Routine bei, die dich hierher bringt.", + "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.", + "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.", + "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel." + } + } }, "recovery": { "title": "Erholung", "description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "emptyTitle": "Noch keine Erholungsdaten", "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" - }, "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", diff --git a/messages/en.json b/messages/en.json index b34ca31d5..68e3b250a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2029,7 +2029,8 @@ "distance": "Walking and running distance over the last 30 days.", "vo2_max": "VO₂ max over the last 30 days.", "body_temp": "Body temperature over the last 30 days." - } + }, + "viewDetail": "View {metric} details" }, "vo2Max": { "statLatest": "Latest", @@ -2547,7 +2548,8 @@ "asleepTotal": "Asleep {value}", "inBedTotal": "In bed {value}", "awakenings": "{count} awakenings", - "source": "Source: {source}" + "source": "Source: {source}", + "estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline." }, "stages": { "deep": "Deep", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Chronotype", "learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.", - "midSleepCaption": "Mid-sleep on free days around {clock}", + "midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.", "advancedToggle": "Advanced", "socialJetlagValue": "{hours}h {minutes}m social jetlag", "socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.", @@ -2587,7 +2589,8 @@ "intermediate": "Intermediate type", "late": "Evening type", "extreme_late": "Strong evening type" - } + }, + "cornerLabel": "Chronotype" }, "rhythm": { "loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Sleep quality", - "subtitle": "Nightly scores from your wearable" + "subtitle": "Nightly scores from your wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "your overall sleep score", + "SLEEP_EFFICIENCY": "sleep efficiency", + "SLEEP_PERFORMANCE": "sleep performance", + "SLEEP_CONSISTENCY": "sleep consistency" + }, + "grade": { + "excellent": "excellent", + "good": "solid", + "fair": "fair", + "low": "on the low side" + }, + "clause": "Over the last 30 nights {metric} is {grade} at {value}.", + "closing": { + "excellent": "Keep the routine that is getting you here.", + "good": "A steadier bed and wake time is the usual next gain.", + "fair": "Protecting a consistent sleep window tends to lift this most.", + "low": "A regular wind-down and a fixed wake time are the first levers to pull." + } + } }, "recovery": { "title": "Recovery", "description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.", "emptyTitle": "No recovery data yet", "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" - }, "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.", "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.", diff --git a/messages/es.json b/messages/es.json index ce1b3b594..3b73054f9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2029,7 +2029,8 @@ "distance": "Distancia caminando y corriendo de los últimos 30 días.", "vo2_max": "VO₂ máx de los últimos 30 días.", "body_temp": "Temperatura corporal de los últimos 30 días." - } + }, + "viewDetail": "Ver detalles de {metric}" }, "vo2Max": { "statLatest": "Actual", @@ -2547,7 +2548,8 @@ "asleepTotal": "Dormido {value}", "inBedTotal": "En la cama {value}", "awakenings": "{count} despertares", - "source": "Fuente: {source}" + "source": "Fuente: {source}", + "estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida." }, "stages": { "deep": "Profundo", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Cronotipo", "learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.", - "midSleepCaption": "Punto medio del sueño en días libres en torno a las {clock}", + "midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.", "advancedToggle": "Avanzado", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", "socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.", @@ -2587,7 +2589,8 @@ "intermediate": "Intermedio", "late": "Vespertino", "extreme_late": "Vespertino marcado" - } + }, + "cornerLabel": "Cronotipo" }, "rhythm": { "loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Calidad del sueño", - "subtitle": "Puntuaciones nocturnas de tu wearable" + "subtitle": "Puntuaciones nocturnas de tu wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "tu puntuación global de sueño", + "SLEEP_EFFICIENCY": "la eficiencia del sueño", + "SLEEP_PERFORMANCE": "el rendimiento del sueño", + "SLEEP_CONSISTENCY": "la regularidad del sueño" + }, + "grade": { + "excellent": "excelente", + "good": "sólida", + "fair": "aceptable", + "low": "más bien baja" + }, + "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.", + "closing": { + "excellent": "Mantén la rutina que te trae hasta aquí.", + "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.", + "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.", + "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas." + } + } }, "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.", "emptyTitle": "Aún no hay datos de recuperación", "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" - }, "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.", diff --git a/messages/fr.json b/messages/fr.json index 5da674e6b..9bec493ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2029,7 +2029,8 @@ "distance": "Distance de marche et de course des 30 derniers jours.", "vo2_max": "VO₂ max des 30 derniers jours.", "body_temp": "Température corporelle des 30 derniers jours." - } + }, + "viewDetail": "Voir le détail de {metric}" }, "vo2Max": { "statLatest": "Actuel", @@ -2547,7 +2548,8 @@ "asleepTotal": "Sommeil {value}", "inBedTotal": "Au lit {value}", "awakenings": "{count} réveils", - "source": "Source : {source}" + "source": "Source : {source}", + "estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée." }, "stages": { "deep": "Profond", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Chronotype", "learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.", - "midSleepCaption": "Milieu de sommeil les jours libres vers {clock}", + "midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.", "advancedToggle": "Avancé", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", "socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.", @@ -2587,7 +2589,8 @@ "intermediate": "Type intermédiaire", "late": "Type du soir", "extreme_late": "Type du soir marqué" - } + }, + "cornerLabel": "Chronotype" }, "rhythm": { "loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Qualité du sommeil", - "subtitle": "Scores nocturnes de votre wearable" + "subtitle": "Scores nocturnes de votre wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "votre score de sommeil global", + "SLEEP_EFFICIENCY": "l’efficacité du sommeil", + "SLEEP_PERFORMANCE": "la performance de sommeil", + "SLEEP_CONSISTENCY": "la régularité du sommeil" + }, + "grade": { + "excellent": "excellente", + "good": "solide", + "fair": "correcte", + "low": "plutôt faible" + }, + "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.", + "closing": { + "excellent": "Gardez la routine qui vous mène jusque-là.", + "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.", + "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.", + "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers." + } + } }, "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.", "emptyTitle": "Pas encore de données de récupération", "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" - }, "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.", diff --git a/messages/it.json b/messages/it.json index d9d741316..c05fab90f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2029,7 +2029,8 @@ "distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.", "vo2_max": "VO₂ max degli ultimi 30 giorni.", "body_temp": "Temperatura corporea degli ultimi 30 giorni." - } + }, + "viewDetail": "Vedi i dettagli di {metric}" }, "vo2Max": { "statLatest": "Attuale", @@ -2547,7 +2548,8 @@ "asleepTotal": "Sonno {value}", "inBedTotal": "A letto {value}", "awakenings": "{count} risvegli", - "source": "Fonte: {source}" + "source": "Fonte: {source}", + "estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata." }, "stages": { "deep": "Profondo", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Cronotipo", "learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.", - "midSleepCaption": "Metà del sonno nei giorni liberi intorno alle {clock}", + "midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.", "advancedToggle": "Avanzate", "socialJetlagValue": "{hours} h {minutes} min di jet lag sociale", "socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.", @@ -2587,7 +2589,8 @@ "intermediate": "Tipo intermedio", "late": "Tipo serale", "extreme_late": "Tipo serale marcato" - } + }, + "cornerLabel": "Cronotipo" }, "rhythm": { "loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Qualità del sonno", - "subtitle": "Punteggi notturni del tuo wearable" + "subtitle": "Punteggi notturni del tuo wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "il tuo punteggio di sonno complessivo", + "SLEEP_EFFICIENCY": "l’efficienza del sonno", + "SLEEP_PERFORMANCE": "la prestazione del sonno", + "SLEEP_CONSISTENCY": "la regolarità del sonno" + }, + "grade": { + "excellent": "eccellente", + "good": "solida", + "fair": "discreta", + "low": "piuttosto bassa" + }, + "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.", + "closing": { + "excellent": "Mantieni la routine che ti porta fin qui.", + "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.", + "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.", + "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve." + } + } }, "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.", "emptyTitle": "Ancora nessun dato di recupero", "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" - }, "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.", diff --git a/messages/pl.json b/messages/pl.json index f71af65cd..62f1c1acd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2029,7 +2029,8 @@ "distance": "Dystans marszu i biegu z ostatnich 30 dni.", "vo2_max": "VO₂ max z ostatnich 30 dni.", "body_temp": "Temperatura ciała z ostatnich 30 dni." - } + }, + "viewDetail": "Zobacz szczegóły: {metric}" }, "vo2Max": { "statLatest": "Aktualne", @@ -2547,7 +2548,8 @@ "asleepTotal": "Sen {value}", "inBedTotal": "W łóżku {value}", "awakenings": "{count} przebudzeń", - "source": "Źródło: {source}" + "source": "Źródło: {source}", + "estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg." }, "stages": { "deep": "Głęboki", @@ -2574,7 +2576,7 @@ "chronotype": { "title": "Chronotyp", "learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.", - "midSleepCaption": "Środek snu w dni wolne około {clock}", + "midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.", "advancedToggle": "Zaawansowane", "socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego", "socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.", @@ -2587,7 +2589,8 @@ "intermediate": "Typ pośredni", "late": "Typ wieczorny", "extreme_late": "Wyraźny typ wieczorny" - } + }, + "cornerLabel": "Chronotyp" }, "rhythm": { "loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje." @@ -3479,25 +3482,34 @@ }, "sleepQuality": { "title": "Jakość snu", - "subtitle": "Nocne wyniki z Twojego urządzenia" + "subtitle": "Nocne wyniki z Twojego urządzenia", + "assessment": { + "metric": { + "SLEEP_SCORE": "Twój ogólny wynik snu", + "SLEEP_EFFICIENCY": "efektywność snu", + "SLEEP_PERFORMANCE": "wydajność snu", + "SLEEP_CONSISTENCY": "regularność snu" + }, + "grade": { + "excellent": "doskonała", + "good": "solidna", + "fair": "przeciętna", + "low": "raczej niska" + }, + "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.", + "closing": { + "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.", + "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.", + "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.", + "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie." + } + } }, "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.", "emptyTitle": "Brak danych o regeneracji", "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" - }, "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.", diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 73ae8e968..0018b2cab 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -206,7 +206,7 @@ describe(" data-gating", () => { // Routed through the shared (dashed bordered card), not the // old hand-rolled div — the empty title comes from the unified primitive. expect(html).toContain("border-dashed"); - expect(html).not.toContain('data-slot="recovery-group-strain"'); + expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); }); it("renders a canonical chart block for the present signal", () => { @@ -218,13 +218,18 @@ describe(" data-gating", () => { }), ); const html = render(); - expect(html).toContain('data-slot="recovery-group-strain"'); + // v1.18.6 — the per-group sub-headings are gone; present blocks render as + // one flat stack. The data gate now reads at the block level. expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"'); // B3 — the redundant "Recovery score" cross-link block is gone; this // surface is reached from the overview already. expect(html).not.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"'); + // The recharge group's only signal (ANS charge) has no data → its block + // is dropped. + expect(html).not.toContain('data-slot="recovery-block-ANS_CHARGE"'); + // The removed sub-headings must not reappear. + expect(html).not.toContain('data-slot="recovery-group-strain"'); + expect(html).not.toContain("Strain & load"); // Empty note must not show when at least one signal is present. expect(html).not.toContain('data-slot="recovery-empty"'); }); diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index a04a0ea17..6452cd205 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -127,6 +127,30 @@ describe(" — timeline bar visibility", () => { expect(render(session)).toContain('role="img"'); }); + it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => { + // WHOOP / Polar report per-stage TOTALS and the server lays the segments + // out in a fixed physiological order — the timeline would invent a phase + // progression that was never measured. So even with distinct, well-timed + // spans, a `reconstructed` night drops the bar and keeps the breakdown. + const session: SleepHypnogramSession = { + ...baseSession, + source: "WHOOP", + reconstructed: true, + }; + const html = render(session); + expect(html).not.toContain('role="img"'); + expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); + expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"'); + }); + + it("keeps the timeline bar for a measured (non-reconstructed) night", () => { + // An Apple Health night carries real per-stage onsets, so the stepped bar + // is genuine and stays. The estimate note never appears. + const html = render({ ...baseSession, reconstructed: false }); + expect(html).toContain('role="img"'); + expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"'); + }); + it("collapses to the breakdown footer for a degenerate single-instant session", () => { // Every segment stamped on ONE instant — no real timeline, so only the // breakdown footer, no bar. diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 05f33aaa0..34e1fe848 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -284,4 +284,22 @@ describe("", () => { expect(html).toContain("Moderate confidence"); expect(html).toContain("Low confidence"); }); + + // ── v1.18.6 — detail-page cross-links (the Insights→steps gap) ────── + it("links each metric card to its detail page", () => { + const html = render(); + // The default BP / weight / mood triple each link to their sub-page. + expect(html).toContain('data-slot="trends-row-detail-link"'); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain('href="/insights/mood"'); + }); + + it("offers the steps detail link when the briefing flags steps", () => { + // Steps has a routed sub-page but the overview never linked to it; a + // briefing finding on steps now charts it AND drills into /insights/steps. + const html = render(); + expect(html).toMatch(/data-metric="steps"/); + expect(html).toContain('href="/insights/steps"'); + }); }); diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx index 09265d492..88527e76e 100644 --- a/src/components/insights/recovery/recovery-metric-block.tsx +++ b/src/components/insights/recovery/recovery-metric-block.tsx @@ -77,12 +77,12 @@ export function RecoveryMetricBlock({ className="text-muted-foreground size-4 shrink-0" aria-hidden="true" /> - {/* v1.18.1 — block title is an `

` (kept at `text-base`): it - nests under the group `SectionHeading` `

`, which itself - sits under the page `

` from `SubPageShell`. Demoting it - keeps the recovery page's heading outline strictly nested - (h1 → h2 → h3) instead of stacking two sibling `

`s. */} -

{title}

+ {/* v1.18.6 — block title is an `

` (at `text-base`): the per-group + sub-headings were removed, so each block now sits directly under + the page `

` from `SubPageShell`. Promoting it from `

` keeps + the recovery page's heading outline strictly nested (h1 → h2) + with no skipped level. */} +

{title}

{explainer} diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 9fa4ba138..701442858 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -14,7 +14,6 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useTranslations } from "@/lib/i18n/context"; import { EmptyState } from "@/components/ui/empty-state"; -import { SectionHeading } from "@/components/insights/section-heading"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/health-score"; @@ -36,9 +35,6 @@ interface BlockSpec { interface BlockGroup { key: string; - headingIcon: typeof Activity; - title: string; - subtitle: string; blocks: BlockSpec[]; } @@ -84,9 +80,6 @@ export function RecoverySection() { const groups: BlockGroup[] = [ { key: "recharge", - headingIcon: Battery, - title: t("insights.recovery.recharge.title"), - subtitle: t("insights.recovery.recharge.subtitle"), blocks: [ { type: "ANS_CHARGE", @@ -103,9 +96,6 @@ export function RecoverySection() { }, { key: "strain", - headingIcon: Gauge, - title: t("insights.recovery.strain.title"), - subtitle: t("insights.recovery.strain.subtitle"), blocks: [ { type: "DAY_STRAIN", @@ -143,9 +133,6 @@ export function RecoverySection() { }, { key: "cardio", - headingIcon: HeartPulse, - title: t("insights.recovery.cardio.title"), - subtitle: t("insights.recovery.cardio.subtitle"), blocks: [ { type: "AVERAGE_HEART_RATE", @@ -190,10 +177,6 @@ export function RecoverySection() { if (isLoading || summaries == null) { return (

-
); @@ -210,51 +193,36 @@ export function RecoverySection() { ); } + // v1.18.6 — the per-group sub-headings ("Recharge" / "Strain & load" / + // "Daily heart & energy") are gone: read next to the page's "Erholung" + // heading they were redundant chrome. The groups still drive ORDER + the + // data gate (a group with no present block contributes nothing); their + // present blocks now render as one flat, evenly-spaced stack, each block + // carrying its own title + explainer so nothing loses context. + const presentBlocks = groups.flatMap((group) => + group.blocks.filter((block) => (summaries[block.type]?.count ?? 0) > 0), + ); + return (
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface. Self-gating: renders nothing unless an episode is active. */} - {groups.map((group) => { - const present = group.blocks.filter( - (block) => (summaries[block.type]?.count ?? 0) > 0, - ); - if (present.length === 0) return null; - return ( -
- - {group.subtitle} -

- } - /> -
- {present.map((block) => ( - - ))} -
-
- ); - })} + {presentBlocks.map((block) => ( + + ))}
); } diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 27b341252..7e6797140 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -58,6 +58,17 @@ export interface SleepHypnogramSession { awakenings: number; stages: Record; segments: SleepHypnogramSegment[]; + /** + * `true` when the winning source has no measured per-stage onset + * timestamps and the server synthesised a contiguous timeline in a fixed + * physiological order (WHOOP / Polar). The "timeline" of such a night is + * not a real hypnogram — it would read as one long Core block followed by + * one Deep block, never the phase-to-phase progression a measured night + * shows — so we never draw the misleading stepped bar for it and fall back + * to the per-stage breakdown alone. A real-series source (Apple Health, + * Withings, Fitbit) is `false` and keeps the timeline. + */ + reconstructed?: boolean; } export interface SleepHypnogramProps { @@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { // breakdown footer. (Gating on the END instant instead hid the bar for an // ordinary multi-stage night whose segments happened to share a right // edge — the regression this restores.) + // A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the + // server laid its segments out in a fixed physiological order, so drawing + // the stepped bar would invent a phase progression that was never recorded + // (the "Core block then Deep block, no real breakdown" the bar reads as). + // For those we keep the per-stage breakdown + totals, which ARE real, and + // drop the misleading timeline entirely. Real-series sources keep the bar. const hasTrack = + session.reconstructed !== true && spans.length > 0 && - (spans.length === 1 || - new Set(spans.map((s) => s.x1)).size > 1); + (spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1); const hasBreakdown = breakdown.length > 0; return ( @@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { })} ) : null} + {/* When the source reports stage TOTALS but no measured onsets + (WHOOP / Polar) the stepped timeline is suppressed — drawing one + would invent a phase order that was never recorded. Surface a calm + note so the breakdown below doesn't read as a missing chart. */} + {session.reconstructed === true && hasBreakdown ? ( + + {t("insights.sleep.hypnogram.estimateNote")} + + ) : null} diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts new file mode 100644 index 000000000..ec27b8be4 --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; + +import { buildSleepQualityAssessment } from "../sleep-quality-assessment"; + +/** + * v1.18.6 — the deterministic sleep-quality grading that backs the grounded + * "Einschätzung" when no AI narrative exists. Pins the band thresholds and the + * worst-grade-wins overall, and the "skip ungradable metrics" contract. + */ +describe("buildSleepQualityAssessment", () => { + it("grades efficiency on the clinical 90 / 85 / 75 floors", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }]) + ?.lead.grade, + ).toBe("excellent"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }]) + ?.lead.grade, + ).toBe("good"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }]) + ?.lead.grade, + ).toBe("fair"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }]) + ?.lead.grade, + ).toBe("low"); + }); + + it("grades 0–100 scores on the 90 / 85 / 70 split", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead + .grade, + ).toBe("excellent"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead + .grade, + ).toBe("fair"); + }); + + it("opens on the headline score and keeps worst-grade-wins overall", () => { + const a = buildSleepQualityAssessment([ + { type: "SLEEP_CONSISTENCY", value: 60 }, + { type: "SLEEP_SCORE", value: 95 }, + { type: "SLEEP_EFFICIENCY", value: 92 }, + ]); + expect(a?.lead.type).toBe("SLEEP_SCORE"); + // The low consistency pulls the overall down even though the score is high. + expect(a?.overall).toBe("low"); + expect(a?.rest.length).toBe(2); + }); + + it("skips metrics it has no recognised band for, and returns null on none", () => { + // SLEEP_NEED (minutes) + disturbance count are not gradable here. + expect( + buildSleepQualityAssessment([ + { type: "SLEEP_NEED", value: 480 }, + { type: "SLEEP_DISTURBANCE_COUNT", value: 3 }, + ]), + ).toBeNull(); + }); + + it("ignores non-finite values", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]), + ).toBeNull(); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx new file mode 100644 index 000000000..f02cfc52d --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.18.6 — the grounded sleep-quality note that fills the assessment slot + * when no AI narrative is available. Pins that it builds substantive copy from + * the user's own averages and that it self-suppresses when the AI is present. + */ +function summary(avg30: number): DataSummary { + return { + count: 30, + latest: avg30, + avg7: avg30, + avg30, + } as unknown as DataSummary; +} + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + const summaries = { + SLEEP_SCORE: summary(82), + SLEEP_EFFICIENCY: summary(88), + }; + + it("renders a substantive, grounded assessment when the AI is absent", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sleep-quality-grounded-note"'); + // The headline score (82 → fair on the 90/85/70 split) leads the read. + expect(html).toContain("overall sleep score"); + expect(html).toContain("fair"); + expect(html).toContain("82"); + }); + + it("renders nothing when the AI assessment is present", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when no quality metric is gradable", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 7e1dc23ae..ffa8e2572 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -110,6 +110,14 @@ describe("", () => { expect(html).toContain("04:30"); }); + it("renders the band as a labelled top-right corner readout, not a badge", () => { + const html = render(); + // The labelled corner carries both the "Chronotype" label and the value. + expect(html).toContain('data-slot="chronotype-corner"'); + expect(html).toContain("Chronotype"); + expect(html).toContain("Intermediate type"); + }); + it("inherits the standard Card rhythm (no compact override)", () => { const learning = render(); const ready = render(); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 1932e7b92..9b7ebfb4c 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -5,7 +5,6 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { LearningGate } from "@/components/ui/learning-gate"; import type { ChronotypeDto } from "./use-sleep-rhythm"; @@ -67,26 +66,38 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { return ( -
- - - {t("insights.sleep.chronotype.title")} - + {/* The resolved chronotype rides the top-right corner as a labelled + readout — a small "Chronotyp" label over the band value — rather + than a badge buried in the body. The title stays left; the value + is the thing the eye should land on first. */} +
+
+ + + {t("insights.sleep.chronotype.title")} + +
+
+ + {t("insights.sleep.chronotype.cornerLabel")} + + + {bandLabel} + +
-
- - {bandLabel} - - {msfClock && ( -

- {t("insights.sleep.chronotype.midSleepCaption", { - clock: msfClock, - })} -

- )} -
+ {msfClock && ( +

+ {t("insights.sleep.chronotype.midSleepCaption", { + clock: msfClock, + })} +

+ )}
- } - enabled={hasSleepScore} - /> + ); } + +/** + * The quality block's "Einschätzung". The richer AI narrative wins when it + * exists; until then (or forever, on an account with no AI provider) a + * deterministic note grounded in the user's own quality averages fills the + * slot, so the assessment is never the bare "no analysis yet" line. + * + * Both reads are gated on the headline sleep score having data — the same gate + * the AI route used — so the assessment only surfaces with a quality series to + * describe. The grounded note reads the SLEEP_SCORE status query (already + * warmed by ``) to know whether the AI text is absent, so it + * adds no second round-trip and the two never stack. + */ +function SleepQualityAssessmentBlock({ + summaries, +}: { + summaries: Record; +}) { + const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0; + const { data: status, isLoading: statusLoading } = useInsightMetricStatus( + "SLEEP_SCORE", + hasSleepScore, + ); + + // The AI narrative is "present" when there is text OR the worker is actively + // assembling one (preparing / revalidating). In those cases the AI card owns + // the slot. The grounded note only steps in once the status query has SETTLED + // with no narrative — so it never flashes ahead of a warm AI assessment. + const aiAbsent = + !statusLoading && + !status?.text && + status?.preparing !== true && + status?.revalidating !== true; + + return ( + <> + } + // Suppress the AI card's static "no analysis yet" / "no provider" + // empty states — the grounded note covers that ground with real + // content. The AI card still renders its text / preparing states. + enabled={hasSleepScore && !aiAbsent} + /> + + + ); +} diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 5b9bcac4b..5bc1c51b3 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -1,7 +1,8 @@ "use client"; import dynamic from "next/dynamic"; -import { TrendingUp } from "lucide-react"; +import Link from "next/link"; +import { ArrowRight, TrendingUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; @@ -301,6 +302,22 @@ export function TrendsRow({ text={t(config.captionKey)} /> )} + {/* v1.18.6 — drill-in link to the metric's detail page. The + overview charted these metrics but never offered a way INTO + their detail pages — notably the steps page, which exists and + is used but was unreachable from Insights. The link sits below + the caption so the chart keeps its own interactions. */} + {config.detailHref ? ( + + {t("insights.trendsRow.viewDetail", { metric: title })} +