mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
59b072750a
commit
bde6880c3e
2 files changed
+263
-4
No files matched your search
+101
-4
@@ -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<Map<string, DayCell>> {
|
||||
const out = new Map<string, DayCell>();
|
||||
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<SeriesPoint[]> {
|
||||
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<SeriesPoint[]>;
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user