mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
}
|
||||
Reference in new issue
Block a user