mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user