diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 2c413b3bb..6aab0d095 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -17,6 +17,10 @@ import { lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; import { pairByTimestamp } from "@/lib/analytics/correlations"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { @@ -161,6 +165,9 @@ async function buildTargetsResponse(user: AuthedUser) { gender: true, glucoseUnit: true, thresholdsJson: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, }, }); @@ -169,12 +176,15 @@ async function buildTargetsResponse(user: AuthedUser) { const heightCm = dbUser?.heightCm ?? null; // Fetch latest measurements for each type + // v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night + // reconstruction read below (the generic per-type average treats each + // stage row as an independent reading and would inflate the night total), + // so it is intentionally excluded from this generic set. const types: MeasurementType[] = [ "WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE", - "SLEEP_DURATION", "BODY_FAT", "ACTIVITY_STEPS", ]; @@ -729,23 +739,91 @@ async function buildTargetsResponse(user: AuthedUser) { } // 4. Sleep Duration + // + // v1.11.5 (deferred v1.11.4 QA L3) — SLEEP_DURATION is stored one row per + // STAGE per night in MINUTES. The generic per-type path treated every stage + // row as an independent reading, summing IN_BED + AWAKE + the bare ASLEEP + // aggregate + the granular CORE/DEEP/REM rows over 30 days and dividing by + // the row count — and then fed those MINUTES straight into a target range + // expressed in HOURS. The result was a bogus ~16 h/day average. Reconstruct + // per-night TIME-ASLEEP through the shared helper (which dedups dual-source + // nights, excludes IN_BED/AWAKE, and counts only the granular partition when + // present), convert to HOURS, then classify and roll up consistency on the + // per-night hour totals so this surface matches the dashboard / series / + // overview headline. const sleepRange = getSleepDurationRange(); - let sleepClassification: { category: string; color: string } | null = null; - if (latestByType.SLEEP_DURATION != null) { - const cls = classifySleepDuration(latestByType.SLEEP_DURATION); - sleepClassification = { category: cls.category, color: cls.color }; - } { + const sleepStageRows = await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true, source: true }, + }); + const nights = reconstructSleepNights( + sleepStageRows as SleepStageRow[], + userTz, + dbUser?.sourcePriorityJson ?? null, + ) + .filter((n) => n.asleepMinutes > 0) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const round1 = (n: number): number => Math.round(n * 100) / 100; + // One event per night carrying the night's asleep HOURS — the right unit + // for the 7–9 h target band and for the per-day consistency bucketing. + const sleepEvents = nights.map((n) => ({ + measuredAt: n.measuredAt, + value: round1(n.asleepMinutes / 60), + })); + + const latestNight = nights.length > 0 ? nights[nights.length - 1] : null; + const currentSleepHours = latestNight + ? round1(latestNight.asleepMinutes / 60) + : null; + const avgSleepHours = + sleepEvents.length > 0 + ? round1( + sleepEvents.reduce((s, e) => s + e.value, 0) / sleepEvents.length, + ) + : null; + + let sleepClassification: { category: string; color: string } | null = null; + if (currentSleepHours != null) { + const cls = classifySleepDuration(currentSleepHours); + sleepClassification = { category: cls.category, color: cls.color }; + } + + // Trend over the per-night hour series (first vs second half), mirroring + // `computeTrend` but on reconstructed nights rather than raw stage rows. + let sleepTrend: "up" | "down" | "stable" | null = null; + if (sleepEvents.length >= 4) { + const mid = Math.floor(sleepEvents.length / 2); + const firstHalf = sleepEvents.slice(0, mid); + const secondHalf = sleepEvents.slice(mid); + const avgFirst = + firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; + const avgSecond = + secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; + const diff = avgSecond - avgFirst; + const threshold = avgFirst * 0.02; + if (diff > threshold) sleepTrend = "up"; + else if (diff < -threshold) sleepTrend = "down"; + else sleepTrend = "stable"; + } + const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"), + sleepEvents, makeRangeClassifier(sleepRange), ); targets.push({ type: "SLEEP_DURATION", label: "Sleep duration", - current: latestByType.SLEEP_DURATION ?? null, - average30: avg30ByType.SLEEP_DURATION ?? null, - trend: computeTrend("SLEEP_DURATION"), + current: currentSleepHours, + average30: avgSleepHours, + trend: sleepTrend, unit: "h", range: sleepRange, classification: sleepClassification, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 2158ea03a..6e73f0a20 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -622,7 +622,59 @@ export function HealthChart({ } >(); + // v1.11.5 — SLEEP_DURATION is stored one row per sleep STAGE per night + // (minutes). The generic `/api/measurements` daily-aggregate path + // averages those stage rows, which neither sums a night nor isolates + // the asleep stages — it surfaced a ~2× inflated, mislabeled chart. The + // `/api/measurements/series?kind=sleep` adapter reconstructs ONE point + // per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM, + // bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE + // excluded, dual-source nights collapsed to one source). Routing the + // sleep chart through it keeps every sleep surface on the same + // reconstruction. One point per night → `sum/count` yields the night's + // hours unchanged. + async function fetchSleepNights() { + const sleepParams = new URLSearchParams(); + sleepParams.set("kind", "sleep"); + // The series route caps sleep at 365 days regardless; clamp the + // request so a longer comparison window still resolves. + sleepParams.set( + "days", + String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))), + ); + const res = await fetch(`/api/measurements/series?${sleepParams}`); + if (!res.ok) return; + const json = await res.json(); + const points = (json.data?.points ?? []) as Array<{ + at: string; + value: number; + }>; + for (const point of points) { + const value = point.value * valueScale; + if (value == null || !Number.isFinite(value)) continue; + const dayKey = toDayKey(point.at, dayKeyFormatter); + const bucket = dailyAggregates.get(dayKey) ?? { + timestamp: dayKeyToTimestamp(dayKey), + values: {}, + }; + const current = bucket.values["SLEEP_DURATION"] ?? { + sum: 0, + count: 0, + min: null, + max: null, + }; + current.sum += value; + current.count += 1; + bucket.values["SLEEP_DURATION"] = current; + dailyAggregates.set(dayKey, bucket); + } + } + async function fetchMeasurementsByType(type: string) { + if (type === "SLEEP_DURATION") { + await fetchSleepNights(); + return; + } const typeParams = new URLSearchParams(); typeParams.set("type", type); typeParams.set("sortBy", "measuredAt"); diff --git a/src/components/insights/sleep-duration-chart.tsx b/src/components/insights/sleep-duration-chart.tsx index 232543d5b..9d5a20a07 100644 --- a/src/components/insights/sleep-duration-chart.tsx +++ b/src/components/insights/sleep-duration-chart.tsx @@ -5,20 +5,23 @@ import { useTranslations } from "@/lib/i18n/context"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; /** - * v1.4.25 W4c — total nightly sleep duration over time. + * v1.4.25 W4c — nightly TIME-ASLEEP over time. * * Thin wrapper around `` so the sleep sub-page gets * the same chart-cog parity (`chartKey="sleep"`) as the dashboard chart - * surfaces. SLEEP_DURATION rows from Apple Health are per-stage (one - * row per stage per night) — the analytics API aggregates per Berlin - * day before summarising, and `HealthChart` itself relies on the - * pre-aggregated `/api/measurements/series` payload, so we pass the - * raw type through and let the existing chart wire do the day-rollup. + * surfaces. * - * Unit semantics: SLEEP_DURATION is canonically stored in MINUTES - * (v1.4.23 schema note). The chart shows the raw minutes value with a - * "min" unit suffix — the parent sub-page renders a separate - * "X h Y min" headline above the chart for the human-friendly read. + * Data path: SLEEP_DURATION rows are stored per-stage (one row per stage + * per night, in MINUTES). `` special-cases SLEEP_DURATION to + * read `/api/measurements/series?kind=sleep`, which reconstructs ONE point + * per night carrying the night's TIME-ASLEEP in HOURS (CORE + DEEP + REM, + * bare-ASLEEP only when no granular stage exists, IN_BED + AWAKE excluded, + * dual-source nights collapsed to one source). The chart therefore renders + * a nightly trend in hours that matches every other sleep surface. + * + * Unit semantics: the chart plots HOURS — the same unit the series adapter + * returns and the `` headline shows — so the axis reads e.g. + * "8 h", not the inflated per-stage minutes the legacy path surfaced. * * v1.4.28 R3d (BK-F-M2) — the inline `dynamic()` call site was retired * in favour of the shared `` re-export so the @@ -40,8 +43,8 @@ export function SleepDurationChart({ types={["SLEEP_DURATION"]} title={t("charts.sleep")} colors={["#8be9fd"]} - unit="min" - yAxisUnit="min" + unit="h" + yAxisUnit="h" compareBaseline={compareBaseline} userTimezone={userTimezone} /> diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e7847ae04..ea68dc0e0 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -23,6 +23,10 @@ import { type CoachDataCluster, } from "@/lib/validations/coach-prefs"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; @@ -455,7 +459,14 @@ async function buildCoachSnapshotImpl( // key is absent). So the prefs read must precede `resolveScope`. const prefsRow = await prisma.user.findUnique({ where: { id: userId }, - select: { coachPrefsJson: true, timezone: true, locale: true }, + select: { + coachPrefsJson: true, + timezone: true, + locale: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, + }, }); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); // Resolve the UI locale for the rolling-profile narrative recall. The @@ -751,7 +762,7 @@ async function buildCoachSnapshotImpl( deletedAt: null, }, orderBy: { measuredAt: "asc" }, - select: { value: true, measuredAt: true, sleepStage: true }, + select: { value: true, measuredAt: true, sleepStage: true, source: true }, }) : null; const workoutRowsPromise = sources.has("workouts") @@ -1082,46 +1093,44 @@ async function buildCoachSnapshotImpl( meta: { cluster: "sleep", source: "sleep" }, }); } else { - // Per-night total minutes (sum of all stage rows that night) for - // the duration timeline. - const perNight = new Map< - string, - { date: Date; total: number; stages: Record } - >(); - for (const r of sleepRows) { - const key = tzDayKey(r.measuredAt, userTz); - const e = perNight.get(key) ?? { - date: r.measuredAt, - total: 0, - stages: {}, - }; - e.total += r.value; - if (r.sleepStage) { - const stage = String(r.sleepStage).toLowerCase(); - e.stages[stage] = (e.stages[stage] ?? 0) + r.value; - } - perNight.set(key, e); - } - // Recent nights: duration + stage breakdown when present. - const recentNights = Array.from(perNight.entries()) - .filter(([, info]) => info.date >= recentCutoff) - .map(([date, info]) => { + // v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared + // helper so the Coach narrates the same nightly numbers every other + // sleep surface shows: stages clustered into sessions, a dual-source + // night collapsed to one canonical source, and the granular + // CORE/DEEP/REM partition counted WITHOUT double-counting the bare + // ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are + // excluded from the asleep total. + const nights = reconstructSleepNights( + sleepRows as SleepStageRow[], + userTz, + prefsRow?.sourcePriorityJson ?? null, + ).filter((n) => n.asleepMinutes > 0); + // Recent nights: asleep duration + stage breakdown when present. + const recentNights = nights + .filter((n) => n.measuredAt >= recentCutoff) + .map((n) => { const row: Record = { - date, - weekday: tzWeekday(info.date, userTz), - minutes: Math.round(info.total), + date: n.measuredAt, + weekday: tzWeekday(n.measuredAt, userTz), + minutes: Math.round(n.asleepMinutes), }; - if (Object.keys(info.stages).length > 0) { + const stageEntries = Object.entries(n.stages).filter( + ([stage]) => stage !== "IN_BED" && stage !== "AWAKE", + ); + if (stageEntries.length > 0) { row.stages = Object.fromEntries( - Object.entries(info.stages).map(([k, v]) => [k, Math.round(v)]), + stageEntries.map(([k, v]) => [ + k.toLowerCase(), + Math.round(v as number), + ]), ); } return row; }) .sort((a, b) => String(a.date).localeCompare(String(b.date))); - const olderNights = Array.from(perNight.values()) - .filter((info) => info.date < recentCutoff) - .map((info) => ({ measuredAt: info.date, value: info.total })); + const olderNights = nights + .filter((n) => n.measuredAt < recentCutoff) + .map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes })); snapshot.sleep = { timeline: { recent: recentNights, diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 8c29c6c06..b5a168eea 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -167,6 +167,67 @@ describe("reconstructSleepNights", () => { expect(nights[0].stages.CORE).toBe(240); }); + it("does NOT double-count a bare ASLEEP aggregate against the granular stages (HIGH, v1.11.5)", () => { + // Apple Health writes BOTH an unspecified `ASLEEP` AGGREGATE row AND the + // granular CORE/DEEP/REM breakdown for the SAME night. The granular rows + // partition the same period the aggregate covers, so summing them would + // ~double the night (here: 480 granular + 480 bare = 960 ≈ 16 h, the + // real-data symptom). The asleep total must be the GRANULAR sum (~8 h), + // never the granular + bare blend. + const rows: SleepStageRow[] = [ + // Granular breakdown — the canonical partition (8 h total). + row("2026-06-04T01:00:00.000Z", "CORE", 240), // 23:00 → 03:00 + row("2026-06-04T03:00:00.000Z", "DEEP", 120), // 03:00 → 05:00 + row("2026-06-04T05:00:00.000Z", "REM", 120), // 05:00 → 07:00 + // Unspecified ASLEEP aggregate covering the SAME 8 h — must be dropped. + row("2026-06-04T05:00:00.000Z", "ASLEEP", 480), + // IN_BED + AWAKE never count toward asleep. + row("2026-06-04T05:00:00.000Z", "IN_BED", 510), + row("2026-06-04T04:30:00.000Z", "AWAKE", 30), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + // Granular only: 240 + 120 + 120 = 480 (8 h), NOT 960 (16 h). + expect(nights[0].asleepMinutes).toBe(480); + expect(nights[0].inBedMinutes).toBe(510); + expect(nights[0].awakeMinutes).toBe(30); + }); + + it("falls back to the bare ASLEEP row when no granular stage exists", () => { + // Legacy iOS 15- writes only the unspecified aggregate — there is no + // granular partition, so the bare ASLEEP row IS the night's total. + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", "ASLEEP", 465), + row("2026-06-04T06:00:00.000Z", "IN_BED", 500), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(465); + expect(nights[0].inBedMinutes).toBe(500); + }); + + it("does not double-count bare+granular WITHIN one source on a dual-source night", () => { + // Both WHOOP and Apple Health report the same night, and EACH writes a + // bare ASLEEP aggregate alongside its granular breakdown. The night must + // collapse to ONE source (WHOOP by default ladder) AND count only that + // source's granular stages — no cross-source sum, no bare double-count. + 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"), + srcRow("2026-06-04T04:30:00.000Z", "ASLEEP", 420, "WHOOP"), // bare WHOOP aggregate + 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"), + srcRow("2026-06-04T04:35:00.000Z", "ASLEEP", 395, "APPLE_HEALTH"), // bare Apple aggregate + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + // WHOOP granular only: 240 + 90 + 90 = 420, not 840 (bare+granular) and + // not the ~1660 four-way blend of both sources' bare + granular rows. + expect(nights[0].asleepMinutes).toBe(420); + }); + it("honours a per-user ladder that prefers Apple Health over WHOOP", () => { const priorityJson = { sleep: ["APPLE_HEALTH", "WHOOP"] }; const rows: SleepStageRow[] = [ @@ -205,6 +266,34 @@ describe("summarizeSleepNights", () => { expect(latestNight?.asleepMinutes).toBe(420); }); + it("averages ~8 h, not ~16 h, when every night carries bare+granular rows (HIGH, v1.11.5)", () => { + // Several consecutive nights, each written the real-data way: a bare + // ASLEEP aggregate PLUS the granular CORE/DEEP/REM partition for the same + // period. The 30-day-style average must reflect the granular ~8 h totals, + // not the ~16 h bare+granular blend that produced the live 16.4 h symptom. + const nightsInput: Array<[string, number, number, number]> = [ + // [wake-day, core, deep, rem] — each sums to 480 min = 8 h. + ["2026-06-01", 240, 120, 120], + ["2026-06-02", 250, 110, 120], + ["2026-06-03", 230, 130, 120], + ["2026-06-04", 240, 120, 120], + ]; + const rows: SleepStageRow[] = []; + for (const [day, core, deep, rem] of nightsInput) { + // Each night's stages land between 01:00 and 06:00 UTC on the wake day. + rows.push(row(`${day}T02:00:00.000Z`, "CORE", core)); + rows.push(row(`${day}T04:00:00.000Z`, "DEEP", deep)); + rows.push(row(`${day}T06:00:00.000Z`, "REM", rem)); + // The overlapping bare ASLEEP aggregate (= core+deep+rem) — must drop. + rows.push(row(`${day}T06:00:00.000Z`, "ASLEEP", core + deep + rem)); + } + const { summary } = summarizeSleepNights(rows, "UTC"); + expect(summary.count).toBe(4); + // Mean of 480 / 480 / 480 / 480 = 480 min = 8 h, NOT 960 min = 16 h. + expect(summary.mean).toBe(480); + expect((summary.mean ?? 0) / 60).toBe(8); + }); + it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => { const rows: SleepStageRow[] = [ row("2026-06-03T23:00:00.000Z", "IN_BED", 60), diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 104a2bd57..a7bcd9c50 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -10,10 +10,19 @@ * * This helper is the one shared place that turns the raw per-stage rows * into per-night totals. The headline "time asleep" is light(CORE) + - * deep + REM (+ legacy bare `ASLEEP`); IN_BED and AWAKE are excluded - * from the asleep total, matching the AASM convention already encoded in - * `sleep-score.ts`'s `ASLEEP_STAGES`. Bare rows with no `sleepStage` - * (legacy / manual) count as that night's asleep total. + * deep + REM; IN_BED and AWAKE are excluded from the asleep total, + * matching the AASM convention. + * + * Granular-over-bare (v1.11.5) + * ---------------------------- + * Apple Health (and some WHOOP shapes) writes BOTH an unspecified `ASLEEP` + * AGGREGATE row AND the granular `CORE` / `DEEP` / `REM` breakdown for the + * SAME sleep period. The granular rows partition the same time the aggregate + * covers, so summing bare-ASLEEP together with the granular rows ~doubles the + * night. We therefore prefer the granular partition: when ANY granular stage + * (CORE / DEEP / REM) is present for a night/source we count ONLY the granular + * stages; the bare `ASLEEP` aggregate (or a stage-less legacy / manual row) is + * the night's asleep total only when no granular stage exists. * * Night grouping * -------------- @@ -68,14 +77,49 @@ import { parseSourcePriority, } from "@/lib/validations/source-priority"; -/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */ -const ASLEEP_STAGES: ReadonlySet = new Set([ - "ASLEEP", +/** + * The GRANULAR asleep stages (iOS 16+ `asleepCore` / `asleepDeep` / + * `asleepREM`). A bare `ASLEEP` row is the legacy iOS 15- `asleepUnspecified` + * AGGREGATE — many sources (Apple Health, some WHOOP shapes) write BOTH the + * unspecified aggregate AND the granular breakdown for the SAME period, so + * summing bare-ASLEEP together with the granular rows ~doubles the night. The + * granular rows partition the same time the aggregate covers, so when any + * granular stage is present for a night/source we count ONLY the granular + * stages and drop the bare aggregate; the bare row is the asleep total only + * when no granular stage exists. + */ +const GRANULAR_ASLEEP_STAGES: ReadonlySet = new Set([ "REM", "CORE", "DEEP", ]); +/** + * Sum a set of stage rows into time-asleep minutes WITHOUT double-counting a + * bare `ASLEEP` aggregate against the granular CORE/DEEP/REM rows it overlaps. + * IN_BED + AWAKE are never asleep; a stage-less (`null`) row is the night's + * total only when no granular stage is present. + */ +function asleepMinutesOf(rows: readonly SleepStageRow[]): number { + let granular = 0; + let fallback = 0; + let sawGranular = false; + for (const r of rows) { + const minutes = Number.isFinite(r.value) ? r.value : 0; + const stage = r.sleepStage; + if (stage != null && GRANULAR_ASLEEP_STAGES.has(stage)) { + granular += minutes; + sawGranular = true; + } else if (stage === "ASLEEP" || stage == null) { + // Bare unspecified aggregate (`ASLEEP`) or a stage-less legacy/manual + // row — only used when no granular stage carries the night. + fallback += minutes; + } + // IN_BED / AWAKE never count toward asleep. + } + return sawGranular ? granular : fallback; +} + /** * Gap (ms) between consecutive stage segments that starts a new sleep * session. Stage segments within one night are contiguous (minutes to a @@ -104,7 +148,7 @@ export interface SleepNight { night: string; /** Latest stage instant in the night — the night's representative timestamp. */ measuredAt: Date; - /** Time asleep in minutes = CORE + DEEP + REM (+ legacy bare ASLEEP). */ + /** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */ asleepMinutes: number; /** In-bed minutes when an IN_BED row exists, else null. */ inBedMinutes: number | null; @@ -128,14 +172,20 @@ function pickSessionSource( rows: SleepStageRow[], sleepLadder: readonly MeasurementSource[], ): string { - const asleepBySource = new Map(); + // Group rows by source, then sum each source's asleep minutes with the + // granular-over-bare rule so a source that writes BOTH a bare ASLEEP + // aggregate and the granular breakdown is not over-weighted in the tiebreak. + const rowsBySource = 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); + const list = rowsBySource.get(src) ?? []; + list.push(r); + rowsBySource.set(src, list); + } + const asleepBySource = new Map(); + for (const [src, srcRows] of rowsBySource) { + const minutes = asleepMinutesOf(srcRows); + if (minutes > 0) asleepBySource.set(src, 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. @@ -230,7 +280,6 @@ export function reconstructSleepNights( const nights: SleepNight[] = []; for (const [night, nightRows] of byNight) { - let asleep = 0; let inBed = 0; let awake = 0; let sawInBed = false; @@ -250,13 +299,15 @@ export function reconstructSleepNights( } else if (stage === "AWAKE") { awake += minutes; sawAwake = true; - } else if (stage && ASLEEP_STAGES.has(stage)) { - asleep += minutes; - } else if (stage == null) { - // Bare SLEEP_DURATION row (no stage) — the night's total asleep. - asleep += minutes; } + // Asleep minutes are summed with the granular-over-bare rule below so a + // bare ASLEEP aggregate is never double-counted against the granular + // CORE/DEEP/REM rows it overlaps. } + // v1.11.5 — single source of truth for the asleep total: prefer the + // granular CORE/DEEP/REM partition; fall back to the bare ASLEEP / + // stage-less row only when no granular stage exists for the night. + const asleep = asleepMinutesOf(nightRows); nights.push({ night, measuredAt: latest,