mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(sleep): read a night of any age and the whole series range
The night view read a year of stage rows whatever date it was asked for, so an older night came back with no main session while a year of rows sat in the heap; it now reads a day and a half either side of the night, and the latest night from the latest asleep row. A day of naps alone answers with its naps and main null. The series cut "All" to a year for sleep without saying so. Postgres now names the nights and the rows are reconstructed ninety nights at a time, so the range reaches back as far as the history does. The metric status read the latest 2190 stage rows, a few weeks on a stage-rich watch, starting mid-night; it now reads the latest 365 nights the same way.
This commit is contained in:
1 parent
3da72136ba
commit
b7e48e933c
6 files changed
+125
-113
No files matched your search
@@ -341,8 +341,17 @@ describe("GET /api/measurements/series", () => {
|
||||
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.
|
||||
// total in hours, not a point per stage. Postgres names the night and
|
||||
// its span; the stage rows of that span are reconstructed.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
night: "2026-06-04",
|
||||
span_start: new Date("2026-06-03T15:00:00.000Z"),
|
||||
span_end: new Date("2026-06-04T05:00:00.000Z"),
|
||||
total: 1,
|
||||
},
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "s1",
|
||||
|
||||
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { redactForExcerpt } from "@/lib/observability/redact-payload";
|
||||
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
|
||||
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
|
||||
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
@@ -55,13 +55,6 @@ import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
|
||||
* window is capped to one year even when the client requests the 3650-day
|
||||
* "Alle" range. Matches the slim slice's `withSleepNightTotals` sleep read.
|
||||
*/
|
||||
const SLEEP_SERIES_MAX_DAYS = 365;
|
||||
|
||||
/**
|
||||
* v1.28.25 — sample-dense kinds read raw rows only up to this window.
|
||||
* A CGM account stores ~288 BLOOD_GLUCOSE rows/day (105k+ rows/year) and
|
||||
@@ -221,7 +214,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = querySchema.safeParse(rawQuery);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
|
||||
// v1.4.43 — iOS chart loader hot path; multi-issue 422 +
|
||||
// audit breadcrumb keyed `measurements.series.validation-failed`.
|
||||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||||
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
|
||||
@@ -295,42 +288,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// dual-source night to one canonical source via the user's `sleep`
|
||||
// priority ladder.
|
||||
//
|
||||
// v1.11.4 — sleep is the one kind read row-per-stage (≈5 rows/night ×
|
||||
// source-count) rather than from the day-bucketed rollup, so an
|
||||
// unbounded "Alle" (days = 3650) request on a multi-year dual-source
|
||||
// account would walk a six-figure row set into JS. Cap the sleep read
|
||||
// at SLEEP_SERIES_MAX_DAYS (365 d) — the same one-year bound the slim
|
||||
// slice's `withSleepNightTotals` uses — so the window stays small
|
||||
// regardless of the requested range. Other kinds read from the rollup
|
||||
// tier and keep the full 3650-day range.
|
||||
const sleepSince = new Date(
|
||||
Date.now() - Math.min(days, SLEEP_SERIES_MAX_DAYS) * 86_400_000,
|
||||
);
|
||||
// Sleep is the one kind read row-per-stage rather than from the rollup
|
||||
// tier, so the nights are named in Postgres and reconstructed a chunk at
|
||||
// a time (`readSleepNights`): "All" reaches as far back as the history
|
||||
// does, and the stage rows in the heap stay a chunk's worth.
|
||||
const [tz, priorityJson] = await Promise.all([
|
||||
resolveUserTimezone(user.id),
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: { gte: sleepSince },
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
const nights = await readSleepNights(user.id, tz, priorityJson, {
|
||||
from: since,
|
||||
// A window of `days` days holds at most one night more than that.
|
||||
maxNights: days + 1,
|
||||
});
|
||||
unit = "h";
|
||||
points = reconstructSleepNights(rows, tz, priorityJson)
|
||||
points = nights
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => {
|
||||
const stageHours = Object.fromEntries(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -76,6 +76,7 @@ function stage(
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
@@ -138,12 +139,48 @@ describe("GET /api/sleep/night", () => {
|
||||
stage("2026-06-02T03:00:00.000Z", "CORE", 200),
|
||||
stage("2026-06-04T03:00:00.000Z", "CORE", 300),
|
||||
] as never);
|
||||
vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
|
||||
measuredAt: new Date("2026-06-04T03:00:00.000Z"),
|
||||
} as never);
|
||||
const res = await GET(req(""));
|
||||
const body = await res.json();
|
||||
expect(body.data.night).toBe("2026-06-04");
|
||||
expect(body.data.main.asleepMinutes).toBe(300);
|
||||
});
|
||||
|
||||
it("reads a night of any age from a day and a half either side of it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2019-03-01T06:00:00.000Z", "CORE", 420),
|
||||
] as never);
|
||||
const res = await GET(req("date=2019-03-01"));
|
||||
const body = await res.json();
|
||||
// Years past any trailing window, the night is still served.
|
||||
expect(body.data.main.asleepMinutes).toBe(420);
|
||||
const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0]!
|
||||
.where as { AND: Array<{ measuredAt: { gte: Date; lte: Date } }> };
|
||||
expect(where.AND[0].measuredAt.gte.toISOString()).toBe(
|
||||
"2019-02-27T12:00:00.000Z",
|
||||
);
|
||||
expect(where.AND[0].measuredAt.lte.toISOString()).toBe(
|
||||
"2019-03-03T12:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers a day of naps alone with its naps and no main night", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
// 13:00 to 14:00 UTC, nothing overnight.
|
||||
stage("2026-06-04T14:00:00.000Z", "CORE", 60),
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
const body = await res.json();
|
||||
expect(body.data.night).toBe("2026-06-04");
|
||||
expect(body.data.main).toBeNull();
|
||||
expect(body.data.naps).toHaveLength(1);
|
||||
expect(body.data.naps[0].asleepMinutes).toBe(60);
|
||||
});
|
||||
|
||||
it("surfaces a daytime nap separately from the main night", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
@@ -169,7 +206,7 @@ describe("GET /api/sleep/night", () => {
|
||||
expect(body.data.naps).toEqual([]);
|
||||
});
|
||||
|
||||
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
|
||||
// A reconstruction edge (a session of only bare ASLEEP / stage-less rows
|
||||
// alongside a granular partition for the same span) must return a valid empty
|
||||
// night, NEVER a 500 from the unguarded `segments[0]` access.
|
||||
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
|
||||
|
||||
@@ -17,13 +17,16 @@
|
||||
* (WHOOP) — the client renders the hypnogram but labels it an approximate
|
||||
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
|
||||
*
|
||||
* `date` omitted → the most recent night in the trailing-year window.
|
||||
* `date` omitted → the most recent night that holds sleep, however old.
|
||||
*
|
||||
* Only the stage rows within a day and a half of the night are read, as the
|
||||
* list's drill-down reads them (`readNightSessions`), so any past night is
|
||||
* answered and a long history never reaches the heap.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -32,18 +35,18 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
reconstructSleepSessions,
|
||||
pickMainNightAndNaps,
|
||||
type SleepSession,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import {
|
||||
latestSleepNight,
|
||||
readNightSessions,
|
||||
} from "@/lib/measurements/list/sleep-nights";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
|
||||
/** Sleep is read row-per-stage, so the window is bounded to one year. */
|
||||
const SLEEP_NIGHT_MAX_DAYS = 365;
|
||||
|
||||
const querySchema = z.object({
|
||||
// Optional wake-day key; when omitted the latest night is returned.
|
||||
date: z
|
||||
@@ -127,36 +130,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
loadUserSourcePriority(user.id),
|
||||
]);
|
||||
|
||||
const since = new Date(Date.now() - SLEEP_NIGHT_MAX_DAYS * 86_400_000);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||||
// stages vs phone in-bed) must not blend into one night.
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
|
||||
// Resolve the target wake day: the requested `date`, else the most
|
||||
// recent wake day that has a scorable session.
|
||||
const scorable = sessions.filter((s) => s.asleepMinutes > 0);
|
||||
const targetDay =
|
||||
date ??
|
||||
(scorable.length > 0 ? scorable[scorable.length - 1].night : undefined);
|
||||
|
||||
const daySessions = sessions.filter((s) => s.night === targetDay);
|
||||
const { main, naps } = pickMainNightAndNaps(daySessions);
|
||||
date ?? (await latestSleepNight(user.id, tz, priorityJson)) ?? undefined;
|
||||
const { main, naps } = targetDay
|
||||
? pickMainNightAndNaps(
|
||||
(await readNightSessions(user.id, targetDay, tz, priorityJson))
|
||||
.sessions,
|
||||
)
|
||||
: { main: null, naps: [] };
|
||||
|
||||
annotate({
|
||||
action: { name: "sleep.night" },
|
||||
@@ -167,13 +148,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (!main) {
|
||||
return apiSuccess({ night: targetDay ?? null, main: null, naps: [] });
|
||||
}
|
||||
|
||||
// A day of naps alone answers with its naps and no main night.
|
||||
return apiSuccess({
|
||||
night: main.night,
|
||||
main: serializeSession(main),
|
||||
night: targetDay ?? null,
|
||||
main: main ? serializeSession(main) : null,
|
||||
naps: naps.map(serializeSession),
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,38 @@ function stubCompletion(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Postgres names the nights the sleep read reconstructs; answer the night
|
||||
* page query with the one night the stage rows make, and every other raw
|
||||
* query with nothing.
|
||||
*/
|
||||
function nightPageOver(rows: Array<{ value: number; measuredAt: Date }>) {
|
||||
const start = Math.min(
|
||||
...rows.map((r) => r.measuredAt.getTime() - r.value * 60_000),
|
||||
);
|
||||
const end = Math.max(...rows.map((r) => r.measuredAt.getTime()));
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation(((
|
||||
_strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
) =>
|
||||
Promise.resolve(
|
||||
values.some((v) =>
|
||||
String((v as { sql?: string })?.sql ?? "").includes("sess AS"),
|
||||
)
|
||||
? [
|
||||
{
|
||||
night: new Date(end).toLocaleDateString("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}),
|
||||
span_start: new Date(start),
|
||||
span_end: new Date(end),
|
||||
total: 1,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)) as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.insightStatusCache.findUnique).mockResolvedValue(null);
|
||||
@@ -167,6 +199,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
stageRows as never,
|
||||
);
|
||||
nightPageOver(stageRows);
|
||||
|
||||
const capture = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
@@ -190,7 +223,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
expect(prompt).not.toContain('"value": 90');
|
||||
});
|
||||
|
||||
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => {
|
||||
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum", async () => {
|
||||
// The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the
|
||||
// granular CORE/DEEP/REM partition for the same span. Pre-fix the graded
|
||||
// `series` came from `buildGradedSeriesWithRollups`, which folds bare + each
|
||||
@@ -249,6 +282,7 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
stageRows as never,
|
||||
);
|
||||
nightPageOver(stageRows);
|
||||
|
||||
const capture = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
|
||||
@@ -71,10 +71,7 @@ import {
|
||||
summarizeSeries,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
import {
|
||||
@@ -295,31 +292,16 @@ export async function generateMetricStatus(args: {
|
||||
const points: Array<{ measuredAt: Date; value: number }> = [];
|
||||
let measurements: Array<{ measuredAt: Date }> = [];
|
||||
if (isSleep) {
|
||||
const [priorityJson, sleepRows] = await Promise.all([
|
||||
loadUserSourcePriority(args.userId),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: args.userId,
|
||||
type: "SLEEP_DURATION",
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 365 * 6, // ≈ 5 stage rows/night × source-count for a year
|
||||
// Writer-level collapse: two HealthKit apps behind one source
|
||||
// (watch stages vs phone in-bed) must not blend into one night.
|
||||
select: {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const nights = reconstructSleepNights(
|
||||
sleepRows as SleepStageRow[],
|
||||
userTz,
|
||||
priorityJson,
|
||||
// The latest 365 nights, the sleep twin of the 365 latest readings the
|
||||
// other types read. A cut on stage rows instead (2190 of them) reached
|
||||
// back a few weeks on a stage-rich watch and began mid-night.
|
||||
const nights = (
|
||||
await readSleepNights(
|
||||
args.userId,
|
||||
userTz,
|
||||
await loadUserSourcePriority(args.userId),
|
||||
{ maxNights: 365, newest: true },
|
||||
)
|
||||
).filter((n) => n.asleepMinutes > 0);
|
||||
for (const n of nights) {
|
||||
points.push({ measuredAt: n.measuredAt, value: n.asleepMinutes });
|
||||
|
||||
Reference in new issue
Block a user