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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:38:48 +02:00
1 parent 6ad2dc858a
commit fb6813bcdb
10 files changed
+99 -51

No files matched your search

+10 -3
View File
@@ -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");
@@ -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.
+27 -7
View File
@@ -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;
@@ -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.
+5 -1
View File
@@ -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).
+13 -5
View File
@@ -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<ReturnType<typeof requireAuth>>["user"];
@@ -270,6 +271,7 @@ function getHealthGreenDaySeries(
function getOnTimePerfectDaySeries(
intakeEvents: IntakeEventRecord[],
schedulesByMedicationId: Map<string, MedicationScheduleRecord[]>,
tz: string,
) {
const eventsByDay = new Map<string, IntakeEventRecord[]>();
@@ -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,
@@ -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.
+12
View File
@@ -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;
+18 -26
View File
@@ -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 };
}
+1
View File
@@ -48,6 +48,7 @@ export {
detectBrowserTimezone,
formatInUserTz,
userDayKey,
hourInTz,
type FormatInUserTzShape,
} from "./format";