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/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index d97dbf8b9..7047ed596 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -7,6 +7,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { formatUpdatedLabel } from "@/lib/i18n/relative-time"; import { useAuth } from "@/hooks/use-auth"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; +import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; import { @@ -138,8 +139,7 @@ interface HeroStripProps { * rare to see /insights at 03:00 and "Guten * Morgen" pre-5am reads odd) */ -function resolveGreetingKey(now: Date): string { - const hour = now.getHours(); +function resolveGreetingKey(hour: number): string { if (hour >= 5 && hour < 12) return "insights.heroGreetingMorning"; if (hour >= 12 && hour < 18) return "insights.heroGreetingAfternoon"; // 18:00-22:59 maps to "Good evening"; 23:00-04:59 also maps to @@ -221,7 +221,14 @@ export function HeroStrip({ // pillar from the number itself). SSR-safe + default-on, matching the // disabled-allowlist gate. const moodEnabled = useModuleEnabled("mood"); - const greetingKey = resolveGreetingKey(now ?? new Date()); + // Greeting hour in the user's configured zone, not the browser's — a + // traveller / VPN user / wrong device clock otherwise sees the wrong + // salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`). + const greetingHour = hourInTz( + now ?? new Date(), + user?.timezone ?? DEFAULT_TIMEZONE, + ); + const greetingKey = resolveGreetingKey(greetingHour); const greetingBase = t(greetingKey); const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase; const subtitle = briefing?.paragraph ?? t("insights.heroFallbackSubtitle"); diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index 881ee565e..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; @@ -273,7 +275,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), ); - const series = dailyComplianceRatesFromLedger(windowRows); + const series = dailyComplianceRatesFromLedger(windowRows, TZ); // Only the Mondays in the window produce a point — Tuesday…Sunday emit // nothing, so a 7×-denser daily denominator can never drag the rate down. @@ -319,7 +321,7 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), ); - const series = dailyComplianceRatesFromLedger(windowRows); + const series = dailyComplianceRatesFromLedger(windowRows, TZ); // Still one point per due Monday — never a point on a non-due weekday. // Five Mondays in the window; the skipped take leaves one as a miss. @@ -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/analytics/compliance.ts b/src/lib/analytics/compliance.ts index c85c11fe1..28f2a12e2 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -52,7 +52,8 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/resolver"; +import { localHmAsUtc } from "@/lib/tz/local-day"; export interface IntakeEvent { takenAt: Date | null; @@ -126,10 +127,19 @@ function parseHHmm(time: string): { hours: number; minutes: number } { } /** - * Build a Date for a given "HH:mm" on a specific date. + * Build the UTC instant of local "HH:mm" on the calendar day `day` falls on. + * + * When `tz` is supplied the window `HH:mm` is interpreted in the user's zone + * (via {@link localHmAsUtc}) on the local day of `day` — so an "08:00" slot is + * 08:00 LOCAL, not 08:00 UTC. Without `tz` it keeps the legacy UTC anchoring + * (`setUTCHours`), which is only correct for a UTC user; a caller far from UTC + * must pass its zone or the punctuality band is offset by the zone's offset. */ -function toDateOnDay(time: string, day: Date): Date { +function toDateOnDay(time: string, day: Date, tz?: string): Date { const { hours, minutes } = parseHHmm(time); + if (tz) { + return localHmAsUtc(day, tz, hours, minutes); + } const d = new Date(day); d.setUTCHours(hours, minutes, 0, 0); return d; @@ -157,18 +167,21 @@ function toDateOnDay(time: string, day: Date): Date { * @param options.lateMinutes Width of the `late` band beyond the 3-hour * on-time tolerance, in minutes. Defaults to 120. Doses past this * tail land in `very_late`. + * @param options.tz IANA zone the window `HH:mm` is interpreted in. Supply + * the user's zone so an "08:00" slot means 08:00 LOCAL; omitting it keeps + * the legacy UTC interpretation (correct only for a UTC user). */ export function classifyIntakeTiming( takenAt: Date | null, windowStart: string, // "HH:mm" windowEnd: string, // "HH:mm" scheduledDate: Date, // the date this was scheduled - options?: { lateMinutes?: number }, + options?: { lateMinutes?: number; tz?: string }, ): IntakeTimingClass { if (takenAt === null) return "missed"; - const start = toDateOnDay(windowStart, scheduledDate); - let end = toDateOnDay(windowEnd, scheduledDate); + const start = toDateOnDay(windowStart, scheduledDate, options?.tz); + let end = toDateOnDay(windowEnd, scheduledDate, options?.tz); // Handle overnight windows (e.g. windowStart="23:00", windowEnd="01:00") if (end <= start) { @@ -1967,9 +1980,16 @@ export interface DailyComplianceRate { * rows (the window hasn't opened) are dropped before grouping, exactly as the * window tally excludes them. A day whose only ledger rows are skips yields no * point (zero denominator) rather than a misleading 0%. + * + * Days are bucketed in the USER's timezone (`tz`). Passing the caller's real + * zone keeps an evening dose on the local day it was actually due: a fixed + * Berlin key filed a `20:00 America/Los_Angeles` dose (04:00 UTC next day) + * into the following Berlin day, splitting one local day's two doses across + * two buckets and skewing every per-day rate for non-Berlin users. */ export function dailyComplianceRatesFromLedger( ledgerRows: DoseHistoryRow[], + tz: string, ): DailyComplianceRate[] { const byDay = new Map< string, @@ -1987,7 +2007,7 @@ export function dailyComplianceRatesFromLedger( // counter — they never enter the day's denominator. if (!isTaken && !isMissed) continue; - const dayKey = toBerlinDayKey(row.at); + const dayKey = userDayKey(row.at, tz); const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; if (isTaken) bucket.taken += 1; else bucket.missed += 1; diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts index da736adab..2801f0613 100644 --- a/src/lib/analytics/schedule-anchored-compliance.ts +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -27,6 +27,7 @@ import { buildComplianceMedicationContext, expectedSlotCountForDay, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { userDayKey } from "@/lib/tz/resolver"; @@ -50,7 +51,9 @@ export async function buildScheduleAnchoredComplianceBuckets( const medications = await prisma.medication.findMany({ where: { userId, active: true }, include: { - schedules: true, + // v1.15.20 — the shared compliance select so a future engine column + // reaches this surface the moment it joins SCHEDULE_COMPLIANCE_SELECT. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware expected counts. scheduleRevisions: { orderBy: { validFrom: "asc" } }, // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index b4aa23537..f2daa4f1c 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -47,6 +47,7 @@ import { buildComplianceMedicationContext, lastNonSkippedTakenAt, tallyComplianceFromLedger, + SCHEDULE_COMPLIANCE_SELECT, type ComplianceSchedule, type IntakeEvent, type MedicationPauseEraLike, @@ -828,7 +829,10 @@ export async function collectDoctorReportData( prisma.medication.findMany({ where: { userId, active: true }, include: { - schedules: true, + // v1.15.20 — the shared compliance select (plus `label`, which the + // FHIR schedule mapping below needs) so a future engine column added + // to SCHEDULE_COMPLIANCE_SELECT reaches the doctor-report surface. + schedules: { select: { ...SCHEDULE_COMPLIANCE_SELECT, label: true } }, // v1.17 W1a — archived schedule eras so the ledger compliance // builder segments expected-slot expansion against the schedule // that was live on each past day (matches the detail page). diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 9cf4aca98..66c53b0ac 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -40,6 +40,7 @@ import { classifyPulse, } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; export type AuthedUser = Awaited>["user"]; @@ -270,6 +271,7 @@ function getHealthGreenDaySeries( function getOnTimePerfectDaySeries( intakeEvents: IntakeEventRecord[], schedulesByMedicationId: Map, + tz: string, ) { const eventsByDay = new Map(); @@ -285,7 +287,6 @@ function getOnTimePerfectDaySeries( for (const [dayKey, events] of eventsByDay.entries()) { if (events.length === 0) continue; - const scheduledDate = dayKeyToDate(dayKey); let isPerfectDay = true; for (const event of events) { @@ -299,9 +300,11 @@ function getOnTimePerfectDaySeries( continue; } - const eventMinutes = - event.scheduledFor.getUTCHours() * 60 + - event.scheduledFor.getUTCMinutes(); + // The scheduled minute-of-day must be read in the user's zone: the raw + // `getUTCHours()` would push a non-UTC user's on-time dose past the 3h + // grace and misread a genuinely-perfect day as imperfect. + const scheduledWall = wallClockInTz(event.scheduledFor, tz); + const eventMinutes = scheduledWall.hour * 60 + scheduledWall.minute; let bestSchedule = schedules[0]; let bestDistance = Infinity; @@ -316,11 +319,15 @@ function getOnTimePerfectDaySeries( } } + // Classify punctuality against the slot's real local day: pass the + // canonical instant as the anchor and the user's zone so the window + // `HH:mm` resolves to the correct local wall time, not 08:00 UTC. const timing = classifyIntakeTiming( event.takenAt, bestSchedule.windowStart, bestSchedule.windowEnd, - scheduledDate, + event.scheduledFor, + { tz }, ); // v1.4.34 IW-C — `early` is also a compliant bucket (dose taken @@ -722,6 +729,7 @@ export async function buildAchievementsResult( const onTimeSeries = getOnTimePerfectDaySeries( intakeEvents, schedulesByMedicationId, + user.timezone, ); const complianceSeries = getCompliance80DaySeries( intakeEvents, diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 32f3bc70d..03f4bf3f8 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -297,12 +297,13 @@ export async function prepareMedicationComplianceStatusForUser( // calendar day as fully dose-due — which made a weekly-cadence med's // per-day series contradict its own compliance30. Reusing the canonical // ledger fixes that by construction. - const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map( - (day) => ({ - measuredAt: day.date, - value: round(day.rate, 1), - }), - ); + const perDayRecords = dailyComplianceRatesFromLedger( + bundle.ledgerRows, + userTz, + ).map((day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + })); // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. 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. diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index afa740d0e..36ad8cc95 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -191,6 +191,18 @@ export function userDayKey(date: Date, tz: string): string { return formatInUserTz(date, tz, "date"); } +/** + * Wall-clock hour (0–23) an observer in `tz` reads off the clock at + * `date`. Used for the time-of-day greeting so a traveller whose device + * clock differs from their configured HealthLog zone still sees the right + * salutation. Falls back to the UTC hour if the zone is unusable. + */ +export function hourInTz(date: Date, tz: string): number { + const safeTz = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; + const parsed = parseInt(wallClockParts(date, safeTz).hour, 10); + return Number.isFinite(parsed) ? parsed % 24 : date.getUTCHours(); +} + interface WallClockParts { year: string; month: string; diff --git a/src/lib/tz/local-day.ts b/src/lib/tz/local-day.ts index 42efc54eb..76cf81573 100644 --- a/src/lib/tz/local-day.ts +++ b/src/lib/tz/local-day.ts @@ -13,38 +13,30 @@ import { wallClockInTz } from "./wall-clock"; /** * Get the start (midnight) and end (23:59:59.999) of "today" in the user's * timezone, returned as UTC Date objects suitable for database queries. + * + * DST-safe. Both bounds route through {@link startOfLocalDayInTz}, whose + * two-pass solver settles the zone offset AT the target local midnight — + * not at `now`. The window therefore spans the user's real local day even + * when it is 23 h (spring-forward) or 25 h (fall-back): the previous + * implementation sampled the offset at `now` and hardcoded a 24 h length, + * so on a fall-back day the last local hour (e.g. a 23:30 dose) fell + * OUTSIDE `[midnight, midnight+24h)` and on a spring-forward day the window + * bled one hour into the next local day. */ export function getUserTodayBounds( now: Date, tz: string, ): { start: Date; end: Date } { - const parts = wallClockInTz(now, tz); - - // Build a UTC date that represents the user's local midnight - // by computing the offset between the real UTC time and the local - // representation. - const localMidnightAsUtc = new Date( - Date.UTC(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0), + const start = startOfLocalDayInTz(now, tz); + // Start of the NEXT local day. Advancing 25 h always crosses into the next + // calendar day (a real day is 23–25 h, never ≤ 12.5 h, so 25 h can neither + // fall short of the next day nor skip past it), then floor back to that + // day's local midnight. `end` is the inclusive last millisecond of today. + const nextDayStart = startOfLocalDayInTz( + new Date(start.getTime() + 25 * 60 * 60 * 1000), + tz, ); - - // Compute offset: how far ahead (positive) or behind (negative) the tz is - // from UTC. localMidnightAsUtc represents "midnight in tz as if it were - // UTC"; we need the UTC instant that corresponds to midnight in the tz. - const localNowAsUtc = new Date( - Date.UTC( - parts.year, - parts.month - 1, - parts.day, - parts.hour, - parts.minute, - parts.second, - ), - ); - const offsetMs = - Math.round((localNowAsUtc.getTime() - now.getTime()) / 60000) * 60000; - - const start = new Date(localMidnightAsUtc.getTime() - offsetMs); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1); + const end = new Date(nextDayStart.getTime() - 1); return { start, end }; } diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index 370dfc449..a51feb7a8 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -48,6 +48,7 @@ export { detectBrowserTimezone, formatInUserTz, userDayKey, + hourInTz, type FormatInUserTzShape, } from "./format";