mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(medications): attribute doses to slots from before the medication existed
A medication added at 16:05 with doses at 09:00, 14:00 and 21:00 showed its creation day wrongly in "Last 90 days". The 14:00 dose, recorded afterwards on its own slot, read as an off-schedule take "due" at 21:00, and a 09:00 row with no time appeared as off-schedule too. The history route minted slots only from the creation instant, so the 14:00 slot never existed for the dose to land on, while the write path, which mints the whole day, had filed it there. The 09:00 row was the pending placeholder the today projector mints for every slot of the day, which the auto-miss pass stamped a day later; with no slot to bind to it surfaced as an off-schedule row. The ledger now mints slots over the requested window and takes the creation instant as the floor of expected slots: a slot before it appears only when a recorded dose claims it (a take by its window or its pin, or a skip), and a pending or auto-missed placeholder on it is dropped. The first archived schedule era also covers the stretch before the creation, so an edited schedule behaves the same. Every adherence surface tallies the same ledger with the same floor, so the detail rates, the calendar and the ledger bar agree on that day. The doctor report, the Coach and the adherence chips previously read the slots before a medication existed as missed whenever their window started earlier; they now read nothing there. Refs #1028
This commit is contained in:
1 parent
9328a4298d
commit
fe7cccfd46
19 files changed
+839
-70
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user