Merge branch 'fix/dose-history-pre-creation-slots' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 06:49:15 +02:00
commit e9449ece61
33 files changed
+1446 -103

No files matched your search

+9 -4
View File
@@ -5984,12 +5984,16 @@ 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 row (a take, a skip, or a miss carried in with restored history) claims it; the server's own
placeholder for 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
row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for
such a slot is not returned.
- in: query
name: to
schema:
@@ -43263,8 +43267,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 row (a take, a skip, or a miss carried in with restored
history) claims it, so the days before the medication existed are never missed."
taken:
type: integer
minimum: 0
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Zuordnung gelöst",
"dueContext": "fällig gewesen: {time} ({delta})",
"confirmTitle": "Slot {time} zuordnen?",
"confirmBody": "Die Einnahme zählt für diesen Slot als verspätet — die Quote wird nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmBody": "Die Einnahme zählt für diesen Slot genau so, als wäre sie dort erfasst worden: pünktlich oder verspätet, je nachdem, wann sie genommen wurde. Die Quote wird also nicht geschönt. Die Zuordnung lässt sich jederzeit wieder lösen.",
"confirmCancel": "Abbrechen",
"confirmAction": "Zuordnen"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Attribution released",
"dueContext": "was due: {time} ({delta})",
"confirmTitle": "Attribute to slot {time}?",
"confirmBody": "The intake counts as taken late for this slot – the rate is never flattered. You can release the attribution at any time.",
"confirmBody": "The intake counts for this slot just as it would if it had been logged there: on time or late depending on when it was taken, so the rate is never flattered. You can release the attribution at any time.",
"confirmCancel": "Cancel",
"confirmAction": "Attribute"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Asignación quitada",
"dueContext": "prevista: {time} ({delta})",
"confirmTitle": "¿Asignar al horario de las {time}?",
"confirmBody": "La toma contará como tomada con retraso para este horario; el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
"confirmBody": "La toma contará para este horario igual que si se hubiera registrado en él: a tiempo o con retraso según cuándo se tomó, así que el porcentaje no se maquilla. Puedes quitar la asignación en cualquier momento.",
"confirmCancel": "Cancelar",
"confirmAction": "Asignar"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Prise détachée du créneau",
"dueContext": "prévue à {time} ({delta})",
"confirmTitle": "Rattacher au créneau de {time} ?",
"confirmBody": "La prise comptera comme prise en retard pour ce créneau – le taux n'est pas embelli. Le rattachement peut être retiré à tout moment.",
"confirmBody": "La prise comptera pour ce créneau exactement comme si elle y avait été enregistrée : à l'heure ou en retard selon le moment où elle a été prise. Le taux n'est donc pas embelli. Le rattachement peut être retiré à tout moment.",
"confirmCancel": "Annuler",
"confirmAction": "Rattacher"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Attribuzione rimossa",
"dueContext": "prevista alle {time} ({delta})",
"confirmTitle": "Attribuire allo slot delle {time}?",
"confirmBody": "L'assunzione conterà come in ritardo per questo slot – la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
"confirmBody": "L'assunzione conterà per questo slot esattamente come se fosse stata registrata lì: puntuale o in ritardo a seconda di quando è stata presa, quindi la percentuale non viene abbellita. L'attribuzione può essere rimossa in qualsiasi momento.",
"confirmCancel": "Annulla",
"confirmAction": "Attribuisci"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "배정을 해제했어요",
"dueContext": "예정 시각: {time} ({delta})",
"confirmTitle": "{time} 시간대에 배정할까요?",
"confirmBody": "이 복용은 해당 시간대에 늦게 복용한 것으로 계산돼요 – 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
"confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
"confirmCancel": "취소",
"confirmAction": "배정"
}
+1 -1
View File
@@ -1795,7 +1795,7 @@
"unpinnedToast": "Przypisanie usunięte",
"dueContext": "termin: {time} ({delta})",
"confirmTitle": "Przypisać do slotu {time}?",
"confirmBody": "Dawka zostanie policzona jako przyjęta z opóźnieniem dla tego slotu – wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
"confirmBody": "Dawka zostanie policzona dla tego slotu tak, jakby została w nim zapisana: jako przyjęta na czas lub z opóźnieniem, zależnie od tego, kiedy ją przyjęto, więc wskaźnik nie jest zawyżany. Przypisanie można w każdej chwili usunąć.",
"confirmCancel": "Anuluj",
"confirmAction": "Przypisz"
}
@@ -0,0 +1,27 @@
-- The today projector mints a pending placeholder for every slot of the
-- current day, and the hourly auto-miss pass used to stamp every stale one a
-- miss. For a medication added in the afternoon that turned the placeholders
-- of that day's earlier slots, which were never expected doses, into
-- forgotten doses: the miss-free streak broke and the Coach read a miss on a
-- day before the medication existed. The pass no longer stamps them; this
-- returns the ones it already stamped to pending.
--
-- Only rows that can be nothing but such a placeholder are touched: minted by
-- the server (source REMINDER), never acted on (no take, no skip, live), and
-- anchored before the medication's creation but on its creation day. The
-- projector mints for the current local day only, which begins less than a
-- day before the creation, so the 24-hour bound is exact for placeholders and
-- leaves any older auto-miss (history carried in by a restore) as it was.
-- `sync_version` bumps so sync clients pick the change up.
UPDATE "medication_intake_events" AS e
SET "auto_missed" = false,
"sync_version" = e."sync_version" + 1
FROM "medications" AS m
WHERE e."medication_id" = m."id"
AND e."auto_missed" = true
AND e."taken_at" IS NULL
AND e."skipped" = false
AND e."deleted_at" IS NULL
AND e."source" = 'REMINDER'
AND e."scheduled_for" < m."created_at"
AND e."scheduled_for" >= m."created_at" - INTERVAL '24 hours';
@@ -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,
@@ -0,0 +1,91 @@
/**
* #1028 — the Coach's adherence over a medication added mid-day.
*
* The Coach reads a 30-day window, which starts long before a medication
* added two days ago. Neither the days before it existed nor the creation
* day's earlier slot (holding only the projector's auto-missed placeholder)
* may read as missed, and the creation day's 14:00 dose, recorded afterwards
* on its own slot, counts as taken: the Coach quotes what the medication
* card and the dose history show.
*/
import { expect, it, vi } from "vitest";
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { buildComplianceBlock } from "../compliance-block";
import { localHmAsUtc } from "@/lib/tz/local-day";
const DAY_MS = 24 * 60 * 60 * 1000;
for (const tz of ["Asia/Kolkata", "America/New_York"]) {
it(`quotes full adherence for a medication added mid-day (${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 now = d2(22, 0);
const take = (slot: Date, takenAt: Date) => ({
scheduledFor: slot,
takenAt,
skipped: false,
autoMissed: false,
});
const snapshot: Record<string, unknown> = {};
buildComplianceBlock({
complianceMeds: [
{
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: d1(16, 5),
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,
},
],
intakeEvents: [
{
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)),
],
},
],
userTz: tz,
cutoff: new Date(now.getTime() - 30 * DAY_MS),
recentCutoff: new Date(now.getTime() - 7 * DAY_MS),
now,
snapshot,
metrics: new Set(),
counts: {},
registerBlock: () => {},
});
const compliance = snapshot.compliance as {
rate: number;
timeline: { recent: Array<{ taken: number; total: number }> };
};
expect(compliance.rate).toBe(100);
expect(compliance.timeline.recent.map((r) => [r.taken, r.total])).toEqual([
[2, 2],
[3, 3],
]);
});
}
@@ -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
+5 -1
View File
@@ -998,13 +998,17 @@ export async function buildFullBackupPayload(
reorderLeadDays: m.reorderLeadDays,
externalSource: m.externalSource,
externalId: m.externalId,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
: {}),
name: m.name,
dose: m.dose,
active: m.active,
// Both purposes carry the creation instant: it is the floor of the
// medication's expected slots, so a restore stamped with the restore
// time would read the whole restored history as predating the
// medication and drop its misses from every rate (#1028).
createdAt: m.createdAt.toISOString(),
schedules: m.schedules.map((s) => ({
...(disasterRecovery
? {
@@ -54,6 +54,8 @@ interface FakeScheduleRow {
interface FakeMedication {
id: string;
/** Defaults to long before every fixture row when omitted. */
createdAt?: Date;
schedules: FakeScheduleRow[];
}
@@ -62,8 +64,9 @@ interface FakeWhere {
autoMissed?: boolean;
takenAt?: null;
deletedAt?: null;
medicationId?: { in: string[] };
scheduledFor?: { lt: Date };
medicationId?: string | { in: string[] };
scheduledFor?: { lt?: Date; gte?: Date };
OR?: FakeWhere[];
}
function matches(row: FakeIntakeRow, where: FakeWhere): boolean {
@@ -74,10 +77,22 @@ function matches(row: FakeIntakeRow, where: FakeWhere): boolean {
if (where.takenAt === null && row.takenAt !== null) return false;
if (where.deletedAt === null && row.deletedAt !== null) return false;
if (
where.medicationId !== undefined &&
typeof where.medicationId === "string" &&
row.medicationId !== where.medicationId
)
return false;
if (
typeof where.medicationId === "object" &&
!where.medicationId.in.includes(row.medicationId)
)
return false;
if (
where.scheduledFor?.gte !== undefined &&
row.scheduledFor.getTime() < where.scheduledFor.gte.getTime()
)
return false;
if (where.OR !== undefined && !where.OR.some((w) => matches(row, w)))
return false;
if (
where.scheduledFor?.lt !== undefined &&
row.scheduledFor.getTime() >= where.scheduledFor.lt.getTime()
@@ -128,7 +143,12 @@ function makeFakePrisma(state: {
},
medication: {
findMany: vi.fn(async ({ where }: { where: { id: { in: string[] } } }) =>
state.medications.filter((m) => where.id.in.includes(m.id)),
state.medications
.filter((m) => where.id.in.includes(m.id))
.map((m) => ({
createdAt: new Date("2020-01-01T00:00:00Z"),
...m,
})),
),
},
} as unknown as PrismaClient;
@@ -445,6 +465,29 @@ describe("runIntakeAutoSkipPass", () => {
expect(rows[0].autoMissed).toBe(false);
});
// #1028 — the today projector mints a pending placeholder for every slot
// of the day, including the slots before the medication was added. Such a
// slot was never expected, so its placeholder must never become a miss:
// the miss-free streak, the Coach and every other raw-row reader would
// count it.
it("never flips a placeholder whose slot predates the medication", async () => {
const createdAt = new Date(NOW_MS - 30 * HOUR_MS);
const rows = [
pendingRow("i-before", "med-new", new Date(NOW_MS - 37 * HOUR_MS)),
pendingRow("i-after", "med-new", new Date(NOW_MS - 25 * HOUR_MS)),
pendingRow("i-old-med", "med-daily", new Date(NOW_MS - 37 * HOUR_MS)),
];
const prisma = makeFakePrisma({
rows,
medications: [{ ...DAILY_MED, id: "med-new", createdAt }, DAILY_MED],
});
const result = await runIntakeAutoSkipPass(prisma, { nowMs: NOW_MS });
expect(rows.find((r) => r.id === "i-before")?.autoMissed).toBe(false);
expect(rows.find((r) => r.id === "i-after")?.autoMissed).toBe(true);
expect(rows.find((r) => r.id === "i-old-med")?.autoMissed).toBe(true);
expect(result.skippedCount).toBe(2);
});
it("uses Date.now() when no nowMs override is supplied", async () => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
+22 -5
View File
@@ -34,7 +34,17 @@
* * `auto_missed = false` AND
* * `takenAt IS NULL` AND
* * `deletedAt IS NULL` AND
* * `scheduledFor < NOW() - <per-medication auto-miss delay>`
* * `scheduledFor < NOW() - <per-medication auto-miss delay>` AND
* * `scheduledFor >= medication.createdAt`
*
* The last clause (#1028): the today projector mints a pending placeholder
* for every slot of the current day, so a medication added at 16:00 carries
* placeholders for its 09:00 and 14:00 slots. Those slots were never
* expected, and a placeholder on one records nothing. Stamping it a miss
* put a forgotten dose into every reader that counts raw rows (the miss-free
* streak, the Coach snapshot) for a day before the medication existed. It
* stays pending instead: the dose history drops it, and a dose the person
* does record for that slot still converges onto it.
*
* The 24 h floor is intentional: a slightly late mark (user took the
* morning dose at noon when the schedule was 09:00) shouldn't be flipped
@@ -228,6 +238,7 @@ export async function runIntakeAutoSkipPass(
where: { id: { in: [...usersByMedication.keys()] } },
select: {
id: true,
createdAt: true,
schedules: {
select: { rrule: true, rollingIntervalDays: true, doseWindows: true },
},
@@ -236,22 +247,28 @@ export async function runIntakeAutoSkipPass(
// Group medications by their derived delay so one `updateMany` covers
// each distinct cutoff instead of one query per medication.
const medsByDelay = new Map<number, string[]>();
const medsByDelay = new Map<number, Array<{ id: string; createdAt: Date }>>();
for (const medication of medications) {
const delayMs = medicationAutoMissDelayMs(medication.schedules);
const group = medsByDelay.get(delayMs) ?? [];
group.push(medication.id);
group.push({ id: medication.id, createdAt: medication.createdAt });
medsByDelay.set(delayMs, group);
}
let skippedCount = 0;
const invalidatedUserIds = new Set<string>();
for (const [delayMs, medicationIds] of medsByDelay) {
for (const [delayMs, group] of medsByDelay) {
const medicationIds = group.map((m) => m.id);
const { count } = await prisma.medicationIntakeEvent.updateMany({
where: {
...pendingWhere,
medicationId: { in: medicationIds },
scheduledFor: { lt: new Date(nowMs - delayMs) },
// Per medication, only slots from its creation on: a placeholder
// for a slot before it was never an expected dose (see above).
OR: group.map((m) => ({
medicationId: m.id,
scheduledFor: { gte: m.createdAt },
})),
},
// `syncVersion` bumps so delta-sync clients pick up the terminal
// state instead of holding a stale pending row forever.
@@ -0,0 +1,86 @@
/**
* The compliance heatmap counts a day's ledger rows. An off-schedule row
* (one that matched no slot) is a real dose only when it carries a taken
* time; an orphaned skip or auto-miss on an instant that is no slot of the
* schedule records no dose and must never colour the day as taken.
*/
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";
const TZ = "Europe/Berlin";
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
it("counts an off-schedule row as taken only when it has a taken time", async () => {
const day = new Date("2026-06-10T12:00:00Z");
const at = (h: number, m = 0) => localHmAsUtc(day, TZ, h, m);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(at(23, 0));
const row = (
scheduledFor: Date,
takenAt: Date | null,
flags: { skipped?: boolean; autoMissed?: boolean } = {},
) => ({
scheduledFor,
takenAt,
skipped: flags.skipped ?? false,
autoMissed: flags.autoMissed ?? false,
attributionSource: "AUTO",
});
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
// The 08:00 slot, taken on time.
row(at(8), at(8)),
// A real off-schedule take in the afternoon.
row(at(15), at(15)),
// Orphans on instants that are no slot of the schedule.
row(at(11), null, { skipped: true }),
row(at(12), null, { autoMissed: true }),
] as never);
const payload = await buildCompliancePayload(
{
id: "med-1",
createdAt: new Date("2026-01-01T00:00:00Z"),
startsOn: null,
endsOn: null,
oneShot: false,
asNeeded: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
daysOfWeek: null,
rrule: null,
rollingIntervalDays: null,
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
},
],
},
"user-1",
TZ,
);
expect(payload.dailyCompliance[userDayKey(at(12), TZ)]).toMatchObject({
// The slot, the real off-schedule take and the orphaned skip.
expected: 3,
taken: 2,
onTime: 2,
skipped: 1,
});
});
@@ -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,
});
});
}
+28 -23
View File
@@ -107,10 +107,14 @@ export function complianceCacheKey(
* Slot rows contribute to `expected` / `expectedCount` (the day's due-slot
* count): a taken slot adds to `taken` and its timing bucket; a missed slot
* stays uncounted in `taken`; a skipped slot lands in `skipped`; an upcoming
* slot is still due but not yet acted on. An ad-hoc row is a real off-schedule
* take — it counts as `taken` AND adds its own `expected` slot (so the
* heatmap's `missed = expected − taken − skipped` math stays non-negative) and
* reads on-time (a logged dose colours green).
* slot is still due but not yet acted on. An ad-hoc row with a taken time is
* a real off-schedule take — it counts as `taken` AND adds its own `expected`
* slot (so the heatmap's `missed = expected − taken − skipped` math stays
* non-negative) and reads on-time (a logged dose colours green). An ad-hoc row
* without a taken time is an orphaned skip or auto-miss on an instant that is
* no slot of the schedule: it records no dose, so it never counts as taken. A
* skip still reads as skipped; an orphaned auto-miss has no slot to have
* missed and counts nothing, as it does in the rate.
*/
function bucketLedgerRow(
entry: DailyComplianceEntry,
@@ -145,12 +149,19 @@ function bucketLedgerRow(
entry.expectedCount++;
break;
case "ad_hoc":
// An off-schedule take: a real taken dose with no scheduled slot. Count
// it as taken + its own expected slot so the heatmap missed math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
if (row.intake?.takenAt) {
// An off-schedule take: a real taken dose with no scheduled slot.
// Count it as taken + its own expected slot so the heatmap missed
// math holds.
entry.expected++;
entry.expectedCount++;
entry.taken++;
entry.onTime++;
} else if (row.intake?.skipped) {
entry.expected++;
entry.expectedCount++;
entry.skipped++;
}
break;
}
}
@@ -184,14 +195,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 +249,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>();
@@ -15,7 +15,11 @@
*/
import { describe, expect, it } from "vitest";
import { buildSlotBands, type SlotWindowInput } from "../attribution";
import {
attributeIntakeToSlot,
buildSlotBands,
type SlotWindowInput,
} from "../attribution";
import {
reconstructDoseHistory,
suggestNearestSlot,
@@ -60,7 +64,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 +75,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 +89,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 +103,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 +116,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 +126,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 +146,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 +169,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 +183,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 +207,7 @@ describe("reconstructDoseHistory", () => {
weeklyBands,
[intake({ scheduledFor: weeklyAt })],
twoDaysLater,
null,
);
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe("upcoming");
@@ -210,6 +223,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 +235,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 +246,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 +265,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,17 +281,51 @@ 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");
expect(morning?.pinned).toBe(true);
});
// A pinned take reads exactly as the same take attributed on its own:
// on time from the early grace before the on-time window through the
// window's end, late otherwise. Pinning never changes the timing.
it("a pinned early take reads the same as the unpinned take", () => {
for (const [h, m] of [
[6, 15],
[5, 30],
[4, 30],
] as const) {
const takenAt = at(h, m);
const pinned = reconstructDoseHistory(
bands,
[intake({ takenAt, scheduledFor: at(7, 0), pinned: true })],
nowEvening,
null,
).find((r) => r.timeOfDay === "07:00")?.status;
const unpinned = attributeIntakeToSlot(takenAt, bands);
const expected =
unpinned?.band.timeOfDay === "07:00"
? unpinned.status === "on_time"
? "taken_on_time"
: "taken_late"
: // Outside every capture band on its own: a pin can only make
// it late, never on time.
"taken_late";
expect(pinned, `${h}:${m}`).toBe(expected);
}
// 05:30 lies in the early grace (on time either way), 04:30 before it.
expect(attributeIntakeToSlot(at(5, 30), bands)?.status).toBe("on_time");
expect(attributeIntakeToSlot(at(4, 30), bands)).toBeNull();
});
it("a pin inside the late tail reads taken_late, never flattered", () => {
const rows = 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 +345,7 @@ describe("reconstructDoseHistory", () => {
}),
],
nowEvening,
null,
);
const morning = rows.find((r) => r.timeOfDay === "07:00");
expect(morning?.intake?.id).toBe("pin");
@@ -315,6 +367,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 +385,7 @@ describe("reconstructDoseHistory", () => {
}),
],
nowEvening,
null,
);
const adhoc = rows.filter((r) => r.kind === "ad_hoc");
expect(adhoc).toHaveLength(1);
@@ -345,6 +399,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 +419,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 +437,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 +451,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 +466,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 +480,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 +518,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 +555,169 @@ 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"]);
});
});
/**
* #1028 — history recorded before the medication was created (an import, a
* restore from an older file). Only what the history records counts: a slot
* no row names stays absent rather than being invented as missed, and a miss
* the history does record is not hidden. The projector's placeholders on the
* creation day are the one pre-creation auto-miss that records nothing.
*/
describe("reconstructDoseHistory — history from before the creation (#1028)", () => {
// Created two days after `day`, so both of `day`'s slots predate it.
const createdAt = new Date(at(12, 0).getTime() + 2 * 24 * 60 * MIN);
const statusRows = (rows: ReturnType<typeof reconstructDoseHistory>) =>
rows.map((r) => [r.kind, r.timeOfDay, r.status, r.intake?.id ?? null]);
it("keeps a carried-in auto-miss as the miss it records", () => {
const rows = reconstructDoseHistory(
bands,
[
intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) }),
intake({ id: "missed", scheduledFor: at(19, 0), autoMissed: true }),
],
nowEvening,
createdAt,
);
expect(statusRows(rows)).toEqual([
["slot", "07:00", "taken_on_time", "taken"],
["slot", "19:00", "missed", "missed"],
]);
});
it("never invents a miss for a slot the history does not name", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "taken", scheduledFor: at(7, 0), takenAt: at(7, 10) })],
nowEvening,
createdAt,
);
expect(statusRows(rows)).toEqual([
["slot", "07:00", "taken_on_time", "taken"],
]);
});
it("still drops a pending row carried in from before the creation", () => {
const rows = reconstructDoseHistory(
bands,
[intake({ id: "pending", scheduledFor: at(19, 0) })],
nowEvening,
createdAt,
);
expect(rows).toEqual([]);
});
});
@@ -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) {
+70 -8
View File
@@ -22,15 +22,20 @@
* `attributionSource = USER_PIN`) binds by its `scheduledFor` anchor like
* a skip, NOT by takenAt-band membership, so a pin outside the late tail
* never degrades back to ad-hoc. Status is `taken_late` unless the
* takenAt happens to sit inside the slot's on-time band — a pin can
* never flatter the timing;
* takenAt sits where the take would read on time on its own (the
* on-time band plus its bounded early grace) — a pin never changes the
* timing;
* - a RELEASED pin (v1.16.0 — "Zuordnung lösen" persists `USER_PIN` with
* `scheduledFor === takenAt`) is a deliberately ad-hoc take: it never
* anchor-binds (not even when its instant sits within epsilon of a slot
* 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 row claims it (#1028).
* See `expectedFrom` below for why the boundary is the creation and
* which pre-creation rows count as recorded.
*
* Pure / synchronous / instant-based — the caller mints the bands DST-correctly
* via `localHmAsUtc` and supplies the per-dose window.
@@ -125,6 +130,13 @@ export interface DoseHistoryRow {
nearestSlot?: NearestSlotContext;
}
/**
* How far before a medication's creation the today projector's placeholders
* can sit: it mints the creation's local day only, which starts less than a
* day before the creation.
*/
const PLACEHOLDER_REACH_MS = 24 * 60 * 60 * 1000;
/** Sub-minute slop for binding an anchored (skip/pending) row to its slot. */
const ANCHOR_EPSILON_MS = 60_000;
@@ -136,7 +148,46 @@ export function reconstructDoseHistory(
bands: SlotBand[],
intakes: HistoryIntake[],
now: Date,
/**
* The instant from which slots are expected: the medication's creation.
* `null` treats every band as expected (a pure-math fixture).
*
* Before it, a slot appears only when a recorded row claims it. Rows get
* there three ways: a dose logged for earlier on the creation day, history
* imported from another app, and history restored from a backup. The
* boundary is chosen so that nothing is invented and nothing recorded is
* hidden:
*
* - An earlier boundary (the first recorded row, or the course start
* date) would read every slot between it and the creation that no row
* names as missed. Nothing supports that reading: an import carries
* takes and skips only (reminder and "no dose information" rows are
* dropped on the way in), a partial or single back-dated entry names a
* few days, and a course start says when the person began, not that
* the unlogged days were missed. Those misses would be invented.
* - With the creation as the boundary, every recorded take (by its
* window or its pin) and skip still claims its slot, and a recorded
* miss does too: an auto-miss anchored more than a day before the
* creation came in with the history (a restore), and it keeps its
* slot as missed.
* - The rows that record nothing are dropped: a pending row, and an
* auto-miss within the day before the creation. The today projector
* mints a placeholder for every slot of the creation day, and a
* placeholder anchored before the creation is the only pre-creation
* auto-miss the server itself can have produced; the day's start is
* less than 24 hours before the creation, which bounds them.
*/
expectedFrom: Date | null,
): DoseHistoryRow[] {
const expectedFromMs = expectedFrom?.getTime() ?? -Infinity;
const beforeExpected = (at: Date): boolean => at.getTime() < expectedFromMs;
// A pre-creation row that records nothing: pending, or an auto-miss inside
// the creation day's placeholder reach (see `expectedFrom`).
const recordsNothing = (i: HistoryIntake): boolean => {
if (i.skipped || !beforeExpected(i.scheduledFor)) return false;
if (!i.autoMissed) return true;
return i.scheduledFor.getTime() >= expectedFromMs - PLACEHOLDER_REACH_MS;
};
// Per-band claim: the intake attributed to it + the resolved status.
const claim = new Map<
SlotBand,
@@ -155,6 +206,9 @@ export function reconstructDoseHistory(
const taken = intakes.filter((i) => i.takenAt !== null && !i.pinned);
for (const i of anchored) {
// A pre-creation placeholder records nothing: drop it whether or not
// its slot was minted.
if (recordsNothing(i)) continue;
const band = nearestAnchorBand(i.scheduledFor, bands);
if (band && !claim.has(band)) {
// Status is time-aware for a pending row: the projector / reminder
@@ -184,8 +238,9 @@ export function reconstructDoseHistory(
// v1.15.20 — pinned takes bind by their stored slot anchor, NOT by
// takenAt-band membership: the whole point of a pin is that the take sits
// outside (or past the tail of) the band it belongs to. Status never
// flatters: taken_late, unless the takenAt happens to sit inside the
// slot's own on-time band anyway. A pin whose slot is gone (schedule
// flatters: taken_late, unless the takenAt sits where band attribution
// would read it on time anyway, the early grace included, so the same
// take reads the same pinned or not. A pin whose slot is gone (schedule
// changed) or already claimed falls through to ad-hoc so nothing vanishes.
for (const i of pinnedTaken) {
// v1.16.0 — a released pin ("Zuordnung lösen") persists USER_PIN with
@@ -201,7 +256,8 @@ export function reconstructDoseHistory(
if (band && !claim.has(band)) {
const t = (i.takenAt as Date).getTime();
const onTime =
t >= band.onTimeStart.getTime() && t <= band.onTimeEnd.getTime();
t >= (band.earlyStart ?? band.onTimeStart).getTime() &&
t <= band.onTimeEnd.getTime();
claim.set(band, {
intake: i,
status: onTime ? "taken_on_time" : "taken_late",
@@ -224,7 +280,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 +319,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 row (a take, a skip, or a miss carried in with restored history) 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 row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned.",
),
to: z.iso
.datetime({ offset: true })
@@ -2890,4 +2890,63 @@ describe("every model the plan claims two-ended survives a real restore", () =>
UNREADABLE_EXPORT_MARKER,
);
});
/**
* #1028 — a medication's creation instant is the floor of its expected
* slots: every adherence surface counts a slot before it only when a
* recorded dose claims it. A restore that stamped the restore time as the
* creation turned the whole restored history into "before the medication
* existed", so its misses dropped out of the rate. Both file purposes must
* bring the creation instant back.
*/
for (const purpose of ["portable-export", "disaster-recovery"] as const) {
it(`restores a medication's creation instant from a ${purpose} file`, async () => {
const prisma = getPrismaClient();
await seedAdminSession(prisma);
await createOwner(prisma);
const createdAt = AT("2025-11-03T16:05:00.000Z");
await prisma.medication.create({
data: {
userId: OWNER_ID,
name: "Metformin",
dose: "500mg",
createdAt,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
},
},
},
});
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
purpose,
});
await prisma.user.delete({ where: { id: OWNER_ID } });
await createOwner(prisma);
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "TWO_ENDED_ROUND_TRIP",
data: encrypt(JSON.stringify(payload)),
},
});
const response = await POST(
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ confirm: "RESTORE" }),
}) as never,
{ params: Promise.resolve({ id: backup.id }) },
);
expect(response.status, JSON.stringify(await response.json())).toBe(200);
const restored = await prisma.medication.findFirstOrThrow({
where: { userId: OWNER_ID, name: "Metformin" },
});
expect(restored.createdAt.toISOString()).toBe(createdAt.toISOString());
});
}
});
@@ -0,0 +1,295 @@
/**
* #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 { readFileSync } from "node:fs";
import { join } from "node:path";
import { getMissFreeDayKeys } from "@/lib/gamification/care-metrics";
import { runIntakeAutoSkipPass } from "@/lib/jobs/intake-auto-skip";
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,
});
});
}
});
/**
* The placeholder itself. The auto-miss pass must leave a placeholder on a
* slot before the medication existed pending, so the raw-row readers (the
* miss-free streak first) never see a forgotten dose on the creation day;
* and the migration returns the ones an earlier pass already stamped,
* without touching an auto-miss that history carried in from before.
*/
describe("placeholders on slots before the medication existed (#1028)", () => {
const tz = "Asia/Kolkata";
async function seed() {
const prisma = getPrismaClient();
const user = await signedInUser(tz);
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",
createdAt: at(16, 5),
schedules: {
create: {
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00", "14:00", "21:00"],
scheduleType: "SCHEDULED",
},
},
},
});
const row = (
scheduledFor: Date,
extra: { takenAt?: Date; autoMissed?: boolean } = {},
) =>
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: med.id,
scheduledFor,
takenAt: extra.takenAt ?? null,
skipped: false,
autoMissed: extra.autoMissed ?? false,
source: "REMINDER",
},
});
return { prisma, user, med, at, row };
}
it("the auto-miss pass leaves it pending and the miss-free streak keeps the day", async () => {
const { prisma, user, at, row } = await seed();
const placeholder = await row(at(9));
await row(at(14), { takenAt: at(14) });
await row(at(21), { takenAt: at(21) });
await runIntakeAutoSkipPass(prisma, { nowMs: Date.now() });
const after = await prisma.medicationIntakeEvent.findUniqueOrThrow({
where: { id: placeholder.id },
});
expect(after.autoMissed).toBe(false);
const events = await prisma.medicationIntakeEvent.findMany({
where: { userId: user.id, deletedAt: null },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
},
});
expect(getMissFreeDayKeys(events, tz)).toContain(userDayKey(at(12), tz));
});
it("the migration returns an already stamped placeholder and nothing else", async () => {
const { prisma, at, row } = await seed();
const placeholder = await row(at(9), { autoMissed: true });
// An auto-miss from well before the creation (history a restore carried
// in) and one after it are real misses and stay.
const carried = await row(new Date(at(9).getTime() - 3 * DAY_MS), {
autoMissed: true,
});
const afterCreation = await row(at(21), { autoMissed: true });
const sql = readFileSync(
join(
process.cwd(),
"prisma/migrations/0346_release_pre_creation_placeholder_misses/migration.sql",
),
"utf8",
);
await prisma.$executeRawUnsafe(sql);
const read = (id: string) =>
prisma.medicationIntakeEvent.findUniqueOrThrow({ where: { id } });
const released = await read(placeholder.id);
expect(released.autoMissed).toBe(false);
expect(released.syncVersion).toBe(placeholder.syncVersion + 1);
expect((await read(carried.id)).autoMissed).toBe(true);
expect((await read(afterCreation.id)).autoMissed).toBe(true);
});
});
@@ -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: {