fix(medications): align the slot lifecycle with the band model end to end

The auto-miss cron stamped every pending row missed a flat day after
its slot even though weekly schedules stay takeable for days, touched
tombstones because it never filtered deleted rows, and a verdict stuck
even after the dose was taken. The cron now derives its cutoff from the
schedule's window reach, skips deleted rows and bumps the sync counter,
and a take clears the auto-miss flag. Editing a schedule tombstones the
open pending rows of the old anchors and reconciles a stale window onto
the new times. The bulk write path attributes takes through the same
band engine as the single-intake routes and accepts forceSlotInstant
per entry. Configured dose windows now reach every compliance call site
through one shared schedule select, and the card window pill takes a
timezone, treats a degenerate window as a point and counts dose times
instead of schedule rows.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:26 +02:00
1 parent fb6e494f64
commit 0a4bf34a2b
16 files changed
+1380 -214

No files matched your search

+4 -1
View File
@@ -18,6 +18,7 @@ import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { getMedicationCategories } from "@/lib/medication-category";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
@@ -258,7 +259,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// Bounded reads — left untouched per the directive.
const medications = await prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
});
const categoryMap = await getMedicationCategories(
medications.map((m) => m.id),
+4 -1
View File
@@ -15,6 +15,7 @@ import {
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import {
@@ -1004,7 +1005,9 @@ async function buildTargetsResponse(user: AuthedUser) {
// 8. Medication Compliance (average across active medications)
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
orderBy: { name: "asc" },
});
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
update: vi.fn(),
},
medicationIntakeEvent: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
@@ -176,6 +179,153 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
});
});
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
schedules: [],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 1,
} as never);
});
it("tombstones open pending rows (today + future) on a schedule replace", async () => {
const res = await PUT(
putReq({
schedules: [{ windowStart: "20:00", windowEnd: "20:30" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
// Only live, never-acted rows from today's local day-start forward —
// taken / skipped / auto-missed history must survive a schedule edit.
expect(arg.where).toMatchObject({
userId: "user-1",
medicationId: "m1",
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
});
expect(
(arg.where.scheduledFor as { gte: Date }).gte,
).toBeInstanceOf(Date);
expect(arg.data.deletedAt).toBeInstanceOf(Date);
expect(arg.data.syncVersion).toEqual({ increment: 1 });
});
it("does NOT touch intake rows when the body carries no schedules array", async () => {
const res = await PUT(putReq({ name: "Renamed" }), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
});
describe("PUT /api/medications/[id] — window / timesOfDay consistency", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
schedules: [],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
});
function createdSchedule(): Record<string, unknown> {
const calls = vi.mocked(prisma.medication.update).mock.calls;
expect(calls.length).toBeGreaterThan(0);
const arg = calls[calls.length - 1][0] as unknown as {
data: { schedules: { create: Array<Record<string, unknown>> } };
};
return arg.data.schedules.create[0];
}
it("pulls a stale window to the min/max of new timesOfDay", async () => {
// The client changed only the dose times and echoed the old window
// back; 21:00 falls outside [08:00, 09:00].
const res = await PUT(
putReq({
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["07:00", "21:00"],
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const created = createdSchedule();
expect(created.windowStart).toBe("07:00");
expect(created.windowEnd).toBe("21:00");
});
it("keeps a window that already covers every time byte-identical", async () => {
const res = await PUT(
putReq({
schedules: [
{
windowStart: "06:00",
windowEnd: "22:00",
timesOfDay: ["08:00", "20:00"],
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const created = createdSchedule();
expect(created.windowStart).toBe("06:00");
expect(created.windowEnd).toBe("22:00");
});
it("keeps an overnight window whose times sit inside the wrap", async () => {
const res = await PUT(
putReq({
schedules: [
{
windowStart: "22:00",
windowEnd: "02:00",
timesOfDay: ["23:00"],
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const created = createdSchedule();
expect(created.windowStart).toBe("22:00");
expect(created.windowEnd).toBe("02:00");
});
});
describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -31,7 +31,10 @@ import {
type CadenceEngineContext,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import {
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -52,7 +55,9 @@ export const GET = apiHandler(
const med = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
});
if (!med) {
return apiError("Medication not found", 404);
+76 -2
View File
@@ -19,10 +19,49 @@ import {
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { getUserTodayBounds } from "@/lib/timezone";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
/** Parse "HH:mm" into minutes-of-day; NaN-safe (malformed → 0). */
function hhmmToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
return h * 60 + m;
}
/**
* Reconcile a schedule's `windowStart` / `windowEnd` with its effective
* `timesOfDay`. The schedule editor can change only the dose times while the
* client echoes the previous window back; a time outside the window then
* breaks every consumer that assumes `timesOfDay ⊆ [windowStart, windowEnd]`
* (the window-status pill, the reminder phases, the legacy compliance
* walker). When ANY time falls outside the window — including the overnight
* `windowEnd < windowStart` shape, checked with wrap-around membership —
* the window is pulled to the min/max of the times so the persisted row is
* self-consistent. A window that already covers every time is left
* byte-identical (explicitly wider windows are a feature).
*/
function reconcileScheduleWindow(
windowStart: string,
windowEnd: string,
timesOfDay: string[],
): { windowStart: string; windowEnd: string } {
if (timesOfDay.length === 0) return { windowStart, windowEnd };
const startMins = hhmmToMinutes(windowStart);
const endMins = hhmmToMinutes(windowEnd);
const overnight = endMins < startMins;
const inWindow = (t: number): boolean =>
overnight ? t >= startMins || t <= endMins : t >= startMins && t <= endMins;
const sorted = [...timesOfDay].sort(
(a, b) => hhmmToMinutes(a) - hhmmToMinutes(b),
);
const allInside = sorted.every((t) => inWindow(hhmmToMinutes(t)));
if (allInside) return { windowStart, windowEnd };
return { windowStart: sorted[0], windowEnd: sorted[sorted.length - 1] };
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -239,6 +278,31 @@ export const PUT = apiHandler(
// If schedules provided, replace all
if (schedules) {
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
// auto-miss later, and depress compliance. Tombstone today's and
// future open pending rows (never an actioned row — `takenAt`,
// `skipped`, and `autoMissed` are user-visible history) and bump
// `syncVersion` so delta-sync clients drop them. The projector and
// the reminder worker re-mint the anchors for the new times on their
// next pass — the route does not pre-create them.
const { start: todayStart } = getUserTodayBounds(
new Date(),
user.timezone || "Europe/Berlin",
);
await prisma.medicationIntakeEvent.updateMany({
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: null,
skipped: false,
autoMissed: false,
scheduledFor: { gte: todayStart },
},
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
@@ -292,9 +356,19 @@ export const PUT = apiHandler(
? s.timesOfDay
: [s.windowStart];
// Invariant 4 — window / times consistency. See
// `reconcileScheduleWindow`: a times-only edit that echoes the
// stale window back gets the window pulled to the min/max of
// the new times.
const window = reconcileScheduleWindow(
s.windowStart,
s.windowEnd,
effectiveTimesOfDay,
);
return {
windowStart: s.windowStart,
windowEnd: s.windowEnd,
windowStart: window.windowStart,
windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
@@ -66,7 +66,15 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
// Explicit timezone keeps the band/slot fixtures below host-TZ-stable:
// the engine expands occurrences in the user's zone, so an unset value
// would silently track the machine's clock.
user: {
id: "user-1",
username: "tester",
role: "USER" as const,
timezone: "Europe/Berlin",
},
};
function postReq(body: unknown): NextRequest {
@@ -480,6 +488,45 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
);
});
it("anchors a pending echo through the canonical snap (band engine is taken-only)", async () => {
// A pending echo (no takenAt) has nothing to attribute by — it must
// keep the anchor snap that binds it to the projector-minted slot row.
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-pending",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-pending",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
scheduledFor: "2026-06-15T05:00:30.000Z",
// no takenAt → pending echo onto the (still pending) slot row
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string }> };
};
// The echo converges onto the canonical slot row (here a no-op update
// path through the shared upsert), never a standalone insert.
expect(body.data.entries[0].status).toBe("updated");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("keeps the idempotencyKey replay contract: a re-submission reports duplicate, not updated", async () => {
// The replay pre-check fires BEFORE the convergence probe so a re-sent
// entry keeps its historical `duplicate` status.
@@ -513,3 +560,193 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
expect(prisma.medicationIntakeEvent.findFirst).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────────
// v1.15.20 — taken writes attribute through the window-band engine
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () => {
const SCHEDULED_MED = {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules: [
{
id: "s1",
windowStart: "07:00",
windowEnd: "07:00",
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
};
beforeEach(() => {
// 20:30 Berlin on 2026-06-15 — past both fixture slots for the day.
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-15T18:30:00.000Z"));
vi.mocked(prisma.medication.findMany).mockResolvedValue([
{ id: "med-1" },
] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue(
SCHEDULED_MED as never,
);
});
afterEach(() => {
vi.useRealTimers();
});
it("attributes a takenAt-only entry to its slot by band membership", async () => {
// 19:30 Berlin sits inside the 19:00 slot's ±60 min on-time band — the
// take must converge onto the slot's pending REMINDER row even though
// the client named no scheduledFor (the legacy snap refused to bind a
// defaulted anchor to a slot; the band engine binds by the take itself).
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-evening",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T17:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-evening",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
takenAt: "2026-06-15T17:30:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
updated: number;
inserted: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.updated).toBe(1);
expect(body.data.inserted).toBe(0);
expect(body.data.entries[0].status).toBe("updated");
expect(body.data.entries[0].id).toBe("row-evening");
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
});
it("forceSlotInstant pins an off-window take onto the named real slot", async () => {
// 14:00 Berlin falls in no band; the client pins the take onto the
// morning slot anchor (05:00Z = 07:00 Berlin).
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "row-morning",
takenAt: null,
skipped: false,
idempotencyKey: null,
scheduledFor: new Date("2026-06-15T05:00:00Z"),
source: "REMINDER",
createdAt: new Date("2026-06-15T00:00:00Z"),
},
] as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({
id: "row-morning",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
takenAt: "2026-06-15T12:00:00.000Z",
forceSlotInstant: "2026-06-15T05:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string; id?: string }> };
};
expect(body.data.updated).toBe(1);
expect(body.data.entries[0].status).toBe("updated");
expect(body.data.entries[0].id).toBe("row-morning");
});
it("marks an entry skipped when forceSlotInstant names no real slot", async () => {
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
takenAt: "2026-06-15T12:00:00.000Z",
forceSlotInstant: "2026-06-15T12:34:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
skipped: Array<{ index: number; reason: string }>;
entries: Array<{ status: string; reason?: string }>;
};
};
expect(body.data.entries[0].status).toBe("skipped");
expect(body.data.entries[0].reason).toBe("force_slot_invalid");
expect(body.data.skipped[0]).toEqual({
index: 0,
reason: "force_slot_invalid",
});
expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled();
});
it("files an out-of-band take as ad-hoc (scheduledFor = takenAt)", async () => {
// 14:00 Berlin, no band, no pin → standalone row anchored on the take.
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({
id: "row-adhoc",
} as never);
const res = await POST(
postReq({
entries: [
{
medicationId: "med-1",
takenAt: "2026-06-15T12:00:00.000Z",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inserted: number; entries: Array<{ status: string }> };
};
expect(body.data.inserted).toBe(1);
expect(body.data.entries[0].status).toBe("inserted");
expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
scheduledFor: new Date("2026-06-15T12:00:00.000Z"),
takenAt: new Date("2026-06-15T12:00:00.000Z"),
}),
}),
);
});
});
+77 -26
View File
@@ -17,6 +17,9 @@
* idempotencyKey?, — Medication.intake_events.idempotency_key
* (existing UNIQUE column; serves as the
* per-entry dedup hint)
* forceSlotInstant?, — ISO timestamp; pins a taken entry onto a
* named real scheduled slot (server-validated
* against the band anchors)
* }
*
* Response (always 200): processed / inserted / duplicates / skipped /
@@ -49,6 +52,8 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import {
applyCanonicalSlotWrite,
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
@@ -79,6 +84,17 @@ const bulkEntrySchema = z.object({
// entry skipped (`injection_site_not_allowed`) without failing the
// whole batch, matching the bulk endpoint's per-entry contract.
injectionSite: injectionSiteEnum.optional(),
// v1.15.20 — late-take "attribute anyway" pin, mirroring the single
// intake route: pin a taken entry onto a named real scheduled slot
// instead of the default window-band attribution. Validated server-side
// against the band anchors; an instant that is not a slot marks the
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
// Ignored on non-taken entries (a pending echo / skip carries no take
// to attribute).
forceSlotInstant: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.optional(),
});
const bulkPayloadSchema = z.object({
@@ -228,32 +244,6 @@ async function postBulk(request: NextRequest): Promise<Response> {
const incomingScheduledFor =
entry.scheduledFor ?? entry.takenAt ?? new Date();
// v1.8.2 — source-agnostic slot snap. iOS posts the "Genommen"
// reminder action here (source API); without this it inserted a
// SECOND row for a slot already carrying the projector/worker's
// pending REMINDER row (the unique key includes `source`, and the
// iOS-vs-server `scheduledFor` drifts by a minute). Snap onto the
// canonical slot instant and update the existing row in place.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
incoming: incomingScheduledFor,
// Only a client-supplied `scheduledFor` names a real slot. A
// `takenAt`-only / defaulted-now write must not snap across the
// wide ±halfGap window onto a far slot (phantom morning dose).
instantIsExplicit: entry.scheduledFor !== undefined,
// Dose-safety: a taken write (has `takenAt`, not skipped) must never
// snap forward onto a future slot. A pending sync echo (no `takenAt`)
// legitimately maps to a future slot, so the guard stays off for it.
isTakenWrite: !entry.skipped && entry.takenAt !== undefined,
});
// The `scheduledFor` instant the row actually lands on — set by the
// branch that performs the write so the rollup recompute below keys
// the same day the stored row anchors to.
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
// C2 — classify the incoming write. An offline-sync replay echoes a
// PENDING projection (no `takenAt`, `skipped:false`) for a slot the
// user may already have actioned; that echo must NEVER clear a
@@ -262,6 +252,67 @@ async function postBulk(request: NextRequest): Promise<Response> {
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
const isExplicitSkip = entry.skipped === true;
// v1.15.20 — taken writes attribute through the window-band engine
// (the SAME minter the read ledger + the compliance % consume), so a
// bulk-synced take binds to a slot identically to the single-route
// write. The optional `forceSlotInstant` pins an off-window take onto
// a named real slot. Pending echoes and skips carry no take to
// attribute by, so they keep the canonical anchor snap that binds
// them to the projector/worker-minted slot row.
let canonicalSlot: Date | null;
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
slotInstant: entry.forceSlotInstant,
});
if (canonicalSlot === null) {
// Mirrors the single route's 422 (`medications.intake.force_slot
// .invalid`) as a per-entry skip so one bad pin never fails the
// batch.
const reason = "force_slot_invalid";
skipped.push({ index: i, reason });
results.push({ index: i, status: "skipped", reason });
continue;
}
} else if (isExplicitTaken) {
const attribution = await resolveSlotForWriteByBand({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
takenAt: entry.takenAt as Date,
});
canonicalSlot = attribution.slotInstant;
} else {
// v1.8.2 — source-agnostic anchor snap for pending echoes + skips.
// iOS posts the reminder actions here (source API); without this
// they inserted a SECOND row for a slot already carrying the
// projector/worker's pending REMINDER row (the unique key includes
// `source`, and the iOS-vs-server `scheduledFor` drifts by a
// minute). Snap onto the canonical slot instant and update the
// existing row in place.
canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: entry.medicationId,
userTz: user.timezone,
incoming: incomingScheduledFor,
// Only a client-supplied `scheduledFor` names a real slot. A
// defaulted-now write must not snap across the wide ±halfGap
// window onto a far slot (phantom morning dose).
instantIsExplicit: entry.scheduledFor !== undefined,
// Pending echoes / skips never record a take; a pending sync
// echo legitimately maps to a future slot, so the taken
// future-slot guard stays off here.
isTakenWrite: false,
});
}
// The `scheduledFor` instant the row actually lands on — set by the
// branch that performs the write so the rollup recompute below keys
// the same day the stored row anchors to.
let effectiveScheduledFor = canonicalSlot ?? incomingScheduledFor;
// v1.8.5 — resolve + validate the optional per-entry injection
// site. A disallowed site marks the entry skipped without failing
// the batch (per-entry contract); a non-injection / tracking-off /