Merge wave-sleep: drop last-night card, big chronotype, fix AI-hint

This commit is contained in:
Marc-André Bombeck committed 2026-06-19 19:48:50 +02:00
commit 5fca70524d
15 files changed
+292 -179

No files matched your search

+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."
+1 -2
View File
@@ -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."
+15 -3
View File
@@ -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 (`<ChronotypeSection>` below); this section is the
sleep-debt headline alone.
*/}
<SleepRhythmSection enabled={!isEmpty} />
@@ -134,6 +137,15 @@ export default function InsightsSchlafPage() {
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
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.
*/}
<ChronotypeSection enabled={!isEmpty} />
</SubPageShell>
);
}
+7 -23
View File
@@ -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 (
<EmptyState
@@ -154,8 +140,6 @@ export function SleepOverview() {
</CardContent>
</Card>
{lastNight && <SleepHypnogram session={lastNight} />}
{sleepStages ? (
<SleepStageStackedBar breakdown={sleepStages} />
) : (
@@ -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<typeof import("../use-sleep-rhythm")>();
return { ...actual, useSleepRhythm: () => rhythmMock() };
});
const { ChronotypeSection } = await import("../chronotype-section");
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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("<ChronotypeSection>", () => {
it("renders the quiet inline notice on a query error", () => {
rhythmMock.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
const html = render(<ChronotypeSection enabled />);
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(<ChronotypeSection enabled />);
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(<ChronotypeSection enabled />);
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(<ChronotypeSection enabled={false} />);
expect(html).toBe("");
});
});
@@ -110,20 +110,20 @@ describe("<ChronotypeCard>", () => {
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(<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");
// 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(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
const ready = render(<ChronotypeCard chronotype={READY_CHRONO} />);
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");
}
@@ -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 `<ChronotypeSection>` (the
* prominent bottom block); this section now owns the sleep-debt headline alone.
*/
const authMock = vi.fn();
@@ -87,54 +90,17 @@ describe("<SleepRhythmSection>", () => {
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(<SleepRhythmSection enabled />);
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(<SleepRhythmSection enabled />);
// 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(<SleepRhythmSection enabled />);
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", () => {
@@ -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 (
<Card>
<Card data-slot="chronotype-card">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-info h-4 w-4" />
@@ -64,40 +66,31 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
<Card>
<Card data-slot="chronotype-card">
<CardHeader className="pb-0">
{/* 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 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>
</CardHeader>
<CardContent className="space-y-3">
{msfClock && (
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.midSleepCaption", {
clock: msfClock,
})}
<CardContent className="space-y-4">
{/* 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. */}
<div className="space-y-1" data-slot="chronotype-band">
<p className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{bandLabel}
</p>
)}
{msfClock && (
<p className="text-muted-foreground text-sm">
{t("insights.sleep.chronotype.midpointHeadline", {
clock: msfClock,
})}
</p>
)}
</div>
<button
type="button"
@@ -0,0 +1,57 @@
"use client";
import { Clock } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { ChronotypeCard } from "./chronotype-card";
/**
* v1.18.7 W-D — chronotype as the prominent bottom block of the Sleep view.
*
* Split out of `<SleepRhythmSection>` (which now owns only the sleep-debt
* headline) so the chronotype gets a full-width treatment at the end of the
* page: a large band label, the natural sleep-midpoint clock beneath it, and an
* expandable disclosure for social jetlag + MSFsc. Reads the SAME
* server-authoritative DTO via `useSleepRhythm`, so it shares the
* `["sleep-rhythm"]` cache the debt section already warmed — no extra
* round-trip. Gated on `enabled` so a source-less account never fetches.
*
* States mirror the debt section: loading → skeleton, error → quiet inline
* notice, settled → the card (which carries its own calm "still learning"
* copy for the no-nights-yet case, since the route always returns a full DTO).
*/
export function ChronotypeSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError } = useSleepRhythm(
isAuthenticated && enabled,
);
if (!enabled) return null;
return (
<section data-slot="chronotype-section" className="space-y-3">
<SectionHeading icon={Clock} title={t("insights.sleep.chronotype.title")} />
{isError ? (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
) : isLoading || !data ? (
<Skeleton
data-slot="chronotype-loading"
className="h-32 w-full rounded-xl"
/>
) : (
<ChronotypeCard chronotype={data.chronotype} />
)}
</section>
);
}
@@ -134,16 +134,28 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
}
/**
* 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.
* The quality block's "Einschätzung".
*
* 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.
* v1.18.7 W-D — the assessment slot was broken: the previous wiring disabled
* the status fetch whenever the AI narrative was absent, so the card could not
* read `hasProvider` and fell back to its `hasProvider: false` default — it
* showed the "connect an AI provider" CTA even for accounts that HAVE a
* provider connected (the text just hadn't been generated yet), AND it stacked
* that CTA under the grounded note. Two assessments, one of them wrong.
*
* The card now stays enabled (gated only on the metric having data), so it
* reports the truthful state from the route:
* - no provider → the route's `hasProvider: false` → the card's own
* clean "connect an AI provider → AI settings" CTA,
* ALONE (the grounded note stands down).
* - provider, no text yet → the grounded note fills the slot with a read
* built from the user's own quality averages, so the
* block is never the bare "no assessment yet" line.
* - provider, text/prep → the AI card owns the slot; the grounded note
* stands down so the two never stack.
*
* The grounded note reads the SAME SLEEP_SCORE status query the card warmed, so
* it adds no second round-trip.
*/
function SleepQualityAssessmentBlock({
summaries,
@@ -156,30 +168,37 @@ function SleepQualityAssessmentBlock({
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 =
// The grounded note only steps in when a provider IS connected but the AI
// narrative has SETTLED with no text (and none is being assembled). When no
// provider is configured the AI card owns the slot with its own CTA, so the
// note never stacks under it; when the AI text exists or is preparing the AI
// card wins. Until the status query settles we show nothing extra, so the
// note never flashes ahead of a warm AI assessment.
const showGroundedNote =
hasSleepScore &&
status?.hasProvider === true &&
!statusLoading &&
!status?.text &&
status?.preparing !== true &&
status?.revalidating !== true;
// Exactly one block fills the assessment slot, so the two never stack: the
// grounded note when a provider is connected but the AI text has settled
// empty, otherwise the status card (which owns the no-provider CTA, the
// preparing state, and the real text). The card's hook fetched regardless of
// which branch renders — it runs at the top of this component — so swapping
// its render for the note loses no `hasProvider` knowledge.
if (showGroundedNote) {
return (
<SleepQualityGroundedNote summaries={summaries} showWhenAiAbsent />
);
}
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}
/>
</>
<MetricStatusCard
metric="SLEEP_SCORE"
icon={<Moon className="h-5 w-5" />}
enabled={hasSleepScore}
/>
);
}
@@ -2,26 +2,28 @@
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { SleepDebtCard } from "./sleep-debt-card";
import { ChronotypeCard } from "./chronotype-card";
/**
* v1.17.0 — sleep-rhythm section on the Sleep page.
*
* Reads the server-authoritative sleep-debt + chronotype DTO and renders the
* sleep-debt headline + the chronotype card. Gated on `enabled` so a
* source-less account never fetches. The values are identical to what the iOS
* client renders (one server computation).
* sleep-debt headline. Gated on `enabled` so a source-less account never
* fetches. The values are identical to what the iOS client renders (one server
* computation).
*
* v1.18.7 W-D — the chronotype card moved out to `<ChronotypeSection>`, the
* prominent bottom treatment of the Sleep view; this section now owns the
* sleep-debt headline alone. Both read the same `["sleep-rhythm"]` cache.
*
* States, mirroring the glucose clinical panel's treatment:
* - loading → skeletons (distinct from a settled-but-empty read)
* - loading → skeleton (distinct from a settled-but-empty read)
* - error → a quiet inline notice, never an endless skeleton
* - settled → the two cards, which carry their OWN calm "still learning" /
* - settled → the debt card, which carries its OWN calm "still learning" /
* source-cue copy for the no-nights-yet case (the route always
* returns a full DTO, so emptiness surfaces inside the cards).
* returns a full DTO, so emptiness surfaces inside the card).
*/
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
@@ -48,29 +50,9 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
return (
<div className="space-y-4" data-slot="sleep-rhythm-loading">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
);
}
// Both cards are compact; when each carries a settled (non-learning) readout
// they sit two-up. A lone card with data spans full width so it never leaves
// a half-width orphan with dead space beside it (mirrors the mood Einordnung
// tiles). The cards' own learning/partial states stay stacked full-width.
const debtHasData = data.sleepDebt.state === "ready";
const chronotypeHasData =
data.chronotype.state === "ready" && data.chronotype.band != null;
const bothHaveData = debtHasData && chronotypeHasData;
return (
<div
className={cn(
"grid gap-4",
bothHaveData ? "lg:grid-cols-2 lg:items-start" : "grid-cols-1",
)}
>
<SleepDebtCard debt={data.sleepDebt} />
<ChronotypeCard chronotype={data.chronotype} />
</div>
);
return <SleepDebtCard debt={data.sleepDebt} />;
}