feat(medications): band-model slot attribution for the intake write/edit paths

Add a shared write-side attributor that runs the SAME cadence-aware band
minter the read ledger and the compliance % consume, so a take is bound to a
slot identically wherever the decision is made. `attributeTakenToSlot` maps a
takenAt onto a slot by dose-window membership (on-time / late) or returns null
for an off-window ad-hoc take; `resolveForcedSlotInstant` validates a
client-pinned slot against the real band anchors. The DB-loading resolvers
(`resolveSlotForWriteByBand`, `resolveForcedSlotForWrite`) load the
medication's schedules + rolling anchors and run the pure core.

This retires the wide ±6h nearest-snap (`snapToleranceMs`) that pulled an
11:29 take onto the 07:00 slot and a 13:02 take onto the 19:00 slot.
This commit is contained in:
Marc-André Bombeck committed 2026-06-08 18:07:31 +02:00
1 parent 79e7c866e4
commit 7b7253b989
3 files changed
+593

No files matched your search

@@ -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> = {}): 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> = {},
): 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();
});
});
@@ -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;
@@ -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<ResolveSlotByBandResult> {
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<Date | null> {
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<Date[] | undefined> {
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);
}