diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 1cac47cd7..8b2845275 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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() { */} + {/* + 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. + */} + + {/* diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx new file mode 100644 index 000000000..0b6a817ed --- /dev/null +++ b/src/components/insights/sleep/sleep-quality-section.tsx @@ -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 (`` 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 ( +
+ + {t("insights.sleepQuality.subtitle")} +

+ } + /> +
+ {present.map((tile) => ( + + ))} +
+
+ ); +}