fix(medications): attribute doses to slots from before the medication existed

A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its
creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards
on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00
row with no time appeared as off-schedule too.

The history route minted slots only from the creation instant, so the 14:00
slot never existed for the dose to land on, while the write path, which
mints the whole day, had filed it there. The 09:00 row was the pending
placeholder the today projector mints for every slot of the day, which the
auto-miss pass stamped a day later; with no slot to bind to it surfaced as
an off-schedule row.

The ledger now mints slots over the requested window and takes the
creation instant as the floor of expected slots: a slot before it appears
only when a recorded dose claims it (a take by its window or its pin, or a
skip), and a pending or auto-missed placeholder on it is dropped. The first
archived schedule era also covers the stretch before the creation, so an
edited schedule behaves the same.

Every adherence surface tallies the same ledger with the same floor, so the
detail rates, the calendar and the ledger bar agree on that day. The doctor
report, the Coach and the adherence chips previously read the slots before
a medication existed as missed whenever their window started earlier; they
now read nothing there.

Refs #1028
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 19:40:27 +02:00
1 parent 9328a4298d
commit fe7cccfd46
19 files changed
+839 -70

No files matched your search

+7 -4
View File
@@ -5984,12 +5984,14 @@ paths:
name: from
schema:
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses
are returned from this instant; expected slots are not minted before the medication's `createdAt`.
are returned from this instant. A slot from before the medication's `createdAt` appears only when a
recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned.
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses
are returned from this instant; expected slots are not minted before the medication's `createdAt`.
are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded
dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned.
- in: query
name: to
schema:
@@ -43263,8 +43265,9 @@ components:
type: integer
minimum: 0
maximum: 9007199254740991
description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the
medication's `createdAt`."
description: "Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the
medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before
the medication existed are never missed."
taken:
type: integer
minimum: 0
@@ -426,3 +426,147 @@ describe("GET /api/medications/[id]/dose-history — early take (#1028 class)",
}
});
});
/**
* #1028 follow-up — a medication added in the afternoon, dosed 09:00 / 14:00
* / 21:00. The 14:00 dose of the creation day was recorded afterwards on its
* own slot, the 21:00 dose came in through the reminder, and the 09:00 slot
* of that day only ever held the pending placeholder the today projector
* mints for every slot of the day, which the auto-miss pass later stamped.
*
* The 14:00 dose must read as its own slot, taken on time, not as an
* off-schedule take "due" at 21:00; the 09:00 slot, which predates the
* medication and holds no recorded dose, must not appear at all.
*/
describe("GET /api/medications/[id]/dose-history — slots before creation (#1028)", () => {
const cases = [
{ tz: "Asia/Kolkata", edited: false },
{ tz: "America/New_York", edited: false },
// The schedule was saved again shortly after the medication was added,
// so the creation day lives in an archived schedule era.
{ tz: "Asia/Kolkata", edited: true },
];
for (const { tz, edited } of cases) {
it(`attributes a recorded dose on a pre-creation slot and hides the unrecorded one (${tz}${edited ? ", edited schedule" : ""})`, async () => {
const day1 = new Date("2026-09-23T12:00:00Z");
const day2 = new Date("2026-09-24T12:00:00Z");
const createdAt = localHmAsUtc(day1, tz, 16, 5);
const now = localHmAsUtc(day2, tz, 22, 0);
const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m);
const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(now);
try {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, timezone: tz },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
oneShot: false,
createdAt,
scheduleRevisions: edited
? [
{
id: "rev-1",
validFrom: createdAt,
validUntil: localHmAsUtc(day1, tz, 16, 30),
supersededByRevisionId: null,
payload: [
{
timesOfDay: ["09:00", "14:00", "21:00"],
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: null,
dose: "1 tablet",
reminderGraceMinutes: null,
},
],
},
]
: [],
schedules: [
{
id: "sched-1",
windowStart: "09:00",
windowEnd: "09:00",
daysOfWeek: null,
timesOfDay: ["09:00", "14:00", "21:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
} as never);
const row = (
id: string,
scheduledFor: Date,
takenAt: Date | null,
extra: Partial<{ autoMissed: boolean; source: string }> = {},
) => ({
id,
scheduledFor,
takenAt,
skipped: false,
autoMissed: extra.autoMissed ?? false,
attributionSource: "AUTO",
doseTaken: null,
source: extra.source ?? "REMINDER",
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
row("d2-2100", d2(21), d2(21, 30)),
row("d2-1400", d2(14), d2(14)),
row("d2-0900", d2(9), d2(10)),
row("d1-2100", d1(21), d1(21)),
row("d1-1400", d1(14), d1(14), { source: "WEB" }),
// The projector's placeholder for the pre-creation 09:00 slot,
// stamped by the auto-miss pass a day later.
row("d1-0900-placeholder", d1(9), null, { autoMissed: true }),
] as never);
const from = new Date(now.getTime() - 90 * 86_400_000).toISOString();
const res = await GET(
getReq(
`?from=${encodeURIComponent(from)}&to=${encodeURIComponent(now.toISOString())}`,
),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
const rows = json.data.rows as Array<{
kind: string;
at: string;
timeOfDay: string | null;
status: string;
nearestSlot?: unknown;
intake: { id: string | null } | null;
}>;
expect(
rows.map((r) => [r.at, r.kind, r.status, r.intake?.id ?? null]),
).toEqual([
[d1(14).toISOString(), "slot", "taken_on_time", "d1-1400"],
[d1(21).toISOString(), "slot", "taken_on_time", "d1-2100"],
[d2(9).toISOString(), "slot", "taken_on_time", "d2-0900"],
[d2(14).toISOString(), "slot", "taken_on_time", "d2-1400"],
[d2(21).toISOString(), "slot", "taken_on_time", "d2-2100"],
]);
} finally {
vi.useRealTimers();
}
});
}
});
@@ -134,26 +134,23 @@ export const GET = apiHandler(
return apiError("Medication not found", 404);
}
// Resolve the window. Default to the trailing 90 days; clamp the floor to
// the medication's creation so pre-existence days never mint phantom
// slots, and cap the span so a pathological `from=1970` can't expand a
// year-plus of bands. `to` defaults to `now`.
// Resolve the window. Default to the trailing 90 days and cap the span
// so a pathological `from=1970` can't expand a year-plus of bands. `to`
// defaults to `now`.
const to = parsed.data.to ?? now;
const requestedFrom =
parsed.data.from ?? new Date(to.getTime() - 90 * DAY_MS);
const spanFloor = new Date(to.getTime() - MAX_WINDOW_DAYS * DAY_MS);
// The window the caller asked for, capped only by the span limit. This is
// the floor for recorded doses: a dose logged with a time before the
// medication was added (created at 16:20, "taken at 14:00") is a real
// record and belongs in the window, exactly as the unwindowed full
// history shows it (#1028).
const intakeFrom = new Date(
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
// The floor for minted slots additionally stops at the medication's
// creation, so pre-existence days never mint phantom missed slots.
// The window the caller asked for, capped only by the span limit. Both
// recorded doses and minted slots use it: a dose logged for a slot
// before the medication was added (created at 16:20, the 14:00 dose
// recorded afterwards) is a real record and must meet its slot, or it
// reads as an off-schedule take due at the next slot (#1028). Slots
// before the creation that no recorded dose claims stay out of the
// ledger: `reconstructDoseHistory` receives the creation instant as the
// floor of expected slots, so pre-existence days never read as missed.
const from = new Date(
Math.max(intakeFrom.getTime(), medication.createdAt.getTime()),
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
);
const events = await prisma.medicationIntakeEvent.findMany({
@@ -264,7 +261,7 @@ export const GET = apiHandler(
// partition, so filter on the stored anchor (a take snapped to an
// out-of-window slot is excluded, matching the compliance read).
const historyIntakes: HistoryIntake[] = mapped
.filter((e) => e.scheduledFor >= intakeFrom && e.scheduledFor <= horizon)
.filter((e) => e.scheduledFor >= from && e.scheduledFor <= horizon)
.map((e) => ({
id: e.id,
scheduledFor: e.scheduledFor,
@@ -276,7 +273,12 @@ export const GET = apiHandler(
source: e.source,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
const rows = reconstructDoseHistory(
bands,
historyIntakes,
now,
medication.createdAt,
);
const serialized: SerializedDoseHistoryRow[] = rows.map((row) => ({
kind: row.kind,
@@ -314,11 +316,10 @@ export const GET = apiHandler(
});
// The window the recorded doses were read over (#1028): from the
// requested start (slots additionally start at the medication's
// creation) to the read horizon, which reaches past `to` only for a dose
// recorded ahead of its slot.
// requested start to the read horizon, which reaches past `to` only for
// a dose recorded ahead of its slot.
return apiSuccess({
from: intakeFrom.toISOString(),
from: from.toISOString(),
to: horizon.toISOString(),
family,
hasExpectedSlots: bands.length > 0,
@@ -159,10 +159,14 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
buildComplianceDisplay(events, schedules, ctx, { now: NOW }),
);
// The ledger window clamps to the medication's creation.
expect(bundle.ledgerFrom).toEqual(createdAt);
// One band per daily slot over the 40-day life → 40 or 41 slot rows
// The ledger mints over the whole 365-day period (#1028); the slots
// before the creation hold no recorded dose, so none of them survive:
// one band per daily slot over the 40-day life → 40 or 41 slot rows
// (the boundary day depends on the creation instant vs slot time).
expect(bundle.ledgerFrom).toEqual(new Date(NOW.getTime() - 365 * DAY_MS));
expect(
bundle.ledgerRows.some((r) => r.at.getTime() < createdAt.getTime()),
).toBe(false);
const slotRows = bundle.ledgerRows.filter((r) => r.kind === "slot");
expect(slotRows.length).toBeGreaterThanOrEqual(39);
expect(slotRows.length).toBeLessThanOrEqual(41);
@@ -3053,7 +3053,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
skipped: e.skipped,
autoMissed: false,
}));
const rows = reconstructDoseHistory(bands, intakes, nowEvening);
const rows = reconstructDoseHistory(bands, intakes, nowEvening, null);
const fromLedger = {
onTime: rows.filter((r) => r.status === "taken_on_time").length,
@@ -0,0 +1,109 @@
/**
* #1028 — slots before a medication's creation, on the compliance side.
*
* The dose-history ledger keeps a slot that predates the medication only
* when a recorded dose claims it. Every adherence surface tallies that same
* ledger, so the rate, the per-window counts and the heatmap must agree with
* the history view: the creation day's 14:00 dose, recorded afterwards on its
* own slot, counts as taken on time, and the 09:00 slot before the medication
* existed (holding only the projector's auto-missed placeholder) counts as
* nothing. A caller whose window starts before the creation (the doctor
* report, the Coach) must not read the pre-existence slots as missed either.
*/
import { describe, expect, it } from "vitest";
import {
buildMedicationComplianceBundle,
tallyComplianceFromLedger,
type ComplianceMedicationContext,
type ComplianceSchedule,
type IntakeEvent,
} from "@/lib/analytics/compliance";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/resolver";
const DAY_MS = 24 * 60 * 60 * 1000;
const schedule: ComplianceSchedule = {
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00", "14:00", "21:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
};
for (const tz of ["Asia/Kolkata", "America/New_York"]) {
describe(`compliance over a medication added mid-day (#1028, ${tz})`, () => {
const day1 = new Date("2026-09-23T12:00:00Z");
const day2 = new Date("2026-09-24T12:00:00Z");
const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m);
const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m);
const createdAt = d1(16, 5);
const now = d2(22, 0);
const ctx: ComplianceMedicationContext = {
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
oneShot: false,
createdAt,
lastIntakeAt: d2(21, 30),
timeZone: tz,
};
const take = (slot: Date, takenAt: Date): IntakeEvent => ({
scheduledFor: slot,
takenAt,
skipped: false,
autoMissed: false,
});
const events: IntakeEvent[] = [
{ scheduledFor: d1(9), takenAt: null, skipped: false, autoMissed: true },
take(d1(14), d1(14)),
take(d1(21), d1(21)),
take(d2(9), d2(10)),
take(d2(14), d2(14)),
take(d2(21), d2(21, 30)),
];
it("counts the recorded pre-creation dose and ignores the placeholder", () => {
const bundle = buildMedicationComplianceBundle(
events,
[schedule],
ctx,
now,
);
expect(bundle.compliance7).toMatchObject({
taken: 5,
missed: 0,
skipped: 0,
rate: 100,
});
const creationDay = bundle.ledgerRows.filter(
(r) => userDayKey(r.at, tz) === userDayKey(d1(12), tz),
);
expect(creationDay.map((r) => [r.kind, r.timeOfDay, r.status])).toEqual([
["slot", "14:00", "taken_on_time"],
["slot", "21:00", "taken_on_time"],
]);
});
it("never reads pre-existence slots as missed in a wider window", () => {
// The doctor report and the Coach tally a window that starts before
// the medication existed.
const tally = tallyComplianceFromLedger(
[],
[schedule],
{ ...ctx, lastIntakeAt: null },
new Date(now.getTime() - 30 * DAY_MS),
d2(3, 0),
d2(3, 0),
);
// Only the creation day's 21:00 slot was expected, and never taken.
expect(tally).toMatchObject({ taken: 0, missed: 1, denominator: 1 });
});
});
}
+28 -11
View File
@@ -317,11 +317,15 @@ export function calculateCompliance(
rate: number;
} | null = null;
if (ledgerCtx) {
// The tally reads the whole period, not only the part after the
// medication's creation: the ledger itself keeps a slot before the
// creation only when a recorded dose claims it (#1028), so the rate
// matches the history view on the creation day.
const tally = tallyComplianceFromLedger(
events,
schedules,
ledgerCtx,
effectiveStart,
periodStart,
now,
now,
);
@@ -484,7 +488,10 @@ export interface MedicationComplianceBundle {
complianceDisplay: ComplianceDisplay;
/** Unified ledger rows over `[ledgerFrom, now]`, chronological. */
ledgerRows: DoseHistoryRow[];
/** Lower bound of the mint window (clamped to the medication's creation). */
/**
* Lower bound of the mint window. Not clamped to the medication's
* creation: the ledger drops pre-creation slots no recorded dose claims.
*/
ledgerFrom: Date;
}
@@ -498,7 +505,8 @@ export interface MedicationComplianceBundle {
* mint — five-plus full band expansions per request. This builder instead:
*
* 1. mints the bands + reconstructs the ledger ONCE over
* `[max(createdAt, now − 365 d), now]` (every served window is a
* `[now − 365 d, now]` (slots before the medication's creation survive
* only when a recorded dose claims them; every served window is a
* suffix of that range and ends at `now`, so a sub-window tally is a
* filter over `row.at`, not a re-expansion);
* 2. builds ONE cadence timeline over the same range for the per-window
@@ -520,7 +528,12 @@ export function buildMedicationComplianceBundle(
const ledgerPeriodStart = new Date(
now.getTime() - COMPLIANCE_LEDGER_WINDOW_DAYS * ONE_DAY_MS,
);
const ledgerFrom =
// The ledger mints over the whole period: a slot before the medication's
// creation stays in it only when a recorded dose claims it (#1028), so no
// pre-existence day reads as missed. The streak timeline keeps its
// creation floor.
const ledgerFrom = ledgerPeriodStart;
const timelineFrom =
ctx.createdAt.getTime() > ledgerPeriodStart.getTime()
? ctx.createdAt
: ledgerPeriodStart;
@@ -535,10 +548,17 @@ export function buildMedicationComplianceBundle(
// the wide timeline reproduces the per-window builds.
const fullDays = Math.max(
1,
Math.ceil((now.getTime() - ledgerFrom.getTime()) / ONE_DAY_MS),
Math.ceil((now.getTime() - timelineFrom.getTime()) / ONE_DAY_MS),
);
const timeline = hasSchedules
? buildTimelineForWindow(events, schedules, ctx, now, ledgerFrom, fullDays)
? buildTimelineForWindow(
events,
schedules,
ctx,
now,
timelineFrom,
fullDays,
)
: [];
const resultForWindow = (days: number): ComplianceResult => {
@@ -563,7 +583,7 @@ export function buildMedicationComplianceBundle(
Math.ceil((now.getTime() - effectiveStart.getTime()) / ONE_DAY_MS),
);
const tally = tallyLedgerRows(ledgerRows, {
from: effectiveStart,
from: periodStart,
to: now,
});
const { current: streak } = streaksFromTimeline(
@@ -590,10 +610,7 @@ export function buildMedicationComplianceBundle(
const expectedOver = (days: number): number => {
const hit = expectedCache.get(days);
if (hit !== undefined) return hit;
const from = Math.max(
ctx.createdAt.getTime(),
now.getTime() - days * ONE_DAY_MS,
);
const from = now.getTime() - days * ONE_DAY_MS;
let count = 0;
for (const row of ledgerRows) {
if (row.kind !== "slot") continue;
+4 -1
View File
@@ -185,7 +185,10 @@ export function buildComplianceLedgerRows(
pinned: e.attributionSource === "USER_PIN",
}));
const rows = reconstructDoseHistory(bands, intakes, now);
// Slots before the medication's creation count only when a recorded dose
// claims them, so a caller's window reaching past the creation never
// mints phantom misses (#1028).
const rows = reconstructDoseHistory(bands, intakes, now, ctx.createdAt);
// v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a
// pause interval. While a medication is paused no dose is expected, so a
@@ -0,0 +1,103 @@
/**
* #1028 — the compliance payload (the detail page's rates and heatmap)
* over a medication added mid-day. The creation day's 14:00 dose was
* recorded afterwards on its own slot; the 09:00 slot before the medication
* existed only ever held the projector's auto-missed placeholder. The
* payload must read the same day the dose-history view does: two slots, both
* taken on time, nothing missed.
*/
import { afterEach, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { medicationIntakeEvent: { findMany: vi.fn() } },
}));
import { prisma } from "@/lib/db";
import { buildCompliancePayload } from "@/lib/medications/compliance-payload";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
for (const tz of ["Asia/Kolkata", "America/New_York"]) {
it(`reads the creation day like the history view (${tz})`, async () => {
const day1 = new Date("2026-09-23T12:00:00Z");
const day2 = new Date("2026-09-24T12:00:00Z");
const d1 = (h: number, m = 0) => localHmAsUtc(day1, tz, h, m);
const d2 = (h: number, m = 0) => localHmAsUtc(day2, tz, h, m);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(d2(22, 0));
const take = (slot: Date, takenAt: Date) => ({
scheduledFor: slot,
takenAt,
skipped: false,
autoMissed: false,
attributionSource: "AUTO",
});
const rows = [
take(d2(21), d2(21, 30)),
take(d2(14), d2(14)),
take(d2(9), d2(10)),
take(d1(21), d1(21)),
take(d1(14), d1(14)),
{
scheduledFor: d1(9),
takenAt: null,
skipped: false,
autoMissed: true,
attributionSource: "AUTO",
},
];
vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation(
(async (args: {
where: { scheduledFor: { gte: Date } };
}): Promise<unknown> =>
rows.filter(
(r) => r.scheduledFor >= args.where.scheduledFor.gte,
)) as never,
);
const payload = await buildCompliancePayload(
{
id: "med-1",
createdAt: d1(16, 5),
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
oneShot: false,
asNeeded: false,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00", "14:00", "21:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
},
"user-1",
tz,
);
expect(payload.compliance7).toMatchObject({
taken: 5,
missed: 0,
rate: 100,
});
expect(payload.dailyCompliance[userDayKey(d1(12), tz)]).toMatchObject({
expected: 2,
taken: 2,
onTime: 2,
skipped: 0,
});
});
}
+7 -13
View File
@@ -184,14 +184,10 @@ export async function buildCompliancePayload(
// v1.15.9 — pin a single `now` and thread it into every cadence
// computation so no block can straddle a day boundary on a slow request.
const now = new Date();
const createdAt = medication.createdAt;
const fetchFrom = new Date(
Math.max(
createdAt.getTime(),
now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS,
),
);
// Not clamped to the medication's creation: a dose recorded for a slot
// before the creation still claims that slot in the ledger (#1028).
const fetchFrom = new Date(now.getTime() - EVENT_FETCH_WINDOW_DAYS * DAY_MS);
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the compliance read.
// Bounded to the 366-day fetch window: every served block is a suffix
@@ -242,12 +238,10 @@ export async function buildCompliancePayload(
// unified dose-history ledger (the same bands the % + the history view
// read), so the per-day timing split (on-time / late) and the per-day
// missed marks can never disagree with the headline rate. The 90-day
// window is carved out of the shared ledger by `row.at`; the window
// floor clamps to the medication's creation so pre-existence days never
// mint phantom slots.
const heatmapFrom = new Date(
Math.max(now.getTime() - 90 * DAY_MS, createdAt.getTime()),
);
// window is carved out of the shared ledger by `row.at`. The ledger
// already drops pre-existence slots no recorded dose claims, so the floor
// is the plain 90-day window, the same one the history view reads (#1028).
const heatmapFrom = new Date(now.getTime() - 90 * DAY_MS);
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
const byDay = new Map<string, DailyComplianceEntry>();
@@ -60,7 +60,7 @@ const nowEvening = at(23, 59);
describe("reconstructDoseHistory", () => {
it("emits one row per expected slot even with no intakes", () => {
const rows = reconstructDoseHistory(bands, [], nowEvening);
const rows = reconstructDoseHistory(bands, [], nowEvening, null);
expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]);
expect(rows.every((r) => r.kind === "slot")).toBe(true);
expect(rows.map((r) => r.status)).toEqual(["missed", "missed"]);
@@ -71,6 +71,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(7, 0), scheduledFor: at(7, 0) })],
nowEvening,
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("taken_on_time");
@@ -84,6 +85,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(8, 30), scheduledFor: at(7, 0) })],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_late",
@@ -97,6 +99,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })],
nowEvening,
null,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc?.status).toBe("ad_hoc");
@@ -109,6 +112,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ skipped: true, scheduledFor: at(7, 0) })],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped");
});
@@ -118,13 +122,14 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ autoMissed: true, scheduledFor: at(7, 0) })],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed");
});
it("marks a future slot upcoming, not missed", () => {
const earlyMorning = at(5, 0); // before every slot's miss cutoff
const rows = reconstructDoseHistory(bands, [], earlyMorning);
const rows = reconstructDoseHistory(bands, [], earlyMorning, null);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("upcoming");
expect(rows.find((r) => r.timeOfDay === "19:00")?.status).toBe("upcoming");
});
@@ -137,6 +142,7 @@ describe("reconstructDoseHistory", () => {
intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }),
],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_on_time",
@@ -159,6 +165,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ scheduledFor: at(7, 0) })],
at(8, 30),
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("upcoming");
@@ -172,6 +179,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ scheduledFor: at(7, 0) })],
at(12, 0),
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed");
});
@@ -195,6 +203,7 @@ describe("reconstructDoseHistory", () => {
weeklyBands,
[intake({ scheduledFor: weeklyAt })],
twoDaysLater,
null,
);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("upcoming");
@@ -210,6 +219,7 @@ describe("reconstructDoseHistory", () => {
morningOnly,
[intake({ scheduledFor: at(19, 0) })],
at(14, 0),
null,
);
expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00"]);
@@ -221,6 +231,7 @@ describe("reconstructDoseHistory", () => {
morningOnly,
[intake({ skipped: true, scheduledFor: at(19, 0) })],
at(14, 0),
null,
);
expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(1);
});
@@ -231,6 +242,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ autoMissed: true, scheduledFor: at(7, 0) })],
at(8, 30),
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("missed");
});
@@ -249,6 +261,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(13, 0), scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("taken_late");
@@ -264,6 +277,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(7, 30), scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.status).toBe("taken_on_time");
@@ -275,6 +289,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(9, 0), scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe(
"taken_late",
@@ -294,6 +309,7 @@ describe("reconstructDoseHistory", () => {
}),
],
nowEvening,
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.intake?.id).toBe("pin");
@@ -315,6 +331,7 @@ describe("reconstructDoseHistory", () => {
}),
],
nowEvening,
null,
);
expect(rows.find((r) => r.timeOfDay === "07:00")?.status).toBe("skipped");
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
@@ -332,6 +349,7 @@ describe("reconstructDoseHistory", () => {
}),
],
nowEvening,
null,
);
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
expect(adhoc).toHaveLength(1);
@@ -345,6 +363,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(9, 0), scheduledFor: at(9, 0), pinned: true })],
nowEvening,
null,
);
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
expect(adhoc).toHaveLength(1);
@@ -364,6 +383,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: t, scheduledFor: t, pinned: true })],
nowEvening,
null,
);
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
expect(adhoc).toHaveLength(1);
@@ -381,6 +401,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(11, 29), scheduledFor: at(7, 0) })],
nowEvening,
null,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00");
@@ -394,6 +415,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ takenAt: at(12, 45), scheduledFor: at(7, 0) })],
nowEvening,
null,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc?.nearestSlot?.timeOfDay).toBe("07:00");
@@ -408,6 +430,7 @@ describe("reconstructDoseHistory", () => {
intake({ id: "b", takenAt: at(7, 20), scheduledFor: at(7, 0) }),
],
nowEvening,
null,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc?.intake?.id).toBe("b");
@@ -421,6 +444,7 @@ describe("reconstructDoseHistory", () => {
morningOnly,
[intake({ skipped: true, scheduledFor: at(19, 0) })],
at(14, 0),
null,
);
const adhoc = rows.find((r) => r.kind === "ad_hoc");
expect(adhoc).toBeDefined();
@@ -458,6 +482,7 @@ describe("reconstructDoseHistory", () => {
bands,
[intake({ id: "x", takenAt: at(13, 0), scheduledFor: at(13, 0) })],
nowEvening,
null,
);
const times = rows.map((r) => r.at.getTime());
expect(times).toEqual([...times].sort((a, b) => a - b));
@@ -494,3 +519,117 @@ describe("suggestNearestSlot fallback cap", () => {
expect(near?.timeOfDay).toBe("09:00");
});
});
/**
* #1028 — a medication created between its 07:00 and 19:00 slots. The 07:00
* slot predates it: it shows only when a recorded dose claims it, and a
* pending or auto-missed placeholder on it never reads as missed or as an
* off-schedule row.
*/
describe("reconstructDoseHistory — slots before the medication existed (#1028)", () => {
const createdAt = at(12, 0);
const statusRows = (rows: ReturnType<typeof reconstructDoseHistory>) =>
rows.map((r) => [r.kind, r.timeOfDay, r.status, r.intake?.id ?? null]);
it("omits an unclaimed pre-creation slot and keeps the later one", () => {
const rows = reconstructDoseHistory(bands, [], nowEvening, createdAt);
expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]);
});
it("drops an auto-missed placeholder on a pre-creation slot", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "placeholder", scheduledFor: at(7, 0), autoMissed: true })],
nowEvening,
createdAt,
);
expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]);
});
it("drops a pending placeholder on a pre-creation slot", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "pending", scheduledFor: at(7, 0) })],
nowEvening,
createdAt,
);
expect(statusRows(rows)).toEqual([["slot", "19:00", "missed", null]]);
});
it("drops an auto-missed placeholder whose pre-creation slot was not minted", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "orphan", scheduledFor: at(3, 0), autoMissed: true })],
nowEvening,
createdAt,
);
expect(rows.some((r) => r.intake?.id === "orphan")).toBe(false);
});
it("materialises a pre-creation slot a take claims by its stored slot instant", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "t", scheduledFor: at(7, 0), takenAt: at(7, 0) })],
nowEvening,
createdAt,
);
expect(statusRows(rows)).toEqual([
["slot", "07:00", "taken_on_time", "t"],
["slot", "19:00", "missed", null],
]);
});
it("materialises a pre-creation slot a take claims by band membership", () => {
// Stored as an unanchored take (scheduledFor === takenAt), late.
const rows = reconstructDoseHistory(
bands,
[intake({ id: "t", scheduledFor: at(9, 30), takenAt: at(9, 30) })],
nowEvening,
createdAt,
);
expect(statusRows(rows)[0]).toEqual(["slot", "07:00", "taken_late", "t"]);
});
it("materialises a pre-creation slot a pin or a skip claims", () => {
const pinned = reconstructDoseHistory(
bands,
[
intake({
id: "p",
scheduledFor: at(7, 0),
takenAt: at(11, 30),
pinned: true,
}),
],
nowEvening,
createdAt,
);
expect(statusRows(pinned)[0]).toEqual(["slot", "07:00", "taken_late", "p"]);
const skipped = reconstructDoseHistory(
bands,
[intake({ id: "s", scheduledFor: at(7, 0), skipped: true })],
nowEvening,
createdAt,
);
expect(statusRows(skipped)[0]).toEqual(["slot", "07:00", "skipped", "s"]);
});
it("does not offer an absent pre-creation slot as due-context", () => {
// A take far outside every band, before the creation.
const rows = reconstructDoseHistory(
bands,
[intake({ id: "x", scheduledFor: at(1, 0), takenAt: at(1, 0) })],
nowEvening,
createdAt,
);
const adHoc = rows.find((r) => r.intake?.id === "x");
expect(adHoc?.kind).toBe("ad_hoc");
expect(adHoc?.nearestSlot?.timeOfDay).not.toBe("07:00");
});
it("treats every band as expected when no floor is given", () => {
const rows = reconstructDoseHistory(bands, [], nowEvening, null);
expect(rows.map((r) => r.timeOfDay)).toEqual(["07:00", "19:00"]);
});
});
@@ -189,6 +189,24 @@ describe("segmentRangeIntoEras", () => {
});
});
describe("segmentRangeIntoEras — before the first revision (#1028)", () => {
it("covers the stretch before the medication's creation with the first era", () => {
// A slot before the creation must still mint so a dose recorded on it
// meets its slot; without a revision it mints against the live rows,
// with one it mints against the schedule that was live first.
const range = {
from: new Date("2026-04-30T00:00:00.000Z"),
to: new Date("2026-06-10T00:00:00.000Z"),
};
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
expect(eras).toHaveLength(2);
expect(eras[0].live).toBe(false);
expect(eras[0].from).toEqual(range.from);
expect(eras[0].schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
expect(eras[1].from).toEqual(replaceAt);
});
});
describe("buildBandsForSchedulesWithEras", () => {
const range = {
from: new Date("2026-05-20T00:00:00.000Z"),
@@ -255,6 +273,7 @@ describe("buildBandsForSchedulesWithEras", () => {
bands,
[{ scheduledFor: takenAt, takenAt, skipped: false }],
range.to,
null,
);
const takenRows = rows.filter((r) => r.status === "taken_on_time");
expect(takenRows).toHaveLength(1);
+6 -1
View File
@@ -921,6 +921,11 @@ function missedFromLedger(
skipped: e.skipped,
autoMissed: e.autoMissed ?? false,
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
const rows = reconstructDoseHistory(
bands,
intakes,
asOf,
engineCtx.createdAt,
);
return rows.filter((r) => r.status === "missed").length;
}
+6 -1
View File
@@ -249,7 +249,12 @@ function ledgerChipCounts(
pinned: e.attributionSource === "USER_PIN",
}));
const rows = reconstructDoseHistory(bands, intakes, asOf);
const rows = reconstructDoseHistory(
bands,
intakes,
asOf,
engineCtx.createdAt,
);
let taken = 0;
let missed = 0;
for (const row of rows) {
+27 -3
View File
@@ -30,7 +30,13 @@
* anchor) and surfaces as an ad-hoc row carrying `pinned`, never as
* `taken_late`;
* - each slot is claimed by at most one intake (first/best wins); extra
* intakes near a filled slot fall through to ad-hoc.
* intakes near a filled slot fall through to ad-hoc;
* - a slot anchored before `expectedFrom` (the medication's creation) was
* never expected: it appears only when a recorded dose claims it (a
* take by band or pin, or a skip). A pending or auto-missed row on such
* a slot is the placeholder the today projector mints for every slot of
* the creation day, not a user action, so it is dropped rather than
* read as a miss or an off-schedule row (#1028).
*
* Pure / synchronous / instant-based — the caller mints the bands DST-correctly
* via `localHmAsUtc` and supplies the per-dose window.
@@ -136,7 +142,16 @@ export function reconstructDoseHistory(
bands: SlotBand[],
intakes: HistoryIntake[],
now: Date,
/**
* The instant from which slots are expected, the medication's creation.
* Bands anchored earlier appear only when a recorded dose claims them.
* `null` treats every band as expected (a caller that already minted from
* the creation instant, or a pure-math fixture).
*/
expectedFrom: Date | null,
): DoseHistoryRow[] {
const expectedFromMs = expectedFrom?.getTime() ?? -Infinity;
const beforeExpected = (at: Date): boolean => at.getTime() < expectedFromMs;
// Per-band claim: the intake attributed to it + the resolved status.
const claim = new Map<
SlotBand,
@@ -155,6 +170,9 @@ export function reconstructDoseHistory(
const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned);
for (const i of anchored) {
// A pending or auto-missed row before the medication existed records
// nothing: drop it whether or not its slot was minted.
if (!i.skipped && beforeExpected(i.scheduledFor)) continue;
const band = nearestAnchorBand(i.scheduledFor, bands);
if (band && !claim.has(band)) {
// Status is time-aware for a pending row: the projector / reminder
@@ -224,7 +242,13 @@ export function reconstructDoseHistory(
}
}
const rows: DoseHistoryRow[] = bands.map((band) => {
// A slot before the medication existed stays absent unless a recorded
// dose claimed it above.
const visibleBands = bands.filter(
(band) => claim.has(band) || !beforeExpected(band.at),
);
const rows: DoseHistoryRow[] = visibleBands.map((band) => {
const c = claim.get(band);
if (c) {
return {
@@ -257,7 +281,7 @@ export function reconstructDoseHistory(
// Orphaned skips (no takenAt) carry no take to attribute, so no context.
const nearestSlot =
i.takenAt !== null
? suggestNearestSlot(i.takenAt, bands, (band) => claim.has(band))
? suggestNearestSlot(i.takenAt, visibleBands, (band) => claim.has(band))
: null;
rows.push({
kind: "ad_hoc",
@@ -14,7 +14,9 @@
* Era rules:
* - revisions are chained: `validFrom` = previous revision's `validUntil`
* (or `medication.createdAt` for the first), `validUntil` = the replace
* instant. The LIVE rows cover `[newest validUntil, ∞)`.
* instant. The LIVE rows cover `[newest validUntil, ∞)`. The first era
* also covers any range before its `validFrom`, so a pre-creation slot
* mints against the schedule that was live first.
* - boundary: a slot belongs to the era its ANCHOR lies in. Eras mint
* with an inclusive sub-range capped 1 ms short of `validUntil`, so an
* anchor exactly at the boundary mints in the NEXT era only. On-time /
@@ -171,10 +173,19 @@ export function segmentRangeIntoEras(
const sorted = revisions
.filter((r) => r.supersededByRevisionId == null)
.sort((a, b) => a.validFrom.getTime() - b.validFrom.getTime());
for (const revision of sorted) {
const from = new Date(
Math.max(revision.validFrom.getTime(), range.from.getTime()),
);
for (const [index, revision] of sorted.entries()) {
// The earliest era also covers the time before its `validFrom` (the
// medication's creation): with no revision that stretch mints against
// the live rows, so with revisions it mints against the schedule that
// was live first. A dose recorded for a slot before the creation then
// meets its slot either way (#1028); unclaimed pre-creation slots are
// dropped by the ledger.
const from =
index === 0
? range.from
: new Date(
Math.max(revision.validFrom.getTime(), range.from.getTime()),
);
// Inclusive sub-range capped 1 ms short of `validUntil` — the anchor
// boundary rule: a slot exactly at the boundary belongs to the era
// that begins there, never to the one ending there.
@@ -571,7 +571,7 @@ export const complianceResult = z
.int()
.nonnegative()
.describe(
"Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.",
"Full denominator over the window: `taken + skipped + missed`. Cadence-aware. A slot from before the medication's `createdAt` counts only when a recorded dose (a take or a skip) claims it, so the days before the medication existed are never missed.",
),
taken: z.number().int().nonnegative(),
skipped: z
@@ -911,7 +911,7 @@ export const doseHistoryQuery = z.object({
.datetime({ offset: true })
.optional()
.describe(
"Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant; expected slots are not minted before the medication's `createdAt`.",
"Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded dose (a take or a skip) claims it; the pending placeholder of such a slot is not returned.",
),
to: z.iso
.datetime({ offset: true })
@@ -0,0 +1,187 @@
/**
* #1028 — a medication added in the afternoon, dosed 09:00 / 14:00 / 21:00.
*
* The 14:00 dose of the creation day is recorded afterwards through the real
* intake route, the 21:00 dose likewise, and the 09:00 slot of that day holds
* only the pending placeholder the today projector mints for every slot of
* the day, stamped auto-missed by the hourly pass. The Verlauf ledger and the
* compliance payload must both read that day as two slots taken on time: the
* 14:00 dose on its own slot (not an off-schedule take "due" at 21:00), and
* no 09:00 row at all, since the medication did not exist yet.
*
* Everything goes through the shipped `POST` / `GET` exports against Postgres,
* so the write-side slot resolution, the stored rows and both read models are
* exercised in one pipe.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const DAY_MS = 24 * 60 * 60 * 1000;
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
let counter = 0;
async function signedInUser(tz: string) {
const suffix = `precreation-${counter++}`;
const user = await getPrismaClient().user.create({
data: {
username: `pc-${suffix}`,
email: `pc-${suffix}@example.test`,
role: "USER",
timezone: tz,
},
});
const session = await getPrismaClient().session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return user;
}
async function postIntake(medicationId: string, body: unknown) {
const { POST } = await import("@/app/api/medications/[id]/intake/route");
return (POST as Handler)(
new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: medicationId }) },
);
}
async function getJson(path: string, medicationId: string) {
const route =
path === "dose-history"
? await import("@/app/api/medications/[id]/dose-history/route")
: await import("@/app/api/medications/[id]/compliance/route");
const from = new Date(Date.now() - 90 * DAY_MS).toISOString();
const response = await (route.GET as Handler)(
new NextRequest(
`http://localhost/api/medications/${medicationId}/${path}?from=${encodeURIComponent(from)}`,
{ method: "GET" },
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(response.status).toBe(200);
return (await response.json()).data;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("a medication added mid-day (#1028)", () => {
for (const tz of ["Asia/Kolkata", "America/New_York"]) {
it(`reads the creation day as two slots taken on time (${tz})`, async () => {
const prisma = getPrismaClient();
const user = await signedInUser(tz);
// Two days back, so every slot of the creation day is long settled
// whatever the clock says when the suite runs.
const day = new Date(Date.now() - 2 * DAY_MS);
const at = (h: number, m = 0) => localHmAsUtc(day, tz, h, m);
const med = await prisma.medication.create({
data: {
userId: user.id,
name: "Amoxicillin",
dose: "500mg",
active: true,
createdAt: at(16, 5),
schedules: {
create: {
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00", "14:00", "21:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
// The placeholder the today projector minted for the 09:00 slot,
// stamped by the auto-miss pass.
await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: med.id,
scheduledFor: at(9),
takenAt: null,
skipped: false,
autoMissed: true,
source: "REMINDER",
},
});
for (const h of [14, 21]) {
const response = await postIntake(med.id, {
skipped: false,
scheduledFor: at(h).toISOString(),
takenAt: at(h).toISOString(),
});
expect(response.status).toBe(201);
}
const dayKey = userDayKey(at(12), tz);
const ledger = await getJson("dose-history", med.id);
const creationDay = (
ledger.rows as Array<{
kind: string;
at: string;
status: string;
}>
).filter((r) => userDayKey(new Date(r.at), tz) === dayKey);
expect(creationDay.map((r) => [r.at, r.kind, r.status])).toEqual([
[at(14).toISOString(), "slot", "taken_on_time"],
[at(21).toISOString(), "slot", "taken_on_time"],
]);
const compliance = await getJson("compliance", med.id);
expect(compliance.dailyCompliance[dayKey]).toMatchObject({
expected: 2,
taken: 2,
onTime: 2,
});
});
}
});
@@ -100,8 +100,9 @@ async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) {
dose: "5mg",
active: true,
startsOn: new Date(Date.now() - 30 * DAY_MS),
// The ledger clamps its window floor to the medication's creation, so a
// medication minted at test time would have no yesterday to read back.
// The ledger shows an unrecorded slot only from the medication's
// creation on, so a medication minted at test time would have no
// missed yesterday to read back.
createdAt: new Date(Date.now() - 30 * DAY_MS),
schedules: {
create: {