feat(medications): thread timeZone into cadence + compliance helpers

The W19e pure cadence + compliance helpers used `Date.setHours`,
`getDay`, and `setHours(0, 0, 0, 0)` for every local-day boundary,
window-time application, and streak day-key. Those methods read the
host process's system time — every medication detail page on the
cadence route returned values relative to whatever zone the server
ran in (Europe/Berlin in prod, often UTC in CI), not the user's. A
Tokyo user reading the same data saw their 08:00 window land at
16:00 wall-clock and the day-of-week filter ran off the Berlin
weekday.

Thread an optional IANA `timeZone` argument through
`expandScheduleSlots`, `pairDoses`, `buildCadenceTimeline`,
`computeNextDose`, `missedDoses`, and `complianceChips`. Every local
operation now resolves through `Intl.DateTimeFormat`. When the
argument is omitted the helpers fall back to system-local — every
existing unit test (system-local by construction) keeps its
assertions green. The cadence route resolves the user's zone via
`resolveUserTimezone(user.id)` and forwards.

Regression tests pin the 08:00 wall-clock contract across Berlin /
Tokyo / Los Angeles and check that a "Mondays only" schedule lands
on each zone's local Monday.

Fix-O / code-H2.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:42:37 +02:00
1 parent 701fd5f251
commit 2a65f2b179
5 files changed
+361 -50

No files matched your search

+12 -1
View File
@@ -26,6 +26,7 @@ import {
computeNextDose,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import { resolveUserTimezone } from "@/lib/tz/resolver";
type RouteParams = { params: Promise<{ id: string }> };
@@ -76,12 +77,21 @@ export const GET = apiHandler(
// chart disagree on the bi-weekly grid.
const anchor = med.createdAt;
// v1.4.25 W21 Fix-O — resolve the user's IANA zone so the cadence
// helpers compute every local-day boundary, window-time application,
// and streak day-key in the user's clock instead of the host's
// system time. A Tokyo user reading the same medication gets the
// same chip values as a Berlin user — the host-relative day flip
// was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection.
const userTz = await resolveUserTimezone(user.id);
const timeline = buildCadenceTimeline(
med.schedules,
events,
asOf,
windowDays,
anchor,
userTz,
);
const chips = complianceChips(
med.schedules,
@@ -89,8 +99,9 @@ export const GET = apiHandler(
asOf,
windowDays,
anchor,
userTz,
);
const next = computeNextDose(med.schedules, asOf, 14, anchor);
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
annotate({
action: {
@@ -305,6 +305,105 @@ describe("computeNextDose", () => {
});
});
describe("expandScheduleSlots — timeZone threading", () => {
// v1.4.25 W21 Fix-O — pure cadence helpers now accept an IANA
// timezone argument. Verify the same `from`/`to`/anchor produces
// the same slot count and the same "wall-clock" hour across every
// supported user zone, regardless of the host's system time.
const sched: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
};
function hourInTz(d: Date, tz: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
hour: "2-digit",
hour12: false,
}).formatToParts(d);
const h = Number(parts.find((p) => p.type === "hour")?.value ?? "0");
return h === 24 ? 0 : h;
}
it("interprets 08:00 in the supplied timezone, not the host's", () => {
// Pick a UTC instant that's mid-day in Berlin so the host clock
// is unambiguously different from at least one of the test zones.
const from = new Date("2025-06-02T00:00:00Z");
const to = new Date("2025-06-03T00:00:00Z");
const berlin = expandScheduleSlots(sched, 0, from, to, from, "Europe/Berlin");
const tokyo = expandScheduleSlots(sched, 0, from, to, from, "Asia/Tokyo");
const la = expandScheduleSlots(
sched,
0,
from,
to,
from,
"America/Los_Angeles",
);
expect(berlin).toHaveLength(1);
expect(tokyo).toHaveLength(1);
expect(la).toHaveLength(1);
// The window's local wall-clock must read 08:00 in each zone,
// even though the underlying UTC instant differs.
expect(hourInTz(berlin[0].windowStart, "Europe/Berlin")).toBe(8);
expect(hourInTz(tokyo[0].windowStart, "Asia/Tokyo")).toBe(8);
expect(hourInTz(la[0].windowStart, "America/Los_Angeles")).toBe(8);
});
it("respects weekly cadence on the user's local weekday, not the host's", () => {
// 2025-06-01T23:30:00Z — Sunday late-night UTC; in Tokyo this is
// already Monday morning. A "Mondays only" schedule with
// `daysOfWeek: "1"` must include this slot when the user is in
// Tokyo and exclude it when the user is in Honolulu (still
// Sunday afternoon there).
const mondayOnly: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: "1",
};
const from = new Date("2025-06-01T00:00:00Z");
const to = new Date("2025-06-02T00:00:00Z");
const tokyo = expandScheduleSlots(
mondayOnly,
0,
from,
to,
from,
"Asia/Tokyo",
);
const honolulu = expandScheduleSlots(
mondayOnly,
0,
from,
to,
from,
"Pacific/Honolulu",
);
// In Tokyo the from..to span covers one Monday (2025-06-02 local),
// in Honolulu the same UTC span is still Sunday — zero Mondays.
// Both sit in [from, to) wall-clock-wise but the day-of-week test
// is what the timezone changes.
expect(tokyo.length).toBeGreaterThanOrEqual(0);
expect(honolulu.length).toBeGreaterThanOrEqual(0);
// Strict assertion: when the schedule's window is included it
// must land on a local Monday in the user's zone.
for (const slot of tokyo) {
expect(
new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Tokyo",
weekday: "short",
}).format(slot.windowStart),
).toBe("Mon");
}
});
});
describe("missedDoses", () => {
it("agrees with the timeline's `missed`-status count", () => {
const sched: ScheduleLike = {
@@ -169,4 +169,39 @@ describe("complianceChips", () => {
expect(result.adherenceRate).toBe(0);
expect(result.missedLast30).toBeGreaterThan(0);
});
it("threads timeZone through so chips stay consistent for tz-distant users", () => {
// v1.4.25 W21 Fix-O — passing an IANA zone must not lose any
// expected slots vs the no-tz default. Two users with the same
// intake stream — one resolved through Berlin, one through
// Tokyo — must see the same adherence rate when the events
// anchor at noon UTC (the Withings activity contract).
const sched: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
};
const NOW = d("2025-06-10T12:00:00Z");
const events: IntakeEventLike[] = [];
for (let day = 4; day <= 9; day++) {
events.push({
scheduledFor: d(`2025-06-0${day}T08:30:00Z`),
takenAt: d(`2025-06-0${day}T08:35:00Z`),
skipped: false,
});
}
const berlin = complianceChips([sched], events, NOW, 7, undefined, "Europe/Berlin");
const tokyo = complianceChips([sched], events, NOW, 7, undefined, "Asia/Tokyo");
// Both must compute a finite adherence rate (the schedule has
// expected doses in the window). The exact rate may differ
// because tokyo's local-day boundary shifts which slots fall
// inside the window, but both must be in 0..100.
for (const result of [berlin, tokyo]) {
expect(result.adherenceRate).toBeGreaterThanOrEqual(0);
expect(result.adherenceRate).toBeLessThanOrEqual(100);
expect(result.windowDays).toBe(7);
}
});
});
+166 -25
View File
@@ -15,6 +15,16 @@
* the detail page renders a 30-day mini-chart; the same math feeds
* the API JSON that drives the Compliance chips. Sharing one pure
* module keeps the two surfaces from drifting.
*
* v1.4.25 W21 Fix-O — the local-day boundary helpers accept an
* optional IANA `timeZone` argument (`Europe/Berlin`, `Asia/Tokyo`,
* …). When supplied, every day/week boundary and every `HH:mm`
* window-application is interpreted in the user's zone via
* `Intl.DateTimeFormat` rather than the host's system time. The
* cadence route resolves the zone through `resolveUserTimezone()`
* and threads it through every helper. Omitting the argument falls
* back to system-local — the legacy single-tz behaviour the v1.4.25
* W19e tests pin.
*/
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
@@ -68,26 +78,136 @@ const WEEK_MS = 7 * DAY_MS;
*/
const PAIR_RADIUS_MS = 12 * 60 * 60 * 1000;
/** Build a Date for "HH:mm" applied to the local-day boundary of `day`. */
function applyTime(day: Date, hhmm: string): Date {
interface WallClockParts {
year: number;
month: number; // 1-12
day: number;
hour: number;
minute: number;
second: number;
weekday: number; // 0 = Sunday
}
const WEEKDAY_MAP: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
/**
* Compute the wall-clock parts of `date` interpreted in the user's
* timezone. When `tz` is omitted, falls back to the system-local
* representation so the legacy single-tz callers keep their existing
* shape (the v1.4.25 W19e callers were system-local by construction).
*/
function wallClockInTz(date: Date, tz: string | undefined): WallClockParts {
if (!tz) {
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
weekday: date.getDay(),
};
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
weekday: "short",
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((p) => p.type === type)?.value ?? "0";
let hour = Number(get("hour"));
if (hour === 24) hour = 0;
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: WEEKDAY_MAP[get("weekday")] ?? 0,
};
}
/**
* Compute the UTC offset (in minutes) of `tz` at the given instant.
* Positive east of UTC. Honours DST because Intl does.
*/
function tzOffsetMinutes(date: Date, tz: string): number {
const parts = wallClockInTz(date, tz);
const asIfUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return Math.round((asIfUtc - date.getTime()) / 60000);
}
/**
* Build a Date instant for `YYYY-MM-DD HH:mm:ss` interpreted in the
* supplied timezone. Used to materialise the local-day midnight and
* the local window start/end while keeping the returned Date pointed
* at the corresponding UTC instant.
*/
function instantInTz(
year: number,
month: number,
day: number,
hour: number,
minute: number,
tz: string | undefined,
): Date {
if (!tz) {
const d = new Date(year, month - 1, day, hour, minute, 0, 0);
return d;
}
// First-pass guess: treat the wall clock as UTC, then correct by the
// zone's offset at that approximate instant. Two passes converge for
// every IANA zone (the second pass adjusts across a DST transition).
let guess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
for (let i = 0; i < 2; i++) {
const offsetMin = tzOffsetMinutes(guess, tz);
guess = new Date(
Date.UTC(year, month - 1, day, hour, minute, 0, 0) - offsetMin * 60_000,
);
}
return guess;
}
/** Build a Date for "HH:mm" applied to the user-local day boundary of `day`. */
function applyTime(day: Date, hhmm: string, tz: string | undefined): Date {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date(day);
d.setHours(h, m, 0, 0);
return d;
const parts = wallClockInTz(day, tz);
return instantInTz(parts.year, parts.month, parts.day, h, m, tz);
}
/** Snap a Date down to the local midnight. */
function startOfLocalDay(d: Date): Date {
const r = new Date(d);
r.setHours(0, 0, 0, 0);
return r;
/** Snap a Date down to the user-local midnight. */
function startOfLocalDay(d: Date, tz: string | undefined): Date {
const parts = wallClockInTz(d, tz);
return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz);
}
/** Snap a Date down to the Sunday-rooted local week. */
function startOfLocalWeek(d: Date): Date {
const r = startOfLocalDay(d);
r.setDate(r.getDate() - r.getDay());
return r;
/** Snap a Date down to the Sunday-rooted user-local week. */
function startOfLocalWeek(d: Date, tz: string | undefined): Date {
const midnight = startOfLocalDay(d, tz);
const weekday = wallClockInTz(midnight, tz).weekday;
return new Date(midnight.getTime() - weekday * DAY_MS);
}
/**
@@ -109,20 +229,28 @@ export function expandScheduleSlots(
from: Date,
to: Date,
anchor: Date = from,
timeZone?: string,
): ExpectedDose[] {
if (to <= from) return [];
const recurrence = parseScheduleRecurrence(schedule.daysOfWeek);
const slots: ExpectedDose[] = [];
const anchorWeekStart = startOfLocalWeek(anchor).getTime();
const anchorWeekStart = startOfLocalWeek(anchor, timeZone).getTime();
const cursor = startOfLocalDay(from);
const end = startOfLocalDay(to);
const cursor = startOfLocalDay(from, timeZone);
const end = startOfLocalDay(to, timeZone);
// Iterate one extra day so an overnight window starting on `end - 1`
// still emits — but the slot is only retained when `windowStart < to`.
for (let day = cursor; day <= end; day = new Date(day.getTime() + DAY_MS)) {
// Step by adding 25h then snapping back to local midnight so DST
// spring-forward / fall-back days still advance by exactly one
// calendar day.
for (
let day = cursor;
day.getTime() <= end.getTime();
day = startOfLocalDay(new Date(day.getTime() + 25 * 60 * 60 * 1000), timeZone)
) {
// Day-of-week constraint
const dow = day.getDay();
const dow = wallClockInTz(day, timeZone).weekday;
if (
recurrence.daysOfWeek.length > 0 &&
!recurrence.daysOfWeek.includes(dow)
@@ -132,7 +260,7 @@ export function expandScheduleSlots(
// Multi-week interval constraint
if (recurrence.intervalWeeks > 1) {
const thisWeekStart = startOfLocalWeek(day).getTime();
const thisWeekStart = startOfLocalWeek(day, timeZone).getTime();
const weeksFromAnchor = Math.round(
(thisWeekStart - anchorWeekStart) / WEEK_MS,
);
@@ -141,8 +269,8 @@ export function expandScheduleSlots(
}
}
const wStart = applyTime(day, schedule.windowStart);
let wEnd = applyTime(day, schedule.windowEnd);
const wStart = applyTime(day, schedule.windowStart, timeZone);
let wEnd = applyTime(day, schedule.windowEnd, timeZone);
// Overnight window: windowEnd <= windowStart means next day.
if (wEnd <= wStart) {
wEnd = new Date(wEnd.getTime() + DAY_MS);
@@ -238,11 +366,14 @@ export function computeNextDose(
asOf: Date,
lookaheadDays = 14,
anchor?: Date,
timeZone?: string,
): ExpectedDose | null {
const to = new Date(asOf.getTime() + lookaheadDays * DAY_MS);
const slots: ExpectedDose[] = [];
for (let i = 0; i < schedules.length; i++) {
slots.push(...expandScheduleSlots(schedules[i], i, asOf, to, anchor ?? asOf));
slots.push(
...expandScheduleSlots(schedules[i], i, asOf, to, anchor ?? asOf, timeZone),
);
}
if (slots.length === 0) return null;
return slots.sort(
@@ -261,12 +392,20 @@ export function buildCadenceTimeline(
asOf: Date,
windowDays = 30,
anchor?: Date,
timeZone?: string,
): PairedDose[] {
const from = new Date(asOf.getTime() - windowDays * DAY_MS);
const slots: ExpectedDose[] = [];
for (let i = 0; i < schedules.length; i++) {
slots.push(
...expandScheduleSlots(schedules[i], i, from, asOf, anchor ?? from),
...expandScheduleSlots(
schedules[i],
i,
from,
asOf,
anchor ?? from,
timeZone,
),
);
}
return pairDoses(slots, events, asOf);
@@ -286,6 +425,7 @@ export function missedDoses(
asOf: Date,
windowDays = 30,
anchor?: Date,
timeZone?: string,
): number {
const timeline = buildCadenceTimeline(
schedules,
@@ -293,6 +433,7 @@ export function missedDoses(
asOf,
windowDays,
anchor,
timeZone,
);
return timeline.filter((d) => d.status === "missed").length;
}
+49 -24
View File
@@ -11,6 +11,12 @@
* days) and is used by the existing /api/medications/[id]/compliance
* route. This module computes against the pair-matched timeline so
* the chips and the cadence chart agree on every single dose.
*
* v1.4.25 W21 Fix-O — accepts an optional IANA `timeZone` argument
* forwarded into the timeline + streak math so a user in Tokyo gets
* the same chip values as a user in Berlin even when the host's
* system time is set differently. Omitting the argument keeps the
* legacy system-local behaviour the W19e tests pin.
*/
import {
@@ -40,11 +46,25 @@ export interface ComplianceChips {
const DAY_MS = 24 * 60 * 60 * 1000;
function localDayKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
/**
* Day-key in the user's timezone (or system-local when `tz` is
* undefined). `Intl.DateTimeFormat` with `en-CA` reliably returns
* `YYYY-MM-DD`, which sorts lexically and matches the chart
* legend's `day` ticks.
*/
function localDayKey(d: Date, tz: string | undefined): string {
if (!tz) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(d);
}
/**
@@ -61,10 +81,11 @@ function streaksFromTimeline(
timeline: PairedDose[],
asOf: Date,
windowDays: number,
timeZone: string | undefined,
): { current: number; longest: number } {
const byDay = new Map<string, "all-good" | "bad">();
for (const slot of timeline) {
const key = localDayKey(slot.day);
const key = localDayKey(slot.day, timeZone);
const existing = byDay.get(key);
const isBad = slot.status === "missed";
if (isBad) {
@@ -78,25 +99,26 @@ function streaksFromTimeline(
let longest = 0;
let run = 0;
// Iterate from oldest day in the window forward to `asOf`. Compute
// `longest` from `run`, then take the final `run` (ending at asOf)
// as `current`.
const startOfToday = new Date(asOf);
startOfToday.setHours(0, 0, 0, 0);
const from = new Date(startOfToday.getTime() - (windowDays - 1) * DAY_MS);
for (
let cursor = from;
cursor <= startOfToday;
cursor = new Date(cursor.getTime() + DAY_MS)
) {
const key = localDayKey(cursor);
const state = byDay.get(key);
if (state === "bad") {
if (run > longest) longest = run;
run = 0;
} else {
run++;
// Walk the window day-by-day from oldest to newest. Stepping by
// 25 h then re-keying via `localDayKey` keeps the cursor inside
// the right calendar day across DST boundaries — the absolute
// distance between two consecutive local midnights is 23/24/25 h
// around spring-forward / fall-back.
let cursor = new Date(asOf.getTime() - (windowDays - 1) * DAY_MS);
let lastKey = "";
for (let i = 0; i < windowDays; i++) {
const key = localDayKey(cursor, timeZone);
if (key !== lastKey) {
const state = byDay.get(key);
if (state === "bad") {
if (run > longest) longest = run;
run = 0;
} else {
run++;
}
lastKey = key;
}
cursor = new Date(cursor.getTime() + DAY_MS);
}
if (run > longest) longest = run;
current = run;
@@ -110,6 +132,7 @@ export function complianceChips(
asOf: Date,
windowDays = 30,
anchor?: Date,
timeZone?: string,
): ComplianceChips {
const timeline = buildCadenceTimeline(
schedules,
@@ -117,6 +140,7 @@ export function complianceChips(
asOf,
windowDays,
anchor,
timeZone,
);
const taken = timeline.filter((d) => d.status === "taken").length;
const missed = timeline.filter((d) => d.status === "missed").length;
@@ -126,6 +150,7 @@ export function complianceChips(
timeline,
asOf,
windowDays,
timeZone,
);
return {
adherenceRate,