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