fix(insights): sleep average via deduped reconstruction + night-reconstruction guard

This commit is contained in:
Marc-André Bombeck committed 2026-06-05 22:09:06 +02:00
1 parent a55816bf58
commit 874794e0e4
9 files changed
+366 -26

No files matched your search

+44 -9
View File
@@ -23,6 +23,12 @@ import {
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import {
resolveDashboardLayout,
type ComparisonBaseline,
@@ -153,16 +159,45 @@ async function buildComparisonSnapshotForUser(
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const summary = summarize(
measurements.map(
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
// raw stage rows mislabels a per-stage average as a night total (and
// double-counts a bare ASLEEP aggregate against its granular twin). Route
// the comparison through the per-night dedup reconstruction so the
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
let dataPoints: DataPoint[];
if (type === "SLEEP_DURATION") {
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: {
measuredAt: true,
value: true,
sleepStage: true,
source: true,
},
}),
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
dataPoints = reconstructSleepNights(
sleepRows as SleepStageRow[],
sleepTz,
sleepPriority,
)
.filter((n) => n.asleepMinutes > 0)
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
} else {
const measurements = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
dataPoints = measurements.map(
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
),
);
);
}
const summary = summarize(dataPoints);
const baselineAvg =
baseline === "lastMonth"
? (summary.avg30LastMonth ?? null)
@@ -139,4 +139,37 @@ describe("GET /api/sleep/night", () => {
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
// A5 — 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 () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T06:00:00.000Z", "IN_BED", 60),
stage("2026-06-04T06:00:00.000Z", "AWAKE", 60),
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // not 500
const body = await res.json();
expect(body.data.main).toBeNull();
expect(body.data.naps).toEqual([]);
});
it("returns 200 on a bare ASLEEP + stage-less + granular mix, never 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
stage("2026-06-04T02:00:00.000Z", "DEEP", 1),
stage("2026-06-04T06:00:00.000Z", "ASLEEP", 480),
// stage-less twin (sleepStage: null) for the same span.
{
value: 480,
measuredAt: new Date("2026-06-04T06:00:00.000Z"),
sleepStage: null,
source: "APPLE_HEALTH",
},
] as never);
const res = await GET(req("date=2026-06-04"));
expect(res.status).toBe(200); // never 500 on the reconstruction edge
});
});