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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:56 +02:00
1 parent a9bf72a7ce
commit 342d73fb36
4 files changed
+159 -3

No files matched your search

@@ -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(),
);
});
});
@@ -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,
}));
+28
View File
@@ -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 };
+6 -2
View File
@@ -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,