mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'fix/dose-history-pre-creation-slots' into release/v1.39.1
This commit is contained in:
commit
e9449ece61
33 files changed
+1446
-103
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1795,7 +1795,7 @@
|
||||
"unpinnedToast": "배정을 해제했어요",
|
||||
"dueContext": "예정 시각: {time} ({delta})",
|
||||
"confirmTitle": "{time} 시간대에 배정할까요?",
|
||||
"confirmBody": "이 복용은 해당 시간대에 늦게 복용한 것으로 계산돼요 – 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
|
||||
"confirmBody": "이 복용은 처음부터 이 시간대에 기록한 것과 똑같이 계산돼요. 복용한 시각에 따라 제시간 또는 늦게 복용한 것으로 계산되어 순응도가 부풀려지지 않아요. 배정은 언제든 해제할 수 있어요.",
|
||||
"confirmCancel": "취소",
|
||||
"confirmAction": "배정"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user