Surface sleep-debt and chronotype on the sleep page

Compute cumulative sleep debt and the MCTQ chronotype server-side off the
canonical night reconstruction the Sleep Score reads, and render them on
the sleep page: a sleep-debt headline with a calm partial state under the
night threshold, and a chronotype card whose social-jetlag detail and
corrected mid-sleep sit behind an advanced disclosure. Both hold a learning
state on thin data and never assert a total or a band.

Serve the same values to the dashboard summary and a new GET /api/sleep/rhythm
read so the native client renders identical figures. Free vs work nights
default to weekend in the user's timezone until day-type tracking exists.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:03:11 +02:00
1 parent 4884f019b7
commit d1ba01548f
22 files changed
+1489 -2

No files matched your search

@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SleepDebtCard } from "../sleep-debt-card";
import { ChronotypeCard } from "../chronotype-card";
import type { SleepDebtDto, ChronotypeDto } from "../use-sleep-rhythm";
/**
* v1.17.0 — sleep-debt + chronotype card rendering, with a focus on the calm
* NOT-YET-READY states (partial / learning): both must show a "still learning"
* nudge and NEVER assert a debt total or a chronotype band off thin data.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
const PARTIAL_DEBT: SleepDebtDto = {
state: "partial",
debtMinutes: 0,
needMinutes: 420,
nightsCounted: 3,
windowNights: 14,
nightsUntilReady: 4,
};
const READY_DEBT: SleepDebtDto = {
state: "ready",
debtMinutes: 600,
needMinutes: 420,
nightsCounted: 10,
windowNights: 14,
nightsUntilReady: 0,
};
const LEARNING_CHRONO: ChronotypeDto = {
state: "learning",
msfMinutes: null,
msfScMinutes: null,
band: null,
socialJetlagMinutes: null,
freeNightsCounted: 1,
workNightsCounted: 5,
freeNightsUntilReady: 2,
};
const READY_CHRONO: ChronotypeDto = {
state: "ready",
msfMinutes: 4 * 60 + 30, // 04:30
msfScMinutes: 4 * 60, // 04:00
band: "intermediate",
socialJetlagMinutes: 75,
freeNightsCounted: 6,
workNightsCounted: 14,
freeNightsUntilReady: 0,
};
describe("<SleepDebtCard>", () => {
it("shows the learning nudge in the partial state, not a debt total", () => {
const html = render(<SleepDebtCard debt={PARTIAL_DEBT} />);
expect(html).toContain("Still learning your sleep");
expect(html).toContain("3 nights");
// No asserted "short" figure while partial.
expect(html).not.toContain("short");
});
it("asserts the cumulative debt in the ready state", () => {
const html = render(<SleepDebtCard debt={READY_DEBT} />);
// 600 min = 10h 0m short.
expect(html).toContain("10h 0m short");
expect(html).toContain("14 nights");
});
it("shows the caught-up state at zero debt", () => {
const html = render(
<SleepDebtCard debt={{ ...READY_DEBT, debtMinutes: 0 }} />,
);
expect(html).toContain("All caught up");
});
});
describe("<ChronotypeCard>", () => {
it("shows the learning state and asserts NO band off thin data", () => {
const html = render(<ChronotypeCard chronotype={LEARNING_CHRONO} />);
expect(html).toContain("Still learning your rhythm");
expect(html).toContain("1 of 3 free nights");
// No band label leaks in the learning state — none of the five MCTQ
// class labels appears.
expect(html).not.toContain("Intermediate type");
expect(html).not.toContain("Morning type");
expect(html).not.toContain("Evening type");
});
it("shows the band + mid-sleep clock in the ready state", () => {
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
expect(html).toContain("Intermediate type");
expect(html).toContain("04:30");
});
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.
expect(html).toContain("Advanced");
expect(html).not.toContain("social jetlag");
expect(html).not.toContain("Corrected mid-sleep");
});
});
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { ChronotypeDto } from "./use-sleep-rhythm";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
function clockLabel(minutesOfDay: number): string {
const wrapped = ((Math.round(minutesOfDay) % 1440) + 1440) % 1440;
const h = Math.floor(wrapped / 60);
const m = wrapped - h * 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Whole hours + minutes from a minute total, for the social-jetlag readout. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
const t = Math.round(totalMinutes);
return { hours: Math.floor(t / 60), minutes: t - Math.floor(t / 60) * 60 };
}
/**
* 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.
*/
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const { t } = useTranslations();
const [showAdvanced, setShowAdvanced] = useState(false);
if (chronotype.state === "learning" || chronotype.band == null) {
const need =
chronotype.freeNightsCounted + chronotype.freeNightsUntilReady;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.chronotype.learning", {
counted: chronotype.freeNightsCounted,
total: need,
})}
</p>
</CardContent>
</Card>
);
}
const bandLabel = t(`insights.sleep.chronotype.band.${chronotype.band}`);
const msfClock =
chronotype.msfMinutes != null ? clockLabel(chronotype.msfMinutes) : null;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Clock className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.chronotype.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Badge variant="secondary" className="text-sm">
{bandLabel}
</Badge>
{msfClock && (
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.midSleepCaption", {
clock: msfClock,
})}
</p>
)}
</div>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
>
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{t("insights.sleep.chronotype.advancedToggle")}
</button>
{showAdvanced && (
<div className="space-y-2 border-t pt-3">
{chronotype.socialJetlagMinutes != null &&
(() => {
const { hours, minutes } = hoursMinutes(
chronotype.socialJetlagMinutes,
);
return (
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("insights.sleep.chronotype.socialJetlagValue", {
hours,
minutes,
})}
</p>
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.socialJetlagCaption")}
</p>
</div>
);
})()}
{chronotype.msfScMinutes != null && (
<div className="space-y-0.5">
<p className="text-sm font-medium tabular-nums">
{t("insights.sleep.chronotype.msfScValue", {
clock: clockLabel(chronotype.msfScMinutes),
})}
</p>
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.msfScCaption")}
</p>
</div>
)}
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.dayTypeNote")}
</p>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,83 @@
"use client";
import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
function hoursMinutes(totalMinutes: number): { hours: number; minutes: number } {
const hours = Math.floor(totalMinutes / 60);
const minutes = Math.round(totalMinutes - hours * 60);
return { hours, minutes };
}
/**
* v1.17.0 — sleep-debt headline.
*
* The cumulative shortfall between sleep need and sleep obtained over the
* rolling window. Under the night threshold it shows the calm `partial`
* state ("still learning — N more nights") and never asserts a total off thin
* data. Mobile-first: a single readable figure + one line of grounded context.
*/
export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
const { t } = useTranslations();
if (debt.state === "partial") {
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.debt.learning", {
counted: debt.nightsCounted,
remaining: debt.nightsUntilReady,
})}
</p>
</CardContent>
</Card>
);
}
const { hours, minutes } = hoursMinutes(debt.debtMinutes);
const isClear = debt.debtMinutes === 0;
return (
<Card className="gap-2 py-4 md:gap-2 md:py-4">
<CardHeader className="pb-0">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-base font-semibold">
{t("insights.sleep.debt.title")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-0.5">
<p className="text-2xl font-semibold tabular-nums">
{isClear
? t("insights.sleep.debt.clearValue")
: t("insights.sleep.debt.value", { hours, minutes })}
</p>
<p className="text-muted-foreground text-xs">
{isClear
? t("insights.sleep.debt.clearCaption", {
nights: debt.windowNights,
})
: t("insights.sleep.debt.caption", {
nights: debt.windowNights,
})}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
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).
*/
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useSleepRhythm(isAuthenticated && enabled);
if (!enabled) return null;
if (isLoading || !data) {
return (
<div className="space-y-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-4">
<SleepDebtCard debt={data.sleepDebt} />
<ChronotypeCard chronotype={data.chronotype} />
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.17.0 — sleep-debt DTO (mirrors the server `SleepDebtDto`). `partial` is
* the calm "still learning" state below the night threshold; `ready` asserts
* the cumulative deficit.
*/
export interface SleepDebtDto {
state: "partial" | "ready";
debtMinutes: number;
needMinutes: number;
nightsCounted: number;
windowNights: number;
nightsUntilReady: number;
}
/** v1.17.0 — chronotype DTO (mirrors the server `ChronotypeDto`). */
export interface ChronotypeDto {
state: "learning" | "ready";
msfMinutes: number | null;
msfScMinutes: number | null;
band:
| "extreme_early"
| "early"
| "intermediate"
| "late"
| "extreme_late"
| null;
socialJetlagMinutes: number | null;
freeNightsCounted: number;
workNightsCounted: number;
freeNightsUntilReady: number;
}
export interface SleepRhythmDto {
sleepDebt: SleepDebtDto;
chronotype: ChronotypeDto;
}
/**
* Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a
* source-less account never fetches; shares the measurement-write
* invalidation prefix (`["sleep-rhythm"]`).
*/
export function useSleepRhythm(enabled: boolean) {
return useQuery({
queryKey: queryKeys.sleepRhythm(),
queryFn: () => apiGet<SleepRhythmDto>("/api/sleep/rhythm"),
enabled,
staleTime: 60 * 1000,
});
}