From 342d73fb366cf749a4f16dc35dc3039f4c949810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:45:52 +0200 Subject: [PATCH] fix(google-health): read a full day of intraday samples once per local day A watch worn without its phone reaches Google's cloud hours after the samples were taken. The two-hour intraday overlap is measured from the end of the previous clean cycle, so anything arriving later than that was never fetched. The first cycle of each local day now reads intraday samples with the full day of overlap; later cycles keep two hours. --- .../sync-intraday-daily-wide.test.ts | 124 ++++++++++++++++++ .../__tests__/sync-outcomes.test.ts | 2 +- src/lib/google-health/sync-core.ts | 28 ++++ src/lib/google-health/sync.ts | 8 +- 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts diff --git a/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts b/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts new file mode 100644 index 000000000..05b0bee44 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-intraday-daily-wide.test.ts @@ -0,0 +1,124 @@ +/** + * v1.42 — a watch worn without its phone uploads hours late. + * + * Those samples keep their original sample time. With a two-hour intraday + * overlap measured from `lastSyncedAt` (stamped at the end of every clean + * cycle), anything that reaches Google's cloud more than two hours late was + * never fetched. The first cycle of each local day now reads intraday samples + * with the full day of overlap; later cycles that day stay at two hours. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, metrics } = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { + findUnique: vi.fn(), + update: vi.fn(async () => ({})), + }, + }, + metrics: vi.fn(async (_userId: string, _opts: unknown) => 0), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncSuccess: vi.fn(async () => {}), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../sync-workout", () => ({ syncUserWorkout: vi.fn(async () => 0) })); +vi.mock("../sync-sleep", () => ({ syncUserSleep: vi.fn(async () => 0) })); +vi.mock("../sync-activity", () => ({ syncUserActivity: vi.fn(async () => 0) })); +vi.mock("../sync-metrics", () => ({ syncUserMetrics: metrics })); + +import { + GOOGLE_HEALTH_DEFAULT_OVERLAP_MS, + GOOGLE_HEALTH_INTRADAY_OVERLAP_MS, + intradayOverlapMs, +} from "../sync-core"; +import { syncUserGoogleHealth } from "../sync"; + +const HOUR = 60 * 60 * 1000; + +function intradayStartOfLastCall(): Date | undefined { + const opts = metrics.mock.calls.at(-1)?.[1] as + { intradayStart?: Date } | undefined; + return opts?.intradayStart; +} + +beforeEach(() => { + vi.useRealTimers(); + metrics.mockClear(); +}); + +describe("intradayOverlapMs", () => { + it("is wide on the first cycle of a local day and narrow after", () => { + const now = new Date("2026-10-08T06:00:00.000Z"); // 08:00 Berlin + // 23:30 Berlin the evening before: a new local day. + expect( + intradayOverlapMs( + new Date("2026-10-07T21:30:00.000Z"), + "Europe/Berlin", + now, + ), + ).toBe(GOOGLE_HEALTH_DEFAULT_OVERLAP_MS); + // 07:00 Berlin the same morning: same local day. + expect( + intradayOverlapMs( + new Date("2026-10-08T05:00:00.000Z"), + "Europe/Berlin", + now, + ), + ).toBe(GOOGLE_HEALTH_INTRADAY_OVERLAP_MS); + }); + + it("uses the local day, not the UTC day", () => { + // 23:30 UTC on the 7th is 01:30 Berlin on the 8th: same local day as now. + const now = new Date("2026-10-08T06:00:00.000Z"); + expect( + intradayOverlapMs( + new Date("2026-10-07T23:30:00.000Z"), + "Europe/Berlin", + now, + ), + ).toBe(GOOGLE_HEALTH_INTRADAY_OVERLAP_MS); + }); +}); + +describe("syncUserGoogleHealth intraday window", () => { + it("reads a full day of intraday samples on the first cycle of the day", async () => { + vi.useFakeTimers({ now: new Date("2026-10-08T06:00:00.000Z") }); + const lastSyncedAt = new Date("2026-10-07T21:00:00.000Z"); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + lastSyncedAt, + }); + + await syncUserGoogleHealth("late-watch-user"); + + expect(intradayStartOfLastCall()?.toISOString()).toBe( + new Date(lastSyncedAt.getTime() - 24 * HOUR).toISOString(), + ); + }); + + it("keeps the two-hour window for later cycles of the same day", async () => { + vi.useFakeTimers({ now: new Date("2026-10-08T10:00:00.000Z") }); + const lastSyncedAt = new Date("2026-10-08T09:00:00.000Z"); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + lastSyncedAt, + }); + + await syncUserGoogleHealth("late-watch-user"); + + expect(intradayStartOfLastCall()?.toISOString()).toBe( + new Date(lastSyncedAt.getTime() - 2 * HOUR).toISOString(), + ); + }); +}); diff --git a/src/lib/google-health/__tests__/sync-outcomes.test.ts b/src/lib/google-health/__tests__/sync-outcomes.test.ts index 4d4012476..db252921a 100644 --- a/src/lib/google-health/__tests__/sync-outcomes.test.ts +++ b/src/lib/google-health/__tests__/sync-outcomes.test.ts @@ -135,7 +135,7 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("../sync-core", () => ({ GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", incrementalStart: vi.fn(() => undefined), - GOOGLE_HEALTH_INTRADAY_OVERLAP_MS: 2 * 60 * 60 * 1000, + intradayOverlapMs: vi.fn(() => 2 * 60 * 60 * 1000), markSynced: vi.fn(async () => {}), runWithGoogleHealthSyncCycle: runCycle, })); diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index eb7f86954..204368f6c 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -20,6 +20,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { encrypt, decrypt } from "@/lib/crypto"; import { getEvent } from "@/lib/logging/context"; +import { userDayKey } from "@/lib/tz/format"; import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status"; @@ -69,6 +70,33 @@ export const GOOGLE_HEALTH_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000; */ export const GOOGLE_HEALTH_INTRADAY_OVERLAP_MS = 2 * 60 * 60 * 1000; +/** + * The intraday overlap for one cycle. Two hours covers a watch that reaches + * the phone a little late, but a watch worn without its phone uploads to + * Google's cloud only when it next meets the phone, which can be many hours + * after the samples were taken. Those samples carry their original sample + * time, so once `lastSyncedAt` has moved past them a two-hour window never + * sees them again. + * + * So the first successful cycle of each local day reads intraday samples + * with the full day of overlap, and every later cycle that day keeps the + * short window. "First of the day" is derived from the watermark itself + * (`lastSyncedAt` falls on an earlier local day than now), so no extra state + * is needed, and a failed wide cycle does not stamp `markSynced`, which makes + * the next cycle wide again. Cost stays bounded: one day-wide read per day + * instead of one per hour, still under the dense page cap (#1023). + */ +export function intradayOverlapMs( + lastSyncedAt: Date | null, + tz: string, + now: Date = new Date(), +): number { + if (!lastSyncedAt) return GOOGLE_HEALTH_INTRADAY_OVERLAP_MS; + return userDayKey(lastSyncedAt, tz) === userDayKey(now, tz) + ? GOOGLE_HEALTH_INTRADAY_OVERLAP_MS + : GOOGLE_HEALTH_DEFAULT_OVERLAP_MS; +} + export interface GoogleHealthTokenInfo { accessToken: string; connection: { id: string; googleUserId: string }; diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 202b4be07..5876b81a8 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -8,6 +8,7 @@ import { } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { runWithGoogleHealthClientOutcome, type GoogleHealthClientOutcome, @@ -19,7 +20,7 @@ import { syncUserWorkout } from "./sync-workout"; import { GOOGLE_HEALTH_INTEGRATION_KEY, incrementalStart, - GOOGLE_HEALTH_INTRADAY_OVERLAP_MS, + intradayOverlapMs, markSynced, runWithGoogleHealthSyncCycle, type GoogleHealthResourceSyncOptions, @@ -176,7 +177,10 @@ export async function syncUserGoogleHealth( }); const intradayStart = incrementalStart(connection.lastSyncedAt, { fullSync: opts.fullSync, - overlapMs: GOOGLE_HEALTH_INTRADAY_OVERLAP_MS, + overlapMs: intradayOverlapMs( + connection.lastSyncedAt, + await resolveUserTimezone(userId), + ), }); const resourceOpts: GoogleHealthResourceSyncOptions = { fullSync: opts.fullSync,