mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
701fd5f251
commit
2a65f2b179
5 files changed
+361
-50
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user