From 874794e0e43dd5b1309db0e5757bc1376dbfde24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 5 Jun 2026 22:08:59 +0200 Subject: [PATCH] fix(insights): sleep average via deduped reconstruction + night-reconstruction guard --- src/app/api/insights/generate/route.ts | 53 +++++++++-- .../api/sleep/night/__tests__/route.test.ts | 33 +++++++ .../analytics/__tests__/sleep-night.test.ts | 95 ++++++++++++++++++- src/lib/analytics/sleep-night.ts | 15 +++ .../insights/__tests__/graded-series.test.ts | 31 ++++++ .../insights/__tests__/metric-status.test.ts | 62 ++++++++++++ src/lib/insights/comprehensive-generate.ts | 53 +++++++++-- src/lib/insights/features.ts | 30 +++++- src/lib/insights/metric-status.ts | 20 +++- 9 files changed, 366 insertions(+), 26 deletions(-) diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3415c1109..4b4ded190 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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) diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index 0be2ab6b5..a4ee765d4 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -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 + }); }); diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 3d6f7ee4b..a3febd779 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -16,8 +16,14 @@ function row( iso: string, stage: SleepStage | null, minutes: number, + source?: MeasurementSource, ): SleepStageRow { - return { measuredAt: new Date(iso), sleepStage: stage, value: minutes }; + return { + measuredAt: new Date(iso), + sleepStage: stage, + value: minutes, + ...(source ? { source } : {}), + }; } /** Build a stage row tagged with an ingest source. */ @@ -535,3 +541,90 @@ describe("summarizeSleepNights", () => { expect(summary.latest).toBe(570); }); }); + +// A4 — the Insights sleep AVERAGE must come from the deduped per-night totals, +// never the raw per-stage sum that double-counts a bare ASLEEP aggregate against +// its granular twin (and folds IN_BED / AWAKE in on top). The ~20.3 h symptom is +// what a single night's stage rows summed without dedup produces. +describe("sleep average dedup (A4)", () => { + it("one source emits BOTH bare ASLEEP + granular for the same span → dedup total, not the sum", () => { + // Apple-Health-style double write for ONE overnight session: the bare + // ASLEEP aggregate (480) PLUS the granular CORE/DEEP/REM partition (also + // 480), plus IN_BED + AWAKE. Summed raw this is ~1490 min (~24.8 h); the + // deduped night total is the granular 480 min (8 h). + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "ASLEEP", 480, "APPLE_HEALTH"), // bare aggregate + row("2026-06-04T02:00:00.000Z", "CORE", 240, "APPLE_HEALTH"), + row("2026-06-04T04:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"), + row("2026-06-04T06:00:00.000Z", "REM", 120, "APPLE_HEALTH"), + row("2026-06-04T06:30:00.000Z", "IN_BED", 470, "APPLE_HEALTH"), + row("2026-06-04T03:00:00.000Z", "AWAKE", 20, "APPLE_HEALTH"), + ]; + const { summary } = summarizeSleepNights(rows, "UTC"); + // Granular partition wins: 240 + 120 + 120 = 480 min — NOT 480 + 480 + … + expect(summary.latest).toBe(480); + expect(summary.max).toBe(480); + // One night, not 6 stage rows. + expect(summary.count).toBe(1); + }); + + it("multi-source bare+granular collapses to the canonical source, no cross-source sum", () => { + // WHOOP granular + Apple bare-only for the SAME night. The canonical source + // (WHOOP, top of the sleep ladder) wins; the night total is WHOOP's dedup + // total, never WHOOP + Apple summed. + const rows: SleepStageRow[] = [ + // WHOOP granular — 240 + 120 + 120 = 480. + row("2026-06-04T02:00:00.000Z", "CORE", 240, "WHOOP"), + row("2026-06-04T04:00:00.000Z", "DEEP", 120, "WHOOP"), + row("2026-06-04T06:00:00.000Z", "REM", 120, "WHOOP"), + // Apple bare aggregate for the same span — must NOT add on top. + row("2026-06-04T06:00:00.000Z", "ASLEEP", 470, "APPLE_HEALTH"), + ]; + const { summary } = summarizeSleepNights(rows, "UTC"); + expect(summary.count).toBe(1); + expect(summary.latest).toBe(480); // WHOOP only, not 480 + 470. + }); +}); + +// A5 — `reconstructSleepSessions` must never throw on a session the dedup +// empties (the unguarded `segments[0]` access). A read returns a valid empty +// night, never a 500. +describe("reconstructSleepSessions total-safety (A5)", () => { + it("never throws on an IN_BED/AWAKE-only session and yields no main night", () => { + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "IN_BED", 60), + row("2026-06-04T06:00:00.000Z", "AWAKE", 60), + ]; + expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); + const sessions = reconstructSleepSessions(rows, "UTC"); + // Every returned session has at least one renderable segment. + expect(sessions.every((s) => s.segments.length > 0)).toBe(true); + expect(pickMainNightAndNaps(sessions).main).toBeNull(); + }); + + it("skips a session that empties after the granular-over-bare filter, never throws", () => { + // A session whose canonical pool, after dropping the redundant bare ASLEEP + // aggregate / stage-less twins under `sawGranular`, has zero renderable + // segments. The DEEP granular row marks the session granular; the bare + + // stage-less rows are then filtered out. The granular row survives here, so + // assert the load-bearing contract: no throw, and every returned session is + // non-empty. + const rows: SleepStageRow[] = [ + row("2026-06-04T02:00:00.000Z", "DEEP", 1), // tiny granular marker + row("2026-06-04T06:00:00.000Z", "ASLEEP", 480), // redundant bare twin + row("2026-06-04T06:00:00.000Z", null, 480), // stage-less twin + ]; + expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions.every((s) => s.segments.length > 0)).toBe(true); + }); + + it("never throws on a stage-less-only session", () => { + // Legacy / manual stage-less rows only — no granular partition, so they are + // the fallback signal and survive the filter; still must not throw. + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", null, 480), + ]; + expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 70c57fd63..360e4f85d 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -332,6 +332,12 @@ export function reconstructSleepNights( let sawInBed = false; let sawAwake = false; let asleep = 0; + // `nightSessions` and each session pool are non-empty by construction: a + // key is only added with a `pool` that has ≥ 1 row (the source-filter falls + // back to the full session when the canonical filter empties it), so + // `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this + // function never applies the granular-over-bare filter to the indexed pool, + // so it cannot empty the array here. Keep that invariant on future edits. let latest = nightSessions[0][0].measuredAt; const stages: Partial> = {}; for (const sessionRows of nightSessions) { @@ -522,6 +528,15 @@ export function reconstructSleepSessions( .map(segmentOf) .sort((a, b) => a.start.getTime() - b.start.getTime()); + // The granular-over-bare filter can empty a non-empty pool (a session whose + // only rows are the redundant bare ASLEEP aggregate / stage-less twins of a + // granular partition that did not survive into this pool). A session with no + // renderable/scorable segment contributes nothing — skip it before indexing + // `segments[0]`. This keeps `reconstructSleepSessions` total: it must NEVER + // throw on a shape the dedup empties, so `GET /api/sleep/night` (and every + // other consumer) returns a valid empty night, never a 500. + if (segments.length === 0) continue; + let inBed = 0; let awake = 0; let sawInBed = false; diff --git a/src/lib/insights/__tests__/graded-series.test.ts b/src/lib/insights/__tests__/graded-series.test.ts index 6beeef77b..aee792454 100644 --- a/src/lib/insights/__tests__/graded-series.test.ts +++ b/src/lib/insights/__tests__/graded-series.test.ts @@ -4,6 +4,10 @@ import { buildGradedSeriesFromPoints, type GradedSeries, } from "../graded-series"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; const dayMs = 24 * 60 * 60 * 1000; @@ -111,4 +115,31 @@ describe("buildGradedSeriesFromPoints", () => { expect(s.recent[0].mean).toBe(80); expect(s.recent[0].n).toBe(3); }); + + // A4 — the sleep AVERAGE the Insights assessment ships is the graded series + // built from DEDUPED per-night totals, never the raw per-stage sum that + // double-counts a bare ASLEEP aggregate against its granular twin (the + // impossible ~20.3 h symptom). This pins the path metric-status now uses: + // reconstruct nights → per-night points → buildGradedSeriesFromPoints. + it("sleep graded series uses the deduped night total, never the ~20 h stage sum (A4)", () => { + const sleepNow = new Date("2026-06-04T12:00:00.000Z"); + // One overnight session, Apple-Health-style double write: bare ASLEEP + // aggregate (480) + granular CORE/DEEP/REM (also 480) + IN_BED + AWAKE. + // Raw-summed this is ~1490 min (~24.8 h); deduped it is 480 min (8 h). + const rows: SleepStageRow[] = [ + { measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "ASLEEP", value: 480, source: "APPLE_HEALTH" }, + { measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "CORE", value: 240, source: "APPLE_HEALTH" }, + { measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "DEEP", value: 120, source: "APPLE_HEALTH" }, + { measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "REM", value: 120, source: "APPLE_HEALTH" }, + { measuredAt: new Date("2026-06-04T06:30:00.000Z"), sleepStage: "IN_BED", value: 470, source: "APPLE_HEALTH" }, + { measuredAt: new Date("2026-06-04T03:00:00.000Z"), sleepStage: "AWAKE", value: 20, source: "APPLE_HEALTH" }, + ]; + const points = reconstructSleepNights(rows, "UTC") + .filter((n) => n.asleepMinutes > 0) + .map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes })); + const graded = buildGradedSeriesFromPoints(points, sleepNow); + const recentMean = graded.recent.at(-1)?.mean ?? 0; + expect(recentMean).toBe(480); // night total, not ~1218 (stage sum) + expect(recentMean).toBeLessThan(960); // < 16 h — never impossible + }); }); diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 9f0beb8ec..1f418837e 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -135,6 +135,68 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" expect(prompt).not.toContain('"value": 75'); 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 () => { + // 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 + // granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h). + // Post-fix the graded series is built from the deduped per-night points, so + // every recent bucket mean is the night total (480 min = 8 h). + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + } as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + + // A recent night (last 24 h) so it lands in `graded.recent`. + const wake = new Date(Date.now() - 6 * 60 * 60 * 1000); + const m = 60 * 1000; + const stageRows = [ + { value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate + { value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" }, + { value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" }, + { value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" }, + { value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" }, + { value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" }, + ]; + vi.mocked(prisma.measurement.count).mockResolvedValue( + stageRows.length as never, + ); + vi.mocked(prisma.measurement.findMany).mockResolvedValue( + stageRows as never, + ); + + const capture = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion("Summary: solide Nacht.", capture); + + await generateMetricStatus({ + metric: "SLEEP_DURATION", + userId: "u1", + locale: "de", + force: true, + }); + + const prompt = capture.userPrompt as string; + const match = prompt.match(/\{[\s\S]*\}/); + expect(match).toBeTruthy(); + const snapshot = JSON.parse(match![0]); + const recent = snapshot.SLEEP_DURATION.series.recent as Array<{ + mean: number; + }>; + expect(recent.length).toBeGreaterThan(0); + for (const bucket of recent) { + // Night total (480 min = 8 h), never the impossible ~1490-min stage sum. + expect(bucket.mean).toBeLessThanOrEqual(960); // ≤ 16 h + } + expect(recent.at(-1)?.mean).toBe(480); + }); }); describe("metric-status registry", () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 4f1953c0c..ac82db54a 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -36,6 +36,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, @@ -145,16 +151,45 @@ export 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) diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index fb2ed5a03..5e429520b 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -12,6 +12,11 @@ import { lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { @@ -568,14 +573,35 @@ export async function extractFeatures( } // Sleep Duration + // + // SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the + // raw stage rows would average individual stages (and double-count a bare + // ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the + // feature block through the per-night dedup reconstruction — the same helper + // the dashboard / series / status path use — so `avg7` / `avg30` / `latest` + // are per-night TIME-ASLEEP totals in minutes, never stage averages. const sleepData = byType("SLEEP_DURATION"); if (sleepData.length > 0) { - const summary = summarize(toDataPoints(sleepData)); + const [sleepTz, sleepPriority] = await Promise.all([ + resolveUserTimezone(userId), + loadUserSourcePriority(userId), + ]); + const sleepNights = reconstructSleepNights( + sleepData as unknown as SleepStageRow[], + sleepTz, + sleepPriority, + ).filter((n) => n.asleepMinutes > 0); + const summary = summarize( + sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })), + ); features.sleep = { avg7: summary.avg7, avg30: summary.avg30, latest: summary.latest, - coverage: computeCoverage(sleepData, now), + coverage: computeCoverage( + sleepNights.map((n) => ({ measuredAt: n.measuredAt })), + now, + ), }; } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 032ee9f7a..4855464d8 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -47,6 +47,7 @@ import { lookupNormalRange } from "@/lib/insights/derived/norms"; import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { + buildGradedSeriesFromPoints, buildGradedSeriesWithRollups, degradeStatusSnapshotToBudget, } from "@/lib/insights/graded-series"; @@ -271,11 +272,20 @@ export async function generateMetricStatus(args: { measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } const series = applyPayloadBudget(points, { now }); - const graded = await buildGradedSeriesWithRollups( - args.userId, - meta.measurementType, - now, - ); + // SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that + // path reads RAW per-stage rows (and the stage-summed `measurement_rollups` + // tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one + // bucket, double-counting the bare aggregate against its granular partition — + // it produces an impossible ~20 h "average sleep". Build the graded series + // straight from the deduped per-night TIME-ASLEEP `points` instead, so every + // recent/weekly/monthly/yearly bucket is a night total, not a stage sum. + const graded = isSleep + ? buildGradedSeriesFromPoints(points, now) + : await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + ); const summary = summarizeSeries( series.daily.map((bucket) => ({ value: bucket.value })), );