From c69e6deb6778d0a0efd2581b258574087692def6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:11:18 +0200 Subject: [PATCH 1/5] fix(sleep): drop the misleading last-night timeline for estimated stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last-night card drew a stepped stage timeline for every source. For a source that reports per-stage totals without measured onsets (WHOOP / Polar), the server lays the segments out in a fixed physiological order, so the bar invents a phase progression that was never recorded — it reads as one Core block then one Deep block, not a real hypnogram. Suppress the timeline when the night is reconstructed and keep the per-stage breakdown + totals, which are real. A calm note explains that stage timing is estimated for that source. Real-series nights (Apple Health, Withings, Fitbit) keep the measured timeline unchanged. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/sleep-hypnogram.test.tsx | 24 ++++++++++++++ src/components/insights/sleep-hypnogram.tsx | 33 +++++++++++++++++-- 8 files changed, 67 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index 3dcc80763..4e8cd7244 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2547,7 +2547,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", diff --git a/messages/en.json b/messages/en.json index a87949920..5c4c0206d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2547,7 +2547,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", diff --git a/messages/es.json b/messages/es.json index 99b6d7782..a69ad908c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2547,7 +2547,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", diff --git a/messages/fr.json b/messages/fr.json index 85cb525e9..8ca52cec5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2547,7 +2547,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", diff --git a/messages/it.json b/messages/it.json index 48507ed1d..7608d2186 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2547,7 +2547,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", diff --git a/messages/pl.json b/messages/pl.json index c60c651f4..32e690173 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2547,7 +2547,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", 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/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} From d8c139698c8b49655979904fdbddf56651989e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:16:43 +0200 Subject: [PATCH 2/5] feat(sleep): surface the chronotype as a labelled corner readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the chronotype band out of a body badge into a labelled top-right corner of the card — a small "Chronotype" label over the value — so the resolved type is the first thing the eye lands on. Tighten the mid-sleep copy to name it as the body's natural sleep midpoint on free days. --- messages/de.json | 28 +++++++++-- messages/en.json | 28 +++++++++-- messages/es.json | 28 +++++++++-- messages/fr.json | 28 +++++++++-- messages/it.json | 28 +++++++++-- messages/pl.json | 28 +++++++++-- .../__tests__/sleep-rhythm-cards.test.tsx | 8 ++++ .../insights/sleep/chronotype-card.tsx | 47 ++++++++++++------- 8 files changed, 187 insertions(+), 36 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4e8cd7244..8f654ac76 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", diff --git a/messages/en.json b/messages/en.json index 5c4c0206d..accbf337f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", diff --git a/messages/es.json b/messages/es.json index a69ad908c..9fa3e2b1e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", diff --git a/messages/fr.json b/messages/fr.json index 8ca52cec5..9c1a433c0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", diff --git a/messages/it.json b/messages/it.json index 7608d2186..77ba27740 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", diff --git a/messages/pl.json b/messages/pl.json index 32e690173..6bd2a8336 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2575,7 +2575,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.", @@ -2588,7 +2588,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." @@ -3480,7 +3481,28 @@ }, "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", 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} + /> + + + ); +} From 71b29f5b6f2fb18ba33040e261978aeb4a29a6ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:20:19 +0200 Subject: [PATCH 4/5] refactor(recovery): drop the per-group sub-headings on the recovery page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read next to the page's "Erholung" heading, the "Recharge" / "Strain & load" / "Daily heart & energy" sub-headings were redundant chrome. Remove them and render the present signal blocks as one flat, evenly-spaced stack — each block already carries its own title + explainer, so nothing loses context. The group structure stays internally to drive block order and the data gate. Promote the block title to an h2 so the page heading outline stays nested (h1 then h2) with no skipped level, and drop the now-orphaned group i18n keys. --- messages/de.json | 12 --- messages/en.json | 12 --- messages/es.json | 12 --- messages/fr.json | 12 --- messages/it.json | 12 --- messages/pl.json | 12 --- .../__tests__/device-score-surfaces.test.tsx | 13 ++- .../recovery/recovery-metric-block.tsx | 12 +-- .../insights/recovery/recovery-section.tsx | 82 ++++++------------- 9 files changed, 40 insertions(+), 139 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8f654ac76..6e9a48271 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3509,18 +3509,6 @@ "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 accbf337f..c33810296 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3509,18 +3509,6 @@ "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 9fa3e2b1e..0a1b03839 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3509,18 +3509,6 @@ "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 9c1a433c0..38a840ea8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3509,18 +3509,6 @@ "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 77ba27740..489c26670 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3509,18 +3509,6 @@ "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 6bd2a8336..9b66dda9e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3509,18 +3509,6 @@ "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/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) => ( + + ))}
); } From 34fbfe1c67ff32a10edcd738a064bd543e963f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:24:02 +0200 Subject: [PATCH 5/5] feat(insights): link the overview Trends cards to their detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview charted each trend but never offered a way into the metric's detail page — the steps page in particular exists and is used, yet Insights never linked to it. Give every Trends-row chart a routed detail target and a discreet "view details" link below the caption, so a metric the row surfaces (steps included, when the briefing flags it) drills into /insights/. --- messages/de.json | 3 ++- messages/en.json | 3 ++- messages/es.json | 3 ++- messages/fr.json | 3 ++- messages/it.json | 3 ++- messages/pl.json | 3 ++- .../insights/__tests__/trends-row.test.tsx | 18 +++++++++++++++ src/components/insights/trends-row.tsx | 19 +++++++++++++++- .../__tests__/trend-chart-select.test.ts | 13 +++++++++++ src/lib/insights/trend-chart-select.ts | 22 +++++++++++++++++++ 10 files changed, 83 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6e9a48271..db398f369 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", diff --git a/messages/en.json b/messages/en.json index c33810296..d8373bc5f 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", diff --git a/messages/es.json b/messages/es.json index 0a1b03839..4c76e91d2 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", diff --git a/messages/fr.json b/messages/fr.json index 38a840ea8..52691bd65 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", diff --git a/messages/it.json b/messages/it.json index 489c26670..d53d58a54 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", diff --git a/messages/pl.json b/messages/pl.json index 9b66dda9e..a6e7821c5 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", 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/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 })} +