mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d84c73af0d
commit
4807c3afd8
11 files changed
+188
-10
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user