refactor(compliance): require the zone a dose window is read in

The UTC reading of a window's HH:mm was right only for a UTC user, and the
one production caller always passes the zone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:58:03 +02:00
1 parent 2f32a4458c
commit d91eb905fe
2 files changed
+80 -58

No files matched your search

@@ -1229,9 +1229,11 @@ describe("classifyIntakeTiming", () => {
const scheduledDate = new Date("2025-01-15T00:00:00Z");
it('returns "missed" when takenAt is null', () => {
expect(classifyIntakeTiming(null, "08:00", "09:00", scheduledDate)).toBe(
"missed",
);
expect(
classifyIntakeTiming(null, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe("missed");
});
// A degenerate POINT window (`windowStart === windowEnd`) is a real shape on
@@ -1243,14 +1245,18 @@ describe("classifyIntakeTiming", () => {
it('keeps a dose at the point itself "on_time"', () => {
const takenAt = new Date("2025-01-15T07:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
it('does not classify a dose ~16h late as "on_time"', () => {
const takenAt = new Date("2025-01-15T23:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, {
tz: "UTC",
}),
).toBe("very_late");
});
@@ -1258,7 +1264,9 @@ describe("classifyIntakeTiming", () => {
// 13:00 is 6h past the point — outside the widened band + graces.
const takenAt = new Date("2025-01-15T13:00:00Z");
expect(
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate),
classifyIntakeTiming(takenAt, "07:00", "07:00", scheduledDate, {
tz: "UTC",
}),
).not.toBe("on_time");
});
@@ -1266,30 +1274,38 @@ describe("classifyIntakeTiming", () => {
// 00:30 the next day sits inside a 23:00 -> 01:00 window.
const takenAt = new Date("2025-01-16T00:30:00Z");
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate),
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
});
it('returns "on_time" when taken within the window', () => {
const takenAt = new Date("2025-01-15T08:30:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
it('returns "on_time" when taken exactly at windowStart', () => {
const takenAt = new Date("2025-01-15T08:00:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
it('returns "on_time" when taken exactly at windowEnd', () => {
const takenAt = new Date("2025-01-15T09:00:00Z");
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"on_time",
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
// Parameterised offset matrix relative to a `08:00 → 09:00` window.
@@ -1319,16 +1335,20 @@ describe("classifyIntakeTiming", () => {
// base instant is 08:00Z; positive offsets walk forward from there.
const baseMs = new Date("2025-01-15T08:00:00Z").getTime();
const takenAt = new Date(baseMs + offsetMin * 60 * 1000);
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
expected,
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe(expected);
});
it('returns "very_late" when taken way before the grace period', () => {
const takenAt = new Date("2025-01-15T03:00:00Z"); // 5h before 08:00
expect(classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate)).toBe(
"very_late",
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", scheduledDate, {
tz: "UTC",
}),
).toBe("very_late");
});
it("respects the configurable `lateMinutes` tail", () => {
@@ -1339,6 +1359,7 @@ describe("classifyIntakeTiming", () => {
const takenLate = new Date("2025-01-15T12:15:00Z");
expect(
classifyIntakeTiming(takenLate, "08:00", "09:00", scheduledDate, {
tz: "UTC",
lateMinutes: 30,
}),
).toBe("late");
@@ -1346,6 +1367,7 @@ describe("classifyIntakeTiming", () => {
const takenVeryLate = new Date("2025-01-15T12:45:00Z");
expect(
classifyIntakeTiming(takenVeryLate, "08:00", "09:00", scheduledDate, {
tz: "UTC",
lateMinutes: 30,
}),
).toBe("very_late");
@@ -1354,33 +1376,41 @@ describe("classifyIntakeTiming", () => {
it("handles overnight windows (windowEnd < windowStart)", () => {
// Schedule: 23:00 - 01:00 (overnight)
const takenAt = new Date("2025-01-15T23:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"on_time",
);
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, {
tz: "UTC",
}),
).toBe("on_time");
});
it("handles overnight window early intake", () => {
// Schedule: 23:00 - 01:00, taken at 22:30 (30 min before windowStart)
const takenAt = new Date("2025-01-15T22:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"early",
);
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, {
tz: "UTC",
}),
).toBe("early");
});
it("handles overnight window late intake", () => {
// Schedule: 23:00 - 01:00, taken at 04:30 (3.5h past 01:00)
const takenAt = new Date("2025-01-16T04:30:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"late",
);
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, {
tz: "UTC",
}),
).toBe("late");
});
it("handles overnight window very late intake", () => {
// Schedule: 23:00 - 01:00, taken at 07:00 (6h past 01:00)
const takenAt = new Date("2025-01-16T07:00:00Z");
expect(classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate)).toBe(
"very_late",
);
expect(
classifyIntakeTiming(takenAt, "23:00", "01:00", scheduledDate, {
tz: "UTC",
}),
).toBe("very_late");
});
it("interprets the window HH:mm in the supplied tz (non-UTC user)", () => {
@@ -1400,9 +1430,11 @@ describe("classifyIntakeTiming", () => {
// 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",
);
expect(
classifyIntakeTiming(takenAt, "08:00", "09:00", slotInstant, {
tz: "UTC",
}),
).toBe("very_late");
});
});
+11 -21
View File
@@ -16,22 +16,12 @@ function parseHHmm(time: string): { hours: number; minutes: number } {
}
/**
* 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.
* Build the UTC instant of local "HH:mm" in `tz` on the local day of `day`
* (via {@link localHmAsUtc}), so an "08:00" slot is 08:00 LOCAL.
*/
function toDateOnDay(time: string, day: Date, tz?: string): 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;
return localHmAsUtc(day, tz, hours, minutes);
}
/**
@@ -56,21 +46,21 @@ function toDateOnDay(time: string, day: Date, tz?: string): 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).
* @param options.tz IANA zone the window `HH:mm` is interpreted in, so an
* "08:00" slot means 08:00 LOCAL. Required: a UTC fallback was right only
* for a UTC user and no caller relied on it.
*/
export function classifyIntakeTiming(
takenAt: Date | null,
windowStart: string, // "HH:mm"
windowEnd: string, // "HH:mm"
scheduledDate: Date, // the date this was scheduled
options?: { lateMinutes?: number; tz?: string },
options: { lateMinutes?: number; tz: string },
): IntakeTimingClass {
if (takenAt === null) return "missed";
let start = toDateOnDay(windowStart, scheduledDate, options?.tz);
let end = toDateOnDay(windowEnd, scheduledDate, options?.tz);
let start = toDateOnDay(windowStart, scheduledDate, options.tz);
let end = toDateOnDay(windowEnd, scheduledDate, options.tz);
// A degenerate POINT window (`windowStart === windowEnd` — a single dose time
// echoed into both fields, a real shape on legacy rows) used to fall into the
@@ -94,7 +84,7 @@ export function classifyIntakeTiming(
// 3h post-window grace: doses up to here are still `on_time`.
const onTimeEnd = new Date(end.getTime() + 3 * HOUR_MS);
// Configurable `late` tail past the on-time grace (default 120 min).
const lateTolerance = (options?.lateMinutes ?? 120) * 60 * 1000;
const lateTolerance = (options.lateMinutes ?? 120) * 60 * 1000;
const lateEnd = new Date(onTimeEnd.getTime() + lateTolerance);
const t = takenAt.getTime();