mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge wave-sleep: drop last-night card, big chronotype, fix AI-hint
This commit is contained in:
commit
5fca70524d
15 files changed
+292
-179
No files matched your search
+1
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in new issue
Block a user