fix(insights): compute the sleep midpoint in the user's timezone

The sleep midpoint was minutes-of-day in UTC, so a non-UTC sleeper's
consistency and timing sub-scores drifted with the offset (a 03:00-local
midpoint read as a different clock time than a UTC user's). Resolve the user's
stored zone in computeSleepScore and express the midpoint against it via the
existing tz helpers; reconstructNights takes an explicit tz (UTC default) so
the pure path stays test-pinnable.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:48:48 +02:00
1 parent 8806ce4e08
commit b7cb92d951
4 files changed
+82 -5

No files matched your search

@@ -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 {
@@ -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 {
@@ -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();
});
});
+26 -5
View File
@@ -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<string, SleepRow[]>();
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(