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,