diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 23bbf4081..483bf42d5 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -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)", () => { diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 22a59049f..e5eab0e07 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -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; diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index 2215d4ef8..662cfbc46 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -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, diff --git a/src/lib/fitbit/sync-workout.ts b/src/lib/fitbit/sync-workout.ts index 7e38a3872..a430e85f7 100644 --- a/src/lib/fitbit/sync-workout.ts +++ b/src/lib/fitbit/sync-workout.ts @@ -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 { diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index f116dd3bb..e9eac4f8c 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -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", () => { diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 4dc5efcf5..68df50b26 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -64,6 +64,75 @@ const WALL_CLOCK_OPTIONS: Omit = 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,