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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 21:08:32 +02:00
1 parent d84c73af0d
commit 4807c3afd8
11 files changed
+188 -10

No files matched your search

+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -72,6 +72,18 @@ describe("<SleepDebtCard>", () => {
expect(html).toContain("14 nights");
});
it("inherits the standard Card rhythm (no compact override)", () => {
const partial = render(<SleepDebtCard debt={PARTIAL_DEBT} />);
const ready = render(<SleepDebtCard debt={READY_DEBT} />);
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(
<SleepDebtCard debt={{ ...READY_DEBT, debtMinutes: 0 }} />,
@@ -98,6 +110,17 @@ describe("<ChronotypeCard>", () => {
expect(html).toContain("04:30");
});
it("inherits the standard Card rhythm (no compact override)", () => {
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");
}
});
it("keeps social jetlag + MSFsc behind the advanced disclosure (collapsed by default)", () => {
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
// The toggle is present, the advanced detail is not rendered until opened.
@@ -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<typeof import("../use-sleep-rhythm")>();
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(
<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: 4 * 60 + 30,
msfScMinutes: 4 * 60,
band: "intermediate",
socialJetlagMinutes: 75,
freeNightsCounted: 6,
workNightsCounted: 14,
freeNightsUntilReady: 0,
},
};
beforeEach(() => {
authMock.mockReturnValue({ isAuthenticated: true });
rhythmMock.mockReset();
});
describe("<SleepRhythmSection>", () => {
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(<SleepRhythmSection enabled />);
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(<SleepRhythmSection enabled />);
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(<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("renders nothing when disabled", () => {
rhythmMock.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
const html = render(<SleepRhythmSection enabled={false} />);
expect(html).toBe("");
});
});
@@ -38,10 +38,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const need =
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<Card>
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<Clock className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
@@ -64,10 +64,10 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<Card>
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<Clock className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
@@ -26,10 +26,10 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
if (debt.state === "partial") {
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<Card>
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<Moon className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
@@ -51,10 +51,10 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
const isClear = debt.debtMinutes === 0;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<Card>
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<Moon className="text-info h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
@@ -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 (
<div
data-slot="sleep-rhythm-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<div className="space-y-4">
<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>