fix(sleep): group per-night totals by session, not per-stage calendar day

Per-stage SLEEP_DURATION rows carry the stage segment's END instant, so a
night where the user falls asleep before local midnight spread its stages
across two calendar days. Keying each stage by its own day split one
overnight sleep into two partial nights, and the headline last-night tile
showed only the post-midnight slice.

Reconstruct nights by clustering stage segments into sessions: a new session
starts only when the next segment's start (end minus its minute duration) is
more than three hours after the running session end. A single long stage
block no longer splits a night, a daytime nap stays separable from the
following overnight block, and the gap is measured on the absolute instant so
the clustering is DST-immune. Each session is keyed by the local wake day of
its latest segment end, matching the convention that a sleep session belongs
to the morning you wake up.

Collapse a multi-source night to one canonical source before summing, using
the user's sleep source-priority ladder (default WHOOP > Apple Health >
Withings), so a dual-source account no longer double-counts the total. Sources
are never blended within a night.

Bound the series sleep read to one year even on the 'Alle' range, matching the
slim slice's sleep window, so a multi-year dual-source account does not walk a
six-figure stage-row set into JS.

Add fixtures straddling local midnight, crossing a DST spring-forward, keeping
a nap separable, and collapsing a dual-source night.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 17:25:19 +02:00
1 parent 4666359470
commit 49a029e023
7 files changed
+366 -26

No files matched your search

@@ -36,6 +36,13 @@ vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
// v1.11.4 — the route loads the user's source-priority ladder to collapse a
// dual-source sleep night; pin it to the defaults (null) so the test stays
// hermetic.
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
+15 -3
View File
@@ -62,6 +62,7 @@ import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
const SPARK_DAYS = 7;
@@ -189,8 +190,12 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
* across the per-stage rows (e.g. a 60-min DEEP row and a 240-min CORE
* row average to 150) rather than summing them into a night total.
*/
function buildSleepSparkline(rows: SleepStageRow[], tz: string): number[] {
return reconstructSleepNights(rows, tz)
function buildSleepSparkline(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown,
): number[] {
return reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100)
.slice(-SPARK_DAYS);
@@ -565,11 +570,16 @@ async function buildDashboardSummary(
measuredAt: { gte: streakWindowStart },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
}),
),
]);
// v1.11.4 — the user's sleep source-priority ladder, used to collapse a
// dual-source night (e.g. WHOOP + Apple Health) to one canonical source
// before the per-night reconstruction sums it.
const sleepPriorityJson = await loadUserSourcePriority(userId);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
@@ -662,6 +672,7 @@ async function buildDashboardSummary(
const sleepSummary = summarizeSleepNights(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
);
const metrics: MetricCard[] = [];
@@ -824,6 +835,7 @@ async function buildDashboardSummary(
const nightSpark = buildSleepSparkline(
sleepStageRows as SleepStageRow[],
userTz,
sleepPriorityJson,
);
const stageHours: Partial<Record<SleepStage, number>> | null = night
? Object.fromEntries(
@@ -19,6 +19,13 @@ vi.mock("@/lib/tz/resolver", async (importOriginal) => {
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
// v1.11.4 — the sleep branch loads the user's source-priority ladder to
// collapse a dual-source night; pin it to the defaults so the test stays
// hermetic (no DB user read).
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
+37 -5
View File
@@ -23,8 +23,16 @@ import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType, SleepStage } from "@/generated/prisma/client";
import { reconstructSleepNights } from "@/lib/analytics/sleep-night";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* 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;
const kindEnum = z.enum([
"weight",
"bloodPressure",
@@ -180,20 +188,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
// night's TIME ASLEEP (CORE + DEEP + REM, excluding IN_BED + AWAKE),
// converted to HOURS. The single-stage rows the legacy path returned
// made the chart show one fragment per stage instead of a nightly
// trend.
const tz = await resolveUserTimezone(user.id);
// trend. The per-night reconstruction clusters stages into sessions
// (so a midnight-spanning night stays one point) and collapses a
// 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,
);
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: since },
measuredAt: { gte: sleepSince },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true, sleepStage: true },
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
},
});
unit = "h";
points = reconstructSleepNights(rows, tz)
points = reconstructSleepNights(rows, tz, priorityJson)
.filter((n) => n.asleepMinutes > 0)
.map((n) => {
const stageHours = Object.fromEntries(
+135 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import type { SleepStage } from "@/generated/prisma/client";
import type {
MeasurementSource,
SleepStage,
} from "@/generated/prisma/client";
import {
reconstructSleepNights,
summarizeSleepNights,
@@ -15,6 +18,16 @@ function row(
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
}
/** Build a stage row tagged with an ingest source. */
function srcRow(
iso: string,
stage: SleepStage | null,
minutes: number,
source: MeasurementSource,
): SleepStageRow {
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes, source };
}
describe("reconstructSleepNights", () => {
it("sums asleep stages into one night, excluding IN_BED + AWAKE", () => {
// One night written as five per-stage rows (WHOOP / Apple shape), all
@@ -68,6 +81,105 @@ describe("reconstructSleepNights", () => {
expect(nights[0].asleepMinutes).toBe(423);
expect(nights[0].inBedMinutes).toBeNull();
});
it("keeps a night that straddles LOCAL midnight as ONE night (HIGH)", () => {
// Berlin = UTC+2 in June. Asleep 22:30 → 06:15 LOCAL across CONTIGUOUS
// stage segments (each ends where the next begins). The stage END instants
// land on BOTH sides of local midnight, so a per-stage day key would split
// the night in two and the headline would lose the pre-midnight sleep.
// Session clustering must collapse them into one night keyed by the LOCAL
// WAKE DAY (Jun 4, the morning the user wakes).
const rows: SleepStageRow[] = [
// IN_BED spans the whole night (22:30 → 06:15 local = 465 min).
row("2026-06-04T04:15:00.000Z", "IN_BED", 465),
row("2026-06-03T21:30:00.000Z", "CORE", 60), // 22:30 → 23:30 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 90), // 23:30 → 01:00 (→ Jun 4)
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:15:00.000Z", "CORE", 195), // 03:00 → 06:15 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
// Keyed by the wake day, not the fall-asleep day.
expect(nights[0].night).toBe("2026-06-04");
// Whole night summed: CORE 60 + DEEP 90 + REM 120 + CORE 195 = 465.
expect(nights[0].asleepMinutes).toBe(465);
expect(nights[0].inBedMinutes).toBe(465);
});
it("keeps a night that straddles a DST spring-forward as one night", () => {
// Europe/Berlin springs forward 2026-03-29 02:00 → 03:00 local (UTC+1 →
// UTC+2). A night asleep ~23:00 Mar 28 → ~07:00 Mar 29 local crosses the
// skipped hour. The absolute-time gap clustering is DST-immune, and the
// wake-day key resolves on the real instant, so it stays one night keyed
// to the wake day (Mar 29).
// Contiguous segments chained in UTC across the spring-forward seam.
const rows: SleepStageRow[] = [
row("2026-03-28T22:40:00.000Z", "CORE", 100), // 23:00 → 23:40 Mar 28 (UTC+1)
row("2026-03-29T00:30:00.000Z", "DEEP", 110), // 23:40 → 01:30 Mar 29 (UTC+1)
// 01:55 UTC = 03:55 Mar 29 local AFTER the spring-forward (UTC+2).
row("2026-03-29T01:55:00.000Z", "REM", 85), // 01:30 → 03:55 Mar 29 (DST seam)
row("2026-03-29T05:00:00.000Z", "CORE", 185), // 03:55 → 07:00 Mar 29 (UTC+2)
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(1);
expect(nights[0].night).toBe("2026-03-29");
// 100 + 110 + 85 + 185 = 480 asleep minutes, summed across the DST seam.
expect(nights[0].asleepMinutes).toBe(480);
});
it("keeps a daytime nap separable from the following overnight night", () => {
// A 15:00 nap and a 19:40→06:00 overnight block are > 3 h apart, so they
// are two distinct sessions — the nap is NOT lumped into the night.
const rows: SleepStageRow[] = [
row("2026-06-03T13:00:00.000Z", "CORE", 45), // 14:15 → 15:00 Jun 3 nap
// Overnight, contiguous: ~19:40 Jun 3 → 06:00 Jun 4 local.
row("2026-06-03T21:00:00.000Z", "CORE", 200), // 19:40 → 23:00 Jun 3
row("2026-06-03T23:00:00.000Z", "DEEP", 120), // 23:00 → 01:00 Jun 4
row("2026-06-04T01:00:00.000Z", "REM", 120), // 01:00 → 03:00 Jun 4
row("2026-06-04T04:00:00.000Z", "CORE", 180), // 03:00 → 06:00 Jun 4
];
const nights = reconstructSleepNights(rows, "Europe/Berlin");
expect(nights).toHaveLength(2);
// Nap keyed to its own wake day (Jun 3); overnight to Jun 4.
expect(nights[0].night).toBe("2026-06-03");
expect(nights[0].asleepMinutes).toBe(45);
expect(nights[1].night).toBe("2026-06-04");
expect(nights[1].asleepMinutes).toBe(620);
});
it("collapses a dual-source night to one canonical source (MEDIUM-1)", () => {
// WHOOP + Apple Health both report the SAME night. The default `sleep`
// ladder is WHOOP > APPLE_HEALTH > WITHINGS, so only WHOOP's stages are
// summed — no double-count, no blend.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"),
// Apple Health's parallel rows for the same night — must be dropped.
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 85, "APPLE_HEALTH"),
srcRow("2026-06-04T04:35:00.000Z", "REM", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC");
expect(nights).toHaveLength(1);
// WHOOP only: 240 + 90 + 90 = 420, NOT the ~825 blend of both sources.
expect(nights[0].asleepMinutes).toBe(420);
expect(nights[0].stages.CORE).toBe(240);
});
it("honours a per-user ladder that prefers Apple Health over WHOOP", () => {
const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] };
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 90, "WHOOP"),
srcRow("2026-06-04T01:05:00.000Z", "CORE", 230, "APPLE_HEALTH"),
srcRow("2026-06-04T03:05:00.000Z", "DEEP", 80, "APPLE_HEALTH"),
];
const nights = reconstructSleepNights(rows, "UTC", priorityJson);
expect(nights).toHaveLength(1);
// Apple Health wins under the override: 230 + 80 = 310.
expect(nights[0].asleepMinutes).toBe(310);
});
});
describe("summarizeSleepNights", () => {
@@ -109,4 +221,26 @@ describe("summarizeSleepNights", () => {
expect(summary.latest).toBeNull();
expect(latestNight).toBeNull();
});
it("the latest night is the most-recent COMPLETE midnight-spanning night", () => {
// The headline reads `latestNight.asleepMinutes`. With a midnight-spanning
// last night, the old per-stage keying would have made the latest "night"
// the post-midnight fragment only. After the fix the latest night carries
// the full asleep total.
const rows: SleepStageRow[] = [
// Older complete night → Jun 3 (contiguous).
row("2026-06-02T23:00:00.000Z", "CORE", 200), // 21:00 → 01:00 Jun 3
row("2026-06-03T01:00:00.000Z", "DEEP", 120), // 01:00 → 03:00 Jun 3
// Last night, asleep before local midnight → Jun 4 (contiguous).
row("2026-06-03T21:00:00.000Z", "CORE", 150), // 20:30 → 23:00 Jun 3
row("2026-06-03T23:30:00.000Z", "DEEP", 150), // 23:00 → 01:30 Jun 4
row("2026-06-04T04:00:00.000Z", "REM", 270), // 23:30 → 06:00 Jun 4
];
const { summary, latestNight } = summarizeSleepNights(rows, "Europe/Berlin");
expect(summary.count).toBe(2);
expect(latestNight?.night).toBe("2026-06-04");
// Full last night = 150 + 150 + 270 = 570 (not just a post-midnight slice).
expect(latestNight?.asleepMinutes).toBe(570);
expect(summary.latest).toBe(570);
});
});
+159 -14
View File
@@ -17,12 +17,39 @@
*
* Night grouping
* --------------
* A "night" is keyed by the wake-day calendar date in the USER's
* timezone (`userDayKey`). Apple Health / WHOOP write each stage with
* `measuredAt` at the stage's start instant; all the stages of one
* sleep session fall on the same wall-clock calendar day, so the tz day
* key collapses them into one bucket. (This matches the existing
* `computeSleepStageBreakdown` convention in the analytics route.)
* A "night" is one SLEEP SESSION, not a calendar day. `measuredAt` is the
* stage segment's END instant (`apple-health-mapping.ts` sets
* `takenAt = endDate`), and a device writes one row per stage segment, so a
* normal overnight sleep (asleep before local midnight) spreads its segments
* across BOTH sides of midnight. Keying each segment by its own calendar day
* would split one physical night into two partial nights and undercount the
* "last night" headline.
*
* Instead we cluster the segments into sessions by time gap: sort by
* `measuredAt`, and start a new session whenever the gap from the previous
* segment exceeds `SESSION_GAP_MS` (3 h). Stage segments inside one sleep
* session are contiguous (minutes to ~2 h apart), so a > 3 h gap reliably
* marks the boundary between a daytime nap and the overnight session, or
* between two genuine sessions in one day — they stay separable. The gap is
* measured on the absolute UTC instant, so it is immune to DST shifts.
*
* Each session is keyed by the LOCAL WAKE DAY — `userDayKey` of the session's
* LAST segment (its end instant) in the user's timezone. This matches the
* Apple Health convention (a sleep session is attributed to the date you wake
* up) and keeps the keying tz/DST-correct because it runs on the real instant.
*
* Source de-dup
* -------------
* A user paired to more than one sleep source (e.g. WHOOP + Apple Health)
* gets per-stage rows for the SAME night from each source. Summing them all
* would ~double the night total. Before summing a session we collapse it to
* ONE source using the user's `sleep` source-priority ladder
* (`DEFAULT_SOURCE_PRIORITY.sleep` = WHOOP > APPLE_HEALTH > WITHINGS): pick the
* highest-ranked source present in the session, falling back to the source
* with the most asleep minutes (then source name) when none of the session's
* sources sits on the ladder. Only that source's segments are summed — sources
* are never blended within a night. This mirrors the per-(type, day) collapse
* the rest of the v1.11.x numeric surfaces apply.
*
* Unit
* ----
@@ -33,9 +60,13 @@
* night totals instead of per-stage rows, which is strictly more
* correct. Callers that want hours convert at the edge (`/ 60`).
*/
import type { SleepStage } from "@/generated/prisma/client";
import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/resolver";
import { summarize, type DataSummary } from "@/lib/analytics/trends";
import {
getSourceLadder,
parseSourcePriority,
} from "@/lib/validations/source-priority";
/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */
const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
@@ -45,10 +76,26 @@ const ASLEEP_STAGES: ReadonlySet<SleepStage> = new Set<SleepStage>([
"DEEP",
]);
/**
* Gap (ms) between consecutive stage segments that starts a new sleep
* session. Stage segments within one night are contiguous (minutes to a
* couple of hours apart); a > 3 h gap separates a nap from the overnight
* block, or two genuine sessions in one day.
*/
const SESSION_GAP_MS = 3 * 60 * 60 * 1000;
export interface SleepStageRow {
value: number;
measuredAt: Date;
sleepStage: SleepStage | null;
/**
* Ingest source of the stage row. Used to collapse a multi-source night
* (e.g. WHOOP + Apple Health) to one canonical source before summing.
* Optional so legacy callers and fixtures that don't carry a source still
* type-check (a missing source is treated as a single anonymous source —
* the de-dup is a no-op for single-source nights).
*/
source?: MeasurementSource | null;
}
/** One reconstructed night: the asleep total + the per-stage breakdown. */
@@ -67,20 +114,117 @@ export interface SleepNight {
stages: Partial<Record<SleepStage, number>>;
}
/** Sentinel for rows that carry no source — collapses to one bucket. */
const NO_SOURCE = "__none__";
/**
* Group raw per-stage rows into per-night totals (tz-aware wake-day key).
* Pure — the caller does the bounded DB read. Nights are returned sorted
* ascending by key so the last element is the most recent night.
* Pick the one canonical source for a session. The user's `sleep`
* source-priority ladder wins (lowest ladder index = highest priority).
* Sources absent from the ladder fall back to "most asleep minutes", then
* a stable source-name tiebreak. Single-source (or source-less) sessions
* resolve to that one bucket with no work.
*/
function pickSessionSource(
rows: SleepStageRow[],
sleepLadder: readonly MeasurementSource[],
): string {
const asleepBySource = new Map<string, number>();
for (const r of rows) {
const src = r.source ?? NO_SOURCE;
const stage = r.sleepStage;
const counts = stage == null || ASLEEP_STAGES.has(stage);
if (!counts) continue;
const minutes = Number.isFinite(r.value) ? r.value : 0;
asleepBySource.set(src, (asleepBySource.get(src) ?? 0) + minutes);
}
// No asleep minutes anywhere (IN_BED / AWAKE only) — fall back to the set
// of all present sources so the session still resolves to a single bucket.
const sources =
asleepBySource.size > 0
? [...asleepBySource.keys()]
: [...new Set(rows.map((r) => r.source ?? NO_SOURCE))];
if (sources.length <= 1) return sources[0] ?? NO_SOURCE;
const rankOf = (src: string): number => {
const i = sleepLadder.indexOf(src as MeasurementSource);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
return sources.sort((a, b) => {
const ra = rankOf(a);
const rb = rankOf(b);
if (ra !== rb) return ra - rb;
// Neither (or both) on the ladder — most asleep minutes wins, then name.
const ma = asleepBySource.get(a) ?? 0;
const mb = asleepBySource.get(b) ?? 0;
if (ma !== mb) return mb - ma;
return a < b ? -1 : 1;
})[0];
}
/**
* Group raw per-stage rows into per-night totals. Stages are clustered into
* sleep SESSIONS by time gap (a > `SESSION_GAP_MS` gap starts a new session),
* each session keyed by the LOCAL WAKE DAY of its last segment, then collapsed
* to ONE source via the user's `sleep` priority ladder before summing.
*
* Pure — the caller does the bounded DB read. `priorityJson` is the user's
* persisted `sourcePriorityJson` (or null for the defaults). Nights are
* returned sorted ascending by key so the last element is the most recent
* night.
*/
export function reconstructSleepNights(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown = null,
): SleepNight[] {
if (rows.length === 0) return [];
const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep");
// Cluster into sessions by the gap between a segment's START and the latest
// END seen so far in the current session. `measuredAt` is the segment END;
// the segment START is `end − value minutes`. A new session begins only when
// the next segment STARTS more than SESSION_GAP_MS after the running end —
// so a single long stage block (a 4 h CORE) does NOT split a night, and two
// sources' interleaved/overlapping rows for the same night stay together
// (their gap is ≤ 0). Gaps are absolute-time, so the clustering is DST-immune.
// Sort by START so contiguous segments compare end-to-start in order.
const startOf = (r: SleepStageRow): number =>
r.measuredAt.getTime() -
(Number.isFinite(r.value) ? r.value : 0) * 60_000;
const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b));
const sessions: SleepStageRow[][] = [];
let current: SleepStageRow[] = [];
let sessionEnd = Number.NEGATIVE_INFINITY;
for (const r of sorted) {
const start = startOf(r);
const end = r.measuredAt.getTime();
if (current.length > 0 && start - sessionEnd > SESSION_GAP_MS) {
sessions.push(current);
current = [];
sessionEnd = Number.NEGATIVE_INFINITY;
}
current.push(r);
if (end > sessionEnd) sessionEnd = end;
}
if (current.length > 0) sessions.push(current);
// Two sessions can land on the same wake day (e.g. a genuine second sleep);
// merge their rows under one key after the source-collapse so the night key
// stays unique. Collapse each session to its canonical source first.
const byNight = new Map<string, SleepStageRow[]>();
for (const row of rows) {
const key = userDayKey(row.measuredAt, tz);
for (const session of sessions) {
const canonical = pickSessionSource(session, sleepLadder);
const kept = session.filter((r) => (r.source ?? NO_SOURCE) === canonical);
const pool = kept.length > 0 ? kept : session;
// The wake day is the LATEST segment END in the session (Apple Health
// attributes a session to the morning you wake up).
const wakeInstant = pool.reduce(
(max, r) => (r.measuredAt.getTime() > max.getTime() ? r.measuredAt : max),
pool[0].measuredAt,
);
const key = userDayKey(wakeInstant, tz);
const list = byNight.get(key) ?? [];
list.push(row);
list.push(...kept);
byNight.set(key, list);
}
@@ -147,8 +291,9 @@ export interface SleepNightSummary {
export function summarizeSleepNights(
rows: SleepStageRow[],
tz: string,
priorityJson: unknown = null,
): SleepNightSummary {
const nights = reconstructSleepNights(rows, tz).filter(
const nights = reconstructSleepNights(rows, tz, priorityJson).filter(
(n) => n.asleepMinutes > 0,
);
const dataPoints = nights.map((n) => ({
+6 -3
View File
@@ -305,12 +305,15 @@ async function withSleepNightTotals(
measuredAt: { gte: since },
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
})) as SleepStageRow[];
if (rows.length === 0) return slice;
const tz = await resolveUserTimezone(userId);
const { summary } = summarizeSleepNights(rows, tz);
const [tz, priorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const { summary } = summarizeSleepNights(rows, tz, priorityJson);
// Preserve the slope tuples the night summary doesn't compute (the
// dashboard tile reads slope30); recompute them off the night series is
// out of scope here — the per-night `summarize()` already fills slope7 /