diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 729404ea8..7c0bc7d4c 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -12,6 +12,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ readBestGranularityRollups: vi.fn().mockResolvedValue(null), })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("UTC"), +})); import { computeDerivedMetric } from "../dispatch"; import { diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts index 0dece59aa..55cac9774 100644 --- a/src/lib/insights/derived/__tests__/readiness.test.ts +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -12,6 +12,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ readBestGranularityRollups: vi.fn().mockResolvedValue(null), })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("UTC"), +})); import { prisma } from "@/lib/db"; import { diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts index 335ceabae..b63dc5347 100644 --- a/src/lib/insights/derived/__tests__/sleep-score.test.ts +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -3,8 +3,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: vi.fn() } }, })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("UTC"), +})); import { prisma } from "@/lib/db"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { computeSleepScore, blendSleepSubScores, @@ -149,6 +153,27 @@ describe("reconstructNights", () => { expect(nights[0].remMinutes).toBe(90); expect(nights[0].deepMinutes).toBe(60); }); + + it("expresses the midpoint in UTC by default", () => { + // Earliest 01:00Z, latest 05:00Z → midpoint 03:00Z = 180 min-of-day. + const rows = night("2026-06-02", [ + ["CORE", 120, "01:00"], + ["CORE", 120, "05:00"], + ]); + const nights = reconstructNights(rows); + expect(nights[0].midpoint).toBe(180); + }); + + it("expresses the midpoint in the user's timezone when one is passed", () => { + // Same 03:00Z midpoint, read in Asia/Kolkata (UTC+5:30, no DST) → + // 08:30 local = 510 min-of-day, not 180. + const rows = night("2026-06-02", [ + ["CORE", 120, "01:00"], + ["CORE", 120, "05:00"], + ]); + const nights = reconstructNights(rows, "Asia/Kolkata"); + expect(nights[0].midpoint).toBe(8 * 60 + 30); + }); }); describe("computeSleepScore", () => { @@ -214,4 +239,29 @@ describe("computeSleepScore", () => { expect(result.coverage.missing).toContain("composition"); } }); + + it("threads an explicit tz into the midpoint without resolving the user zone", async () => { + const rows = [ + ...night("2026-05-31", [ + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ]), + ...night("2026-06-01", [ + ["CORE", 250, "03:10"], + ["DEEP", 55, "04:10"], + ]), + ...night("2026-06-02", [ + ["CORE", 235, "03:05"], + ["DEEP", 65, "04:05"], + ]), + ]; + findMany.mockResolvedValue(rows); + // A pinned tz must score without falling back to the user-zone resolver. + const result = await computeSleepScore("u1", PROFILE, { + now: NOW, + tz: "Asia/Kolkata", + }); + expect(result.status).toBe("ok"); + expect(resolveUserTimezone).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts index e3e3359ff..9d9c588bd 100644 --- a/src/lib/insights/derived/sleep-score.ts +++ b/src/lib/insights/derived/sleep-score.ts @@ -34,6 +34,8 @@ */ import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { getLocalDateParts } from "@/lib/timezone"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { buildInsufficient, buildOk, @@ -317,8 +319,16 @@ export interface NightSummary { * Group raw stage rows into per-night summaries. A "night" is keyed by the * latest stage row's calendar day (the wake day). Pure — the caller does * the bounded DB read and passes rows in. + * + * `tz` is the IANA zone the midpoint is expressed against: a sleeper's + * 03:00-local midpoint must read as minutes-of-day in THEIR wall clock, not + * UTC, so a non-UTC user's consistency / timing sub-scores don't drift with + * the offset. Defaults to UTC for back-compatible pure use. */ -export function reconstructNights(rows: SleepRow[]): NightSummary[] { +export function reconstructNights( + rows: SleepRow[], + tz: string = "UTC", +): NightSummary[] { // Bucket rows by the wake-day key (UTC day of the row's measuredAt). const byNight = new Map(); for (const row of rows) { @@ -369,7 +379,7 @@ export function reconstructNights(rows: SleepRow[]): NightSummary[] { const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null; const midpoint = Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest - ? minutesOfDay(new Date((earliest + latest) / 2)) + ? minutesOfDay(new Date((earliest + latest) / 2), tz) : null; nights.push({ night, @@ -385,8 +395,10 @@ export function reconstructNights(rows: SleepRow[]): NightSummary[] { return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); } -function minutesOfDay(d: Date): number { - return d.getUTCHours() * 60 + d.getUTCMinutes(); +function minutesOfDay(d: Date, tz: string): number { + if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes(); + const { hour, minute } = getLocalDateParts(d, tz); + return hour * 60 + minute; } // ── compute ───────────────────────────────────────────────────────────── @@ -394,6 +406,11 @@ function minutesOfDay(d: Date): number { export interface SleepScoreOpts { windowDays?: number; now?: Date; + /** + * IANA zone the sleep midpoint is expressed against. Omit to resolve the + * user's stored zone (the production path); pass explicitly in tests. + */ + tz?: string; } /** @@ -409,6 +426,10 @@ export async function computeSleepScore( const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; const now = opts.now ?? new Date(); const computedAt = nowProvenanceTimestamp(now); + // The midpoint is the user's wall-clock minutes-of-day, not UTC's, so a + // non-UTC sleeper's consistency / timing sub-scores don't drift with the + // offset. Resolve the stored zone unless a caller pins one. + const tz = opts.tz ?? (await resolveUserTimezone(userId)); const inputs = ["SLEEP_DURATION"]; const required = 1; const since = new Date(now.getTime() - windowDays * MS_PER_DAY); @@ -439,7 +460,7 @@ export async function computeSleepScore( }); } - const nights = reconstructNights(rows).filter( + const nights = reconstructNights(rows, tz).filter( (n) => n.asleepMinutes > 0, ); const scorableNights = nights.filter(