From 4b5970fbb0ef1a71932cc933de855a395de84187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 18:41:27 +0200 Subject: [PATCH] fix(sleep): reconcile the analytics stage breakdown with the tile and series computeSleepStageBreakdown keyed each stage row by the calendar day of its own measuredAt and never collapsed multi-source, so a night where sleep began before midnight split across two perNight buckets and a WHOOP + Apple Health night double-counted every stage. The stacked-bar therefore disagreed with the dashboard tile and the chart series for the same dates. Route it through the canonical reconstructSleepNights: session-cluster the stages, key by the wake day, collapse to one canonical source, and apply the granular-over-bare de-dup. The stacked-bar now agrees with the rest of the sleep surface. --- src/app/api/analytics/route.ts | 66 ++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 75d77a253..0d66a6e01 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -7,7 +7,9 @@ import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path"; @@ -420,46 +422,58 @@ async function computeSleepStageBreakdown( } | null> { const DAY_MS = 24 * 60 * 60 * 1000; const since = new Date(Date.now() - 30 * DAY_MS); + // v1.11.5 — read the FULL per-stage row set (not `sleepStage: { not: null }`) + // and route it through the canonical `reconstructSleepNights` so the + // stacked-bar agrees with the dashboard tile + chart series. The old path + // keyed each stage row by its OWN calendar day (splitting a midnight- + // spanning night across two buckets) and never collapsed multi-source + // (double-counting a WHOOP + Apple Health night). Routing through the + // helper fixes both: it session-clusters, keys by the wake day, and picks + // one canonical source per night. The bare-vs-granular de-dup is applied + // per-night to the per-stage breakdown too. The `source` is selected so + // the helper's source-collapse can run. const rows = await prisma.measurement.findMany({ where: { userId, type: "SLEEP_DURATION", - sleepStage: { not: null }, measuredAt: { gte: since }, deletedAt: null, }, - select: { value: true, measuredAt: true, sleepStage: true }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + }, }); if (rows.length === 0) return null; - const stages: Record = {}; - const dayKeys = new Set(); - let totalMinutes = 0; - // v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the - // chart's 7/14/30-day slicer can build a left-aligned series. - const perNightMap = new Map>(); - for (const row of rows) { - if (!row.sleepStage) continue; - stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value; - totalMinutes += row.value; - const dayKey = userDayKey(row.measuredAt, userTz); - dayKeys.add(dayKey); - const nightStages = perNightMap.get(dayKey) ?? {}; - nightStages[row.sleepStage] = - (nightStages[row.sleepStage] ?? 0) + row.value; - perNightMap.set(dayKey, nightStages); - } + const priorityJson = await loadUserSourcePriority(userId); + const nights = reconstructSleepNights(rows, userTz, priorityJson).filter( + (n) => Object.keys(n.stages).length > 0, + ); + if (nights.length === 0) return null; - // Sort per-night ascending so the chart consumer can slice the - // trailing N entries for the 7d / 14d / 30d toggle. - const perNight = Array.from(perNightMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([dayKey, stages]) => ({ dayKey, stages })); + // Aggregate the per-night stage breakdowns into the window totals + the + // per-night series the chart slices for its 7 / 14 / 30 toggle. + const stages: Record = {}; + let totalMinutes = 0; + const perNight = nights + .map((n) => { + const nightStages: Record = {}; + for (const [stage, mins] of Object.entries(n.stages)) { + nightStages[stage] = mins; + stages[stage] = (stages[stage] ?? 0) + mins; + totalMinutes += mins; + } + return { dayKey: n.night, stages: nightStages }; + }) + .sort((a, b) => a.dayKey.localeCompare(b.dayKey)); return { windowDays: 30, - nights: dayKeys.size, + nights: nights.length, totalMinutes, stages, perNight,