From 37645fc4bbdc79fa60361b0d9c0e5208ddd3063a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:48:13 +0200 Subject: [PATCH] fix(measurements): cut drain days in the zone the server publishes for the account --- .../consolidation-scan-paged.test.ts | 30 +++++++++++++++++++ src/lib/measurements/consolidation-base.ts | 8 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts index 1add7ea76..28661fa62 100644 --- a/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts +++ b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +const appSettings = vi.hoisted(() => ({ + findUnique: vi.fn(async () => ({ defaultUserTimezone: "America/New_York" })), +})); +vi.mock("@/lib/db", () => ({ prisma: { appSettings } })); + import { bucketRowsByDay, iterateDayBuckets, @@ -303,4 +308,29 @@ describe("runConsolidation — streamed walk", () => { expect(result.stoppedEarly).toBe(true); expect(findMany).toHaveBeenCalledTimes(1); }); + + it("cuts days in the zone the server publishes for the account", async () => { + // A stored zone the server sets aside reads as the instance default, as + // it does for `/api/auth/me` and the hour-bucket writer; never as the + // drain's own fallback. + const findMany = buildPagingFindMany(hourlyRows(1)); + const base = params(findMany, async () => ({ + kind: "written" as const, + sourceRowsRemoved: 0, + })); + const zones: string[] = []; + await runConsolidation({ + ...base, + prismaClient: { + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "user-2", timezone: null }]), + }, + measurement: { findMany }, + } as unknown as PrismaClient, + onUserStart: ({ tz }) => zones.push(tz), + }); + expect(zones).toEqual(["America/New_York"]); + }); }); diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 0f1b21a73..2b717a0d3 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -44,6 +44,7 @@ import { type PerSampleRow, } from "./consolidation-tz"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; // Re-export the shared grace constant so the established // `consolidation-base` import surface stays unchanged for callers. @@ -454,7 +455,12 @@ export async function runConsolidation( const users = await loadConsolidationUsers(prismaClient, options.userId); walk: for (const user of users) { - const tz = resolveUserTimezone(user.timezone); + // The zone the server publishes for the account (`/api/auth/me`), which + // is the zone the phone cuts its `stats:` days and hour buckets in and + // the one the hour-bucket writer stores them under. A stored value the + // server sets aside falls back to the instance default here as it does + // there, never to a second fallback of the drain's own. + const tz = await resolveStoredTimezone(user.timezone); // Aligned to the account's local day: a day is folded whole or not at // all, never in two runs (see `foldBoundary`). const cutoffAt =