diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index ab2f3e0b4..8ebc0e5f2 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -3,7 +3,9 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { - measurement: { groupBy: vi.fn() }, + // v1.11.4 — `findMany` reads the raw per-stage SLEEP_DURATION rows for + // the night-total sleep tile. + measurement: { groupBy: vi.fn(), findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), // v1.4.39 W-SERVER-FIX-2 — the dashboard route now backfills @@ -82,6 +84,10 @@ beforeEach(() => { // per-type all-time count + most-recent timestamp. Default to an // empty aggregate so legacy tests keep their "no data" expectations. vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + // v1.11.4 — the sleep tile reads the raw per-stage SLEEP_DURATION rows + // via `measurement.findMany` to reconstruct the night total. Default to + // no rows so legacy tests keep their "no sleep" expectation. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, ); @@ -443,6 +449,50 @@ describe("GET /api/dashboard/summary", () => { expect(glucose?.lastSeenAt).toBe(tenDaysAgo.toISOString()); }); + it("emits the sleep tile as the night TIME-ASLEEP total in hours, not one stage (v1.11.4)", async () => { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). The + // tile must SUM the asleep stages of the latest night and convert to + // hours, NOT surface a single stage. Excludes IN_BED + AWAKE. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const wake = new Date("2026-06-04T06:00:00.000Z"); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ + { + type: "SLEEP_DURATION", + _count: { _all: 5 }, + _max: { measuredAt: wake }, + }, + ] as never); + // Raw per-stage rows for the night (the sleep findMany read). + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" }, + { value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" }, + { value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" }, + { value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" }, + { value: 20, measuredAt: wake, sleepStage: "AWAKE" }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + metrics: Array<{ + id: string; + latestValue: number | null; + unit: string | null; + sleepStages: Record | null; + }>; + }; + }; + const sleep = body.data.metrics.find((m) => m.id === "sleep"); + expect(sleep, "sleep tile must be emitted").toBeDefined(); + // Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410 min → 6.83 h. + // (IN_BED 480 + AWAKE 20 excluded.) + expect(sleep?.latestValue).toBeCloseTo(410 / 60, 2); + expect(sleep?.unit).toBe("h"); + // Stage breakdown is exposed in hours for a future detail view. + expect(sleep?.sleepStages?.CORE).toBeCloseTo(4, 2); + expect(sleep?.sleepStages?.DEEP).toBeCloseTo(1.5, 2); + expect(sleep?.sleepStages?.REM).toBeCloseTo(80 / 60, 2); + }); + it("paints the steps sparkline from rollup sum_value, not mean (v1.4.39 W-SUM)", async () => { // The ACTIVITY_STEPS tile renders the per-day SUM, not the // per-bucket MEAN. The sparkline query feeds the SQL aggregate diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 39a42b224..15d518282 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -57,6 +57,12 @@ import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; +import { + summarizeSleepNights, + reconstructSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import type { SleepStage } from "@/generated/prisma/client"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -96,6 +102,24 @@ interface MetricCard { * without another wire-shape change. */ unitKey: string; + /** + * v1.11.4 — explicit unit token for clients that need to know the + * value's unit without inferring it from the metric kind. Most tiles + * leave this `null` (the `unitKey` i18n key already carries the + * display unit). The `sleep` tile sets it to `"h"` because its + * `latestValue` is a per-NIGHT total expressed in HOURS (a float), + * not the canonical `SLEEP_DURATION` minutes — see the sleep block + * below for why the night total replaced the single-stage value. + */ + unit: string | null; + /** + * v1.11.4 — per-stage minutes for the headline night, sleep tile only. + * `null` for every other kind and for a sleep night with no + * stage-tagged rows (a legacy bare-duration night). Additive: a future + * sleep detail view can render the breakdown without changing the + * headline `latestValue`. + */ + sleepStages: Partial> | null; trend: "up" | "down" | "flat" | "unknown"; sparkline: number[]; updatedAt: string | null; @@ -158,6 +182,20 @@ const METRIC_UNIT_KEYS: Record = { oxygenSaturation: "dashboard.metric.unit.oxygenSaturation", }; +/** + * v1.11.4 — sleep sparkline = the trailing-7 nights' TIME-ASLEEP in + * hours, reconstructed per night from the raw stage rows. The generic + * DAY-bucket rollup sparkline can't be used for sleep because it means + * 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) + .filter((n) => n.asleepMinutes > 0) + .map((n) => Math.round((n.asleepMinutes / 60) * 100) / 100) + .slice(-SPARK_DAYS); +} + function trendOf(values: number[]): MetricCard["trend"] { if (values.length < 2) return "unknown"; const first = values[0]; @@ -420,6 +458,7 @@ async function buildDashboardSummary( todaysIntakes, streakActivity, measurementStreakDays, + sleepStageRows, ] = await Promise.all([ time("latestEver", () => prisma.$queryRaw` @@ -508,6 +547,27 @@ async function buildDashboardSummary( AND m."deleted_at" IS NULL `, ), + // v1.11.4 — raw per-stage SLEEP_DURATION rows for the night-total + // tile. `SLEEP_DURATION` is stored one row per STAGE per night + // (minutes), so the single-most-recent row (the `latestEver` path + // above) is just ONE stage, not the night. The sleep tile instead + // sums the asleep stages of the latest night via + // `summarizeSleepNights`. Bounded to the streak window (≈ a year of + // ~5 rows/night) so the read stays small; the headline only needs + // the most-recent night but the window also feeds the iOS sparkline + // night totals. + time("sleepNights", () => + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: streakWindowStart }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true }, + }), + ), ]); // Per-type metadata lookup — typed Map so a metric with no readings @@ -594,6 +654,16 @@ async function buildDashboardSummary( return sparkByType.get(type) ?? []; } + // v1.11.4 — collapse the per-stage SLEEP_DURATION rows into per-night + // asleep totals. The sleep tile's headline is last night's TIME ASLEEP + // (CORE/light + DEEP + REM, excluding IN_BED + AWAKE), emitted in HOURS + // with an explicit `unit: "h"`, replacing the single-stage minutes the + // `latestEver` read used to surface. + const sleepSummary = summarizeSleepNights( + sleepStageRows as SleepStageRow[], + userTz, + ); + const metrics: MetricCard[] = []; // v1.4.33 maintainer-item-1 — every emitted card now carries @@ -614,6 +684,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.weight, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -652,6 +724,8 @@ async function buildDashboardSummary( latestValue: latestSys?.value ?? null, secondaryValue: latestDia?.value ?? null, unitKey: METRIC_UNIT_KEYS.bloodPressure, + unit: null, + sleepStages: null, trend: trendOf(sysSpark), sparkline: sysSpark, updatedAt: @@ -679,6 +753,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.pulse, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at?.toISOString() ?? meta.lastSeenAt, @@ -704,6 +780,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS.bodyFat, + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, @@ -730,6 +808,47 @@ async function buildDashboardSummary( const latest = latestOf(type); const meta = metaForType(type); if (!latest && meta.allTimeCount === 0) continue; + + // v1.11.4 — sleep is night-aggregated, not single-stage. Emit the + // latest night's TIME ASLEEP in HOURS (float) with an explicit + // `unit: "h"`, the per-stage breakdown, and a sparkline of the + // trailing nights' asleep hours. Every other kind keeps the + // single-row latest value + canonical-unit i18n key. + if (kind === "sleep") { + const night = sleepSummary.latestNight; + const toHours = (minutes: number): number => + Math.round((minutes / 60) * 100) / 100; + // Sparkline = trailing nights' asleep hours from the night + // reconstruction (the DAY-bucket rollup sparkline blends stages + // and would be misleading for sleep). + const nightSpark = buildSleepSparkline( + sleepStageRows as SleepStageRow[], + userTz, + ); + const stageHours: Partial> | null = night + ? Object.fromEntries( + Object.entries(night.stages).map(([s, m]) => [s, toHours(m)]), + ) + : null; + metrics.push({ + id: kind, + kind, + titleKey: METRIC_TITLE_KEYS[kind], + latestValue: night ? toHours(night.asleepMinutes) : null, + secondaryValue: null, + unitKey: METRIC_UNIT_KEYS[kind], + unit: "h", + sleepStages: + stageHours && Object.keys(stageHours).length > 0 ? stageHours : null, + trend: trendOf(nightSpark), + sparkline: nightSpark, + updatedAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + allTimeCount: meta.allTimeCount, + lastSeenAt: night?.measuredAt.toISOString() ?? meta.lastSeenAt, + }); + continue; + } + const spark = sparkOf(type); metrics.push({ id: kind, @@ -738,6 +857,8 @@ async function buildDashboardSummary( latestValue: latest?.value ?? null, secondaryValue: null, unitKey: METRIC_UNIT_KEYS[kind], + unit: null, + sleepStages: null, trend: trendOf(spark), sparkline: spark, updatedAt: latest?.at.toISOString() ?? meta.lastSeenAt, diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index 982c849de..62ce50743 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -10,6 +10,13 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night; +// pin it so the night grouping is deterministic. +vi.mock("@/lib/tz/resolver", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveUserTimezone: vi.fn(async () => "UTC") }; +}); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -117,6 +124,48 @@ describe("GET /api/measurements/series", () => { expect(body.data.points[0].secondary).toBeNull(); }); + it("collapses sleep stage rows into one night point in hours (v1.11.4)", async () => { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). The + // series must return ONE point per night carrying the TIME-ASLEEP + // total in hours, not a point per stage. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "s1", value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" }, + { id: "s2", value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" }, + { id: "s3", value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" }, + { id: "s4", value: 60, measuredAt: new Date("2026-06-04T05:00:00.000Z"), sleepStage: "AWAKE" }, + { id: "s5", value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" }, + ] as never); + const res = await GET(req("kind=sleep&days=30")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + unit: string; + points: Array<{ + value: number; + sleepStages: Record | null; + }>; + }; + }; + expect(body.data.unit).toBe("h"); + // One point for the single night. + expect(body.data.points).toHaveLength(1); + // Time asleep = CORE + DEEP + REM = 410 min → 6.83 h (IN_BED + AWAKE + // excluded). + expect(body.data.points[0].value).toBeCloseTo(410 / 60, 2); + expect(body.data.points[0].sleepStages?.CORE).toBeCloseTo(4, 2); + }); + + it("returns an explicit unit for a non-sleep kind (v1.11.4)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "w1", value: 78.4, measuredAt: new Date() }, + ] as never); + const res = await GET(req("kind=weight&days=7")); + const body = (await res.json()) as { data: { unit: string } }; + expect(body.data.unit).toBe("kg"); + }); + it("accepts the ten-year window (days=3650 — iOS 'Alle'-range)", async () => { // v1.5.5 — the previous 365-day cap rejected the iOS app's // "Alle"-range request with a 422, painting an error banner on diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 02536ac2b..b33117d4a 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -21,7 +21,9 @@ import { import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { redactSensitiveFields } from "@/lib/observability/redact-payload"; -import type { MeasurementType } from "@/generated/prisma/client"; +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; const kindEnum = z.enum([ "weight", @@ -71,11 +73,41 @@ const KIND_TO_TYPE: Record, MeasurementType> = { vo2Max: "VO2_MAX", }; +/** + * v1.11.4 — explicit per-kind unit token returned at the top level so + * the client never has to infer the value's unit. `bloodPressure` reuses + * the systolic type's unit (mmHg, same for both lines). `sleep` is + * overridden at request time to `"h"` because the route returns per-night + * TIME-ASLEEP in hours rather than the canonical per-stage minutes. + */ +const SERIES_UNIT: Record, string> = { + weight: "kg", + bloodPressure: "mmHg", + pulse: "bpm", + bodyFat: "%", + glucose: "mg/dL", + sleep: "h", + steps: "steps", + totalBodyWater: "kg", + boneMass: "kg", + oxygenSaturation: "%", + restingHeartRate: "bpm", + heartRateVariability: "ms", + vo2Max: "mL/(kg·min)", +}; + interface SeriesPoint { id: string; at: string; value: number; secondary: number | null; + /** + * v1.11.4 — per-stage minutes for a sleep night point. `null` for + * every non-sleep kind. Lets a sleep detail view render the night's + * stage breakdown without a second fetch; the headline `value` stays + * the night's TIME-ASLEEP total. + */ + sleepStages?: Partial> | null; } function stdDev(values: number[]): number { @@ -136,8 +168,50 @@ export const GET = apiHandler(async (request: NextRequest) => { const { kind, days } = parsed.data; const since = new Date(Date.now() - days * 86_400_000); + // v1.11.4 — explicit unit token so the client never infers the unit + // from the kind. Sleep is special-cased below (per-night TIME-ASLEEP + // in HOURS); every other kind carries its canonical stored unit. + let unit = SERIES_UNIT[kind]; + let points: SeriesPoint[] = []; - if (kind === "bloodPressure") { + if (kind === "sleep") { + // SLEEP_DURATION is stored one row per STAGE per night (minutes). + // Collapse the stage rows into ONE point per night carrying the + // 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); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "SLEEP_DURATION", + measuredAt: { gte: since }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true, sleepStage: true }, + }); + unit = "h"; + points = reconstructSleepNights(rows, tz) + .filter((n) => n.asleepMinutes > 0) + .map((n) => { + const stageHours = Object.fromEntries( + Object.entries(n.stages).map(([s, m]) => [ + s, + Math.round((m / 60) * 100) / 100, + ]), + ) as Partial>; + return { + id: `sleep:${n.night}`, + at: n.measuredAt.toISOString(), + value: Math.round((n.asleepMinutes / 60) * 100) / 100, + secondary: null, + sleepStages: + Object.keys(stageHours).length > 0 ? stageHours : null, + }; + }); + } else if (kind === "bloodPressure") { const [sys, dia] = await Promise.all([ prisma.measurement.findMany({ where: { @@ -213,6 +287,7 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess({ kind, + unit, points, stats: summary ? { diff --git a/src/app/page.tsx b/src/app/page.tsx index 0631e6550..b98f7b105 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -151,6 +151,39 @@ interface RangeDisplayConfig { range: TrafficRange | null; } +/** + * v1.11.4 — minutes→hours projection of a `DataSummary` for the sleep + * tile. The server emits `summaries.SLEEP_DURATION` as per-night minutes; + * the tile renders hours. Every value-bearing field (latest / min / max / + * mean / median / avg7 / avg30 / avg30LastMonth / avg30LastYear) divides + * by 60; the slope tuples scale their `slope` (per-day rate) by the same + * factor so the trend arrow stays consistent; count / direction / + * confidence / anomalyCount are unit-free and pass through. + */ +function toHoursSummary(s: DataSummary): DataSummary { + const h = (v: number | null | undefined): number | null => + v == null ? null : Math.round((v / 60) * 100) / 100; + const scaleSlope = (slope: DataSummary["slope7"]): DataSummary["slope7"] => + slope == null + ? null + : { ...slope, slope: Math.round((slope.slope / 60) * 1000) / 1000 }; + return { + ...s, + latest: h(s.latest), + min: h(s.min), + max: h(s.max), + mean: h(s.mean), + median: h(s.median), + avg7: h(s.avg7), + avg30: h(s.avg30), + avg30LastMonth: h(s.avg30LastMonth), + avg30LastYear: h(s.avg30LastYear), + slope7: scaleSlope(s.slope7), + slope30: scaleSlope(s.slope30), + slope90: scaleSlope(s.slope90), + }; +} + function getHourForTimeZone(timeZone?: string): number { const now = new Date(); if (!timeZone) return now.getHours(); @@ -445,6 +478,15 @@ export default function DashboardPage() { const p = data?.summaries?.PULSE; const bf = data?.summaries?.BODY_FAT; const sleepSummary = data?.summaries?.SLEEP_DURATION; + // v1.11.4 — `summaries.SLEEP_DURATION` now carries per-NIGHT time-asleep + // totals in MINUTES (the server collapses the per-stage rows into one + // night value; see `summaries-slice.ts`). The sleep tile renders hours, + // so convert every value field minutes→hours here while keeping the + // staleness / count metadata untouched. This is the web-parity twin of + // the iOS dashboard-summary route which already emits `unit:"h"`. + const sleepSummaryHours = sleepSummary + ? toHoursSummary(sleepSummary) + : undefined; const stepsSummary = data?.summaries?.ACTIVITY_STEPS; // v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics // auto-populates this summary because the route iterates over the @@ -1124,16 +1166,16 @@ export default function DashboardPage() { ), diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts new file mode 100644 index 000000000..969de1272 --- /dev/null +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import type { SleepStage } from "@/generated/prisma/client"; +import { + reconstructSleepNights, + summarizeSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; + +/** Build a stage row with a fixed wall-clock instant. */ +function row( + iso: string, + stage: SleepStage | null, + minutes: number, +): SleepStageRow { + return { measuredAt: new Date(iso), sleepStage: stage, value: minutes }; +} + +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 + // on the same UTC calendar day so the night-key collapses them. + const rows: SleepStageRow[] = [ + row("2026-06-04T00:00:00.000Z", "IN_BED", 480), + row("2026-06-04T00:30:00.000Z", "CORE", 240), + row("2026-06-04T02:00:00.000Z", "DEEP", 90), + row("2026-06-04T04:00:00.000Z", "REM", 80), + row("2026-06-04T05:00:00.000Z", "AWAKE", 20), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + const main = nights[0]; + expect(main.night).toBe("2026-06-04"); + // Time asleep = CORE + DEEP + REM = 240 + 90 + 80 = 410. AWAKE excluded. + expect(main.asleepMinutes).toBe(410); + // AWAKE recorded but not counted as asleep. + expect(main.awakeMinutes).toBe(20); + // IN_BED row present → in-bed total surfaced. + expect(main.inBedMinutes).toBe(480); + expect(main.stages.CORE).toBe(240); + expect(main.stages.DEEP).toBe(90); + expect(main.stages.REM).toBe(80); + }); + + it("groups all stages of one night by the user's tz calendar day", () => { + // In a non-UTC zone (Auckland = UTC+12 in June) a night whose stages + // straddle UTC midnight still collapses to one local day. These + // instants are all 2026-06-04 LOCAL (12:00–17:00 UTC = 00:00–05:00 + // Jun 4 Auckland) but straddle UTC midnight if keyed naively. + const rows: SleepStageRow[] = [ + row("2026-06-03T12:00:00.000Z", "IN_BED", 480), // 00:00 Jun 4 NZST + row("2026-06-03T13:00:00.000Z", "CORE", 240), // 01:00 Jun 4 + row("2026-06-03T15:00:00.000Z", "DEEP", 90), // 03:00 Jun 4 + row("2026-06-03T17:00:00.000Z", "REM", 80), // 05:00 Jun 4 + ]; + const nights = reconstructSleepNights(rows, "Pacific/Auckland"); + expect(nights).toHaveLength(1); + expect(nights[0].night).toBe("2026-06-04"); + expect(nights[0].asleepMinutes).toBe(410); + expect(nights[0].inBedMinutes).toBe(480); + }); + + it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => { + const rows: SleepStageRow[] = [ + row("2026-06-04T06:00:00.000Z", null, 423), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(423); + expect(nights[0].inBedMinutes).toBeNull(); + }); +}); + +describe("summarizeSleepNights", () => { + it("summarises per-night totals, not per-stage rows", () => { + const rows: SleepStageRow[] = [ + // Night A — 2026-06-03: 200 + 100 = 300 asleep min. + row("2026-06-03T01:00:00.000Z", "CORE", 200), + row("2026-06-03T03:00:00.000Z", "DEEP", 100), + // Night B — 2026-06-04: 240 + 90 + 90 = 420 asleep min. + row("2026-06-04T01:00:00.000Z", "CORE", 240), + row("2026-06-04T03:00:00.000Z", "DEEP", 90), + row("2026-06-04T04:30:00.000Z", "REM", 90), + ]; + const { summary, latestNight } = summarizeSleepNights(rows, "UTC"); + // count = nights, NOT stage rows (5). + expect(summary.count).toBe(2); + // latest = most-recent night total (minutes), not a single stage. + expect(summary.latest).toBe(420); + expect(summary.min).toBe(300); + expect(summary.max).toBe(420); + expect(summary.mean).toBe(360); + expect(latestNight?.night).toBe("2026-06-04"); + expect(latestNight?.asleepMinutes).toBe(420); + }); + + it("drops nights with zero asleep minutes (IN_BED / AWAKE only)", () => { + const rows: SleepStageRow[] = [ + row("2026-06-03T23:00:00.000Z", "IN_BED", 60), + row("2026-06-03T23:30:00.000Z", "AWAKE", 60), + ]; + const { summary, latestNight } = summarizeSleepNights(rows, "UTC"); + expect(summary.count).toBe(0); + expect(latestNight).toBeNull(); + }); + + it("returns an empty summary for no rows", () => { + const { summary, latestNight } = summarizeSleepNights([], "UTC"); + expect(summary.count).toBe(0); + expect(summary.latest).toBeNull(); + expect(latestNight).toBeNull(); + }); +}); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts new file mode 100644 index 000000000..2541678ee --- /dev/null +++ b/src/lib/analytics/sleep-night.ts @@ -0,0 +1,162 @@ +/** + * v1.11.4 — per-night sleep aggregation (iOS #1, HIGH). + * + * `SLEEP_DURATION` is stored in MINUTES, one row per sleep STAGE per + * night (IN_BED / AWAKE / ASLEEP / REM / CORE / DEEP) since v1.4.23. The + * value is NOT cumulative — a single stage row carries only that stage's + * minutes. Every "last night's sleep" surface therefore has to SUM the + * stage rows belonging to one night before it shows a headline number; + * reading the single most-recent row gives one stage, not the night. + * + * 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. + * + * 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.) + * + * Unit + * ---- + * Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit + * (`getUnitForType` → "minutes") — so the existing web consumers that + * already treat the summary as minutes (e.g. the insights overview's + * `formatHoursMinutes`) keep working unchanged; they now average per- + * 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 { userDayKey } from "@/lib/tz/resolver"; +import { summarize, type DataSummary } from "@/lib/analytics/trends"; + +/** Stages that count toward "time asleep" (excludes IN_BED + AWAKE). */ +const ASLEEP_STAGES: ReadonlySet = new Set([ + "ASLEEP", + "REM", + "CORE", + "DEEP", +]); + +export interface SleepStageRow { + value: number; + measuredAt: Date; + sleepStage: SleepStage | null; +} + +/** One reconstructed night: the asleep total + the per-stage breakdown. */ +export interface SleepNight { + /** Wake-day key (YYYY-MM-DD) in the user's timezone. */ + 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). */ + asleepMinutes: number; + /** In-bed minutes when an IN_BED row exists, else null. */ + inBedMinutes: number | null; + /** Awake-in-bed minutes when an AWAKE row exists, else null. */ + awakeMinutes: number | null; + /** Per-stage minutes for the night (only stages the device reported). */ + stages: Partial>; +} + +/** + * 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. + */ +export function reconstructSleepNights( + rows: SleepStageRow[], + tz: string, +): SleepNight[] { + const byNight = new Map(); + for (const row of rows) { + const key = userDayKey(row.measuredAt, tz); + const list = byNight.get(key) ?? []; + list.push(row); + byNight.set(key, list); + } + + const nights: SleepNight[] = []; + for (const [night, nightRows] of byNight) { + let asleep = 0; + let inBed = 0; + let awake = 0; + let sawInBed = false; + let sawAwake = false; + let latest = nightRows[0].measuredAt; + const stages: Partial> = {}; + for (const r of nightRows) { + const minutes = Number.isFinite(r.value) ? r.value : 0; + if (r.measuredAt.getTime() > latest.getTime()) latest = r.measuredAt; + const stage = r.sleepStage; + if (stage) { + stages[stage] = (stages[stage] ?? 0) + minutes; + } + if (stage === "IN_BED") { + inBed += minutes; + sawInBed = true; + } 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; + } + } + nights.push({ + night, + measuredAt: latest, + asleepMinutes: asleep, + inBedMinutes: sawInBed ? inBed : null, + awakeMinutes: sawAwake ? awake : null, + stages, + }); + } + return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); +} + +export interface SleepNightSummary { + /** + * A `DataSummary` whose every value is a per-night TIME-ASLEEP total + * (minutes). Drop-in replacement for the per-stage `summaries. + * SLEEP_DURATION` the slim slice produced. Null when no night has any + * asleep minutes in the supplied rows. + */ + summary: DataSummary; + /** The most recent night (for the dashboard/series headline), or null. */ + latestNight: SleepNight | null; +} + +/** + * Reconstruct nights from raw rows and summarise the per-night asleep + * totals. Nights with zero asleep minutes are dropped (an IN_BED-only or + * AWAKE-only fragment is not a scorable night). The returned `summary` + * uses one DataPoint per night so `summarize()`'s windowed avg7 / avg30 + * become "average nightly sleep over the trailing N days". + */ +export function summarizeSleepNights( + rows: SleepStageRow[], + tz: string, +): SleepNightSummary { + const nights = reconstructSleepNights(rows, tz).filter( + (n) => n.asleepMinutes > 0, + ); + const dataPoints = nights.map((n) => ({ + date: n.measuredAt, + value: n.asleepMinutes, + })); + return { + summary: summarize(dataPoints), + latestNight: nights.length > 0 ? nights[nights.length - 1] : null, + }; +} diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 08ce29746..606e2fb93 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -72,6 +72,11 @@ import { probeRollupCoverage, } from "@/lib/rollups/measurement-coverage"; import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; +import { + summarizeSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -239,7 +244,7 @@ export async function computeSummariesSlice( // brand-new-type case stays correct. const coverage = await probeRollupCoverage(userId); if (isFullyCovered(coverage)) { - return computeFromRollups(userId); + return withSleepNightTotals(userId, await computeFromRollups(userId)); } // v1.4.38.7 — annotate the live fallback with the per-type // coverage map so the operator can see WHICH type stranded the @@ -261,7 +266,57 @@ export async function computeSummariesSlice( }, }, }); - return computeFromLiveAggregate(userId); + return withSleepNightTotals(userId, await computeFromLiveAggregate(userId)); +} + +/** + * v1.11.4 — replace the per-stage `summaries.SLEEP_DURATION` with a + * per-NIGHT summary (iOS #1, HIGH). + * + * `SLEEP_DURATION` is stored one row per sleep STAGE per night (minutes). + * The slim slice's generic aggregate therefore treats each stage as an + * independent reading: `latest` is the most-recent STAGE (not the night), + * `mean` / `avg7` / `avg30` average across stage rows. For the dashboard + * sleep tile and the insights overview that is wrong — they want the + * per-night TIME-ASLEEP total. + * + * This post-pass reads the SLEEP_DURATION rows once and overwrites the + * summary with one computed over per-night asleep totals (one DataPoint + * per night). The unit stays MINUTES (canonical), so existing consumers + * that already treat the summary as minutes keep working — they now + * average nights instead of stages, which is strictly more correct. The + * read is bounded to a year (~5 rows/night) and is skipped entirely when + * the user has logged no sleep. + */ +async function withSleepNightTotals( + userId: string, + slice: SummariesSlice, +): Promise { + const existing = slice.summaries.SLEEP_DURATION; + // No sleep data at all → nothing to recompute (the empty summary stays). + if (!existing || existing.count === 0) return slice; + + const since = new Date(Date.now() - 365 * DAY_MS); + const rows = (await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true }, + })) as SleepStageRow[]; + if (rows.length === 0) return slice; + + const tz = await resolveUserTimezone(userId); + const { summary } = summarizeSleepNights(rows, tz); + // 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 / + // slope30 / slope90 from the night DataPoints, so use them directly. + slice.summaries.SLEEP_DURATION = summary; + return slice; } /** diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 6ae2e89c7..a6b4218fe 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -523,6 +523,20 @@ function buildMetricStates( const summary = summaries[type]; const lastSeen = lastSeenByType[type]; if (!summary || summary.latest === null || !lastSeen) continue; + // v1.11.4 — `summaries.SLEEP_DURATION.latest` now carries last night's + // TIME-ASLEEP total in MINUTES (the slim slice collapses the per-stage + // rows). Emit the cold-launch seed in HOURS with an explicit `unit: + // "h"` so it matches the `/api/dashboard/summary` sleep tile, which + // already emits hours. Every other metric passes through with its + // canonical stored unit. + if (type === "SLEEP_DURATION") { + out[metricKindRaw] = { + value: Math.round((summary.latest / 60) * 100) / 100, + measuredAt: lastSeen.lastSeenAt, + unit: "h", + }; + continue; + } out[metricKindRaw] = { value: summary.latest, measuredAt: lastSeen.lastSeenAt,