From 4807c3afd8bea9bbb69cb5bc3c92a128ea09e194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:08:32 +0200 Subject: [PATCH] Align sleep rhythm cards with the house Card rhythm and states Drop the compact gap/padding override on the sleep-debt and chronotype cards so they inherit the standard Card rhythm, matching the glucose panel. Switch the card icons to the semantic text-info token. Branch SleepRhythmSection on a query error to a quiet inline notice instead of an endless skeleton, keeping loading and settled states distinct. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../__tests__/sleep-rhythm-cards.test.tsx | 23 ++++ .../__tests__/sleep-rhythm-section.test.tsx | 114 ++++++++++++++++++ .../insights/sleep/chronotype-card.tsx | 8 +- .../insights/sleep/sleep-debt-card.tsx | 8 +- .../insights/sleep/sleep-rhythm-section.tsx | 27 ++++- 11 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx diff --git a/messages/de.json b/messages/de.json index da188c5c9..d1bf6dc03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2697,6 +2697,9 @@ "late": "Abendtyp", "extreme_late": "Ausgeprägter Abendtyp" } + }, + "rhythm": { + "loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht." } }, "correlations": "Korrelationen", diff --git a/messages/en.json b/messages/en.json index 162ac5504..61dca2fda 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2697,6 +2697,9 @@ "late": "Evening type", "extreme_late": "Strong evening type" } + }, + "rhythm": { + "loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles." } }, "correlations": "Correlations", diff --git a/messages/es.json b/messages/es.json index ac43c3815..3e96aa0ad 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2697,6 +2697,9 @@ "late": "Vespertino", "extreme_late": "Vespertino marcado" } + }, + "rhythm": { + "loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión." } }, "correlations": "Correlaciones", diff --git a/messages/fr.json b/messages/fr.json index 1f8eaa98e..5d8addd49 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2697,6 +2697,9 @@ "late": "Type du soir", "extreme_late": "Type du soir marqué" } + }, + "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." } }, "correlations": "Corrélations", diff --git a/messages/it.json b/messages/it.json index bc5a81c1c..fa1f6622a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2697,6 +2697,9 @@ "late": "Tipo serale", "extreme_late": "Tipo serale marcato" } + }, + "rhythm": { + "loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile." } }, "correlations": "Correlazioni", diff --git a/messages/pl.json b/messages/pl.json index e66363b37..29e6d5098 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2697,6 +2697,9 @@ "late": "Typ wieczorny", "extreme_late": "Wyraźny typ wieczorny" } + }, + "rhythm": { + "loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje." } }, "correlations": "Korelacje", 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 9e313557f..7e1dc23ae 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -72,6 +72,18 @@ describe("", () => { expect(html).toContain("14 nights"); }); + it("inherits the standard Card rhythm (no compact override)", () => { + const partial = render(); + const ready = render(); + for (const html of [partial, ready]) { + expect(html).not.toContain("gap-2 py-4 md:gap-2 md:py-4"); + expect(html).toContain("md:py-6"); + // Semantic info token, not the raw Dracula cyan. + expect(html).toContain("text-info"); + expect(html).not.toContain("text-dracula-cyan"); + } + }); + it("shows the caught-up state at zero debt", () => { const html = render( , @@ -98,6 +110,17 @@ describe("", () => { expect(html).toContain("04:30"); }); + it("inherits the standard Card rhythm (no compact override)", () => { + 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"); + } + }); + it("keeps social jetlag + MSFsc behind the advanced disclosure (collapsed by default)", () => { const html = render(); // The toggle is present, the advanced detail is not rendered until opened. diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx new file mode 100644 index 000000000..696d6b981 --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx @@ -0,0 +1,114 @@ +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.17.0 — sleep-rhythm SECTION states. + * + * The cards' own partial/learning copy is covered in `sleep-rhythm-cards`; + * this file pins the section wrapper's three states, mirroring the glucose + * 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 + * `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() }; +}); + +// Import after the mocks are registered. +const { SleepRhythmSection } = await import("../sleep-rhythm-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: 4 * 60 + 30, + msfScMinutes: 4 * 60, + band: "intermediate", + 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, not an infinite skeleton", () => { + rhythmMock.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + const html = render(); + expect(html).toContain('data-slot="sleep-rhythm-error"'); + expect(html).toContain("Your sleep rhythm"); + // No skeletons while errored. + expect(html).not.toContain('data-slot="sleep-rhythm-loading"'); + }); + + it("shows the skeletons while loading", () => { + rhythmMock.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + }); + const html = render(); + expect(html).toContain('data-slot="sleep-rhythm-loading"'); + expect(html).not.toContain('data-slot="sleep-rhythm-error"'); + }); + + it("renders both cards on a settled DTO with the standard Card rhythm", () => { + 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("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/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 7af1f9a26..280b0a544 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -38,10 +38,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { const need = chronotype.freeNightsCounted + chronotype.freeNightsUntilReady; return ( - +
- + {t("insights.sleep.chronotype.title")} @@ -64,10 +64,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null; return ( - +
- + {t("insights.sleep.chronotype.title")} diff --git a/src/components/insights/sleep/sleep-debt-card.tsx b/src/components/insights/sleep/sleep-debt-card.tsx index d0454adf9..5558feb05 100644 --- a/src/components/insights/sleep/sleep-debt-card.tsx +++ b/src/components/insights/sleep/sleep-debt-card.tsx @@ -26,10 +26,10 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { if (debt.state === "partial") { return ( - +
- + {t("insights.sleep.debt.title")} @@ -51,10 +51,10 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) { const isClear = debt.debtMinutes === 0; return ( - +
- + {t("insights.sleep.debt.title")} diff --git a/src/components/insights/sleep/sleep-rhythm-section.tsx b/src/components/insights/sleep/sleep-rhythm-section.tsx index 6e0ef9c6e..68c578377 100644 --- a/src/components/insights/sleep/sleep-rhythm-section.tsx +++ b/src/components/insights/sleep/sleep-rhythm-section.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/use-auth"; import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; import { useSleepRhythm } from "./use-sleep-rhythm"; import { SleepDebtCard } from "./sleep-debt-card"; import { ChronotypeCard } from "./chronotype-card"; @@ -13,16 +14,38 @@ import { ChronotypeCard } from "./chronotype-card"; * 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). + * + * States, mirroring the glucose clinical panel's treatment: + * - loading → skeletons (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" / + * source-cue copy for the no-nights-yet case (the route always + * returns a full DTO, so emptiness surfaces inside the cards). */ export function SleepRhythmSection({ enabled }: { enabled: boolean }) { const { isAuthenticated } = useAuth(); - const { data, isLoading } = useSleepRhythm(isAuthenticated && enabled); + const { t } = useTranslations(); + const { data, isLoading, isError } = useSleepRhythm( + isAuthenticated && enabled, + ); if (!enabled) return null; + if (isError) { + return ( +
+ {t("insights.sleep.rhythm.loadError")} +
+ ); + } + if (isLoading || !data) { return ( -
+