From 88bff471cc003da789135379e6347f6c64cdce6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:50:08 +0200 Subject: [PATCH] test(tz): pin DST-safe today bounds, user-tz compliance bucketing, greeting zone - getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today, a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary days stay 24h, and a negative-offset zone anchors on the local day. - dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day doses land in ONE bucket where the old fixed-Berlin key split them. - classifyIntakeTiming: an 08:00-local dose reads on_time with the user's zone and very_late without it. - hourInTz: reads the wall-clock hour in the given zone. - hero-strip greeting: resolves the hour in the stored zone, not the browser clock; existing greeting fixtures re-pinned to explicit UTC instants mapped to Berlin wall-clock. --- .../insights/__tests__/hero-strip.test.tsx | 35 ++++++--- .../__tests__/compliance-bundle.test.ts | 37 ++++++++++ .../analytics/__tests__/compliance.test.ts | 22 ++++++ src/lib/tz/__tests__/local-day.test.ts | 71 ++++++++++++++++++- src/lib/tz/__tests__/resolver.test.ts | 25 +++++++ 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 29b79c01d..6eb372350 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -33,11 +33,13 @@ const sampleBriefing: DailyBriefingPayload = { keyFindings: [], }; -// `getHours()` reads from the local representation. Force a known -// local hour by constructing from a local-time fields tuple. -const morningLocal = new Date(2026, 4, 10, 9, 0, 0); // May 10, 09:00 local -const afternoonLocal = new Date(2026, 4, 10, 14, 0, 0); // May 10, 14:00 local -const eveningLocal = new Date(2026, 4, 10, 20, 0, 0); // May 10, 20:00 local +// The greeting bucket is now computed in the user's configured zone +// (the mocked `Europe/Berlin`), not the host's — so pin explicit UTC +// instants and translate them to the intended Berlin wall-clock hour. +// May 10 2026 is CEST (UTC+2), so Berlin = UTC + 2h. +const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin +const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin +const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin describe("", () => { it("renders the slot wrapper + Dracula gradient utility", () => { @@ -178,9 +180,10 @@ describe("", () => { }); it("uses the now override deterministically (smoke for greeting-bucket logic)", () => { - // Five different hours map to four different greeting buckets: - // 09:00 → morning, 14:00 → afternoon, 20:00 → evening, 02:00 → night. - const earlyMorning = new Date(2026, 4, 10, 2, 0, 0); + // Buckets are read in the mocked user's Berlin zone: 09:00 → morning, + // 14:00 → afternoon, 20:00 → evening, 02:00 → night. 00:00 UTC is + // 02:00 Berlin (CEST). + const earlyMorning = new Date("2026-05-10T00:00:00Z"); // 02:00 Berlin const html = render(); // Night maps to "Good evening" in EN per the resolver mapping. expect(html).toContain("Good evening"); @@ -188,12 +191,24 @@ describe("", () => { expect(html).not.toContain("Good morning"); }); - it("uses the morning bucket at noon-1 (11:59 boundary)", () => { - const justBeforeNoon = new Date(2026, 4, 10, 11, 59, 0); + it("uses the morning bucket at noon-1 (11:59 Berlin boundary)", () => { + const justBeforeNoon = new Date("2026-05-10T09:59:00Z"); // 11:59 Berlin const html = render(); expect(html).toContain("Good morning"); }); + it("resolves the greeting hour in the user's stored zone, not the browser", () => { + // 11:30 UTC is still MORNING in UTC but 13:30 (AFTERNOON) in the mocked + // Berlin user's zone. The afternoon greeting proves the hour comes from + // the stored zone — the /insights hero used to read the browser clock. + const utcMorningBerlinAfternoon = new Date("2026-05-10T11:30:00Z"); + const html = render( + , + ); + expect(html).toContain("Good afternoon"); + expect(html).not.toContain("Good morning"); + }); + // ── B4 — Weekly-report banner card ───────────────────────────────── // v1.4.28 retired the weekly-report path. The banner, the report // route, the schema slot and the i18n keys are gone; the hero diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index f39030849..3a042a24d 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -23,6 +23,8 @@ import { tallyLedgerRows, type ComplianceSchedule, } from "../compliance"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; const TZ = "UTC"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -334,6 +336,41 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = const aggregateRate = Math.round((taken / (taken + missed)) * 100); expect(aggregateRate).toBe(bundle.compliance30.rate); }); + + it("buckets a non-Berlin user's two same-local-day doses into ONE day", () => { + // America/Los_Angeles user, twice-daily 08:00 + 20:00 LOCAL on the same + // local calendar day (2026-06-15). The 20:00 PDT dose is 03:00 UTC the + // NEXT day — and 05:00 the next Berlin day — so a fixed-Berlin day key + // splits one local day's two doses across two buckets. + const takenOnTime = (h: number, m: number): DoseHistoryRow => { + const at = zonedWallClockToUtc( + { year: 2026, month: 6, day: 15, hour: h, minute: m }, + "America/Los_Angeles", + ); + return { + kind: "slot", + at, + timeOfDay: `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`, + status: "taken_on_time", + intake: null, + }; + }; + const rows: DoseHistoryRow[] = [takenOnTime(8, 0), takenOnTime(20, 0)]; + + // Bucketed in the user's own zone: one local day, two taken. + const laSeries = dailyComplianceRatesFromLedger( + rows, + "America/Los_Angeles", + ); + expect(laSeries.length).toBe(1); + expect(laSeries[0].taken).toBe(2); + expect(laSeries[0].rate).toBe(100); + + // Sanity: the fixed-Berlin key (the pre-fix behaviour) would split these + // same two doses across two calendar buckets — the defect the fix closes. + const berlinSeries = dailyComplianceRatesFromLedger(rows, "Europe/Berlin"); + expect(berlinSeries.length).toBe(2); + }); }); describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 50de9cd93..fa844f670 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1367,6 +1367,28 @@ describe("classifyIntakeTiming", () => { "very_late", ); }); + + it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => { + // America/Los_Angeles is UTC-7 in summer. An "08:00" slot means 08:00 + // LOCAL = 15:00 UTC on 2026-06-15; a dose taken 15 min into the window + // is 15:15 UTC. The slot's canonical instant is the anchor date. + const slotInstant = new Date("2026-06-15T15:00:00Z"); // 08:00 LA + const takenAt = new Date("2026-06-15T15:15:00Z"); // 08:15 LA + + // With the user's zone the dose is on time. + expect( + classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, { + tz: "America/Los_Angeles", + }), + ).toBe("on_time"); + + // Without a tz the window HH:mm is read as UTC (08:00 UTC), so the very + // same on-time dose reads as 7h15m off — very_late. This is the defect + // the achievements perfect-day check hit for users far from UTC. + expect(classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant)).toBe( + "very_late", + ); + }); }); // v1.8.5 — the cadence-density rule + the per-dose uptime strip. diff --git a/src/lib/tz/__tests__/local-day.test.ts b/src/lib/tz/__tests__/local-day.test.ts index e9eac4f8c..7d6205abf 100644 --- a/src/lib/tz/__tests__/local-day.test.ts +++ b/src/lib/tz/__tests__/local-day.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; +import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +const HOUR_MS = 60 * 60 * 1000; + describe("zonedWallClockToUtc", () => { it("resolves a wall clock in a positive-offset zone to the right UTC instant", () => { // 00:30 CEST (UTC+2) on 2026-05-11 → 22:30 UTC on 2026-05-10. @@ -82,3 +84,70 @@ describe("startOfLocalDayInTz", () => { expect(floor.getDate()).toBe(15); }); }); + +describe("getUserTodayBounds — DST-safe local-day window", () => { + it("spans a full 24h on an ordinary (non-transition) day", () => { + const now = new Date("2026-06-15T14:30:00.000Z"); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + // Berlin is CEST (UTC+2) in June: local midnight = 2026-06-14T22:00Z. + expect(start.toISOString()).toBe("2026-06-14T22:00:00.000Z"); + // Inclusive end is the last ms before the next local midnight. + expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1); + }); + + it("fall-back day is 25h and keeps a 23:30-local dose inside 'today'", () => { + // Europe/Berlin falls back 2026-10-25 03:00→02:00 (a 25-hour local day). + // A read taken at 23:30 local that evening. + const now = zonedWallClockToUtc( + { year: 2026, month: 10, day: 25, hour: 23, minute: 30 }, + "Europe/Berlin", + ); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + + // The window covers the real 25-hour local day, not a hardcoded 24h. + expect(end.getTime() - start.getTime()).toBe(25 * HOUR_MS - 1); + + // The 23:30 dose — which the old 24h window dropped off the med cards — + // now falls inside [start, end]. + const dose2330 = zonedWallClockToUtc( + { year: 2026, month: 10, day: 25, hour: 23, minute: 30 }, + "Europe/Berlin", + ); + expect(dose2330.getTime()).toBeGreaterThanOrEqual(start.getTime()); + expect(dose2330.getTime()).toBeLessThanOrEqual(end.getTime()); + + // Both bounds sit at local midnight of their respective calendar days. + expect(wallClockInTz(start, "Europe/Berlin").hour).toBe(0); + expect(wallClockInTz(start, "Europe/Berlin").day).toBe(25); + }); + + it("spring-forward day is 23h and does not bleed into tomorrow's 00:30 dose", () => { + // Europe/Berlin springs forward 2026-03-29 02:00→03:00 (a 23-hour day). + const now = zonedWallClockToUtc( + { year: 2026, month: 3, day: 29, hour: 20, minute: 0 }, + "Europe/Berlin", + ); + const { start, end } = getUserTodayBounds(now, "Europe/Berlin"); + + // The window covers only the real 23-hour local day. + expect(end.getTime() - start.getTime()).toBe(23 * HOUR_MS - 1); + + // Tomorrow's 00:30 dose belongs to 2026-03-30, so it must fall OUTSIDE + // today — the old fixed-24h window double-counted it as "today". + const tomorrow0030 = zonedWallClockToUtc( + { year: 2026, month: 3, day: 30, hour: 0, minute: 30 }, + "Europe/Berlin", + ); + expect(tomorrow0030.getTime()).toBeGreaterThan(end.getTime()); + }); + + it("is correct for a negative-offset zone (America/Los_Angeles)", () => { + // 2026-06-15 20:00 PDT (UTC-7) → 2026-06-16T03:00Z. The local day is + // still the 15th, so the window must anchor on LA's 2026-06-15 midnight. + const now = new Date("2026-06-16T03:00:00.000Z"); + const { start, end } = getUserTodayBounds(now, "America/Los_Angeles"); + expect(start.toISOString()).toBe("2026-06-15T07:00:00.000Z"); + expect(end.getTime() - start.getTime()).toBe(24 * HOUR_MS - 1); + expect(wallClockInTz(start, "America/Los_Angeles").day).toBe(15); + }); +}); diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts index 2b133c7d8..0f81b2aef 100644 --- a/src/lib/tz/__tests__/resolver.test.ts +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -23,6 +23,7 @@ import { formatInUserTz, invalidateServerDefaultTimezone, invalidateUserTimezone, + hourInTz, isNearUtc, isValidTimezone, resolveServerDefaultTimezone, @@ -262,6 +263,30 @@ describe("userDayKey", () => { }); }); +describe("hourInTz", () => { + it("reads the wall-clock hour in the user's zone, not UTC", () => { + // 2026-06-15T03:00Z is 20:00 the previous day in Los Angeles (UTC-7), + // 05:00 in Berlin (UTC+2), 12:00 in Tokyo (UTC+9). + const instant = new Date("2026-06-15T03:00:00.000Z"); + expect(hourInTz(instant, "UTC")).toBe(3); + expect(hourInTz(instant, "America/Los_Angeles")).toBe(20); + expect(hourInTz(instant, "Europe/Berlin")).toBe(5); + expect(hourInTz(instant, "Asia/Tokyo")).toBe(12); + }); + + it("normalises local midnight to 0, never 24", () => { + const instant = new Date("2026-06-14T22:00:00.000Z"); // 00:00 Berlin + expect(hourInTz(instant, "Europe/Berlin")).toBe(0); + }); + + it("falls back to the default zone for an unusable tz string", () => { + // Mirrors formatInUserTz / userDayKey: an invalid zone degrades to + // DEFAULT_TIMEZONE (Europe/Berlin), which is UTC+2 in summer. + const instant = new Date("2026-06-15T09:00:00.000Z"); + expect(hourInTz(instant, "Mars/Olympus")).toBe(11); + }); +}); + describe("isNearUtc", () => { // Pin a summer-side instant so DST is on for Berlin (+2) and off for // most southern-hemisphere zones — keeps the assertions readable.