mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
merge: sleep, insights and recovery surfacing
Suppress the synthesized sleep timeline for non-measured nights while keeping measured-night detail, and refine the insights and recovery surfacing.
This commit is contained in:
commit
d1355a2999
22 files changed
+815
-202
No files matched your search
+29
-17
@@ -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.",
|
||||
|
||||
+29
-17
@@ -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.",
|
||||
|
||||
+29
-17
@@ -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í.",
|
||||
|
||||
+29
-17
@@ -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.",
|
||||
|
||||
+29
-17
@@ -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é.",
|
||||
|
||||
+29
-17
@@ -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.",
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("<RecoverySection> data-gating", () => {
|
||||
// Routed through the shared <EmptyState> (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("<RecoverySection> data-gating", () => {
|
||||
}),
|
||||
);
|
||||
const html = render(<RecoverySection />);
|
||||
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"');
|
||||
});
|
||||
|
||||
@@ -127,6 +127,30 @@ describe("<SleepHypnogram> — 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.
|
||||
|
||||
@@ -284,4 +284,22 @@ describe("<TrendsRow>", () => {
|
||||
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(<TrendsRow />);
|
||||
// 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(<TrendsRow briefing={briefing(["steps"])} />);
|
||||
expect(html).toMatch(/data-metric="steps"/);
|
||||
expect(html).toContain('href="/insights/steps"');
|
||||
});
|
||||
});
|
||||
@@ -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 `<h3>` (kept at `text-base`): it
|
||||
nests under the group `SectionHeading` `<h2>`, which itself
|
||||
sits under the page `<h1>` from `SubPageShell`. Demoting it
|
||||
keeps the recovery page's heading outline strictly nested
|
||||
(h1 → h2 → h3) instead of stacking two sibling `<h2>`s. */}
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{/* v1.18.6 — block title is an `<h2>` (at `text-base`): the per-group
|
||||
sub-headings were removed, so each block now sits directly under
|
||||
the page `<h1>` from `SubPageShell`. Promoting it from `<h3>` keeps
|
||||
the recovery page's heading outline strictly nested (h1 → h2)
|
||||
with no skipped level. */}
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{explainer}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3" data-slot="recovery-loading">
|
||||
<SectionHeading
|
||||
icon={Battery}
|
||||
title={t("insights.recovery.recharge.title")}
|
||||
/>
|
||||
<ChartSkeleton />
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className="space-y-8" data-slot="recovery-section">
|
||||
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface.
|
||||
Self-gating: renders nothing unless an episode is active. */}
|
||||
<RestModeBanner annotation={restMode} />
|
||||
{groups.map((group) => {
|
||||
const present = group.blocks.filter(
|
||||
(block) => (summaries[block.type]?.count ?? 0) > 0,
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
key={group.key}
|
||||
data-slot={`recovery-group-${group.key}`}
|
||||
className="space-y-4"
|
||||
>
|
||||
<SectionHeading
|
||||
icon={group.headingIcon}
|
||||
title={group.title}
|
||||
action={
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{group.subtitle}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
{present.map((block) => (
|
||||
<RecoveryMetricBlock
|
||||
key={block.type}
|
||||
type={block.type}
|
||||
chartKey={block.chartKey}
|
||||
statusMetric={block.statusMetric}
|
||||
title={block.title}
|
||||
explainer={block.explainer}
|
||||
icon={block.icon}
|
||||
color={block.color}
|
||||
unit={block.unit}
|
||||
summary={summaries[block.type] ?? null}
|
||||
fractionDigits={block.fractionDigits}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{presentBlocks.map((block) => (
|
||||
<RecoveryMetricBlock
|
||||
key={block.type}
|
||||
type={block.type}
|
||||
chartKey={block.chartKey}
|
||||
statusMetric={block.statusMetric}
|
||||
title={block.title}
|
||||
explainer={block.explainer}
|
||||
icon={block.icon}
|
||||
color={block.color}
|
||||
unit={block.unit}
|
||||
summary={summaries[block.type] ?? null}
|
||||
fractionDigits={block.fractionDigits}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,17 @@ export interface SleepHypnogramSession {
|
||||
awakenings: number;
|
||||
stages: Record<string, number>;
|
||||
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) {
|
||||
})}
|
||||
</span>
|
||||
) : 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 ? (
|
||||
<span
|
||||
data-slot="sleep-hypnogram-estimate-note"
|
||||
className="text-muted-foreground/70 text-xs"
|
||||
>
|
||||
{t("insights.sleep.hypnogram.estimateNote")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SleepQualityGroundedNote>", () => {
|
||||
const summaries = {
|
||||
SLEEP_SCORE: summary(82),
|
||||
SLEEP_EFFICIENCY: summary(88),
|
||||
};
|
||||
|
||||
it("renders a substantive, grounded assessment when the AI is absent", () => {
|
||||
const html = render(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={true}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when no quality metric is gradable", () => {
|
||||
const html = render(
|
||||
<SleepQualityGroundedNote
|
||||
summaries={{ SLEEP_NEED: summary(480) }}
|
||||
showWhenAiAbsent={true}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,14 @@ describe("<ChronotypeCard>", () => {
|
||||
expect(html).toContain("04:30");
|
||||
});
|
||||
|
||||
it("renders the band as a labelled top-right corner readout, not a badge", () => {
|
||||
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
// 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(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
|
||||
const ready = render(<ChronotypeCard chronotype={READY_CHRONO} />);
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
{/* 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. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.chronotype.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col items-end text-right"
|
||||
data-slot="chronotype-corner"
|
||||
>
|
||||
<span className="text-muted-foreground text-[0.625rem] font-medium tracking-wide uppercase">
|
||||
{t("insights.sleep.chronotype.cornerLabel")}
|
||||
</span>
|
||||
<span className="text-foreground text-sm font-semibold">
|
||||
{bandLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
{bandLabel}
|
||||
</Badge>
|
||||
{msfClock && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.midSleepCaption", {
|
||||
clock: msfClock,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{msfClock && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.chronotype.midSleepCaption", {
|
||||
clock: msfClock,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.18.6 — deterministic, data-grounded sleep-quality assessment.
|
||||
*
|
||||
* The AI "Einschätzung" under the sleep-quality block reads "no assessment
|
||||
* yet / unavailable" until a provider has run (and never at all on an account
|
||||
* with no AI provider). That left the most universally useful read — the one
|
||||
* the user's OWN numbers already support — blank. This pure helper turns the
|
||||
* sleep-quality summaries that the section already has in hand into a short,
|
||||
* grounded sentence: it names the night-over-night averages and grades them
|
||||
* against recognised reference ranges, with NO provider call and NO recompute
|
||||
* of a stored value (it only reads the server-authoritative averages).
|
||||
*
|
||||
* Grading thresholds are the conventional consumer-sleep-platform bands:
|
||||
* - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM);
|
||||
* ≥ 90 % is excellent, < 75 % is poor.
|
||||
* - Performance / consistency / headline score: 0–100, graded on the
|
||||
* standard 85 / 70 split most rings use (good / fair / low).
|
||||
* The copy is intentionally cautious — it describes, it never diagnoses.
|
||||
*
|
||||
* Kept free of React + i18n so the grading is unit-testable in isolation; the
|
||||
* component maps the returned shape onto localised strings.
|
||||
*/
|
||||
|
||||
export type QualityGrade = "excellent" | "good" | "fair" | "low";
|
||||
|
||||
export interface SleepQualityMetricRead {
|
||||
/** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */
|
||||
type: string;
|
||||
/** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface SleepQualityFinding {
|
||||
type: string;
|
||||
value: number;
|
||||
grade: QualityGrade;
|
||||
}
|
||||
|
||||
export interface SleepQualityAssessment {
|
||||
/** The lead metric the assessment opens on (best-known quality signal). */
|
||||
lead: SleepQualityFinding;
|
||||
/** The remaining graded metrics, most-informative first. */
|
||||
rest: SleepQualityFinding[];
|
||||
/** Worst grade across the present metrics — drives the closing nudge. */
|
||||
overall: QualityGrade;
|
||||
}
|
||||
|
||||
/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */
|
||||
const PERCENT_METRICS = new Set<string>([
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
]);
|
||||
|
||||
/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */
|
||||
function gradeEfficiency(pct: number): QualityGrade {
|
||||
if (pct >= 90) return "excellent";
|
||||
if (pct >= 85) return "good";
|
||||
if (pct >= 75) return "fair";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function gradeScore(value: number): QualityGrade {
|
||||
if (value >= 90) return "excellent";
|
||||
if (value >= 85) return "good";
|
||||
if (value >= 70) return "fair";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade one quality metric. Returns null for a metric the helper has no
|
||||
* recognised band for (e.g. raw disturbance counts or sleep-need minutes),
|
||||
* so the assessment never grades a value against a scale it does not fit.
|
||||
*/
|
||||
function gradeMetric(type: string, value: number): QualityGrade | null {
|
||||
if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value);
|
||||
if (PERCENT_METRICS.has(type)) return gradeScore(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Worst grade wins — a single low signal pulls the overall read down. */
|
||||
const GRADE_RANK: Record<QualityGrade, number> = {
|
||||
excellent: 3,
|
||||
good: 2,
|
||||
fair: 1,
|
||||
low: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lead-metric preference: the headline score reads first when present, then
|
||||
* efficiency, then performance, then consistency. Keeps the opening sentence
|
||||
* anchored on the most holistic signal the night carries.
|
||||
*/
|
||||
const LEAD_ORDER = [
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_EFFICIENCY",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Build a deterministic assessment from the present quality metrics. Returns
|
||||
* null when none of the supplied metrics is gradable, so the caller falls back
|
||||
* to its existing copy rather than printing an empty read.
|
||||
*/
|
||||
export function buildSleepQualityAssessment(
|
||||
reads: readonly SleepQualityMetricRead[],
|
||||
): SleepQualityAssessment | null {
|
||||
const findings: SleepQualityFinding[] = [];
|
||||
for (const r of reads) {
|
||||
if (!Number.isFinite(r.value)) continue;
|
||||
const grade = gradeMetric(r.type, r.value);
|
||||
if (grade == null) continue;
|
||||
findings.push({ type: r.type, value: r.value, grade });
|
||||
}
|
||||
if (findings.length === 0) return null;
|
||||
|
||||
const leadIndex = (type: string): number => {
|
||||
const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]);
|
||||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||
};
|
||||
const sorted = [...findings].sort(
|
||||
(a, b) => leadIndex(a.type) - leadIndex(b.type),
|
||||
);
|
||||
|
||||
const overall = sorted.reduce<QualityGrade>(
|
||||
(worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst),
|
||||
"excellent",
|
||||
);
|
||||
|
||||
return {
|
||||
lead: sorted[0],
|
||||
rest: sorted.slice(1),
|
||||
overall,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { Moon } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
buildSleepQualityAssessment,
|
||||
type QualityGrade,
|
||||
type SleepQualityFinding,
|
||||
} from "./sleep-quality-assessment";
|
||||
|
||||
/**
|
||||
* v1.18.6 — the grounded sleep-quality "Einschätzung".
|
||||
*
|
||||
* The AI assessment under the sleep-quality block reads "no assessment yet"
|
||||
* until a provider has run, and stays blank forever on an account with no AI
|
||||
* provider. This card fills that gap with a substantive read built ENTIRELY
|
||||
* from the user's own quality averages (efficiency / performance / consistency
|
||||
* / headline score) graded against recognised reference bands — no provider,
|
||||
* no recompute of a stored value. It describes; it does not diagnose.
|
||||
*
|
||||
* Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so
|
||||
* the richer AI narrative still wins when it exists and the user never sees
|
||||
* two assessments stacked.
|
||||
*/
|
||||
|
||||
const QUALITY_TYPES = [
|
||||
"SLEEP_SCORE",
|
||||
"SLEEP_EFFICIENCY",
|
||||
"SLEEP_PERFORMANCE",
|
||||
"SLEEP_CONSISTENCY",
|
||||
] as const;
|
||||
|
||||
/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */
|
||||
function clauseFor(
|
||||
finding: SleepQualityFinding,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
|
||||
const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
|
||||
// Efficiency / performance / consistency are percentages; the headline score
|
||||
// is a 0–100 index. Both read naturally as a rounded integer.
|
||||
const value = Math.round(finding.value);
|
||||
return t("insights.sleepQuality.assessment.clause", {
|
||||
metric: metricLabel,
|
||||
grade: gradeLabel,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
export interface SleepQualityGroundedNoteProps {
|
||||
/** The analytics summaries the section already holds (no extra fetch). */
|
||||
summaries: Record<string, DataSummary | undefined> | undefined;
|
||||
/**
|
||||
* Whether the AI assessment is absent. The grounded note only renders when
|
||||
* the AI narrative is not there, so the two never stack.
|
||||
*/
|
||||
showWhenAiAbsent: boolean;
|
||||
}
|
||||
|
||||
export function SleepQualityGroundedNote({
|
||||
summaries,
|
||||
showWhenAiAbsent,
|
||||
}: SleepQualityGroundedNoteProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!showWhenAiAbsent || !summaries) return null;
|
||||
|
||||
const reads = QUALITY_TYPES.map((type) => {
|
||||
const s = summaries[type];
|
||||
const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null;
|
||||
return value == null
|
||||
? null
|
||||
: ({ type, value } as { type: string; value: number });
|
||||
}).filter((r): r is { type: string; value: number } => r !== null);
|
||||
|
||||
const assessment = buildSleepQualityAssessment(reads);
|
||||
if (!assessment) return null;
|
||||
|
||||
const clauses = [assessment.lead, ...assessment.rest].map((f) =>
|
||||
clauseFor(f, t),
|
||||
);
|
||||
const closing = t(
|
||||
`insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="sleep-quality-grounded-note"
|
||||
className="gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader
|
||||
icon={QualityNoteIcon}
|
||||
title={t("insights.assessmentTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{clauses.join(" ")} {closing}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityNoteIcon({ className }: { className?: string }) {
|
||||
return <Moon className={className} aria-hidden="true" />;
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { SleepQualityGroundedNote } from "@/components/insights/sleep/sleep-quality-grounded-note";
|
||||
|
||||
/**
|
||||
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
|
||||
@@ -101,13 +104,6 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
if (present.length === 0) return null;
|
||||
|
||||
// v1.18.1 — the quality block is a distinct chart group, so it carries its
|
||||
// OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
|
||||
// rule the recovery page meets). Gated on the nightly headline sleep score
|
||||
// having data so the generic assessment route only fires when there is a
|
||||
// quality series to read.
|
||||
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<section data-slot="sleep-quality-section" className="space-y-3">
|
||||
<SectionHeading
|
||||
@@ -132,11 +128,58 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_SCORE"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
enabled={hasSleepScore}
|
||||
/>
|
||||
<SleepQualityAssessmentBlock summaries={summaries} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<MetricStatusCard>`) 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<string, DataSummary | undefined>;
|
||||
}) {
|
||||
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 (
|
||||
<>
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_SCORE"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
// 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}
|
||||
/>
|
||||
<SleepQualityGroundedNote
|
||||
summaries={summaries}
|
||||
showWhenAiAbsent={hasSleepScore && aiAbsent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Link
|
||||
href={config.detailHref}
|
||||
data-slot="trends-row-detail-link"
|
||||
data-metric={config.metric}
|
||||
className="text-muted-foreground hover:text-foreground mt-auto inline-flex items-center gap-1 self-start text-xs font-medium transition-colors"
|
||||
>
|
||||
{t("insights.trendsRow.viewDetail", { metric: title })}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -151,4 +151,17 @@ describe("selectTrendCharts", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every chart-bearing sourceMetric carries a routed detailHref", () => {
|
||||
// v1.18.6 — the overview Trends row drills into the metric's detail page.
|
||||
// Each chartable slot must resolve to a `/insights/<slug>` target so the
|
||||
// cross-link never lands on a non-route — steps in particular.
|
||||
for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
|
||||
if (!config) continue;
|
||||
expect(config.detailHref, `${metric} detailHref`).toMatch(
|
||||
/^\/insights\//,
|
||||
);
|
||||
}
|
||||
expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,15 @@ export interface TrendChartConfig {
|
||||
* for additive metrics, which fall back to {@link captionKey}.
|
||||
*/
|
||||
annotationKey?: TrendAnnotationKey;
|
||||
/**
|
||||
* Routed detail page for the metric (`/insights/<slug>`), when one exists.
|
||||
* The Trends-row card links to it so the overview can drill into the metric
|
||||
* — notably the one cross-link to `/insights/steps`, which the overview
|
||||
* otherwise never offered despite the steps page existing and being used.
|
||||
* Omitted for slots with no dedicated page (mood routes through its own
|
||||
* surface; metabolic re-frames live on their composite pages).
|
||||
*/
|
||||
detailHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +109,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
titleKey: "charts.bloodPressure",
|
||||
captionKey: "insights.trendsRow.caption.bp",
|
||||
annotationKey: "bp",
|
||||
detailHref: "/insights/blood-pressure",
|
||||
},
|
||||
weight: {
|
||||
metric: "weight",
|
||||
@@ -110,6 +120,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
titleKey: "charts.weight",
|
||||
captionKey: "insights.trendsRow.caption.weight",
|
||||
annotationKey: "weight",
|
||||
detailHref: "/insights/weight",
|
||||
},
|
||||
pulse: {
|
||||
metric: "pulse",
|
||||
@@ -120,6 +131,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "bpm",
|
||||
titleKey: "charts.pulse",
|
||||
captionKey: "insights.trendsRow.caption.pulse",
|
||||
detailHref: "/insights/pulse",
|
||||
},
|
||||
mood: {
|
||||
metric: "mood",
|
||||
@@ -129,6 +141,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
titleKey: "charts.mood",
|
||||
captionKey: "insights.trendsRow.caption.mood",
|
||||
annotationKey: "mood",
|
||||
detailHref: "/insights/mood",
|
||||
},
|
||||
sleep: {
|
||||
metric: "sleep",
|
||||
@@ -139,6 +152,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "h",
|
||||
titleKey: "charts.sleep",
|
||||
captionKey: "insights.trendsRow.caption.sleep",
|
||||
detailHref: "/insights/sleep",
|
||||
},
|
||||
steps: {
|
||||
metric: "steps",
|
||||
@@ -149,6 +163,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "steps",
|
||||
titleKey: "charts.steps",
|
||||
captionKey: "insights.trendsRow.caption.steps",
|
||||
detailHref: "/insights/steps",
|
||||
},
|
||||
hrv: {
|
||||
metric: "hrv",
|
||||
@@ -159,6 +174,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "ms",
|
||||
titleKey: "charts.hrv",
|
||||
captionKey: "insights.trendsRow.caption.hrv",
|
||||
detailHref: "/insights/hrv",
|
||||
},
|
||||
resting_hr: {
|
||||
metric: "resting_hr",
|
||||
@@ -169,6 +185,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "bpm",
|
||||
titleKey: "charts.restingHeartRate",
|
||||
captionKey: "insights.trendsRow.caption.resting_hr",
|
||||
detailHref: "/insights/resting-pulse",
|
||||
},
|
||||
active_energy: {
|
||||
metric: "active_energy",
|
||||
@@ -179,6 +196,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "kcal",
|
||||
titleKey: "charts.activeEnergy",
|
||||
captionKey: "insights.trendsRow.caption.active_energy",
|
||||
detailHref: "/insights/active-energy",
|
||||
},
|
||||
flights: {
|
||||
metric: "flights",
|
||||
@@ -189,6 +207,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "flights",
|
||||
titleKey: "charts.flights",
|
||||
captionKey: "insights.trendsRow.caption.flights",
|
||||
detailHref: "/insights/flights-climbed",
|
||||
},
|
||||
distance: {
|
||||
metric: "distance",
|
||||
@@ -199,6 +218,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "km",
|
||||
titleKey: "charts.distance",
|
||||
captionKey: "insights.trendsRow.caption.distance",
|
||||
detailHref: "/insights/walking-distance",
|
||||
},
|
||||
vo2_max: {
|
||||
metric: "vo2_max",
|
||||
@@ -209,6 +229,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "mL/kg·min",
|
||||
titleKey: "charts.vo2Max",
|
||||
captionKey: "insights.trendsRow.caption.vo2_max",
|
||||
detailHref: "/insights/cardio-fitness",
|
||||
},
|
||||
body_temp: {
|
||||
metric: "body_temp",
|
||||
@@ -219,6 +240,7 @@ export const TREND_CHART_CONFIG: Record<
|
||||
yAxisUnit: "°C",
|
||||
titleKey: "charts.bodyTemperature",
|
||||
captionKey: "insights.trendsRow.caption.body_temp",
|
||||
detailHref: "/insights/body-temperature",
|
||||
},
|
||||
// Adherence-context findings — no single plottable trend series in
|
||||
// this row. A finding on one of these is skipped during selection so
|
||||
|
||||
Reference in new issue
Block a user