feat(sleep): surface the chronotype as a labelled corner readout

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:16:43 +02:00
1 parent c69e6deb67
commit d8c139698c
8 files changed
+187 -36

No files matched your search

+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
+25 -3
View File
@@ -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",
@@ -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"