diff --git a/messages/de.json b/messages/de.json index 7a8f945a7..bea52bb4c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Abendtyp", "extreme_late": "Ausgeprägter Abendtyp" }, - "cornerLabel": "Chronotyp" + "midpointHeadline": "Deine natürliche Schlafmitte liegt etwa um {clock}." }, "rhythm": { "loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht." diff --git a/messages/en.json b/messages/en.json index 6262c25cb..650a8ddac 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Evening type", "extreme_late": "Strong evening type" }, - "cornerLabel": "Chronotype" + "midpointHeadline": "Your natural sleep midpoint is around {clock}." }, "rhythm": { "loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles." diff --git a/messages/es.json b/messages/es.json index 1c2de3bf1..ab0edfa93 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Vespertino", "extreme_late": "Vespertino marcado" }, - "cornerLabel": "Cronotipo" + "midpointHeadline": "El punto medio natural de tu sueño ronda las {clock}." }, "rhythm": { "loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión." diff --git a/messages/fr.json b/messages/fr.json index 98cdb7079..5026783b9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Type du soir", "extreme_late": "Type du soir marqué" }, - "cornerLabel": "Chronotype" + "midpointHeadline": "Le milieu naturel de votre sommeil se situe vers {clock}." }, "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." diff --git a/messages/it.json b/messages/it.json index 837b62f0b..7ff1785b9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Tipo serale", "extreme_late": "Tipo serale marcato" }, - "cornerLabel": "Cronotipo" + "midpointHeadline": "Il punto medio naturale del tuo sonno è intorno alle {clock}." }, "rhythm": { "loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile." diff --git a/messages/pl.json b/messages/pl.json index ddc529b75..f09dacc22 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2573,7 +2573,6 @@ "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": "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 +2586,7 @@ "late": "Typ wieczorny", "extreme_late": "Wyraźny typ wieczorny" }, - "cornerLabel": "Chronotyp" + "midpointHeadline": "Naturalny środek Twojego snu przypada około {clock}." }, "rhythm": { "loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje." diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index d6f4271de..f8e779af6 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -14,6 +14,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SleepOverview } from "@/components/insights/sleep-overview"; import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section"; +import { ChronotypeSection } from "@/components/insights/sleep/chronotype-section"; import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section"; import { SubPageShell } from "@/components/insights/sub-page-shell"; @@ -116,9 +117,11 @@ export default function InsightsSchlafPage() { /> {/* - v1.17.0 — sleep-debt headline + chronotype card. Server-authoritative - (the same DTO iOS renders), gated on the data-bearing branch so a - source-less account never fetches. + v1.17.0 — sleep-debt headline. Server-authoritative (the same DTO iOS + renders), gated on the data-bearing branch so a source-less account + never fetches. v1.18.7 W-D — the chronotype moved out to the prominent + bottom block (`` below); this section is the + sleep-debt headline alone. */} @@ -134,6 +137,15 @@ export default function InsightsSchlafPage() { + + {/* + v1.18.7 W-D — chronotype as the PROMINENT bottom treatment of the + Sleep view (maintainer directive): a large band label with the natural + sleep-midpoint clock beneath it and an expandable disclosure for social + jetlag + MSFsc. Reads the same `["sleep-rhythm"]` cache the debt section + warmed, so it adds no round-trip; gated on the data-bearing branch. + */} + ); } diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 8a4a9a824..6ff200c1c 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -2,13 +2,11 @@ import Link from "next/link"; import { Moon } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; -import { queryKeys } from "@/lib/query-keys"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; @@ -16,9 +14,7 @@ import { SleepStageStackedBar, type SleepStageBreakdown, } from "./sleep-stage-stacked-bar"; -import { SleepHypnogram, type SleepHypnogramSession } from "./sleep-hypnogram"; import { SleepDurationChart } from "./sleep-duration-chart"; -import { apiGet } from "@/lib/api/api-fetch"; /** * v1.4.25 W4c — Sleep insights composition. @@ -30,9 +26,15 @@ import { apiGet } from "@/lib/api/api-fetch"; * 1. Headline card with the average nightly total (h / min) and the * number of nights covered. * 2. Stage composition stacked bar (REM / Deep / Core / Awake / In - * bed / Asleep). + * bed / Asleep) — the canonical sleep-phase distribution. * 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`). * + * v1.18.7 W-D — the single-night "Last night" hypnogram card was removed: + * its per-stage breakdown (time + %) duplicated the phase distribution the + * stage stacked bar already shows directly below it. The `/api/sleep/night` + * route stays (the dashboard still reads it); only this redundant surface is + * gone. + * * Empty state: no SLEEP_DURATION rows yet → render the "Apple Health * sync is coming in v1.5" message with a deep-link to Settings → * Devices so the user can prepare. We do NOT render any of the three @@ -84,22 +86,6 @@ export function SleepOverview() { const sleepStages = data?.sleepStages ?? null; const totalCount = sleepSummary?.count ?? 0; - // v1.11.5 — last-night hypnogram. Read-only over existing per-stage rows; - // gated on the same data-bearing branch so a source-less account never - // fetches. Shares the measurement-write invalidation prefix. - const nightQuery = useQuery({ - queryKey: queryKeys.sleepNight(), - queryFn: async () => { - return apiGet<{ - night: string | null; - main: SleepHypnogramSession | null; - }>("/api/sleep/night"); - }, - enabled: isAuthenticated && totalCount > 0, - staleTime: 60 * 1000, - }); - const lastNight = nightQuery.data?.main ?? null; - if (!isLoading && totalCount === 0) { return ( - {lastNight && } - {sleepStages ? ( ) : ( diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx new file mode 100644 index 000000000..7d09a89d0 --- /dev/null +++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { SleepRhythmDto } from "../use-sleep-rhythm"; + +/** + * v1.18.7 W-D — chronotype SECTION states (the prominent bottom block). + * + * Mirrors the sleep-rhythm section guard: error → quiet inline notice, + * loading → skeleton, settled → the large band treatment. `useAuth` + + * `useSleepRhythm` are mocked so each branch is deterministic. + */ + +const authMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => authMock(), +})); + +const rhythmMock = vi.fn(); +vi.mock("../use-sleep-rhythm", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useSleepRhythm: () => rhythmMock() }; +}); + +const { ChronotypeSection } = await import("../chronotype-section"); + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const READY_DTO: SleepRhythmDto = { + sleepDebt: { + state: "ready", + debtMinutes: 600, + needMinutes: 420, + nightsCounted: 10, + windowNights: 14, + nightsUntilReady: 0, + }, + chronotype: { + state: "ready", + msfMinutes: 5 * 60 + 41, // 05:41 + msfScMinutes: 5 * 60, + band: "late", + socialJetlagMinutes: 75, + freeNightsCounted: 6, + workNightsCounted: 14, + freeNightsUntilReady: 0, + }, +}; + +beforeEach(() => { + authMock.mockReturnValue({ isAuthenticated: true }); + rhythmMock.mockReset(); +}); + +describe("", () => { + it("renders the quiet inline notice on a query error", () => { + rhythmMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + const html = render(); + expect(html).toContain('data-slot="chronotype-error"'); + expect(html).not.toContain('data-slot="chronotype-loading"'); + }); + + it("shows the skeleton while loading", () => { + rhythmMock.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + const html = render(); + expect(html).toContain('data-slot="chronotype-loading"'); + expect(html).not.toContain('data-slot="chronotype-error"'); + }); + + it("renders the large band treatment + midpoint clock on a settled DTO", () => { + rhythmMock.mockReturnValue({ + data: READY_DTO, + isLoading: false, + isError: false, + }); + const html = render(); + expect(html).toContain('data-slot="chronotype-band"'); + expect(html).toContain("Evening type"); + expect(html).toContain("05:41"); + // The section heading carries the chronotype title. + expect(html).toContain('data-slot="section-heading"'); + }); + + it("renders nothing when disabled", () => { + rhythmMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }); + const html = render(); + expect(html).toBe(""); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index ffa8e2572..c2c0bc53f 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -110,20 +110,20 @@ describe("", () => { expect(html).toContain("04:30"); }); - it("renders the band as a labelled top-right corner readout, not a badge", () => { + it("v1.18.7 W-D — renders the band as the large hero label, not a corner readout", () => { 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"); + // The reworked bottom treatment leads with a large band label + the + // natural-midpoint headline. The retired top-right corner readout is gone. + expect(html).not.toContain('data-slot="chronotype-corner"'); + expect(html).toContain('data-slot="chronotype-band"'); expect(html).toContain("Intermediate type"); + expect(html).toContain("text-2xl"); }); - it("inherits the standard Card rhythm (no compact override)", () => { + it("uses the semantic info token, never the raw Dracula cyan", () => { const learning = render(); const ready = render(); for (const html of [learning, ready]) { - expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4"); - expect(html).toContain("md:py-6"); expect(html).toContain("text-info"); expect(html).not.toContain("text-dracula-cyan"); } diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx index 639992829..2d254ed3e 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -12,8 +12,11 @@ import type { SleepRhythmDto } from "../use-sleep-rhythm"; * clinical panel's treatment: * - a query error renders a quiet inline notice, NOT an endless skeleton * - loading (and settled-but-undefined) shows the skeletons - * - a settled DTO renders both cards + * - a settled DTO renders the sleep-debt card * `useAuth` + `useSleepRhythm` are mocked so each branch is deterministic. + * + * v1.18.7 W-D — the chronotype card moved out to `` (the + * prominent bottom block); this section now owns the sleep-debt headline alone. */ const authMock = vi.fn(); @@ -87,54 +90,17 @@ describe("", () => { expect(html).not.toContain('data-slot="sleep-rhythm-error"'); }); - it("renders both cards on a settled DTO with the standard Card rhythm", () => { + it("renders the sleep-debt card on a settled DTO and NOT the chronotype", () => { rhythmMock.mockReturnValue({ data: READY_DTO, isLoading: false, isError: false, }); const html = render(); - expect(html).toContain("Intermediate type"); expect(html).toContain("10h 0m short"); - // The cards inherit the house Card rhythm (gap-4 py-4 md:gap-6 md:py-6) — - // the old compact override must NOT reappear. - expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4"); - expect(html).toContain("md:py-6"); - }); - - it("places the two cards side by side when both carry settled data", () => { - rhythmMock.mockReturnValue({ - data: READY_DTO, - isLoading: false, - isError: false, - }); - const html = render(); - // Two-up on large screens, single column below. - expect(html).toContain("lg:grid-cols-2"); - }); - - it("keeps a lone data-bearing card full width when the other is still learning", () => { - rhythmMock.mockReturnValue({ - data: { - ...READY_DTO, - // Sleep-debt settled, chronotype still learning → no two-up grid. - chronotype: { - state: "learning", - msfMinutes: null, - msfScMinutes: null, - band: null, - socialJetlagMinutes: null, - freeNightsCounted: 2, - workNightsCounted: 5, - freeNightsUntilReady: 4, - }, - } satisfies SleepRhythmDto, - isLoading: false, - isError: false, - }); - const html = render(); - expect(html).toContain("grid-cols-1"); - expect(html).not.toContain("lg:grid-cols-2"); + // The chronotype band lives in the separate bottom section now, never here. + expect(html).not.toContain("Intermediate type"); + expect(html).not.toContain('data-slot="chronotype-band"'); }); it("renders nothing when disabled", () => { diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index e7ddfa94e..7d616d8a9 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -25,10 +25,12 @@ function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } /** * v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag). * - * Progressive disclosure: the band + mid-sleep clock time show by default; the - * social-jetlag detail + the sleep-debt-corrected MSFsc sit behind an - * "advanced" toggle. While `learning` it shows "still learning your rhythm — - * N of M nights" and asserts NO type. Warm, grounded copy; mobile-first. + * v1.18.7 W-D — reworked from a small corner-label card into the PROMINENT + * bottom treatment of the Sleep view: a LARGE band label is the hero, with the + * natural sleep-midpoint clock beneath it, and an "advanced" disclosure that + * reveals social jetlag + the sleep-debt-corrected MSFsc. While `learning` it + * keeps the calm "still learning your rhythm — N of M nights" copy and asserts + * NO band. Warm, grounded copy; mobile-first. */ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { const { t } = useTranslations(); @@ -38,7 +40,7 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { const need = chronotype.freeNightsCounted + chronotype.freeNightsUntilReady; return ( - +
@@ -64,40 +66,31 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null; return ( - + - {/* 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} - -
+
+ + + {t("insights.sleep.chronotype.title")} +
- - {msfClock && ( -

- {t("insights.sleep.chronotype.midSleepCaption", { - clock: msfClock, - })} + + {/* The band is the hero of the bottom treatment — a large label the + eye lands on first, with the natural sleep-midpoint clock as the + grounding caption beneath it. */} +

+

+ {bandLabel}

- )} + {msfClock && ( +

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

+ )} +