fix(compliance): compute medication streak in the user's timezone

The analytics compliance streak keyed days with host-local
getFullYear/getMonth/getDate against a timeline whose slot.day is minted
in the user's IANA zone, so the streak drifted off by a day whenever the
server clock's zone differed from the user's. Consolidate onto the
detail-page streaksFromTimeline helper threaded with the medication's
timezone so both call sites share one correct day-keyed walk.

Also fix the DST step in that helper: it stepped a fixed +24h while its
comment claimed 25h, which could skip a local day on the 23h
spring-forward boundary and run the window short. Snap each cursor to
the user-local midnight and advance +25h then re-snap, mirroring the
cadence expander, so every local calendar day is visited exactly once
across 23/24/25h boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 13:51:54 +02:00
1 parent 3b9f95e0b7
commit 037da560ad
5 files changed
+167 -49

No files matched your search

@@ -785,6 +785,44 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.taken).toBe(2);
expect(result.rate).toBe(100);
});
it("streak is computed in the USER's timezone, not the host's", () => {
// The timeline `slot.day` is minted in the user's IANA zone. The
// streak walk must key days in the SAME zone — the prior host-tz
// `getFullYear/getMonth/getDate` walk drifted off by a day whenever
// the server clock's zone differed from the user's. Use a far-east
// user zone (UTC+13) so the user-local day for a near-UTC-midnight
// intake clearly differs from the host/UTC day; a daily-taken stream
// must still yield an unbroken streak.
const tz = "Pacific/Auckland";
vi.setSystemTime(new Date("2025-06-10T11:00:00Z")); // ~23:00 NZST.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
},
];
// Taken near 08:00 Auckland (= ~20:00 UTC previous day) for each of
// the past 7 user-local days.
const events = Array.from({ length: 7 }, (_, i) => {
const sched = new Date(
new Date("2025-06-09T20:00:00Z").getTime() - i * DAY_MS,
);
return { scheduledFor: sched, takenAt: sched, skipped: false };
});
const result = calculateCompliance(events, schedules, 7, undefined, {
medicationContext: ctx({
timeZone: tz,
createdAt: new Date("2025-05-01T00:00:00Z"),
}),
});
// Every user-local day in the window was taken → a multi-day streak.
// The host-tz walk would have mis-keyed the boundary days and broken
// the streak early; the user-tz walk counts them all.
expect(result.streak).toBeGreaterThanOrEqual(6);
});
});
// ────────────────────────────────────────────────────────────────────
+19 -27
View File
@@ -25,6 +25,7 @@ import {
type IntakeEventLike,
type ScheduleLike,
} from "@/lib/medications/scheduling/cadence";
import { streaksFromTimeline } from "@/lib/medications/scheduling/compliance";
import {
occurrencesBetween,
type CanonicalSchedule,
@@ -694,33 +695,24 @@ export function calculateCompliance(
// Streak: consecutive days, ending today, where every expected dose
// was taken or skipped. Days with no expected dose advance the
// streak — out-of-cadence days are not failures. Walks from today
// backwards through the timeline grouped by local day.
const dayKey = (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}`;
};
const byDay = new Map<string, "all-good" | "bad">();
for (const slot of timeline) {
const key = dayKey(slot.day);
const existing = byDay.get(key);
if (slot.status === "missed") {
byDay.set(key, "bad");
} else if (existing !== "bad") {
byDay.set(key, "all-good");
}
}
let streak = 0;
for (let d = 0; d < effectiveDays; d++) {
const cursor = new Date(now.getTime() - d * DAY_MS);
if (medicationCreatedAt && cursor <= medicationCreatedAt) break;
const state = byDay.get(dayKey(cursor));
if (state === "bad") break;
if (state === "all-good") streak++;
// No state = no expected dose that day → streak advances silently.
}
// streak — out-of-cadence days are not failures. Delegated to the
// shared `streaksFromTimeline` so the analytics streak and the
// detail-page chip streak agree on every dose AND so the day keys are
// computed in the USER's IANA timezone, not the host's. The prior
// host-tz `getFullYear/getMonth/getDate` walk drifted off by a day
// whenever the server clock's zone differed from the user's — the
// timeline `slot.day` is already minted in the user zone, so the
// walk has to match it. The window is `effectiveDays` ending at
// `now`, which starts no earlier than `effectiveStart` (= the later
// of the period start and the medication's creation) — so days
// before the medication existed are never iterated, preserving the
// old `cursor <= medicationCreatedAt` break by construction.
const { current: streak } = streaksFromTimeline(
timeline,
now,
effectiveDays,
engineCtx?.timeZone,
);
return { totalExpected, taken, skipped, missed, rate, streak };
}
@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from "vitest";
import { complianceChips } from "../compliance";
import { complianceChips, streaksFromTimeline } from "../compliance";
import { buildCadenceTimeline } from "../cadence";
import type {
CadenceEngineContext,
@@ -357,3 +357,76 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
});
});
describe("streaksFromTimeline — DST-correct day walk", () => {
const DAILY_8AM: ScheduleLike = {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
};
it("does not drop a day across a spring-forward (23h) boundary", () => {
// Europe/Berlin spring-forward is 2025-03-30 (23h-wide local day).
// Take every dose for a window spanning the transition; the streak
// must count every local calendar day, not skip the 23h day. The
// prior fixed +24h step landed past the 23h day and the windowDays
// iteration ran short.
const tz = "Europe/Berlin";
// NOW = local noon on 2025-04-02 (CEST, UTC+2 → 10:00 UTC).
const NOW = new Date("2025-04-02T10:00:00Z");
const windowDays = 7; // covers 2025-03-27 .. 2025-04-02 inclusive.
const events: IntakeEventLike[] = [];
for (
let day = new Date("2025-03-27T06:30:00Z");
day.getTime() <= NOW.getTime();
day = new Date(day.getTime() + 24 * 60 * 60 * 1000)
) {
events.push({
scheduledFor: new Date(day),
takenAt: new Date(day.getTime() + 30 * 60_000),
skipped: false,
});
}
const timeline = buildCadenceTimeline(
[DAILY_8AM],
events,
NOW,
windowDays,
undefined,
tz,
);
const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz);
// Every one of the 7 local days (including the 23h spring-forward
// day) was taken → an unbroken 7-day streak.
expect(current).toBe(7);
});
it("visits each local day once across a fall-back (25h) boundary", () => {
// Europe/Berlin fall-back is 2025-10-26 (25h-wide local day).
const tz = "Europe/Berlin";
const NOW = new Date("2025-10-29T11:00:00Z"); // local noon CET.
const windowDays = 7;
const events: IntakeEventLike[] = [];
for (
let day = new Date("2025-10-23T07:30:00Z");
day.getTime() <= NOW.getTime();
day = new Date(day.getTime() + 24 * 60 * 60 * 1000)
) {
events.push({
scheduledFor: new Date(day),
takenAt: new Date(day.getTime() + 30 * 60_000),
skipped: false,
});
}
const timeline = buildCadenceTimeline(
[DAILY_8AM],
events,
NOW,
windowDays,
undefined,
tz,
);
const { current } = streaksFromTimeline(timeline, NOW, windowDays, tz);
expect(current).toBe(7);
});
});
+1 -1
View File
@@ -274,7 +274,7 @@ function hhmmToMinutes(hhmm: string): number | null {
}
/** Snap a Date down to the user-local midnight. */
function startOfLocalDay(d: Date, tz: string | undefined): Date {
export function startOfLocalDay(d: Date, tz: string | undefined): Date {
const parts = wallClockInTz(d, tz);
return instantInTz(parts.year, parts.month, parts.day, 0, 0, tz);
}
+35 -20
View File
@@ -21,6 +21,7 @@
import {
buildCadenceTimeline,
startOfLocalDay,
type CadenceEngineContext,
type IntakeEventLike,
type PairedDose,
@@ -77,8 +78,13 @@ function localDayKey(d: Date, tz: string | undefined): string {
* (inclusive of today). The streak does not reset on a day that had
* no expected doses — the user gets credit for not breaking on
* non-scheduled days.
*
* Exported so `src/lib/analytics/compliance.ts` can share this exact
* timezone-aware streak math instead of duplicating it with host-tz
* day keys — the two call sites stay byte-for-byte identical on the
* streak rule.
*/
function streaksFromTimeline(
export function streaksFromTimeline(
timeline: PairedDose[],
asOf: Date,
windowDays: number,
@@ -100,26 +106,35 @@ function streaksFromTimeline(
let longest = 0;
let run = 0;
// 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;
// Walk the window day-by-day from oldest to newest. Snap each cursor
// to the user-local midnight and advance by +25 h then re-snap — the
// distance between two consecutive local midnights is 23 / 24 / 25 h
// around spring-forward / fall-back, and +25 h always clears the
// shortest (23 h) day while the re-snap collapses the longest (25 h)
// day back to its own midnight. This visits every local calendar day
// in the window exactly once instead of the prior fixed +24 h step
// that could skip a day on a 23 h spring-forward boundary (its
// comment already claimed 25 h while the code stepped 24 h).
const startDay = startOfLocalDay(
new Date(asOf.getTime() - (windowDays - 1) * DAY_MS),
timeZone,
);
const endDay = startOfLocalDay(asOf, timeZone);
for (
let cursor = startDay;
cursor.getTime() <= endDay.getTime();
cursor = startOfLocalDay(
new Date(cursor.getTime() + 25 * 60 * 60 * 1000),
timeZone,
)
) {
const state = byDay.get(localDayKey(cursor, timeZone));
if (state === "bad") {
if (run > longest) longest = run;
run = 0;
} else {
run++;
}
cursor = new Date(cursor.getTime() + DAY_MS);
}
if (run > longest) longest = run;
current = run;