feat(sleep): surface the nightly sleep-quality scores on the Sleep page

WHOOP and Oura write sleep efficiency, performance, consistency, need, the
disturbance count, and the Oura sleep score every night; until now none of them
rendered anywhere. Add a Sleep quality block after the rhythm section that
surfaces all six through the device-score tile.

Each tile is data-gated, so a non-wearable account never sees an empty card and
the whole block collapses metric-by-metric down to nothing when no source
writes them. It reads the same analytics summaries slice the page already
fetched for its duration gate, so it costs no extra round-trip.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:39:07 +02:00
1 parent 794192c089
commit e063f79353
2 files changed
+125

No files matched your search

+11
View File
@@ -13,6 +13,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 { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -89,6 +90,16 @@ export default function InsightsSchlafPage() {
*/}
<SleepRhythmSection enabled={!isEmpty} />
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
sleep-quality scores (efficiency / performance / consistency / need /
disturbance count + the Oura sleep score) that were ingested but never
rendered. Each tile is data-gated, so the block is invisible for
non-wearable users and collapses metric-by-metric. Server-authoritative
— the tiles render stored values.
*/}
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
@@ -0,0 +1,114 @@
"use client";
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
*
* Surfaces the WHOOP / Oura nightly sleep-quality scores that were ingested
* end-to-end but rendered nowhere: efficiency, performance, consistency, need,
* disturbance count, and the Oura headline sleep score. Each tile is gated on
* the metric having data (`<DeviceScoreTile>` returns null at count 0), so a
* non-wearable user never sees an empty card and the whole section collapses
* when none of the six has readings.
*
* Reads the shared `["analytics", "summaries"]` slice — the same cache the
* page's duration gate already populated, so this adds no network round-trip.
* Server-authoritative: the tiles render the stored values, never a recompute.
*/
export function SleepQualitySection({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const { data } = useInsightsAnalytics("SLEEP_DURATION");
if (!enabled) return null;
const summaries = data?.summaries;
if (!summaries) return null;
const tiles: Array<{
type: string;
title: string;
icon: typeof Moon;
color: string;
unit?: string;
}> = [
{
type: "SLEEP_SCORE",
title: t("measurements.typeSleepScore"),
icon: Moon,
color: "#bd93f9",
unit: t("insights.deviceScore.unitScore"),
},
{
type: "SLEEP_PERFORMANCE",
title: t("measurements.typeSleepPerformance"),
icon: Target,
color: "#8be9fd",
unit: "%",
},
{
type: "SLEEP_EFFICIENCY",
title: t("measurements.typeSleepEfficiency"),
icon: Gauge,
color: "#50fa7b",
unit: "%",
},
{
type: "SLEEP_CONSISTENCY",
title: t("measurements.typeSleepConsistency"),
icon: Repeat,
color: "#f1fa8c",
unit: "%",
},
{
type: "SLEEP_NEED",
title: t("measurements.typeSleepNeed"),
icon: Waves,
color: "#ffb86c",
unit: t("insights.deviceScore.unitMinutes"),
},
{
type: "SLEEP_DISTURBANCE_COUNT",
title: t("measurements.typeSleepDisturbanceCount"),
icon: Activity,
color: "#ff79c6",
},
];
const present = tiles.filter(
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
);
if (present.length === 0) return null;
return (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
icon={Moon}
title={t("insights.sleepQuality.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.sleepQuality.subtitle")}
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
/>
))}
</div>
</section>
);
}