From bde6880c3e0491d79823906b0aeb9cee854cd28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 14:11:46 +0200 Subject: [PATCH] fix(timeline): read the sleep line as time asleep per night Sleep is stored one row per stage, and the timeline read it like any other type: the live path took the mean of a day's rows and the long path the rolled-up month of them, so IN_BED, AWAKE and every CORE fragment counted as a night, and a quarter of seven-hour nights read as under two hours. The sleep series now reconstructs nights the way the sleep page and the day view do (CORE + DEEP + REM of the night's winning writer, keyed by its wake day) and averages the bucket's nights, on every window length; the rollups are not read for it. The stage rows are read a year at a time with a day's margin on either side, so no read holds a decade of rows and no night is cut where one read ends. A long window is cached per record like the other long series. --- src/lib/timeline/series.ts | 105 +++++++++++- .../integration/timeline-sleep-nights.test.ts | 162 ++++++++++++++++++ 2 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 tests/integration/timeline-sleep-nights.test.ts diff --git a/src/lib/timeline/series.ts b/src/lib/timeline/series.ts index 942115a0f..dd85a67e7 100644 --- a/src/lib/timeline/series.ts +++ b/src/lib/timeline/series.ts @@ -20,7 +20,13 @@ * * Every value is the mean of the bucket's days, each day weighing one (a * cumulative type's days are its day totals, a pulse-like type's day is the - * mean of its hourly means, `daily-stats.ts`), matching the charts. Each + * mean of its hourly means, `daily-stats.ts`), matching the charts. Sleep is + * the exception to both sources: it is stored one row per stage, so a day + * mean or a rolled-up month would average stage fragments (and count an + * in-bed row beside the stages inside it). Its value is the night's time + * asleep as the sleep page reads it (`reconstructSleepNights`), one per + * night keyed by its wake day, and a bucket's mean is the mean of its + * nights. Each * point carries the readings behind it, so the chart can mark a mean that * rests on one or two. A bucket without a reading is not sent, and a series * without a point is not sent. `MOOD` is the mood score, read from the @@ -28,6 +34,7 @@ */ import type { MeasurementType } from "@/generated/prisma/enums"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { caches, cached } from "@/lib/cache/server-cache"; import { readLocalDailyCells, type DayCell } from "@/lib/day/daily-stats"; import type { TimelineBucket, TimelineSeries } from "@/lib/day/contract"; @@ -218,6 +225,93 @@ function readsRollups(from: string, to: string): boolean { return days >= LIVE_WINDOW_MAX_DAYS; } +/** Sleep is read a year of stage rows at a time, so no read holds more. */ +const SLEEP_CHUNK_DAYS = 366; + +/** + * Margin read around each chunk, so a night that crosses the chunk's edge is + * read whole in the chunk its wake day belongs to and not cut in two. + */ +const SLEEP_CHUNK_MARGIN_MS = 24 * 3_600_000; + +/** + * Time asleep per night for the nights that woke in `[from, to]` (local + * day keys), each a cell of one reading. The stage rows are read a year at + * a time and folded into nights before the next year is read, so a decade + * of nights never sits in memory as rows at once. + */ +export async function readNightCells(args: { + userId: string; + from: string; + to: string; + tz: string; + priorityJson: unknown; +}): Promise> { + const out = new Map(); + for ( + let chunkFrom = args.from; + chunkFrom <= args.to; + chunkFrom = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS) + ) { + const next = shiftDateKey(chunkFrom, SLEEP_CHUNK_DAYS); + const chunkTo = next > args.to ? args.to : shiftDateKey(next, -1); + const start = startOfLocalDayKey(chunkFrom, args.tz); + const end = startOfLocalDayKey(shiftDateKey(chunkTo, 1), args.tz); + const rows = await prisma.measurement.findMany({ + where: { + userId: args.userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { + gte: new Date(start.getTime() - SLEEP_CHUNK_MARGIN_MS), + lt: new Date(end.getTime() + SLEEP_CHUNK_MARGIN_MS), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }); + for (const night of reconstructSleepNights( + rows, + args.tz, + args.priorityJson, + )) { + if (night.night < chunkFrom || night.night > chunkTo) continue; + if (night.asleepMinutes <= 0) continue; + out.set(night.night, { value: night.asleepMinutes, count: 1 }); + } + } + return out; +} + +/** The sleep series: the mean time asleep of each bucket's nights. */ +async function sleepPoints(args: { + userId: string; + from: string; + to: string; + tz: string; + bucket: TimelineBucket; + priorityJson: unknown; +}): Promise { + const read = async () => foldDays(await readNightCells(args), args.bucket); + if (!readsRollups(args.from, args.to)) return read(); + // A long window is cached under the record's prefix: every measurement + // write marks it stale with the rest of the analytics bucket. + const key = `${args.userId}|timeline-sleep|${args.bucket}|${args.tz}|${args.from}|${args.to}`; + return cached( + caches.analytics, + key, + read, + undefined, + PRE_FOLD_TTL_MS, + ) as Promise; +} + async function measurementSeries(args: { userId: string; type: MeasurementType; @@ -231,9 +325,12 @@ async function measurementSeries(args: { const start = startOfLocalDayKey(args.from, args.tz); const end = startOfLocalDayKey(shiftDateKey(args.to, 1), args.tz); let points: SeriesPoint[]; - // Weeks and days never read the rollups: their windows are short (a - // `day` range is under six weeks), and the rollups cut at UTC midnight. - if ( + // Sleep reads its nights at every length. Weeks and days never read the + // rollups: their windows are short (a `day` range is under six weeks), and + // the rollups cut at UTC midnight. + if (args.type === "SLEEP_DURATION") { + points = await sleepPoints(args); + } else if ( args.bucket !== "week" && args.bucket !== "day" && readsRollups(args.from, args.to) diff --git a/tests/integration/timeline-sleep-nights.test.ts b/tests/integration/timeline-sleep-nights.test.ts new file mode 100644 index 000000000..d6ee07a80 --- /dev/null +++ b/tests/integration/timeline-sleep-nights.test.ts @@ -0,0 +1,162 @@ +/** + * v1.42 — the timeline's sleep line is the time asleep per night, averaged + * over the bucket's nights. Against Postgres. + * + * Sleep is stored one row per stage. Read like any other type, the timeline + * averaged those rows as if each were a night (IN_BED, AWAKE and every CORE + * fragment alike), so a quarter of seven-hour nights read as a little over + * two and a half hours. A night here is what the sleep page and the day view + * call one: CORE + DEEP + REM of the night's winning writer, IN_BED and + * AWAKE left out, keyed by its wake day. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const TZ = "Europe/Berlin"; +const NOW = new Date("2026-10-09T12:00:00.000Z"); + +let user = ""; +let seq = 0; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + // A fresh record per test: the long windows are cached per record. + user = `timeline-sleep-${++seq}`; + await getPrismaClient().user.create({ + data: { + id: user, + username: user, + email: `${user}@example.test`, + timezone: TZ, + }, + }); +}); + +/** A Berlin wall-clock instant (summer and winter offsets both handled). */ +function berlin(day: string, hhmm: string): Date { + const guess = new Date(`${day}T${hhmm}:00.000Z`); + const wall = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(guess); + const [d, t] = wall.split(", "); + const shown = new Date(`${d}T${t}:00.000Z`); + return new Date(guess.getTime() - (shown.getTime() - guess.getTime())); +} + +/** + * One night that wakes on `wake`: in bed from 22:40 the evening before to + * 06:40, asleep 50 + 150 + 90 + 100 + `lastCore` minutes around one AWAKE + * spell, the first segment ending before midnight. Each row's instant is its + * segment's end, as the importers write it. + */ +async function seedNight(wake: string, lastCore: number) { + const { shiftDateKey } = await import("@/lib/tz/format"); + const eve = shiftDateKey(wake, -1); + const rows: Array<[string, number, string, string]> = [ + ["IN_BED", 480, wake, "06:40"], + ["CORE", 50, eve, "23:50"], + ["CORE", 150, wake, "02:20"], + ["DEEP", 90, wake, "03:50"], + ["REM", 100, wake, "05:30"], + ["AWAKE", 20, wake, "05:50"], + ["CORE", lastCore, wake, "06:30"], + ]; + await getPrismaClient().measurement.createMany({ + data: rows.map(([stage, minutes, day, end], i) => ({ + userId: user, + type: "SLEEP_DURATION" as const, + value: minutes, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: berlin(day, end), + externalId: `uuid-${wake}-${i}`, + sleepStage: stage as "IN_BED" | "CORE" | "DEEP" | "REM" | "AWAKE", + })), + }); +} + +async function sleepSeries( + from: string, + to: string, + bucket: "month" | "quarter", +) { + const { loadTimelineSeries } = await import("@/lib/timeline/series"); + const [out] = await loadTimelineSeries({ + userId: user, + keys: ["SLEEP_DURATION"], + from, + to, + tz: TZ, + bucket, + priorityJson: null, + now: NOW, + }); + return out?.points ?? []; +} + +describe("the timeline's sleep line", () => { + it("averages the nights of a quarter, each night its time asleep", async () => { + await seedNight("2026-07-03", 40); // 430 min asleep + await seedNight("2026-08-12", 50); // 440 + await seedNight("2026-09-20", 60); // 450 + expect(await sleepSeries("2026-07-01", "2026-09-30", "quarter")).toEqual([ + { t: "2026-07-01", mean: 440, count: 3 }, + ]); + expect(await sleepSeries("2026-07-01", "2026-09-30", "month")).toEqual([ + { t: "2026-07-01", mean: 430, count: 1 }, + { t: "2026-08-01", mean: 440, count: 1 }, + { t: "2026-09-01", mean: 450, count: 1 }, + ]); + }); + + it("reads the nights, not the rolled-up stage rows, over a long window", async () => { + await seedNight("2023-02-03", 40); // 430 + await seedNight("2023-03-04", 60); // 450 + // What the rollup tier holds for those days: the mean of the stage rows. + await getPrismaClient().measurementRollup.createMany({ + data: ["2023-02-01", "2023-03-01"].map((month) => ({ + userId: user, + type: "SLEEP_DURATION" as const, + granularity: "MONTH" as const, + bucketStart: new Date(`${month}T00:00:00.000Z`), + source: "APPLE_HEALTH" as const, + count: 6, + mean: 155, + minValue: 20, + maxValue: 480, + sumValue: 930, + })), + }); + expect(await sleepSeries("2016-10-09", "2026-10-09", "quarter")).toEqual([ + { t: "2023-01-01", mean: 440, count: 2 }, + ]); + }); + + it("keeps a night whole where one year's read ends and the next begins", async () => { + const { readNightCells } = await import("@/lib/timeline/series"); + const { shiftDateKey } = await import("@/lib/tz/format"); + // The second read starts 366 days after the first. + const from = "2024-01-01"; + const boundary = shiftDateKey(from, 366); + await seedNight(boundary, 40); + const nights = await readNightCells({ + userId: user, + from, + to: "2025-12-31", + tz: TZ, + priorityJson: null, + }); + expect([...nights]).toEqual([[boundary, { value: 430, count: 1 }]]); + }); +});