diff --git a/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts new file mode 100644 index 000000000..5ba803cf7 --- /dev/null +++ b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; + +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * Wearable daily-record anchoring (H-TZ1 / H-TZ2). Day-grained (date-only) + * daily records from Oura / Polar / Fitbit must anchor at NOON, not UTC + * midnight: a UTC-midnight anchor (`${day}T00:00:00.000Z`) double-shifts the + * calendar day for west-of-UTC users on read, because the read path + * re-buckets via `userDayKey(measuredAt, userTz)`. A noon anchor sits a full + * 12 h inside the day, so it round-trips back to the same calendar day for + * every IANA zone within ±12 h of UTC. + * + * These tests pin that invariant directly: + * userDayKey(canonicalDailyTimestamp(day, tz), tz) === day + */ +describe("canonicalDailyTimestamp — day round-trip across TZ offsets", () => { + const days = [ + "2026-06-01", + "2026-01-01", + "2026-12-31", + "2025-03-09", // US spring-forward + "2025-11-02", // US fall-back + ]; + + const zones = [ + "UTC", + "America/Los_Angeles", // negative offset (UTC-7/-8) + "America/New_York", // negative offset (UTC-4/-5) + "Asia/Tokyo", // positive offset (UTC+9, no DST) + "Pacific/Kiritimati", // extreme positive (UTC+14) + "Etc/GMT+12", // extreme negative (UTC-12) + "Asia/Kathmandu", // sub-hour offset (UTC+5:45) + ]; + + for (const tz of zones) { + for (const day of days) { + it(`round-trips ${day} in ${tz}`, () => { + const anchor = canonicalDailyTimestamp(day, tz); + expect(userDayKey(anchor, tz)).toBe(day); + }); + } + } + + it("anchors at the user's local noon when a tz is given", () => { + // 2026-06-01 noon in America/Los_Angeles (PDT, UTC-7) === 19:00 UTC. + const anchor = canonicalDailyTimestamp("2026-06-01", "America/Los_Angeles"); + expect(anchor.toISOString()).toBe("2026-06-01T19:00:00.000Z"); + }); +}); + +describe("canonicalDailyTimestamp — noon-UTC fallback (no tz)", () => { + it("anchors at 12:00 UTC when tz is omitted", () => { + const anchor = canonicalDailyTimestamp("2026-06-01"); + expect(anchor.toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + + it("the noon-UTC fallback round-trips for every zone within ±12 h", () => { + // This is the shape the wearable mappers (Oura/Polar/Fitbit) take, since + // they do not have the user's timezone in scope at map time. + const day = "2026-06-01"; + const anchor = canonicalDailyTimestamp(day); // 12:00 UTC + for (const tz of [ + "UTC", + "America/Los_Angeles", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + "Etc/GMT+11", + "Etc/GMT-11", + ]) { + expect(userDayKey(anchor, tz)).toBe(day); + } + }); +}); diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index 1f58be04c..41eb8e578 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -88,18 +88,32 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number { } /** - * Compute the canonical timestamp for a calendar-day key in the user's - * timezone. Returns the JS-Date instant at the user's local 12:00 noon - * — matches the Withings activity sync convention (one daily row per - * type, anchored to midday so the row sorts cleanly between same-day - * spot samples). The string returned by `toISOString()` is UTC. + * Compute the canonical timestamp for a calendar-day key. With a `tz`, + * returns the JS-Date instant at the user's local 12:00 noon; without + * one, returns 12:00 UTC of the day. Either anchor sits a full 12 h + * inside its calendar day, so it round-trips back to the same day + * through `userDayKey()` for every zone within ±12 h — which is the + * whole point of anchoring date-only daily records at noon rather than + * midnight (a UTC-midnight anchor double-shifts the day for west-of-UTC + * users on read). Matches the Withings activity sync convention (one + * daily row per type, anchored to midday so the row sorts cleanly + * between same-day spot samples). The string returned by + * `toISOString()` is UTC. + * + * The optional-`tz` shape is what the wearable daily mappers (Oura, + * Polar, Fitbit) anchor on — they do not have the user's timezone in + * scope at map time, so they take the noon-UTC fallback, which is + * correct for every zone within ±12 h. */ -export function canonicalDailyTimestamp(dateKey: string, tz: string): Date { +export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { + const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); + // No timezone in scope: 12:00 UTC is a safe day-stable anchor for + // every zone within ±12 h. + if (!tz) return utcNoon; // Compute the UTC offset for noon-local of the given day. We don't // have a lightweight TZ-math library on the server, so the trick is: // build "12:00 UTC of the day", read what wall-clock that shows in // the target zone, then shift by the resulting offset. - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); // utcNoon represents 12:00 UTC. The user's local clock reads // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 53e42414f..661ede829 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -473,10 +473,12 @@ describe("mapResilience", () => { } }); - it("anchors the row at the day's UTC midnight", () => { + it("anchors the date-only row at noon UTC so it round-trips the day", () => { + // Noon, not midnight: a UTC-midnight anchor double-shifts the calendar + // day for west-of-UTC users when the read path re-buckets via userDayKey. const mapped = mapResilience({ day: "2026-06-10", level: "adequate" }); expect(mapped[0]?.measuredAt.toISOString()).toBe( - "2026-06-10T00:00:00.000Z", + "2026-06-10T12:00:00.000Z", ); }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 08c045cf0..e492c51bd 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -14,6 +14,7 @@ * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; @@ -455,10 +456,16 @@ export interface MappedMeasurement { } function dayAnchor(day: string, bedtimeEnd?: string): Date | null { - // Prefer the precise wake instant when present; else anchor the day at UTC - // midnight. The recovery resolver reads the local day key off this stamp. - const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`; - const d = new Date(candidate); + // Prefer the precise wake instant when present; else anchor the date-only + // day at noon (canonicalDailyTimestamp), NOT UTC midnight — a midnight + // anchor double-shifts the calendar day for west-of-UTC users when the + // recovery resolver re-buckets via userDayKey(). Noon sits a full 12 h + // inside the day, so it round-trips to the same day for every zone ±12 h. + if (bedtimeEnd) { + const d = new Date(bedtimeEnd); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = canonicalDailyTimestamp(day); return Number.isNaN(d.getTime()) ? null : d; } diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 35ddd1fe7..9798679d9 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -312,14 +312,16 @@ describe("mapSleep", () => { ); }); - it("falls back to a midnight-UTC anchor when the window is missing", () => { + it("falls back to a noon-UTC anchor when the window is missing", () => { const mapped = mapSleep({ date: "2026-06-10", light_sleep: 3600, sleep_score: 80, }); const core = mapped.find((m) => m.sleepStage === "CORE")!; - expect(core.measuredAt.toISOString()).toBe("2026-06-10T00:00:00.000Z"); + // Noon, not midnight: keeps the date-only row on the same calendar day for + // every zone when the read path re-buckets via userDayKey. + expect(core.measuredAt.toISOString()).toBe("2026-06-10T12:00:00.000Z"); expect(core.reconstructed).toBeUndefined(); // No IN_BED envelope without a window. expect(mapped.find((m) => m.sleepStage === "IN_BED")).toBeUndefined(); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 16065f34d..3b20ab991 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -21,6 +21,7 @@ * treated as success. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; @@ -387,13 +388,13 @@ function rechargeStatusToScore(status: number): number { * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date at local midnight UTC anchor (the wake + * `measuredAt` is the record's date anchored at noon (the wake * morning the night belongs to). */ export function mapNightlyRecharge( r: PolarNightlyRecharge, ): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; @@ -537,10 +538,10 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { return out; } - // Fallback: no usable window. Anchor at midnight UTC of the wake date and emit + // Fallback: no usable window. Anchor at noon of the wake date and emit // untimed stage rows (the pre-v1.17.1 shape) so a record missing the window // fields still contributes a night total. - const measuredAt = new Date(`${s.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(s.date); if (Number.isNaN(measuredAt.getTime())) return []; for (const [sec, stage, fieldTag] of stages) { if (typeof sec === "number" && sec >= 0) { @@ -572,7 +573,7 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { * total `calories`, which includes BMR — matching the Fitbit active-only * convention). */ export function mapActivity(a: PolarActivity): MappedMeasurement[] { - const measuredAt = new Date(`${a.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(a.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; const steps = a["active-steps"]; @@ -615,7 +616,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so * guard on `number` only. */ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { - const measuredAt = new Date(`${c.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(c.date); if (Number.isNaN(measuredAt.getTime())) return []; if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; return [ @@ -632,7 +633,7 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { /** Map one Polar SpO2 test result: `blood_oxygen_percentage` → * `OXYGEN_SATURATION` (percent, 0..100). */ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const pct = r.blood_oxygen_percentage; if (typeof pct !== "number" || pct <= 0 || pct > 100) return [];