From fb6813bcdbd23102f75ed194035a1b07b1ce7e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 8 Jul 2026 23:38:48 +0200 Subject: [PATCH] fix(medications): bucket the local day in the user's zone, DST-safe Five timezone-correctness fixes across the medication / compliance surfaces: - getUserTodayBounds now derives both bounds through the DST-safe startOfLocalDayInTz, so the today window is 23h on spring-forward and 25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30 local dose no longer drops off the med cards, dashboard, PWA badge and reminder idempotency near midnight on a transition day. - dailyComplianceRatesFromLedger takes the user's zone and buckets each per-day rate with userDayKey, so a non-Berlin user's evening dose is filed on the local day it was due rather than the next Berlin day. - classifyIntakeTiming interprets the window HH:mm in an optional zone; the achievements perfect-day check passes the user's zone and reads the scheduled minute-of-day in-zone, so a dose far from UTC is graded against local wall time. - The /insights greeting resolves the hour in the user's stored zone via the new hourInTz helper, matching the dashboard hero. - The doctor-report and schedule-anchored compliance queries load schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad include, so a future engine column reaches both surfaces. --- src/components/insights/hero-strip.tsx | 13 ++++-- .../__tests__/compliance-bundle.test.ts | 4 +- src/lib/analytics/compliance.ts | 34 +++++++++++--- .../analytics/schedule-anchored-compliance.ts | 5 ++- src/lib/doctor-report-data.ts | 6 ++- src/lib/gamification/achievements-result.ts | 18 +++++--- .../insights/medication-compliance-status.ts | 13 +++--- src/lib/tz/format.ts | 12 +++++ src/lib/tz/local-day.ts | 44 ++++++++----------- src/lib/tz/resolver.ts | 1 + 10 files changed, 99 insertions(+), 51 deletions(-) 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..f39030849 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -273,7 +273,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 +319,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. 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/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";