mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user