mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(medications): attribute historical intake edits against the era live then
The write/edit slot resolver loaded a medication's archived schedule revisions but dropped them before running band attribution, so editing a historical intake snapped it onto the LIVE schedule's slots instead of the era that was valid at the dose's time. The read side (dose-history, compliance) already era-resolves; the write side now matches. `loadAttributeMedication` threads `scheduleRevisions` into the attributor projection. Both `resolveSlotForWriteByBand` and `resolveForcedSlotForWrite` now pick the correct era for any past instant.
This commit is contained in:
1 parent
bb59018ac9
commit
25a7834dec
2 files changed
+167
No files matched your search
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* v1.16.13 — schedule-era correctness for the WRITE/EDIT slot resolution seam.
|
||||
*
|
||||
* `resolveSlotForWriteByBand` (and `resolveForcedSlotForWrite`) load the
|
||||
* medication via `loadAttributeMedication` and run the shared band attributor.
|
||||
* The loader MUST thread the archived `scheduleRevisions` into the attributor
|
||||
* projection — otherwise a historical intake edit attributes against the LIVE
|
||||
* schedule instead of the era that was valid at the dose's `takenAt`, silently
|
||||
* misfiling old doses after a times edit.
|
||||
*
|
||||
* The read side (dose-history / compliance) already threads revisions; this
|
||||
* pins the write side to the same era resolution.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
resolveSlotForWriteByBand,
|
||||
resolveForcedSlotForWrite,
|
||||
} from "../slot-upsert";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const createdAt = new Date("2026-05-01T08:00:00.000Z");
|
||||
// The schedule was replaced on 31 May at 10:00 UTC (12:00 Berlin):
|
||||
// old era ran 07:00 / 19:00 Berlin; the live schedule runs 09:00 / 21:00.
|
||||
const replaceAt = new Date("2026-05-31T10:00:00.000Z");
|
||||
|
||||
const liveSchedule = {
|
||||
id: "live-1",
|
||||
windowStart: "09:00",
|
||||
windowEnd: "21:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["09:00", "21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
const oldEraRevision = {
|
||||
id: "rev-1",
|
||||
validFrom: createdAt,
|
||||
validUntil: replaceAt,
|
||||
supersededByRevisionId: null,
|
||||
payload: [
|
||||
{
|
||||
timesOfDay: ["07:00", "19:00"],
|
||||
windowStart: "07:00",
|
||||
windowEnd: "19:00",
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
label: null,
|
||||
dose: null,
|
||||
reminderGraceMinutes: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Fake Prisma client whose `medication.findFirst` returns a med carrying the
|
||||
* archived old-era revision exactly as `MEDICATION_SELECT` would, plus the
|
||||
* live (post-replace) schedules.
|
||||
*/
|
||||
function makeClient(withRevisions: boolean) {
|
||||
return {
|
||||
medication: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt,
|
||||
schedules: [liveSchedule],
|
||||
scheduleRevisions: withRevisions ? [oldEraRevision] : [],
|
||||
})),
|
||||
},
|
||||
medicationIntakeEvent: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BandClient = Parameters<typeof resolveSlotForWriteByBand>[0]["client"];
|
||||
|
||||
describe("resolveSlotForWriteByBand — schedule eras", () => {
|
||||
it("attributes a historical edit against the era live at the takenAt", async () => {
|
||||
const client = makeClient(true) as unknown as BandClient;
|
||||
// Editing a take back to 25 May 07:10 Berlin (05:10 UTC) — old era's
|
||||
// 07:00 slot, NOT the live 09:00 slot.
|
||||
const takenAt = new Date("2026-05-25T05:10:00.000Z");
|
||||
const out = await resolveSlotForWriteByBand({
|
||||
userId: "u1",
|
||||
medicationId: "med-1",
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
client,
|
||||
});
|
||||
expect(out.slotInstant?.toISOString()).toBe("2026-05-25T05:00:00.000Z");
|
||||
expect(out.status).toBe("on_time");
|
||||
});
|
||||
|
||||
it("without revisions the same instant does NOT snap to the old 07:00 slot", async () => {
|
||||
const client = makeClient(false) as unknown as BandClient;
|
||||
const takenAt = new Date("2026-05-25T05:10:00.000Z");
|
||||
const out = await resolveSlotForWriteByBand({
|
||||
userId: "u1",
|
||||
medicationId: "med-1",
|
||||
userTz: TZ,
|
||||
takenAt,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
client,
|
||||
});
|
||||
// Live schedule (09:00 / 21:00) would not place a 07:10 take on a 07:00
|
||||
// anchor — proves the revision is what fixes the attribution.
|
||||
expect(out.slotInstant?.toISOString()).not.toBe(
|
||||
"2026-05-25T05:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveForcedSlotForWrite — schedule eras", () => {
|
||||
it("validates an old-era slot anchor against the era live then", async () => {
|
||||
const client = makeClient(true) as unknown as BandClient;
|
||||
// Pin onto the old era's 07:00 Berlin slot on 25 May (05:00 UTC).
|
||||
const slotInstant = new Date("2026-05-25T05:00:00.000Z");
|
||||
const resolved = await resolveForcedSlotForWrite({
|
||||
userId: "u1",
|
||||
medicationId: "med-1",
|
||||
userTz: TZ,
|
||||
slotInstant,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
client,
|
||||
});
|
||||
expect(resolved?.toISOString()).toBe("2026-05-25T05:00:00.000Z");
|
||||
});
|
||||
|
||||
it("rejects the old-era anchor when revisions are dropped", async () => {
|
||||
const client = makeClient(false) as unknown as BandClient;
|
||||
const slotInstant = new Date("2026-05-25T05:00:00.000Z");
|
||||
const resolved = await resolveForcedSlotForWrite({
|
||||
userId: "u1",
|
||||
medicationId: "med-1",
|
||||
userTz: TZ,
|
||||
slotInstant,
|
||||
now: new Date("2026-06-05T00:00:00.000Z"),
|
||||
client,
|
||||
});
|
||||
// The live schedule mints no 07:00 anchor, so the pin can't validate.
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -495,6 +495,12 @@ async function loadAttributeMedication(input: {
|
||||
oneShot: medication.oneShot,
|
||||
createdAt: medication.createdAt,
|
||||
schedules: medication.schedules as WorkerScheduleRow[],
|
||||
// v1.16.13 — thread the archived eras into the attributor projection.
|
||||
// `MEDICATION_SELECT` fetches them, but dropping them here made the
|
||||
// write/edit path attribute a historical intake against the LIVE
|
||||
// schedule, not the era valid at the dose's `takenAt` — the read side
|
||||
// (dose-history, compliance) already era-resolves. Now both match.
|
||||
scheduleRevisions: medication.scheduleRevisions,
|
||||
},
|
||||
lastIntakeAt,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user