Files
HealthLog/src/components/insights/sleep-duration-chart.tsx
T
Marc-André Bombeck 2a7195601b fix(sleep): stop double-counting bare ASLEEP against granular stages
Apple Health (and some WHOOP shapes) write BOTH an unspecified ASLEEP
aggregate row AND the granular CORE/DEEP/REM breakdown for the same
sleep period. The per-night reconstruction summed both, so a real ~8 h
night surfaced as ~16 h across every sleep surface — the chart showed
~16-18 h points and the target-range 30-day average read ~16.4 h/day.

Prefer the granular partition: when any CORE/DEEP/REM stage is present
for a night/source, count only those; fall back to the bare ASLEEP (or
a stage-less legacy/manual) row only when no granular stage exists.
IN_BED and AWAKE stay excluded; dual-source nights still collapse to one
canonical source via the user's sleep priority ladder. The same rule
applies inside the per-source asleep tiebreak so a source writing both
shapes is not over-weighted.

Align the surfaces that bypassed the shared helper:

- insights/targets: SLEEP_DURATION was averaged as raw per-stage rows
  (minutes) and fed into a target band expressed in hours, producing the
  bogus 16.4 h/day. Reconstruct per-night time-asleep, convert to hours,
  and classify + roll up consistency on the nightly hour totals.
- coach snapshot: route the sleep block through reconstructSleepNights
  so the prompt narrates the same nightly numbers (was summing every
  stage row, including IN_BED + AWAKE).
- sleep card chart: read /api/measurements/series?kind=sleep (night
  reconstruction, hours) instead of the generic measurement
  daily-average path, and label the axis in hours instead of minutes.

Add regression fixtures: a night with both a bare ASLEEP row and the
granular breakdown asserts the granular total (no double-count); a
dual-source night with both shapes per source; and a multi-night 30-day
average that lands at ~8 h, not ~16 h.
2026-06-04 18:22:55 +02:00

53 lines
1.9 KiB
TypeScript

"use client";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { useTranslations } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
* v1.4.25 W4c — nightly TIME-ASLEEP over time.
*
* Thin wrapper around `<HealthChartDynamic>` so the sleep sub-page gets
* the same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
* surfaces.
*
* Data path: SLEEP_DURATION rows are stored per-stage (one row per stage
* per night, in MINUTES). `<HealthChart>` special-cases SLEEP_DURATION to
* read `/api/measurements/series?kind=sleep`, which reconstructs ONE point
* per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM,
* bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE excluded,
* dual-source nights collapsed to one source). The chart therefore renders
* a nightly trend in hours that matches every other sleep surface.
*
* Unit semantics: the chart plots HOURS — the same unit the series adapter
* returns and the `<SleepOverview>` headline shows — so the axis reads e.g.
* "8 h", not the inflated per-stage minutes the legacy path surfaced.
*
* v1.4.28 R3d (BK-F-M2) — the inline `dynamic()` call site was retired
* in favour of the shared `<HealthChartDynamic>` re-export so the
* `<ChartSkeleton>` + `ssr: false` configuration stays in one place.
*/
export interface SleepDurationChartProps {
compareBaseline?: ComparisonBaseline;
userTimezone?: string;
}
export function SleepDurationChart({
compareBaseline,
userTimezone,
}: SleepDurationChartProps) {
const { t } = useTranslations();
return (
<HealthChartDynamic
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("charts.sleep")}
colors={["#8be9fd"]}
unit="h"
yAxisUnit="h"
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
);
}