diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts index ff5fc0e81..c8c43c269 100644 --- a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts +++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts @@ -194,3 +194,20 @@ describe("adherence storyline ↔ dashboard-tile engine parity", () => { expect(story).toBeNull(); }); }); + +describe("adherence storyline course window", () => { + it("keeps a course on its last day in the user's zone", async () => { + // 2026-06-21 20:00 in Los Angeles, already the 22nd in UTC. A course + // whose `endsOn` (a calendar date) is the 21st is still running. + const now = new Date("2026-06-22T03:00:00.000Z"); + await buildAdherenceStoryline("user-1", "America/Los_Angeles", now); + const where = mocks.medicationFindMany.mock.calls[0]![0].where as { + OR: [unknown, { endsOn: { gte: Date } }]; + }; + const lastDay = new Date("2026-06-21T00:00:00.000Z"); // @db.Date value + expect(where.OR[1].endsOn.gte.getTime()).toBeLessThanOrEqual( + lastDay.getTime(), + ); + expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString()); + }); +}); diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index 893ff68f0..fd06e1a8b 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -28,6 +28,7 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { inferMedTargetClass, @@ -35,6 +36,7 @@ import { type MedTargetClass, } from "@/lib/medications/med-target-map"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; +import { dbDate } from "@/lib/tz/date-only"; const MS_PER_DAY = 24 * 60 * 60 * 1000; const MAD_TO_SIGMA = 1.4826; @@ -160,7 +162,11 @@ export async function buildAdherenceStoryline( timezone: string | null, now: Date = new Date(), ): Promise { - // 1. Active medications with a confidently-known target class. + // 1. Active medications with a confidently-known target class. `endsOn` is + // a calendar date, so a course is over only once the user's own day has + // passed it; comparing the column against the instant `now` dropped the + // course's last day. + const todayKey = userDayKey(now, timezone ?? DEFAULT_TIMEZONE); const meds = await prisma.medication.findMany({ where: { userId, @@ -168,7 +174,7 @@ export async function buildAdherenceStoryline( // v1.39.1 (#1033) — the adherence figure below counts tracked // medications only, so a record-only one cannot be its subject. ...TRACKED_INTAKE_WHERE, - OR: [{ endsOn: null }, { endsOn: { gte: now } }], + OR: [{ endsOn: null }, { endsOn: { gte: dbDate(todayKey) } }], }, select: { name: true, treatmentClass: true }, }); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index df98246d9..92526a625 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -34,3 +34,13 @@ export function dateOnlyAtNoonUtc(key: string): Date { export function dateOnlyKey(value: Date): string { return value.toISOString().slice(0, 10); } + +/** + * The value a `@db.Date` column holds for calendar date `key` (Prisma reads + * and writes those columns as UTC midnight). Use it to compare such a column + * with a day: `endsOn >= dbDate(todayKey)` keeps a course through its last + * day, where comparing against the instant `now` dropped that day. + */ +export function dbDate(key: string): Date { + return new Date(`${key}T00:00:00.000Z`); +}