diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 8ebc0e5f2..82b75acae 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -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), })); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 15d518282..4c0b8bc47 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -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 = { * 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> | null = night ? Object.fromEntries( diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 62ce50743..8dc041f8a 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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), })); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index b33117d4a..91a0d7549 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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( diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 969de1272..8c29c6c06 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -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); + }); }); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 2541678ee..104a2bd57 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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 = new Set([ @@ -45,10 +76,26 @@ const ASLEEP_STAGES: ReadonlySet = new Set([ "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>; } +/** 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(); + 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(); - 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) => ({ diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 606e2fb93..ece84d573 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -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 /