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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 16:44:53 +02:00
1 parent 6e241c16a4
commit ebeb16daa3
9 files changed
+691 -11

No files matched your search

@@ -3,7 +3,9 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn() },
// v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for
// the night-total sleep tile.
measurement: { groupBy: vi.fn(), findMany: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
// v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills
@@ -82,6 +84,10 @@ beforeEach(() => {
// per-type all-time count + most-recent timestamp. Default to an
// empty aggregate so legacy tests keep their "no data" expectations.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
// v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows
// via `measurement.findMany` to reconstruct the night total. Default to
// no rows so legacy tests keep their "no sleep" expectation.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
@@ -443,6 +449,50 @@ describe("GET /api/dashboard/summary", () => {
expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString());
});
it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// tile must SUM the asleep stages of the latest night and convert to
// hours, NOT surface a single stage. Excludes IN_BED + AWAKE.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const wake = new Date("2026-06-04T06:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "SLEEP_DURATION",
_count: { _all: 5 },
_max: { measuredAt: wake },
},
] as never);
// Raw per-stage rows for the night (the sleep findMany read).
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
] as never);
const res = await callGet(makeReq());
const body = (await res.json()) as {
data: {
metrics: Array<{
id: string;
latestValue: number | null;
unit: string | null;
sleepStages: Record<string, number> | null;
}>;
};
};
const sleep = body.data.metrics.find((m) => m.id === "sleep");
expect(sleep, "sleep tile must be emitted").toBeDefined();
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410 min → 6.83 h.
// (IN_BED 480 + AWAKE 20 excluded.)
expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2);
expect(sleep?.unit).toBe("h");
// Stage breakdown is exposed in hours for a future detail view.
expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2);
expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2);
expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2);
});
it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => {
// The ACTIVITY_STEPS tile renders the per-day SUM, not the
// per-bucket MEAN. The sparkline query feeds the SQL aggregate
+121
View File
@@ -57,6 +57,12 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import {
summarizeSleepNights,
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import type { SleepStage } from "@/generated/prisma/client";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -96,6 +102,24 @@ interface MetricCard {
* without another wire-shape change.
*/
unitKey: string;
/**
* v1.11.4 — explicit unit token for clients that need to know the
* value's unit without inferring it from the metric kind. Most tiles
* leave this `null` (the `unitKey` i18n key already carries the
* display unit). The `sleep` tile sets it to `"h"` because its
* `latestValue` is a per-NIGHT total expressed in HOURS (a float),
* not the canonical `SLEEP_DURATION` minutes — see the sleep block
* below for why the night total replaced the single-stage value.
*/
unit: string | null;
/**
* v1.11.4 — per-stage minutes for the headline night, sleep tile only.
* `null` for every other kind and for a sleep night with no
* stage-tagged rows (a legacy bare-duration night). Additive: a future
* sleep detail view can render the breakdown without changing the
* headline `latestValue`.
*/
sleepStages: Partial<Record<SleepStage, number>> | null;
trend: "up" | "down" | "flat" | "unknown";
sparkline: number[];
updatedAt: string | null;
@@ -158,6 +182,20 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
};
/**
* v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in
* hours, reconstructed per night from the raw stage rows. The generic
* DAY-bucket rollup sparkline can't be used for sleep because it means
* across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE
* row average to 150) rather than summing them into a night total.
*/
function buildSleepSparkline(rows: SleepStageRow[], tz: string): number[] {
return reconstructSleepNights(rows, tz)
.filter((n) => n.asleepMinutes > 0)
.map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100)
.slice(-SPARK_DAYS);
}
function trendOf(values: number[]): MetricCard["trend"] {
if (values.length < 2) return "unknown";
const first = values[0];
@@ -420,6 +458,7 @@ async function buildDashboardSummary(
todaysIntakes,
streakActivity,
measurementStreakDays,
sleepStageRows,
] = await Promise.all([
time("latestEver", () =>
prisma.$queryRaw<LatestEverRow[]>`
@@ -508,6 +547,27 @@ async function buildDashboardSummary(
AND m."deleted_at" IS NULL
`,
),
// v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total
// tile. `SLEEP_DURATION` is stored one row per STAGE per night
// (minutes), so the single-most-recent row (the `latestEver` path
// above) is just ONE stage, not the night. The sleep tile instead
// sums the asleep stages of the latest night via
// `summarizeSleepNights`. Bounded to the streak window (≈ a year of
// ~5 rows/night) so the read stays small; the headline only needs
// the most-recent night but the window also feeds the iOS sparkline
// night totals.
time("sleepNights", () =>
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: streakWindowStart },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
}),
),
]);
// Per-type metadata lookup — typed Map so a metric with no readings
@@ -594,6 +654,16 @@ async function buildDashboardSummary(
return sparkByType.get(type) ?? [];
}
// v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night
// asleep totals. The sleep tile's headline is last night's TIME ASLEEP
// (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS
// with an explicit `unit: "h"`, replacing the single-stage minutes the
// `latestEver` read used to surface.
const sleepSummary = summarizeSleepNights(
sleepStageRows as SleepStageRow[],
userTz,
);
const metrics: MetricCard[] = [];
// v1.4.33 maintainer-item-1 — every emitted card now carries
@@ -614,6 +684,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.weight,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -652,6 +724,8 @@ async function buildDashboardSummary(
latestValue: latestSys?.value ?? null,
secondaryValue: latestDia?.value ?? null,
unitKey: METRIC_UNIT_KEYS.bloodPressure,
unit: null,
sleepStages: null,
trend: trendOf(sysSpark),
sparkline: sysSpark,
updatedAt:
@@ -679,6 +753,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.pulse,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt,
@@ -704,6 +780,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.bodyFat,
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -730,6 +808,47 @@ async function buildDashboardSummary(
const latest = latestOf(type);
const meta = metaForType(type);
if (!latest && meta.allTimeCount === 0) continue;
// v1.11.4 — sleep is night-aggregated, not single-stage. Emit the
// latest night's TIME ASLEEP in HOURS (float) with an explicit
// `unit: "h"`, the per-stage breakdown, and a sparkline of the
// trailing nights' asleep hours. Every other kind keeps the
// single-row latest value + canonical-unit i18n key.
if (kind === "sleep") {
const night = sleepSummary.latestNight;
const toHours = (minutes: number): number =>
Math.round((minutes / 60) * 100) / 100;
// Sparkline = trailing nights' asleep hours from the night
// reconstruction (the DAY-bucket rollup sparkline blends stages
// and would be misleading for sleep).
const nightSpark = buildSleepSparkline(
sleepStageRows as SleepStageRow[],
userTz,
);
const stageHours: Partial<Record<SleepStage, number>> | null = night
? Object.fromEntries(
Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]),
)
: null;
metrics.push({
id: kind,
kind,
titleKey: METRIC_TITLE_KEYS[kind],
latestValue: night ? toHours(night.asleepMinutes) : null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS[kind],
unit: "h",
sleepStages:
stageHours && Object.keys(stageHours).length > 0 ? stageHours : null,
trend: trendOf(nightSpark),
sparkline: nightSpark,
updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
allTimeCount: meta.allTimeCount,
lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt,
});
continue;
}
const spark = sparkOf(type);
metrics.push({
id: kind,
@@ -738,6 +857,8 @@ async function buildDashboardSummary(
latestValue: latest?.value ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS[kind],
unit: null,
sleepStages: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt,
@@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night;
// pin it so the night grouping is deterministic.
vi.mock("@/lib/tz/resolver", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tz/resolver")>();
return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") };
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -117,6 +124,48 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[0].secondary).toBeNull();
});
it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => {
// SLEEP_DURATION is stored one row per STAGE per night (minutes). The
// series must return ONE point per night carrying the TIME-ASLEEP
// total in hours, not a point per stage.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
{ id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
{ id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
{ id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" },
{ id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
] as never);
const res = await GET(req("kind=sleep&days=30"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
unit: string;
points: Array<{
value: number;
sleepStages: Record<string, number> | null;
}>;
};
};
expect(body.data.unit).toBe("h");
// One point for the single night.
expect(body.data.points).toHaveLength(1);
// Time asleep = CORE + DEEP + REM = 410 min → 6.83 h (IN_BED + AWAKE
// excluded).
expect(body.data.points[0].value).toBeCloseTo(410 / 60, 2);
expect(body.data.points[0].sleepStages?.CORE).toBeCloseTo(4, 2);
});
it("returns an explicit unit for a non-sleep kind (v1.11.4)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ id: "w1", value: 78.4, measuredAt: new Date() },
] as never);
const res = await GET(req("kind=weight&days=7"));
const body = (await res.json()) as { data: { unit: string } };
expect(body.data.unit).toBe("kg");
});
it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => {
// v1.5.5 — the previous 365-day cap rejected the iOS app's
// "Alle"-range request with a 422, painting an error banner on
+77 -2
View File
@@ -21,7 +21,9 @@ import {
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
const kindEnum = z.enum([
"weight",
@@ -71,11 +73,41 @@ const KIND_TO_TYPE: Record<z.infer<typeof kindEnum>, MeasurementType> = {
vo2Max: "VO2_MAX",
};
/**
* v1.11.4 — explicit per-kind unit token returned at the top level so
* the client never has to infer the value's unit. `bloodPressure` reuses
* the systolic type's unit (mmHg, same for both lines). `sleep` is
* overridden at request time to `"h"` because the route returns per-night
* TIME-ASLEEP in hours rather than the canonical per-stage minutes.
*/
const SERIES_UNIT: Record<z.infer<typeof kindEnum>, string> = {
weight: "kg",
bloodPressure: "mmHg",
pulse: "bpm",
bodyFat: "%",
glucose: "mg/dL",
sleep: "h",
steps: "steps",
totalBodyWater: "kg",
boneMass: "kg",
oxygenSaturation: "%",
restingHeartRate: "bpm",
heartRateVariability: "ms",
vo2Max: "mL/(kg·min)",
};
interface SeriesPoint {
id: string;
at: string;
value: number;
secondary: number | null;
/**
* v1.11.4 — per-stage minutes for a sleep night point. `null` for
* every non-sleep kind. Lets a sleep detail view render the night's
* stage breakdown without a second fetch; the headline `value` stays
* the night's TIME-ASLEEP total.
*/
sleepStages?: Partial<Record<SleepStage, number>> | null;
}
function stdDev(values: number[]): number {
@@ -136,8 +168,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { kind, days } = parsed.data;
const since = new Date(Date.now() - days * 86_400_000);
// v1.11.4 — explicit unit token so the client never infers the unit
// from the kind. Sleep is special-cased below (per-night TIME-ASLEEP
// in HOURS); every other kind carries its canonical stored unit.
let unit = SERIES_UNIT[kind];
let points: SeriesPoint[] = [];
if (kind === "bloodPressure") {
if (kind === "sleep") {
// SLEEP_DURATION is stored one row per STAGE per night (minutes).
// Collapse the stage rows into ONE point per night carrying the
// night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE),
// converted to HOURS. The single-stage rows the legacy path returned
// made the chart show one fragment per stage instead of a nightly
// trend.
const tz = await resolveUserTimezone(user.id);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "SLEEP_DURATION",
measuredAt: { gte: since },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true, sleepStage: true },
});
unit = "h";
points = reconstructSleepNights(rows, tz)
.filter((n) => n.asleepMinutes > 0)
.map((n) => {
const stageHours = Object.fromEntries(
Object.entries(n.stages).map(([s, m]) => [
s,
Math.round((m / 60) * 100) / 100,
]),
) as Partial<Record<SleepStage, number>>;
return {
id: `sleep:${n.night}`,
at: n.measuredAt.toISOString(),
value: Math.round((n.asleepMinutes / 60) * 100) / 100,
secondary: null,
sleepStages:
Object.keys(stageHours).length > 0 ? stageHours : null,
};
});
} else if (kind === "bloodPressure") {
const [sys, dia] = await Promise.all([
prisma.measurement.findMany({
where: {
@@ -213,6 +287,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiSuccess({
kind,
unit,
points,
stats: summary
? {
+48 -6
View File
@@ -151,6 +151,39 @@ interface RangeDisplayConfig {
range: TrafficRange | null;
}
/**
* v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep
* tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes;
* the tile renders hours. Every value-bearing field (latest / min / max /
* mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides
* by 60; the slope tuples scale their `slope` (per-day rate) by the same
* factor so the trend arrow stays consistent; count / direction /
* confidence / anomalyCount are unit-free and pass through.
*/
function toHoursSummary(s: DataSummary): DataSummary {
const h = (v: number | null | undefined): number | null =>
v == null ? null : Math.round((v / 60) * 100) / 100;
const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] =>
slope == null
? null
: { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 };
return {
...s,
latest: h(s.latest),
min: h(s.min),
max: h(s.max),
mean: h(s.mean),
median: h(s.median),
avg7: h(s.avg7),
avg30: h(s.avg30),
avg30LastMonth: h(s.avg30LastMonth),
avg30LastYear: h(s.avg30LastYear),
slope7: scaleSlope(s.slope7),
slope30: scaleSlope(s.slope30),
slope90: scaleSlope(s.slope90),
};
}
function getHourForTimeZone(timeZone?: string): number {
const now = new Date();
if (!timeZone) return now.getHours();
@@ -445,6 +478,15 @@ export default function DashboardPage() {
const p = data?.summaries?.PULSE;
const bf = data?.summaries?.BODY_FAT;
const sleepSummary = data?.summaries?.SLEEP_DURATION;
// v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep
// totals in MINUTES (the server collapses the per-stage rows into one
// night value; see `summaries-slice.ts`). The sleep tile renders hours,
// so convert every value field minutes→hours here while keeping the
// staleness / count metadata untouched. This is the web-parity twin of
// the iOS dashboard-summary route which already emits `unit:"h"`.
const sleepSummaryHours = sleepSummary
? toHoursSummary(sleepSummary)
: undefined;
const stepsSummary = data?.summaries?.ACTIVITY_STEPS;
// v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics
// auto-populates this summary because the route iterates over the
@@ -1124,16 +1166,16 @@ export default function DashboardPage() {
<TrendCard
key="sleep"
label={t("dashboard.sleepShort") ?? "Sleep"}
latest={sleepSummary?.latest ?? null}
latest={sleepSummaryHours?.latest ?? null}
unit="h"
avg7={sleepSummary?.avg7 ?? null}
avg30={sleepSummary?.avg30 ?? null}
slope30={sleepSummary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummary)}
avg7={sleepSummaryHours?.avg7 ?? null}
avg30={sleepSummaryHours?.avg30 ?? null}
slope30={sleepSummaryHours?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(sleepSummaryHours)}
icon={Moon}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(sleepSummary)}
compareDelta={tileCompareDelta(sleepSummaryHours)}
staleDays={tileStaleDays("SLEEP_DURATION")}
/>
),
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import type { SleepStage } from "@/generated/prisma/client";
import {
reconstructSleepNights,
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
/** Build a stage row with a fixed wall-clock instant. */
function row(
iso: string,
stage: SleepStage | null,
minutes: number,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
}
describe("reconstructSleepNights", () => {
it("sums asleep stages into one night, excluding IN_BED + AWAKE", () => {
// One night written as five per-stage rows (WHOOP / Apple shape), all
// on the same UTC calendar day so the night-key collapses them.
const rows: SleepStageRow[] = [
row("2026-06-04T00:00:00.000Z", "IN_BED", 480),
row("2026-06-04T00:30:00.000Z", "CORE", 240),
row("2026-06-04T02:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:00:00.000Z", "REM", 80),
row("2026-06-04T05:00:00.000Z", "AWAKE", 20),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
const main = nights[0];
expect(main.night).toBe("2026-06-04");
// Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410. AWAKE excluded.
expect(main.asleepMinutes).toBe(410);
// AWAKE recorded but not counted as asleep.
expect(main.awakeMinutes).toBe(20);
// IN_BED row present → in-bed total surfaced.
expect(main.inBedMinutes).toBe(480);
expect(main.stages.CORE).toBe(240);
expect(main.stages.DEEP).toBe(90);
expect(main.stages.REM).toBe(80);
});
it("groups all stages of one night by the user's tz calendar day", () => {
// In a non-UTC zone (Auckland = UTC+12 in June) a night whose stages
// straddle UTC midnight still collapses to one local day. These
// instants are all 2026-06-04 LOCAL (12:00–17:00 UTC = 00:00–05:00
// Jun 4 Auckland) but straddle UTC midnight if keyed naively.
const rows: SleepStageRow[] = [
row("2026-06-03T12:00:00.000Z", "IN_BED", 480), // 00:00 Jun 4 NZST
row("2026-06-03T13:00:00.000Z", "CORE", 240), // 01:00 Jun 4
row("2026-06-03T15:00:00.000Z", "DEEP", 90), // 03:00 Jun 4
row("2026-06-03T17:00:00.000Z", "REM", 80), // 05:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Pacific/Auckland");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-06-04");
expect(nights[0].asleepMinutes).toBe(410);
expect(nights[0].inBedMinutes).toBe(480);
});
it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => {
const rows: SleepStageRow[] = [
row("2026-06-04T06:00:00.000Z", null, 423),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
expect(nights[0].asleepMinutes).toBe(423);
expect(nights[0].inBedMinutes).toBeNull();
});
});
describe("summarizeSleepNights", () => {
it("summarises per-night totals, not per-stage rows", () => {
const rows: SleepStageRow[] = [
// Night A — 2026-06-03: 200 + 100 = 300 asleep min.
row("2026-06-03T01:00:00.000Z", "CORE", 200),
row("2026-06-03T03:00:00.000Z", "DEEP", 100),
// Night B — 2026-06-04: 240 + 90 + 90 = 420 asleep min.
row("2026-06-04T01:00:00.000Z", "CORE", 240),
row("2026-06-04T03:00:00.000Z", "DEEP", 90),
row("2026-06-04T04:30:00.000Z", "REM", 90),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
// count = nights, NOT stage rows (5).
expect(summary.count).toBe(2);
// latest = most-recent night total (minutes), not a single stage.
expect(summary.latest).toBe(420);
expect(summary.min).toBe(300);
expect(summary.max).toBe(420);
expect(summary.mean).toBe(360);
expect(latestNight?.night).toBe("2026-06-04");
expect(latestNight?.asleepMinutes).toBe(420);
});
it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => {
const rows: SleepStageRow[] = [
row("2026-06-03T23:00:00.000Z", "IN_BED", 60),
row("2026-06-03T23:30:00.000Z", "AWAKE", 60),
];
const { summary, latestNight } = summarizeSleepNights(rows, "UTC");
expect(summary.count).toBe(0);
expect(latestNight).toBeNull();
});
it("returns an empty summary for no rows", () => {
const { summary, latestNight } = summarizeSleepNights([], "UTC");
expect(summary.count).toBe(0);
expect(summary.latest).toBeNull();
expect(latestNight).toBeNull();
});
});
+162
View File
@@ -0,0 +1,162 @@
/**
* 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,
};
}
+57 -2
View File
@@ -72,6 +72,11 @@ import {
probeRollupCoverage,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
summarizeSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { resolveUserTimezone } from "@/lib/tz/resolver";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -239,7 +244,7 @@ export async function computeSummariesSlice(
// brand-new-type case stays correct.
const coverage = await probeRollupCoverage(userId);
if (isFullyCovered(coverage)) {
return computeFromRollups(userId);
return withSleepNightTotals(userId, await computeFromRollups(userId));
}
// v1.4.38.7 — annotate the live fallback with the per-type
// coverage map so the operator can see WHICH type stranded the
@@ -261,7 +266,57 @@ export async function computeSummariesSlice(
},
},
});
return computeFromLiveAggregate(userId);
return withSleepNightTotals(userId, await computeFromLiveAggregate(userId));
}
/**
* v1.11.4 — replace the per-stage `summaries.SLEEP_DURATION` with a
* per-NIGHT summary (iOS #1, HIGH).
*
* `SLEEP_DURATION` is stored one row per sleep STAGE per night (minutes).
* The slim slice's generic aggregate therefore treats each stage as an
* independent reading: `latest` is the most-recent STAGE (not the night),
* `mean` / `avg7` / `avg30` average across stage rows. For the dashboard
* sleep tile and the insights overview that is wrong — they want the
* per-night TIME-ASLEEP total.
*
* This post-pass reads the SLEEP_DURATION rows once and overwrites the
* summary with one computed over per-night asleep totals (one DataPoint
* per night). The unit stays MINUTES (canonical), so existing consumers
* that already treat the summary as minutes keep working — they now
* average nights instead of stages, which is strictly more correct. The
* read is bounded to a year (~5 rows/night) and is skipped entirely when
* the user has logged no sleep.
*/
async function withSleepNightTotals(
userId: string,
slice: SummariesSlice,
): Promise<SummariesSlice> {
const existing = slice.summaries.SLEEP_DURATION;
// No sleep data at all → nothing to recompute (the empty summary stays).
if (!existing || existing.count === 0) return slice;
const since = new Date(Date.now() - 365 * DAY_MS);
const rows = (await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
})) as SleepStageRow[];
if (rows.length === 0) return slice;
const tz = await resolveUserTimezone(userId);
const { summary } = summarizeSleepNights(rows, tz);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /
// slope30 / slope90 from the night DataPoints, so use them directly.
slice.summaries.SLEEP_DURATION = summary;
return slice;
}
/**
+14
View File
@@ -523,6 +523,20 @@ function buildMetricStates(
const summary = summaries[type];
const lastSeen = lastSeenByType[type];
if (!summary || summary.latest === null || !lastSeen) continue;
// v1.11.4 — `summaries.SLEEP_DURATION.latest` now carries last night's
// TIME-ASLEEP total in MINUTES (the slim slice collapses the per-stage
// rows). Emit the cold-launch seed in HOURS with an explicit `unit:
// "h"` so it matches the `/api/dashboard/summary` sleep tile, which
// already emits hours. Every other metric passes through with its
// canonical stored unit.
if (type === "SLEEP_DURATION") {
out[metricKindRaw] = {
value: Math.round((summary.latest / 60) * 100) / 100,
measuredAt: lastSeen.lastSeenAt,
unit: "h",
};
continue;
}
out[metricKindRaw] = {
value: summary.latest,
measuredAt: lastSeen.lastSeenAt,