fix(fitbit): anchor offset-less sleep timestamps to the user timezone

The classic 1.2 sleep log and the activities list emit local wall-clock
ISO strings without an offset. `parseLocalInstant` parsed them with a bare
`new Date(iso)`, which reads an offset-less string in the process zone (UTC
in production). A non-UTC user's sleep-segment `measuredAt` (the segment
end) was therefore shifted by their UTC offset, and a near-midnight segment
could flip onto the wrong wake-day in the night reconstruction.

Resolve offset-less timestamps against the user's stored timezone via a new
`zonedWallClockToUtc` helper (the inverse of `wallClockInTz`, DST-safe via
the same two-pass converge as `startOfLocalDayInTz`). Thread the resolved
zone through the sleep and workout sync paths. Strings that already carry an
offset or `Z` stay absolute and parse as-is; an absent zone keeps the prior
host-local fallback.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 08:09:57 +02:00
1 parent 745991a922
commit 68c67e4700
6 files changed
+229 -13

No files matched your search

+56
View File
@@ -503,6 +503,62 @@ describe("sleep mapping (1.2 levels.data)", () => {
expect(mapSleepSession({ levels: { data: [] } })).toHaveLength(0);
expect(mapSleepSession({})).toHaveLength(0);
});
it("anchors a near-midnight segment END to the user's timezone, not the process zone", () => {
// A Berlin user (UTC+2 in May / CEST) whose last segment ENDS at 00:30
// local on 2026-05-11. The offset-less wall clock `2026-05-11T00:30:00`
// must resolve to 22:30 UTC on 2026-05-10 — NOT 00:30 UTC (which a bare
// `new Date(iso)` in a UTC process would produce, flipping the wake-day).
const sessions = readSleepSessions({
sleep: [
{
logId: 4242,
startTime: "2026-05-10T23:00:00.000",
endTime: "2026-05-11T00:30:00.000",
levels: {
data: [
{
dateTime: "2026-05-11T00:00:00.000",
level: "deep",
seconds: 1800, // ends 00:30 Berlin local
},
],
},
},
],
});
const out = mapSleepSession(sessions[0]!, "Europe/Berlin");
expect(out).toHaveLength(1);
// 00:30 CEST (UTC+2) → 22:30 UTC on the PRIOR civil day.
expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T22:30:00.000Z");
});
it("anchors a logId-less session anchor against the user's timezone", () => {
const sessions = readSleepSessions({
sleep: [
{
// No logId → the anchor falls back to the END instant, which must
// also be tz-resolved so the externalId is stable across syncs.
endTime: "2026-05-11T00:30:00.000",
levels: {
data: [
{
dateTime: "2026-05-11T00:00:00.000",
level: "rem",
seconds: 1800,
},
],
},
},
],
});
const out = mapSleepSession(sessions[0]!, "Europe/Berlin");
expect(out).toHaveLength(1);
// anchor = end instant ISO (UTC) → fieldTag starts with that instant.
expect(out[0]!.fieldTag).toBe("2026-05-10T22:30:00.000Z:sleep_rem:0");
});
});
describe("workout mapping (activities list)", () => {
+50 -10
View File
@@ -39,6 +39,7 @@
import { createHash, randomBytes } from "node:crypto";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
/** Classic Fitbit Web API base. */
@@ -929,13 +930,13 @@ interface FitbitSleepSession {
* The stable session anchor for a sleep externalId — the logId, else the end (or
* start) instant. A re-scored night reuses the same logId → overwrites in place.
*/
function sleepSessionAnchor(s: FitbitSleepSession): string {
function sleepSessionAnchor(s: FitbitSleepSession, tz?: string): string {
if (typeof s.logId === "number" && Number.isFinite(s.logId)) {
return String(s.logId);
}
for (const t of [s.endTime, s.startTime]) {
if (typeof t === "string") {
const d = parseLocalInstant(t);
const d = parseLocalInstant(t, tz);
if (d) return d.toISOString();
}
}
@@ -943,12 +944,40 @@ function sleepSessionAnchor(s: FitbitSleepSession): string {
}
/**
* Parse a Fitbit sleep timestamp. The 1.2 sleep log emits LOCAL wall-clock ISO
* strings WITHOUT an offset (e.g. `2020-01-26T03:02:30.000`); `new Date(...)`
* treats an offset-less ISO string as local time, which is the user's intent
* here (the night belongs to the user's local clock). Returns null on a miss.
* Matches an offset-less local ISO wall-clock string Fitbit emits on the classic
* sleep + activity logs (`2020-01-26T03:02:30` / `...T03:02:30.000`). No trailing
* `Z`, no `±hh:mm` — those denote an absolute instant and are honoured verbatim.
*/
function parseLocalInstant(iso: string): Date | null {
const OFFSET_LESS_LOCAL_ISO =
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?$/;
/**
* Parse a Fitbit timestamp into a UTC instant. The classic 1.2 sleep log and the
* activities list emit LOCAL wall-clock strings WITHOUT an offset (e.g.
* `2020-01-26T03:02:30.000`) — the night/activity belongs to the user's local
* clock, so the wall clock must be resolved against the USER'S timezone, not the
* process zone. A bare `new Date(iso)` parses an offset-less string in the host
* zone (UTC in production), which shifts a non-UTC user's segment by their UTC
* offset and can flip the wake-day in the night reconstruction. When `tz` is
* omitted (no user zone on the path) the host-local fallback preserves the prior
* behaviour. Strings that DO carry an offset/`Z` are absolute and parsed as-is.
* Returns null on a miss.
*/
function parseLocalInstant(iso: string, tz?: string): Date | null {
const m = OFFSET_LESS_LOCAL_ISO.exec(iso.trim());
if (m) {
return zonedWallClockToUtc(
{
year: Number(m[1]),
month: Number(m[2]),
day: Number(m[3]),
hour: Number(m[4]),
minute: Number(m[5]),
second: m[6] ? Number(m[6]) : 0,
},
tz,
);
}
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? null : d;
}
@@ -959,14 +988,20 @@ function parseLocalInstant(iso: string): Date | null {
* Each segment carries a stage-scoped, INDEXED fieldTag so the several segments
* of one stage stay distinct under the `(userId, type, source, externalId)`
* dedup key. Unknown stage labels are skipped.
*
* The 1.2 sleep log emits OFFSET-LESS local wall-clock timestamps, so `tz` (the
* user's stored zone) anchors them to the correct UTC instant — without it a
* non-UTC user's near-midnight segment END would shift by their UTC offset and
* could land on the wrong wake-day in the night reconstruction.
*/
export function mapSleepSession(
session: FitbitSleepSession,
tz?: string,
): FitbitMappedMeasurement[] {
const data = session.levels?.data;
if (!Array.isArray(data) || data.length === 0) return [];
const anchor = sleepSessionAnchor(session);
const anchor = sleepSessionAnchor(session, tz);
const out: FitbitMappedMeasurement[] = [];
let segIndex = 0;
for (const seg of data) {
@@ -975,7 +1010,7 @@ export function mapSleepSession(
if (!positive(seg.seconds)) continue;
const startStr = typeof seg.dateTime === "string" ? seg.dateTime : null;
if (!startStr) continue;
const start = parseLocalInstant(startStr);
const start = parseLocalInstant(startStr, tz);
if (!start) continue;
const minutes = seg.seconds / 60;
const end = new Date(start.getTime() + seg.seconds * 1000);
@@ -1083,13 +1118,18 @@ export interface FitbitMappedWorkout {
* there is no usable start + positive duration. The externalId anchors on the
* `logId` (stable) so a re-fetch overwrites the same row. The classic
* activities-list endpoint does not surface min/max HR, so those stay null.
*
* `startTime` is an offset-less local wall-clock string, so `tz` (the user's
* stored zone) anchors it to the correct UTC instant rather than the process
* zone.
*/
export function mapWorkout(
entry: FitbitActivityLogEntry,
tz?: string,
): FitbitMappedWorkout | null {
const start =
typeof entry.startTime === "string"
? parseLocalInstant(entry.startTime)
? parseLocalInstant(entry.startTime, tz)
: null;
if (!start) return null;
if (!positive(entry.duration)) return null;
+7 -1
View File
@@ -32,6 +32,7 @@ import {
type FitbitResourceSyncOptions,
} from "./sync";
import { annotate } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export async function syncUserSleep(
userId: string,
@@ -40,6 +41,11 @@ export async function syncUserSleep(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// The classic 1.2 sleep log emits offset-less local wall-clock timestamps;
// anchor them against the user's stored zone so a near-midnight segment END
// lands on the correct wake-day rather than being shifted by the process zone.
const tz = await resolveUserTimezone(userId);
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? new Date();
const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS);
@@ -53,7 +59,7 @@ export async function syncUserSleep(
return handleCollectionFetchError("fetchSleep", userId, err);
}
for (const session of readSleepSessions(body)) {
for (const m of mapSleepSession(session)) {
for (const m of mapSleepSession(session, tz)) {
readings.push({
type: m.type,
value: m.value,
+6 -1
View File
@@ -29,6 +29,7 @@ import {
} from "./sync";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Activities per page on the `activities/list` walk. */
const WORKOUT_PAGE_SIZE = 100;
@@ -42,6 +43,10 @@ export async function syncUserWorkout(
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// `startTime` on the activities list is offset-less local wall-clock; anchor
// it to the user's stored zone rather than the process zone.
const tz = await resolveUserTimezone(userId);
const afterDate =
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
@@ -67,7 +72,7 @@ export async function syncUserWorkout(
let imported = 0;
for (const entry of entries) {
const w: FitbitMappedWorkout | null = mapWorkout(entry);
const w: FitbitMappedWorkout | null = mapWorkout(entry, tz);
if (!w) continue; // no usable time span — not a workout
try {
+41 -1
View File
@@ -1,6 +1,46 @@
import { describe, expect, it } from "vitest";
import { startOfLocalDayInTz } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
describe("zonedWallClockToUtc", () => {
it("resolves a wall clock in a positive-offset zone to the right UTC instant", () => {
// 00:30 CEST (UTC+2) on 2026-05-11 → 22:30 UTC on 2026-05-10.
const utc = zonedWallClockToUtc(
{ year: 2026, month: 5, day: 11, hour: 0, minute: 30 },
"Europe/Berlin",
);
expect(utc.toISOString()).toBe("2026-05-10T22:30:00.000Z");
});
it("round-trips through wallClockInTz across the spring-forward boundary", () => {
// 03:30 local on Berlin's spring-forward day (clocks jumped 02:00→03:00).
const utc = zonedWallClockToUtc(
{ year: 2026, month: 3, day: 29, hour: 3, minute: 30 },
"Europe/Berlin",
);
const parts = wallClockInTz(utc, "Europe/Berlin");
expect(parts.hour).toBe(3);
expect(parts.minute).toBe(30);
expect(parts.day).toBe(29);
});
it("resolves a negative-offset zone", () => {
// 00:30 NZST (UTC+12) on 2026-06-15 → 12:30 UTC on 2026-06-14.
const utc = zonedWallClockToUtc(
{ year: 2026, month: 6, day: 15, hour: 0, minute: 30 },
"Pacific/Auckland",
);
expect(utc.toISOString()).toBe("2026-06-14T12:30:00.000Z");
});
it("falls back to host-local when tz is undefined", () => {
const utc = zonedWallClockToUtc(
{ year: 2026, month: 6, day: 15, hour: 14, minute: 30, second: 15 },
undefined,
);
expect(utc.getTime()).toBe(new Date(2026, 5, 15, 14, 30, 15).getTime());
});
});
describe("startOfLocalDayInTz", () => {
it("anchors at the user's local midnight, not UTC midnight", () => {
+69
View File
@@ -64,6 +64,75 @@ const WALL_CLOCK_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
weekday: "short",
});
/**
* The wall-clock fields a `zonedWallClockToUtc` caller supplies. Months are
* 1-based to match `WallClockParts`; second defaults to 0 when omitted.
*/
export interface WallClockInput {
year: number;
/** 1–12 (January = 1, December = 12). */
month: number;
/** 1–31. */
day: number;
/** 0–23. */
hour: number;
minute: number;
second?: number;
}
/**
* Inverse of `wallClockInTz`: given the wall-clock an observer in `tz` reads
* (e.g. an offset-less `2020-01-26T03:02:30` Fitbit sleep timestamp), return the
* UTC instant it denotes. DST-safe via the same two-pass converge as
* `startOfLocalDayInTz` — the second pass settles the zone offset across a DST
* transition for every IANA zone. When `tz` is omitted the parts are read as the
* host's system-local clock so offset-less callers keep their legacy shape.
*/
export function zonedWallClockToUtc(
parts: WallClockInput,
tz: string | undefined,
): Date {
const second = parts.second ?? 0;
if (!tz) {
return new Date(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
second,
0,
);
}
// Treat the wall clock as if it were UTC, then correct by the zone offset at
// that approximate instant; one extra pass settles the DST boundary.
const asIfUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
second,
0,
);
let guess = new Date(asIfUtc);
for (let i = 0; i < 2; i++) {
const at = wallClockInTz(guess, tz);
const guessAsIfUtc = Date.UTC(
at.year,
at.month - 1,
at.day,
at.hour,
at.minute,
at.second,
0,
);
const offsetMin = Math.round((guessAsIfUtc - guess.getTime()) / 60_000);
guess = new Date(asIfUtc - offsetMin * 60_000);
}
return guess;
}
export function wallClockInTz(
date: Date,
tz: string | undefined,