fix(integrations): anchor Oura and Polar daily records at local noon

Date-only daily scalars from Oura and Polar were stored at UTC midnight
(`${day}T00:00:00.000Z`). On read these rows re-bucket through
`userDayKey(measuredAt, userTz)`, so for any west-of-UTC user the day
shifted back one: a June 1 SpO2 or readiness row filed under May 31 and
fell out of alignment with the HR/sleep rows that carry true instants.

Extend the existing `canonicalDailyTimestamp` helper to accept an optional
timezone and anchor a date-only day at local noon (noon UTC when no zone
is supplied) — a full twelve hours inside the calendar day, so it
round-trips through `userDayKey` to the same day for every zone within
plus/minus twelve hours. Route every Oura day-keyed mapper (via the shared
`dayAnchor` fallback) and all five Polar daily mappers through it; real
wake/measurement instants stay untouched. Fitbit already anchored at noon
and is now consistent; Whoop and the Withings cursor anchors were verified
not to be measurement anchors.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:27:46 +02:00
1 parent bc617adc2c
commit c371310fc0
6 files changed
+124 -22

No files matched your search

@@ -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);
}
});
});
+21 -7
View File
@@ -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,
+4 -2
View File
@@ -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",
);
});
});
+11 -4
View File
@@ -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;
}
+4 -2
View File
@@ -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();
+8 -7
View File
@@ -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 [];