From 2f32a4458cd421a96d2cd8a98710cf2740995e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 09:58:02 +0200 Subject: [PATCH] fix(environment): end the nightly window on the person's today The default lookback ended on the UTC date, so east of UTC the local today stayed out of the window until the next night's run. --- .../__tests__/environment-fetch-range.test.ts | 35 ++++++++++++++++++ src/lib/jobs/environment-fetch.ts | 37 +++++++++++-------- 2 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 src/lib/jobs/__tests__/environment-fetch-range.test.ts diff --git a/src/lib/jobs/__tests__/environment-fetch-range.test.ts b/src/lib/jobs/__tests__/environment-fetch-range.test.ts new file mode 100644 index 000000000..96c6df4e2 --- /dev/null +++ b/src/lib/jobs/__tests__/environment-fetch-range.test.ts @@ -0,0 +1,35 @@ +/** + * The nightly environment window ends on the person's today, not the UTC + * date: east of UTC the local day begins hours before the UTC one. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { resolveRange } from "../environment-fetch"; + +describe("resolveRange", () => { + // 20:00 UTC on 1 March is already 2 March in Auckland. + const now = new Date("2026-03-01T20:00:00.000Z"); + + it("ends the lookback on the local today", () => { + expect(resolveRange({}, "Pacific/Auckland", now)).toEqual({ + startDate: "2026-02-23", + endDate: "2026-03-02", + }); + expect(resolveRange({}, "America/Los_Angeles", now)).toEqual({ + startDate: "2026-02-22", + endDate: "2026-03-01", + }); + }); + + it("keeps an explicit range as given", () => { + expect( + resolveRange( + { startDate: "2025-01-01", endDate: "2025-01-31" }, + "Pacific/Auckland", + now, + ), + ).toEqual({ startDate: "2025-01-01", endDate: "2025-01-31" }); + }); +}); diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts index 866a12971..eef50e48d 100644 --- a/src/lib/jobs/environment-fetch.ts +++ b/src/lib/jobs/environment-fetch.ts @@ -39,8 +39,9 @@ import { ENVIRONMENT_MAX_BACKFILL_DAYS, fetchAndStoreEnvironment, fillAirQualityGaps, - utcDayKey, } from "@/lib/environment/service"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; import { enumerateDayCount } from "@/lib/environment/day-span"; import { recordError } from "@/lib/jobs/worker-status"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -54,8 +55,6 @@ export const ENVIRONMENT_FETCH_CRON = "10 2 * * *"; export const ENVIRONMENT_FETCH_CONCURRENCY = 1; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - /** * Per-user fetch payload. An empty payload (no `userId`) is the daily discovery * tick. `startDate`/`endDate` (YYYY-MM-DD) drive the on-demand backfill; omitted @@ -67,18 +66,23 @@ export interface EnvironmentFetchPayload { endDate?: string; } -/** Resolve the date range for a per-user run (explicit range, else lookback). */ -function resolveRange(payload: EnvironmentFetchPayload): { - startDate: string; - endDate: string; -} { - const today = new Date(); - const endDate = payload.endDate ?? utcDayKey(today); +/** + * Resolve the date range for a per-user run (explicit range, else lookback). + * + * The lookback ends on the person's today, in the zone the service keys the + * stored days in, not on the UTC date: east of UTC the local day starts up to + * fourteen hours before the UTC one, and a window ending on the UTC date left + * the local today out until the next night's run. + */ +export function resolveRange( + payload: EnvironmentFetchPayload, + tz: string, + now: Date = new Date(), +): { startDate: string; endDate: string } { + const today = userDayKey(now, tz); + const endDate = payload.endDate ?? today; const startDate = - payload.startDate ?? - utcDayKey( - new Date(today.getTime() - ENVIRONMENT_LOOKBACK_DAYS * MS_PER_DAY), - ); + payload.startDate ?? shiftDateKey(today, -ENVIRONMENT_LOOKBACK_DAYS); return { startDate, endDate }; } @@ -208,7 +212,10 @@ export async function handleEnvironmentFetch( return jobDone({ skipped: "environment_module_disabled" }); } - const { startDate, endDate } = resolveRange(payload); + const { startDate, endDate } = resolveRange( + payload, + await resolveUserTimezone(userId), + ); // Clamp the span so a crafted backfill can never fan out an unbounded range. const span = enumerateDayCount(startDate, endDate); if (span > ENVIRONMENT_MAX_BACKFILL_DAYS) {