fix(sleep): dedup the bare ASLEEP aggregate across the stage breakdown and hypnogram

Apple Health writes an unspecified ASLEEP aggregate alongside the granular
CORE/DEEP/REM partition for the same period. The asleep total already preferred
the granular partition, but the per-night/per-session `stages` map and the
hypnogram `segments` list recorded every stage row unconditionally, so a night
ended up carrying the granular set PLUS its ~equal bare twin — a double-height
green ASLEEP block on the stacked bar and the hypnogram, and a doubled total in
the analytics stage breakdown. Route the `stages` map and the `segments` list
through the same granular-over-bare gate as the asleep total: when a session
carries a granular stage, drop the redundant bare ASLEEP (and stage-less) rows
everywhere. WHOOP never emits a bare ASLEEP, so WHOOP nights are unaffected.

Apply the gate per session, not over the merged night pool, so a granular
overnight no longer strips a same-day bare-only nap's minutes from the night
total. Render the hypnogram per-segment in the stage palette (one ReferenceArea
per segment) instead of a single flat cyan line, and show the breakdown plus
totals footer whenever any per-stage minutes exist — reserving the unavailable
state for a truly empty session — so a legacy stage-summary night still surfaces
its breakdown. Thread the user's source priority into the CSV export's sleep
dedup, and honour offset with a true pre-slice night count on the sleep list.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 19:39:37 +02:00
1 parent c0065437a7
commit 766b395900
7 files changed
+343 -156

No files matched your search

+4 -1
View File
@@ -24,6 +24,7 @@ import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -44,18 +45,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
? "raw"
: "night";
const [measurements, userTz] = await Promise.all([
const [measurements, userTz, sourcePriorityJson] = await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
]);
const csv = toCSV(
formatMeasurementsForExport(measurements, userTz, {
granularity,
sleepTz: userTz,
sourcePriorityJson,
}),
);
+8 -2
View File
@@ -12,6 +12,7 @@ import {
formatMoodEntriesForExport,
} from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -49,7 +50,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = user.id;
const userTz = await resolveUserTimezone(userId);
const [userTz, sourcePriorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const data: Record<string, unknown> = {};
@@ -63,10 +67,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
// v1.11.5 — sleep collapses to one row per night by default (the
// formatter's `night` granularity), matching the per-type CSV route.
// Thread the user's source-priority so a multi-source night dedups to
// the same canonical source the UI shows.
const measurementRecords = formatMeasurementsForExport(
measurements,
userTz,
{ sleepTz: userTz },
{ sleepTz: userTz, sourcePriorityJson },
);
data.measurements =
format === "csv" ? toCSV(measurementRecords) : measurementRecords;
+10 -4
View File
@@ -135,6 +135,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
from,
to,
limit,
offset,
sortDir,
dayKey,
});
@@ -677,11 +678,12 @@ async function sleepListResponse(
from?: Date;
to?: Date;
limit: number;
offset: number;
sortDir: "asc" | "desc";
dayKey?: string;
},
) {
const { from, to, limit, sortDir, dayKey } = opts;
const { from, to, limit, offset, sortDir, dayKey } = opts;
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
@@ -809,17 +811,21 @@ async function sleepListResponse(
});
}
// Honour offset + limit against the full night set, and report the true
// pre-slice night count as `meta.total` — matching the non-sleep list
// branch's pagination contract so a client can page through nights.
const totalNights = nightRows.length;
const measurements = nightRows
.sort((a, b) => {
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
return sortDir === "asc" ? cmp : -cmp;
})
.slice(0, limit);
.slice(offset, offset + limit);
annotate({
action: { name: "measurement.list" },
meta: {
total: measurements.length,
total: totalNights,
type: "SLEEP_DURATION",
mode: "sleep-per-night",
rowsScanned: rows.length,
@@ -827,7 +833,7 @@ async function sleepListResponse(
});
return apiSuccess({
measurements,
meta: { total: nightRows.length, limit, offset: 0, groupBy: "night" },
meta: { total: totalNights, limit, offset, groupBy: "night" },
});
}