diff --git a/src/lib/analytics/__tests__/resting-pulse.test.ts b/src/lib/analytics/__tests__/resting-pulse.test.ts index 666e6500f..af41b0e4d 100644 --- a/src/lib/analytics/__tests__/resting-pulse.test.ts +++ b/src/lib/analytics/__tests__/resting-pulse.test.ts @@ -12,11 +12,45 @@ import { resolveRestingPulseSeries, type PulseSample, } from "../resting-pulse"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; -const day = (iso: string, value: number): PulseSample => ({ - measuredAt: new Date(iso), - value, -}); +/** + * The zone `deriveRestingProxyFromPulse` buckets in by default + * (`toBerlinDayKey`). Naming it here is the point: the fixtures used to + * pass offset-less ISO literals, which `new Date(...)` reads as HOST-local. + * That made every expectation mean something different on every developer + * machine, and — because a Berlin day and a UTC day coincide for samples in + * the middle of the day — swapping the implementation's day-key zone left + * the whole file green. Building each instant explicitly in Berlin lets the + * assertions actually pin the bucketing. + */ +const PROXY_DAY_ZONE = "Europe/Berlin"; + +/** + * A pulse sample at the given `YYYY-MM-DDTHH:MM:SS` wall clock **in + * Berlin**, expressed as the UTC instant it denotes. + */ +const day = (wallClock: string, value: number): PulseSample => { + const m = wallClock.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/, + ); + if (!m) throw new Error(`fixture wall clock must be local, got ${wallClock}`); + const [, y, mo, d, h, mi, sec] = m; + return { + measuredAt: zonedWallClockToUtc( + { + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(h), + minute: Number(mi), + second: Number(sec), + }, + PROXY_DAY_ZONE, + ), + value, + }; +}; describe("deriveRestingProxyFromPulse", () => { it("excludes a workout burst — the daily proxy tracks the resting floor", () => { @@ -154,3 +188,55 @@ describe("resolveRestingPulseSeries", () => { ).toEqual({ series: [], which: "none" }); }); }); + +describe("deriveRestingProxyFromPulse — the day bucket is the PROFILE day", () => { + it("keeps after-midnight local samples on their own local day, not the UTC one", () => { + // Berlin is UTC+2 in June, so 00:20–01:00 local on 2026-06-02 is + // 22:20–23:00 UTC on 2026-06-01 — a DIFFERENT UTC calendar day. All + // six samples belong to one Berlin day and must produce ONE bucket. + // Under a UTC day-key they split 3/3 and produce two. + const samples = [ + day("2026-06-02T00:20:00", 60), + day("2026-06-02T00:40:00", 62), + day("2026-06-02T01:00:00", 64), + day("2026-06-02T09:00:00", 66), + day("2026-06-02T15:00:00", 68), + day("2026-06-02T21:00:00", 70), + ]; + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(1); + }); + + it("splits two local days that a UTC key would merge", () => { + // 23:10–23:50 Berlin on 2026-06-01 is 21:10–21:50 UTC the same day, + // while 00:10–00:50 Berlin on 2026-06-02 is 22:10–22:50 UTC on + // 2026-06-01. A UTC key merges all six into one bucket; the Berlin key + // must see two days. + const samples = [ + day("2026-06-01T23:10:00", 60), + day("2026-06-01T23:30:00", 62), + day("2026-06-01T23:50:00", 64), + day("2026-06-02T00:10:00", 80), + day("2026-06-02T00:30:00", 82), + day("2026-06-02T00:50:00", 84), + ]; + const proxy = deriveRestingProxyFromPulse(samples); + expect(proxy).toHaveLength(2); + }); + + it("honours an injected day-key function over the Berlin default", () => { + // The targets route threads `userDayKey(d, userTz)`. Same samples, + // different zone → a different bucketing. This is the seam the default + // hides, so it gets its own pin. + const samples = [ + day("2026-06-02T00:20:00", 60), + day("2026-06-02T00:40:00", 62), + day("2026-06-02T01:00:00", 64), + ]; + const utcKey = (d: Date) => d.toISOString().slice(0, 10); + // In UTC these three are 2026-06-01 22:20/22:40/23:00 → one UTC day. + expect(deriveRestingProxyFromPulse(samples, utcKey)).toHaveLength(1); + // And that UTC day is 06-01, whereas the Berlin bucket is 06-02. + expect(utcKey(samples[0].measuredAt)).toBe("2026-06-01"); + }); +}); diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index f14be4e05..b360f6218 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -5,8 +5,18 @@ * the numbers now honour `daysOfWeek` and `intervalWeeks`. These * tests pin the contract for every cadence the production app * exercises: daily 1×/day, daily multi-dose, weekly Mondays-only, - * bi-weekly, weekday-only multi-dose, and the DST-boundary day. - * Closes #214. + * bi-weekly, and weekday-only multi-dose. Closes #214. + * + * The DST local-day walk is NOT pinned here. A "DST boundary" case used + * to sit in this file passing no timezone, so the slot builder took the + * legacy UTC path where the date carries no transition — it asserted + * `totalExpected === 7`, which holds in every zone and therefore could + * never see a double-emit. Threading a real zone through it does not + * help: the assertion stays true in Berlin, UTC, UTC+14 and UTC−11 + * alike. The property it claimed lives in + * `src/lib/medications/scheduling/__tests__/compliance.test.ts`, which + * walks `buildCadenceTimeline` with an explicit zone across BOTH the 23 h + * spring-forward and the 25 h fall-back day. * * Each test uses `vi.useFakeTimers()` to pin `now` so the rolling * window is deterministic. `classifyIntakeTiming` tests below the @@ -343,38 +353,6 @@ describe("calculateCompliance — cadence-aware adapter", () => { expect(result.missed).toBe(0); }); - it("DST boundary (Europe/Berlin spring-forward) does not inflate the expected count", () => { - // Spring-forward in Berlin: 2025-03-30 02:00 → 03:00. A daily - // schedule across this boundary must still emit one slot per - // local day — not two on the short day and zero on the next. - // Pin `now` to Apr-02 noon UTC and run a 7-day window so the - // boundary day is inside it. Generate one event per day - // (working backwards from NOW − 1 day so each event lands inside - // the window) and assert the totalExpected lands at 7 (not 8). - vi.setSystemTime(new Date("2025-04-02T12:00:00Z")); - const schedules: ComplianceSchedule[] = [ - { windowStart: "10:00", windowEnd: "11:00", daysOfWeek: null }, - ]; - // Slots emitted by the timeline: Mar-27..Apr-02 (Mar-26's window - // sits below the rolling-window start of Mar-26 12:00). Generate - // one taken event per slot — today's 10:30 slot is also in the - // past relative to NOW = 12:00 so it counts as taken too. - const events = []; - for (let d = 0; d <= 6; d++) { - const at = new Date("2025-04-02T10:30:00Z"); - at.setUTCDate(at.getUTCDate() - d); - events.push(eventAt(at, true)); - } - const result = calculateCompliance(events, schedules, 7); - // The hard contract: totalExpected stays at 7 — not 8 (which - // would mean the DST short day double-emitted) and not 6 (which - // would mean the short day silently dropped a slot). - expect(result.totalExpected).toBe(7); - expect(result.taken).toBe(7); - expect(result.missed).toBe(0); - expect(result.rate).toBe(100); - }); - it("filters events outside the rolling window", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, diff --git a/vitest.config.mts b/vitest.config.mts index 6972701c9..caee54df7 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,6 +9,15 @@ export default defineConfig({ test: { globals: true, environment: "node", + // Pin the host timezone. CI runs UTC; without this a contributor in any + // other zone sees a different suite than the gate does — verified: a + // UTC+14 host fails five tests on an otherwise green tree. Pinning makes + // local and CI agree, and makes "which zone does this test mean?" an + // explicit choice the fixture has to state rather than a property of the + // machine it runs on. Tests that need a different HOST zone set + // `process.env.TZ` themselves and restore it afterwards (see + // `next-due-day-label.test.ts`). + env: { TZ: "UTC" }, // Primes the i18n locale cache so provider mounts resolve every // locale synchronously (production gets the active bundle as an RSC // prop instead — see src/lib/i18n/load-locale.ts). diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 4ae52780a..3a36a7989 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -13,6 +13,9 @@ export default defineConfig({ test: { globals: true, environment: "node", + // Same host-timezone pin as the unit config — CI runs UTC, so the + // integration contracts must be read against the same clock. + env: { TZ: "UTC" }, include: ["tests/integration/**/*.test.ts"], // Container boot + migration apply is slow; give each test a generous // budget and beforeAll/afterAll twice that.