diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts new file mode 100644 index 000000000..84f2e9ba9 --- /dev/null +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -0,0 +1,178 @@ +/** + * v1.15.18 — WRITE/EDIT-path band attribution. + * + * `attributeTakenToSlot` is the write-side counterpart to the read-side + * ledger: both run the SAME shared band minter so a take is bound to a slot + * identically wherever it is decided. These tests pin the behaviour the audit + * called out (HIGH-5: the ±6h snap fully gone) + the late-take force-attribute + * guard (Marc's "diesem Slot zuordnen?" nudge): + * + * - on-time take → its own slot anchor; + * - clearly off-window → ad-hoc (null), NOT the nearest snapped slot; + * - late-but-in-tail → the slot, status late; + * - PRN / no schedule → always ad-hoc (hasExpectedSlots false); + * - force-attribute → resolves only a REAL slot anchor, never an + * arbitrary instant. + */ +import { describe, expect, it } from "vitest"; + +import { + attributeTakenToSlot, + resolveForcedSlotInstant, + type AttributeIntakeMedication, +} from "../attribute-intake"; +import type { WorkerScheduleRow } from "../worker-helpers"; +import { localHmAsUtc } from "@/lib/timezone"; + +const TZ = "Europe/Berlin"; + +function schedule(over: Partial = {}): WorkerScheduleRow { + return { + id: "sched-1", + windowStart: "08:00", + windowEnd: "08:00", + daysOfWeek: null, + timesOfDay: [], + reminderGraceMinutes: null, + rrule: null, + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + ...over, + }; +} + +function med( + schedules: WorkerScheduleRow[], + over: Partial = {}, +): AttributeIntakeMedication { + return { + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00Z"), + schedules, + ...over, + }; +} + +/** Berlin wall-clock HH:mm on the day implied by `dayRef`. */ +function at(dayRef: Date, h: number, m: number): Date { + return localHmAsUtc(dayRef, TZ, h, m); +} + +const DAY = new Date("2026-06-08T12:00:00Z"); // CEST + +describe("attributeTakenToSlot — twice-daily 07:00 / 19:00 (Marc's case)", () => { + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + + it("snaps an on-time morning take to the 07:00 slot", () => { + const takenAt = at(DAY, 7, 5); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 7, 0).getTime()); + expect(out.status).toBe("on_time"); + }); + + it("treats the 11:29 take as ad-hoc, NOT a snapped 07:00 dose (HIGH-5)", () => { + // The retired ±6h snap pulled 11:29 onto 07:00. Band membership orphans it. + const takenAt = at(DAY, 11, 29); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant).toBeNull(); + expect(out.status).toBeNull(); + expect(out.hasExpectedSlots).toBe(true); + }); + + it("treats a 13:02 take as ad-hoc, never the 19:00 slot (HIGH-5)", () => { + const takenAt = at(DAY, 13, 2); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant).toBeNull(); + }); + + it("snaps a slightly-late evening take to the 19:00 slot as late", () => { + // 19:00 + 90min is inside the daily late tail (180min default). + const takenAt = at(DAY, 20, 30); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(DAY, 19, 0).getTime()); + expect(out.status).toBe("late"); + }); +}); + +describe("attributeTakenToSlot — PRN / unscheduled", () => { + it("returns ad-hoc with hasExpectedSlots false for a PRN schedule", () => { + const m = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]); + const out = attributeTakenToSlot({ + medication: m, + userTz: TZ, + takenAt: at(DAY, 14, 0), + }); + expect(out.slotInstant).toBeNull(); + expect(out.hasExpectedSlots).toBe(false); + }); + + it("returns ad-hoc for a medication with no schedules", () => { + const m = med([]); + const out = attributeTakenToSlot({ + medication: m, + userTz: TZ, + takenAt: at(DAY, 7, 0), + }); + expect(out.slotInstant).toBeNull(); + expect(out.hasExpectedSlots).toBe(false); + }); +}); + +describe("attributeTakenToSlot — DST correctness", () => { + it("snaps a winter (CET) on-time take to its slot", () => { + const winterDay = new Date("2026-01-15T12:00:00Z"); // CET + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + const takenAt = at(winterDay, 19, 10); + const out = attributeTakenToSlot({ medication: m, userTz: TZ, takenAt }); + expect(out.slotInstant?.getTime()).toBe(at(winterDay, 19, 0).getTime()); + expect(out.status).toBe("on_time"); + }); +}); + +describe("resolveForcedSlotInstant — the late-take nudge guard", () => { + const m = med([schedule({ timesOfDay: ["07:00", "19:00"] })]); + + it("resolves a real slot anchor the client pins onto", () => { + const slot = at(DAY, 7, 0); + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: slot, + }); + expect(out?.getTime()).toBe(slot.getTime()); + }); + + it("tolerates sub-minute client drift on the pinned anchor", () => { + const slot = new Date(at(DAY, 19, 0).getTime() + 30_000); + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: slot, + }); + expect(out?.getTime()).toBe(at(DAY, 19, 0).getTime()); + }); + + it("refuses an arbitrary off-slot instant (cannot pin a non-slot)", () => { + const out = resolveForcedSlotInstant({ + medication: m, + userTz: TZ, + slotInstant: at(DAY, 11, 29), + }); + expect(out).toBeNull(); + }); + + it("refuses any pin for a PRN medication (no slots)", () => { + const prn = med([schedule({ scheduleType: "PRN", timesOfDay: [] })]); + const out = resolveForcedSlotInstant({ + medication: prn, + userTz: TZ, + slotInstant: at(DAY, 7, 0), + }); + expect(out).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/attribute-intake.ts b/src/lib/medications/scheduling/attribute-intake.ts new file mode 100644 index 000000000..9d329562e --- /dev/null +++ b/src/lib/medications/scheduling/attribute-intake.ts @@ -0,0 +1,228 @@ +/** + * v1.15.18 — band-model intake→slot attribution for the WRITE + EDIT paths. + * + * The write-side counterpart to `tallyComplianceFromLedger` (the read-side + * ledger): both consume the SAME shared band minter so a take is bound to a + * slot identically wherever it is decided. This retires the wide ±6h + * nearest-snap in `resolve-slot-instant.ts` (`snapToleranceMs`) that pulled a + * 13:02 take onto the 19:00 slot and a 15:46 take onto the 07:00 slot. + * + * Given a medication's schedules, the user's timezone, a `takenAt`, and the + * pre-fetched rolling-anchor instants, `attributeTakenToSlot` mints the day's + * `SlotBand[]` (per schedule, never pooled into one minter call — audit + * MEDIUM-9) and runs `attributeIntakeToSlot`: + * + * - the take lands in a slot's band → that slot's canonical anchor instant + * (`band.at`), which the caller writes as `scheduledFor` (snap to slot); + * - the take lands in no band → `null`: an ad-hoc / PRN take the + * caller inserts standalone with `scheduledFor = takenAt`. + * + * PRN / empty / malformed schedules mint no bands (`hasExpectedSlots:false`) + * and therefore always return `null` — every PRN intake is a standalone row. + * + * Pure / synchronous: no DB access. The caller `select`s the schedule columns + * + threads `lastIntakeAt` / `intakeInstants` in (mirrors + * `resolve-slot-instant.ts`). + */ +import { + attributeIntakeToSlot, + type SlotBand, +} from "@/lib/medications/scheduling/attribution"; +import { + buildBandsForSchedules, + type BandMinterMedication, + type DoseWindowConfig, +} from "@/lib/medications/scheduling/band-minter"; +import { + buildCanonicalSchedule, + buildRecurrenceContext, + type WorkerMedicationRow, + type WorkerScheduleRow, +} from "@/lib/medications/scheduling/worker-helpers"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** The medication projection the attributor reads (matches the write `select`). */ +export interface AttributeIntakeMedication extends WorkerMedicationRow { + schedules: WorkerScheduleRow[]; +} + +export interface AttributeTakenInput { + medication: AttributeIntakeMedication; + userTz: string; + /** The real intake instant whose slot we resolve. */ + takenAt: Date; + /** + * Latest non-tombstoned `takenAt` for the medication — anchors the rolling + * retrospective grid's next-due. Pass `null` when unknown / not rolling. + */ + lastIntakeAt?: Date | null; + /** + * Non-skipped intake instants for the medication, ascending. REQUIRED for a + * rolling cadence (the retrospective grid anchors AT each intake); ignored + * otherwise. + */ + intakeInstants?: Date[]; + /** Per-dose window override; defaults to `DOSE_WINDOW_DEFAULTS`. */ + windowConfig?: DoseWindowConfig; + /** Wall-clock reference; defaults to `takenAt`. Injectable for tests. */ + now?: Date; +} + +export interface AttributeTakenResult { + /** + * The canonical slot anchor the take attributes to, or `null` for an ad-hoc + * / PRN take (the caller inserts it standalone with `scheduledFor=takenAt`). + */ + slotInstant: Date | null; + /** on_time / late when matched, null when ad-hoc. */ + status: "on_time" | "late" | null; + /** False for PRN / empty / malformed medications (no slot machinery). */ + hasExpectedSlots: boolean; +} + +/** + * Build the day's bands for every schedule of a medication, pooled into one + * `SlotBand[]`. Bands are minted PER SCHEDULE (no tail clipping across + * schedules), then pooled — each slot is claimed by ≤1 intake downstream, so + * the union is safe (mirrors `tallyComplianceFromLedger`). + */ +export function buildMedicationDayBands(input: { + medication: AttributeIntakeMedication; + userTz: string; + /** The instant whose local day's slots are minted (the take's own time). */ + around: Date; + lastIntakeAt?: Date | null; + intakeInstants?: Date[]; + windowConfig?: DoseWindowConfig; + now?: Date; +}): { bands: SlotBand[]; hasExpectedSlots: boolean } { + const schedules = input.medication.schedules ?? []; + if (schedules.length === 0) { + return { bands: [], hasExpectedSlots: false }; + } + + const bandMinterMedication: BandMinterMedication = { + id: input.medication.id, + startsOn: input.medication.startsOn, + endsOn: input.medication.endsOn, + oneShot: input.medication.oneShot, + createdAt: input.medication.createdAt, + }; + const ctx = buildRecurrenceContext({ + medication: input.medication, + userTz: input.userTz, + lastIntakeAt: input.lastIntakeAt ?? null, + }); + const canonicalSchedules = schedules.map(buildCanonicalSchedule); + + // Mint over the take's local day padded a day each side so a slot near the + // local-midnight boundary (and any DST shift) is still captured — mirrors + // `resolve-slot-instant.ts`. A weekly/rolling band reaches further; its + // minter widens the probe internally, so the padded day window is enough to + // surface the anchor that the take could pair to. + const from = new Date(input.around.getTime() - ONE_DAY_MS); + const to = new Date(input.around.getTime() + ONE_DAY_MS); + + const groups = buildBandsForSchedules({ + medication: bandMinterMedication, + schedules: canonicalSchedules, + ctx, + userTz: input.userTz, + range: { from, to }, + now: input.now ?? input.around, + windowConfig: input.windowConfig, + intakeInstants: input.intakeInstants, + }); + + const bands: SlotBand[] = []; + let hasExpectedSlots = false; + for (const g of groups) { + if (g.hasExpectedSlots) { + hasExpectedSlots = true; + bands.push(...g.bands); + } + } + return { bands, hasExpectedSlots }; +} + +/** + * Attribute a TAKEN intake to its scheduled slot via band membership, or + * `null` when it falls in no band (ad-hoc / PRN). + */ +export function attributeTakenToSlot( + input: AttributeTakenInput, +): AttributeTakenResult { + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication: input.medication, + userTz: input.userTz, + around: input.takenAt, + lastIntakeAt: input.lastIntakeAt, + intakeInstants: input.intakeInstants, + windowConfig: input.windowConfig, + now: input.now ?? input.takenAt, + }); + + if (!hasExpectedSlots) { + return { slotInstant: null, status: null, hasExpectedSlots: false }; + } + + const matched = attributeIntakeToSlot(input.takenAt, bands); + if (!matched) { + return { slotInstant: null, status: null, hasExpectedSlots: true }; + } + return { + slotInstant: matched.band.at, + status: matched.status, + hasExpectedSlots: true, + }; +} + +/** + * Validate that `slotInstant` is a real scheduled slot of `medication` on its + * own local day — the server-side guard behind the "diesem Slot zuordnen?" + * late-take nudge. A client may pin an off-window take onto a chosen slot, but + * only onto a slot the schedule actually mints (never an arbitrary instant). + * Returns the canonical band anchor (which the caller writes as + * `scheduledFor`) when the instant matches a band anchor within a minute, else + * `null`. + */ +export function resolveForcedSlotInstant(input: { + medication: AttributeIntakeMedication; + userTz: string; + /** The slot instant the client asks to pin the take onto. */ + slotInstant: Date; + lastIntakeAt?: Date | null; + intakeInstants?: Date[]; + windowConfig?: DoseWindowConfig; + now?: Date; +}): Date | null { + const { bands, hasExpectedSlots } = buildMedicationDayBands({ + medication: input.medication, + userTz: input.userTz, + around: input.slotInstant, + lastIntakeAt: input.lastIntakeAt, + intakeInstants: input.intakeInstants, + windowConfig: input.windowConfig, + now: input.now ?? input.slotInstant, + }); + if (!hasExpectedSlots) return null; + + const target = input.slotInstant.getTime(); + let best: { at: Date; dist: number } | null = null; + for (const band of bands) { + const dist = Math.abs(band.at.getTime() - target); + if (dist <= ANCHOR_MATCH_EPSILON_MS && (best === null || dist < best.dist)) { + best = { at: band.at, dist }; + } + } + return best?.at ?? null; +} + +/** + * Sub-minute slop for matching a client-supplied force-attribute instant to a + * real band anchor. The client echoes a slot anchor it read from the + * dose-history / today endpoints; sub-minute iOS-vs-server drift must still + * resolve, but an arbitrary off-slot instant must not. + */ +const ANCHOR_MATCH_EPSILON_MS = 60_000; diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 85a135037..88d0d6d4b 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -19,6 +19,11 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma as defaultPrisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant"; +import { + attributeTakenToSlot, + resolveForcedSlotInstant, + type AttributeIntakeMedication, +} from "@/lib/medications/scheduling/attribute-intake"; import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; @@ -387,3 +392,185 @@ export async function resolveSlotInstantForWrite( now: input.now, }); } + +/** + * v1.15.18 — load a medication's schedules + rolling anchors and adapt them to + * the shared band attributor's projection. Mirrors `resolveSlotInstantForWrite` + * but returns the full medication so the caller can run band attribution AND + * the force-attribute guard from one load. + */ +async function loadAttributeMedication(input: { + userId: string; + medicationId: string; + client: PrismaLike; +}): Promise<{ + medication: AttributeIntakeMedication; + lastIntakeAt: Date | null; +} | null> { + const medication = await input.client.medication.findFirst({ + where: { id: input.medicationId, userId: input.userId }, + select: MEDICATION_SELECT, + }); + if (!medication || medication.schedules.length === 0) return null; + + // Rolling cadence anchors off the last logged intake — fetch it only when a + // schedule actually needs it (mirrors the projector's gate). + let lastIntakeAt: Date | null = null; + if (medication.schedules.some((s) => s.rollingIntervalDays !== null)) { + const lastIntake = await input.client.medicationIntakeEvent.findFirst({ + where: { + userId: input.userId, + medicationId: input.medicationId, + deletedAt: null, + takenAt: { not: null }, + }, + orderBy: { takenAt: "desc" }, + select: { takenAt: true }, + }); + lastIntakeAt = lastIntake?.takenAt ?? null; + } + + return { + medication: { + id: medication.id, + startsOn: medication.startsOn, + endsOn: medication.endsOn, + oneShot: medication.oneShot, + createdAt: medication.createdAt, + schedules: medication.schedules as WorkerScheduleRow[], + }, + lastIntakeAt, + }; +} + +export interface ResolveSlotByBandInput { + userId: string; + medicationId: string; + userTz: string; + /** The real intake instant the slot is resolved from. */ + takenAt: Date; + /** Reference "now"; defaults to the current time. */ + now?: Date; + /** Inject a Prisma client/tx in tests; defaults to the app client. */ + client?: PrismaLike; +} + +export interface ResolveSlotByBandResult { + /** Canonical slot anchor the take attributes to, or null (ad-hoc / PRN). */ + slotInstant: Date | null; + /** on_time / late when matched, null when ad-hoc. */ + status: "on_time" | "late" | null; + /** False for PRN / empty / malformed medications (no slot machinery). */ + hasExpectedSlots: boolean; +} + +/** + * v1.15.18 — band-model slot resolution for the intake WRITE/EDIT paths, + * replacing the wide ±6h `snapToleranceMs` nearest-snap. The take is bound to + * a slot by band membership against the SAME minter the read ledger + the + * compliance % consume, so the three surfaces can never disagree. `null` + * means ad-hoc / PRN → the caller inserts a standalone row with + * `scheduledFor = takenAt`. + */ +export async function resolveSlotForWriteByBand( + input: ResolveSlotByBandInput, +): Promise { + const client = input.client ?? defaultPrisma; + const loaded = await loadAttributeMedication({ + userId: input.userId, + medicationId: input.medicationId, + client, + }); + if (!loaded) { + return { slotInstant: null, status: null, hasExpectedSlots: false }; + } + + return attributeTakenToSlot({ + medication: loaded.medication, + userTz: input.userTz, + takenAt: input.takenAt, + lastIntakeAt: loaded.lastIntakeAt, + intakeInstants: await rollingIntakeInstantsIfNeeded( + loaded.medication, + input.userId, + input.medicationId, + input.takenAt, + client, + ), + now: input.now, + }); +} + +export interface ResolveForcedSlotInput { + userId: string; + medicationId: string; + userTz: string; + /** The slot instant the client asks to pin an off-window take onto. */ + slotInstant: Date; + now?: Date; + client?: PrismaLike; +} + +/** + * v1.15.18 — validate the client's "diesem Slot zuordnen?" pin: the supplied + * instant must be a REAL scheduled slot of this medication on its day (within + * a minute of a band anchor). Returns the canonical anchor on a match, or + * `null` when the instant is not a slot — the route then rejects the pin. + */ +export async function resolveForcedSlotForWrite( + input: ResolveForcedSlotInput, +): Promise { + const client = input.client ?? defaultPrisma; + const loaded = await loadAttributeMedication({ + userId: input.userId, + medicationId: input.medicationId, + client, + }); + if (!loaded) return null; + + return resolveForcedSlotInstant({ + medication: loaded.medication, + userTz: input.userTz, + slotInstant: input.slotInstant, + lastIntakeAt: loaded.lastIntakeAt, + intakeInstants: await rollingIntakeInstantsIfNeeded( + loaded.medication, + input.userId, + input.medicationId, + input.slotInstant, + client, + ), + now: input.now, + }); +} + +/** + * Fetch the non-skipped `takenAt` instants (≤ `around`) a rolling cadence + * anchors its retrospective grid on. Returns `undefined` for non-rolling + * medications so the band minter skips the (unused) work. + */ +async function rollingIntakeInstantsIfNeeded( + medication: AttributeIntakeMedication, + userId: string, + medicationId: string, + around: Date, + client: PrismaLike, +): Promise { + if (!medication.schedules.some((s) => s.rollingIntervalDays !== null)) { + return undefined; + } + const rows = await client.medicationIntakeEvent.findMany({ + where: { + userId, + medicationId, + deletedAt: null, + skipped: false, + takenAt: { not: null, lte: around }, + }, + orderBy: { takenAt: "asc" }, + select: { takenAt: true }, + }); + return rows + .map((r) => r.takenAt) + .filter((d): d is Date => d !== null); +}