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
? {