Files
HealthLog/src/lib/analytics/sleep-night.ts
T
Marc-André Bombeck ebeb16daa3 fix(sleep): emit the per-night total, not a single stage, on the sleep tile and series
SLEEP_DURATION is stored in minutes, one row per sleep stage per night
(IN_BED / AWAKE / ASLEEP / CORE / DEEP / REM) since the HealthKit
category-sample ingest landed. The value is not cumulative, so reading
the single most-recent row surfaced one stage's minutes — never the
night. The dashboard sleep tile and the sleep series therefore showed a
fragment of the night rather than the night.

Add a shared `summarizeSleepNights` helper that groups the per-stage
rows by the wake-day calendar date in the user's timezone and sums the
asleep stages (CORE + DEEP + REM, plus a legacy bare-duration row),
excluding IN_BED and AWAKE per the AASM convention already used by the
sleep score. Wire it into:

- the dashboard summary tile, which now emits last night's time asleep
  in hours with an explicit `unit: "h"` and a per-stage breakdown for a
  future detail view;
- the sleep series, which now returns one point per night (time asleep
  in hours) plus a top-level `unit` for every kind so clients never
  infer the unit;
- the slim summaries slice, so the web dashboard tile and the insights
  overview read per-night totals (kept in canonical minutes there; the
  web tile converts to hours) — strictly more correct than averaging
  across stage rows, and parity with the iOS value.
2026-06-04 16:44:53 +02:00

163 lines
5.9 KiB
TypeScript

/**
* v1.11.4 — per-night sleep aggregation (iOS #1, HIGH).
*
* `SLEEP_DURATION` is stored in MINUTES, one row per sleep STAGE per
* night (IN_BED / AWAKE / ASLEEP / REM / CORE / DEEP) since v1.4.23. The
* value is NOT cumulative — a single stage row carries only that stage's
* minutes. Every "last night's sleep" surface therefore has to SUM the
* stage rows belonging to one night before it shows a headline number;
* reading the single most-recent row gives one stage, not the night.
*
* This helper is the one shared place that turns the raw per-stage rows
* into per-night totals. The headline "time asleep" is light(CORE) +
* deep + REM (+ legacy bare `ASLEEP`); IN_BED and AWAKE are excluded
* from the asleep total, matching the AASM convention already encoded in
* `sleep-score.ts`'s `ASLEEP_STAGES`. Bare rows with no `sleepStage`
* (legacy / manual) count as that night's asleep total.
*
* Night grouping
* --------------
* A "night" is keyed by the wake-day calendar date in the USER's
* timezone (`userDayKey`). Apple Health / WHOOP write each stage with
* `measuredAt` at the stage's start instant; all the stages of one
* sleep session fall on the same wall-clock calendar day, so the tz day
* key collapses them into one bucket. (This matches the existing
* `computeSleepStageBreakdown` convention in the analytics route.)
*
* Unit
* ----
* Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit
* (`getUnitForType` → "minutes") — so the existing web consumers that
* already treat the summary as minutes (e.g. the insights overview's
* `formatHoursMinutes`) keep working unchanged; they now average per-
* night totals instead of per-stage rows, which is strictly more
* correct. Callers that want hours convert at the edge (`/ 60`).
*/
import type { SleepStage } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/resolver";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"ASLEEP",
"REM",
"CORE",
"DEEP",
]);
export interface SleepStageRow {
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
}
/** One reconstructed night: the asleep total + the per-stage breakdown. */
export interface SleepNight {
/** Wake-day key (YYYY-MM-DD) in the user's timezone. */
night: string;
/** Latest stage instant in the night — the night's representative timestamp. */
measuredAt: Date;
/** Time asleep in minutes = CORE + DEEP + REM (+ legacy bare ASLEEP). */
asleepMinutes: number;
/** In-bed minutes when an IN_BED row exists, else null. */
inBedMinutes: number | null;
/** Awake-in-bed minutes when an AWAKE row exists, else null. */
awakeMinutes: number | null;
/** Per-stage minutes for the night (only stages the device reported). */
stages: Partial<Record<SleepStage, number>>;
}
/**
* Group raw per-stage rows into per-night totals (tz-aware wake-day key).
* Pure — the caller does the bounded DB read. Nights are returned sorted
* ascending by key so the last element is the most recent night.
*/
export function reconstructSleepNights(
rows: SleepStageRow[],
tz: string,
): SleepNight[] {
const byNight = new Map<string, SleepStageRow[]>();
for (const row of rows) {
const key = userDayKey(row.measuredAt, tz);
const list = byNight.get(key) ?? [];
list.push(row);
byNight.set(key, list);
}
const nights: SleepNight[] = [];
for (const [night, nightRows] of byNight) {
let asleep = 0;
let inBed = 0;
let awake = 0;
let sawInBed = false;
let sawAwake = false;
let latest = nightRows[0].measuredAt;
const stages: Partial<Record<SleepStage, number>> = {};
for (const r of nightRows) {
const minutes = Number.isFinite(r.value) ? r.value : 0;
if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt;
const stage = r.sleepStage;
if (stage) {
stages[stage] = (stages[stage] ?? 0) + minutes;
}
if (stage === "IN_BED") {
inBed += minutes;
sawInBed = true;
} else if (stage === "AWAKE") {
awake += minutes;
sawAwake = true;
} else if (stage && ASLEEP_STAGES.has(stage)) {
asleep += minutes;
} else if (stage == null) {
// Bare SLEEP_DURATION row (no stage) — the night's total asleep.
asleep += minutes;
}
}
nights.push({
night,
measuredAt: latest,
asleepMinutes: asleep,
inBedMinutes: sawInBed ? inBed : null,
awakeMinutes: sawAwake ? awake : null,
stages,
});
}
return nights.sort((a, b) => (a.night < b.night ? -1 : 1));
}
export interface SleepNightSummary {
/**
* A `DataSummary` whose every value is a per-night TIME-ASLEEP total
* (minutes). Drop-in replacement for the per-stage `summaries.
* SLEEP_DURATION` the slim slice produced. Null when no night has any
* asleep minutes in the supplied rows.
*/
summary: DataSummary;
/** The most recent night (for the dashboard/series headline), or null. */
latestNight: SleepNight | null;
}
/**
* Reconstruct nights from raw rows and summarise the per-night asleep
* totals. Nights with zero asleep minutes are dropped (an IN_BED-only or
* AWAKE-only fragment is not a scorable night). The returned `summary`
* uses one DataPoint per night so `summarize()`'s windowed avg7 / avg30
* become "average nightly sleep over the trailing N days".
*/
export function summarizeSleepNights(
rows: SleepStageRow[],
tz: string,
): SleepNightSummary {
const nights = reconstructSleepNights(rows, tz).filter(
(n) => n.asleepMinutes > 0,
);
const dataPoints = nights.map((n) => ({
date: n.measuredAt,
value: n.asleepMinutes,
}));
return {
summary: summarize(dataPoints),
latestNight: nights.length > 0 ? nights[nights.length - 1] : null,
};
}