fix(medications): converge intake writes onto one row per scheduled slot

A twice-daily medication ended up with two rows for one dose slot: the
projector / reminder worker mints a pending row (source REMINDER) at the
slot's `localHmAsUtc` instant, while a "taken" write from the per-med or
bulk intake route inserted a fresh row (source WEB / API). Because
`source` is part of the `(userId, medicationId, scheduledFor, source)`
unique key — and the written instant could drift by a minute from the
projected one — the two rows coexisted: one taken, one still pending and
overdue. The card then read the slot as taken, counted 100%, and
suppressed the "due now" prompt for a dose the user had not taken.

Resolve every scheduled intake write to the slot's canonical instant
before writing and upsert in place, source-agnostic:

- `resolve-slot-instant.ts` snaps an incoming instant to the nearest
  scheduled slot for that day (enumerated through the canonical
  recurrence engine, so PRN and cyclic off-weeks resolve to no slot) and
  anchors it on the same `localHmAsUtc` the projector and worker use.
  Off-slot and as-needed writes resolve to null and keep insert semantics.
- The per-med and bulk routes look up the existing row at the canonical
  `(userId, medicationId, scheduledFor)` ignoring source and update it in
  place — mirroring the status-toggle route — instead of inserting a
  sibling. The bulk per-entry response gains an additive `updated` status.
- The reminder worker's missed-dose mint now skips a slot that already
  carries an actioned row (taken or skipped) from any source, not only an
  existing pending REMINDER row.

PRN / as-needed writes are unaffected. Regression tests pin the collapse
(exact and one-minute drift), the PRN exemption, and the worker guard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 08:06:37 +02:00
1 parent 4578078cc1
commit 0d7d08f616
11 files changed
+1033 -52

No files matched your search

@@ -26,6 +26,10 @@ vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
// v1.8.2 — the slot-snap resolver loads the medication via
// findFirst; default to a schedule-less med so the resolver returns
// null (unscheduled path) and the original insert behaviour holds.
findFirst: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
@@ -307,6 +311,16 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => {
skipped: false,
};
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
// v1.8.2 — slot-snap resolver loads the med; no schedules → null slot
// → original insert + dedup path preserved.
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: true,
createdAt: new Date(),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
null as never, // dedup probe
);
+108 -21
View File
@@ -19,6 +19,7 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -74,6 +75,29 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
// v1.8.2 — source-agnostic slot snap. A twice-daily med carries a
// pending REMINDER row the projector/worker minted at the canonical
// `localHmAsUtc` slot instant. Without this, a manual "Genommen" write
// (source WEB) inserted a SECOND row for the slot because the unique
// key includes `source` and the iOS-vs-server `scheduledFor` can drift
// by a minute — inflating compliance to 100% and suppressing the
// "take now" prompt for a dose the user hadn't taken. Snap the write
// to the canonical slot and update the existing row in place.
//
// Resolved BEFORE the idempotency/dedup window so a scheduled dose
// routes through the slot upsert (which is itself the dedup): the
// legacy 60-second window would otherwise short-circuit by returning
// the slot's pending REMINDER row WITHOUT applying the user's takenAt.
const canonicalSlot = await resolveSlotInstantForWrite({
userId: user.id,
medicationId: id,
userTz: user.timezone,
incoming: incomingScheduledFor,
});
// Idempotency check (explicit key or server-side dedup window)
if (idempotencyKey) {
const existing = await prisma.medicationIntakeEvent.findFirst({
@@ -86,8 +110,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
if (existing) {
return apiSuccess(existing);
}
} else {
// Server-side dedup: prevent double-logging within 60 seconds
} else if (!canonicalSlot) {
// Unscheduled / PRN only — the slot upsert handles dedup for
// scheduled doses by collapsing onto the canonical slot row.
// Server-side dedup: prevent double-logging within 60 seconds.
const recentDuplicate = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
@@ -102,28 +128,89 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
}
}
const [event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
let event;
if (canonicalSlot) {
// Scheduled dose — find the slot row regardless of `source` (the
// pending REMINDER row, or any prior row for this slot) and update
// it in place; mirrors the status-toggle route. Falls through to a
// create when the slot has no row yet.
const existingSlotRow = await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: id,
scheduledFor: scheduledFor ?? takenAt ?? new Date(),
takenAt: skipped ? null : (takenAt ?? new Date()),
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
scheduledFor: canonicalSlot,
deletedAt: null,
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
});
if (existingSlotRow) {
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.update({
where: { id: existingSlotRow.id },
data: {
takenAt: resolvedTakenAt,
skipped,
syncVersion: { increment: 1 },
idempotencyKey:
idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
},
}),
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
} else {
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: canonicalSlot,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
},
}),
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
} else {
// Unscheduled / PRN / off-slot — keep the original insert behaviour.
[event] = await prisma.$transaction([
prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: id,
scheduledFor: incomingScheduledFor,
takenAt: resolvedTakenAt,
skipped,
source: "WEB",
idempotencyKey: idempotencyKey ?? null,
},
}),
// Reset snooze when medication is taken
...(!skipped
? [
prisma.medication.update({
where: { id },
data: { snoozedUntil: null },
}),
]
: []),
]);
}
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
+75 -18
View File
@@ -47,6 +47,7 @@ import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/rollups/medication-compliance-rollups";
import { resolveSlotInstantForWrite } from "@/lib/medications/scheduling/slot-upsert";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -70,7 +71,12 @@ const bulkPayloadSchema = z.object({
entries: z.array(bulkEntrySchema).min(1).max(MAX_ENTRIES_PER_BATCH),
});
type EntryStatus = "inserted" | "duplicate" | "skipped";
// v1.8.2 — `updated` joins the per-entry status vocabulary: a write that
// snaps onto an existing scheduled-slot row (e.g. the pending REMINDER
// row) updates it in place rather than inserting. The iOS sync engine
// treats `updated` the same as `inserted` for cursor advancement — both
// mean "the server accepted this entry and produced a row id".
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
@@ -154,6 +160,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
const results: EntryResult[] = [];
let inserted = 0;
let updated = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
@@ -173,23 +180,70 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
try {
const scheduledFor = entry.scheduledFor ?? new Date();
// The idempotencyKey, when supplied, has a UNIQUE index. A
// re-submission of the same key returns the existing row via
// P2002 → status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
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,
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
const scheduledFor = canonicalSlot ?? incomingScheduledFor;
const existingSlotRow = canonicalSlot
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor: canonicalSlot,
deletedAt: null,
},
select: { id: true, idempotencyKey: true },
})
: null;
if (existingSlotRow) {
// Scheduled dose with an existing slot row — update in place
// (keep the row's original `source`), report "updated".
const row = await prisma.medicationIntakeEvent.update({
where: { id: existingSlotRow.id },
data: {
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
syncVersion: { increment: 1 },
idempotencyKey:
entry.idempotencyKey ?? existingSlotRow.idempotencyKey ?? null,
},
});
updated += 1;
results.push({ index: i, status: "updated", id: row.id });
} else {
// No existing slot row (fresh scheduled dose, or unscheduled /
// PRN) — insert. The idempotencyKey, when supplied, has a UNIQUE
// index; a re-submission returns the existing row via P2002 →
// status: "duplicate".
const row = await prisma.medicationIntakeEvent.create({
data: {
userId: user.id,
medicationId: entry.medicationId,
scheduledFor,
takenAt: entry.takenAt ?? null,
skipped: entry.skipped,
source: "API",
idempotencyKey: entry.idempotencyKey ?? null,
},
});
inserted += 1;
results.push({ index: i, status: "inserted", id: row.id });
}
const dayKey = dayKeyForScheduledFor(scheduledFor, user.timezone);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
@@ -231,6 +285,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -241,6 +296,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted,
updated,
duplicates,
skipped: skipped.length,
},
@@ -249,7 +305,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user medications + compliance + achievement
// caches when at least one row landed so the next read reflects the
// ingested batch.
if (inserted > 0) {
if (inserted > 0 || updated > 0) {
invalidateUserMedications(user.id);
}
@@ -282,6 +338,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiSuccess({
processed: entries.length,
inserted,
updated,
duplicates,
skipped,
entries: results,
+16 -13
View File
@@ -15,6 +15,7 @@ import {
buildCanonicalSchedule,
buildRecurrenceContext,
scheduleEmitsInWindow,
shouldMintMissedDoseRow,
} from "@/lib/medications/scheduling/worker-helpers";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
@@ -736,21 +737,23 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
// RED phase: create missed intake event for this slot.
if (currentPhase === "RED") {
// v1.7.0 sync — intentionally NO `deletedAt: null` filter:
// a tombstoned row still occupies the `(userId, medicationId,
// scheduledFor, source)` unique slot, so the missed-dose
// create must treat it as present to avoid a P2002 collision.
const existingMissed = await prisma.medicationIntakeEvent.count({
where: {
medicationId: med.id,
userId: med.user.id,
scheduledFor: slotScheduledFor,
takenAt: null,
source: "REMINDER",
},
// v1.8.2 — gate the missed-dose mint through the shared
// guard. It refuses to mint when the slot already carries an
// existing pending REMINDER row (P2002-collision avoidance,
// tombstones included) OR an ACTIONED row (taken / skipped)
// from ANY source. The intake write paths snap a "Genommen" /
// "Übersprungen" write onto the canonical slot instant
// (source-agnostic update), so a user who acted before the
// RED phase opens has a live taken/skipped row at this exact
// slot — minting here would re-create the duplicate the
// write paths just collapsed.
const shouldMint = await shouldMintMissedDoseRow(prisma, {
userId: med.user.id,
medicationId: med.id,
scheduledFor: slotScheduledFor,
});
if (existingMissed === 0) {
if (shouldMint) {
await prisma.medicationIntakeEvent.create({
data: {
userId: med.user.id,
@@ -0,0 +1,146 @@
/**
* v1.8.2 — canonical slot-snap helper tests.
*
* Proves the snap contract the intake write paths depend on:
* - multi-time (07:00 / 19:00): an incoming instant near a slot snaps
* to that slot's canonical `localHmAsUtc` instant; a ±1-minute drift
* still snaps to the same instant (the duplicate-row root cause).
* - single-time: snaps to the one slot.
* - PRN schedule → null (keep insert behaviour, never collapse).
* - off-slot beyond tolerance → null.
* - DST spring-forward day still snaps to the canonical instant.
*/
import { describe, expect, it } from "vitest";
import { resolveCanonicalSlotInstant } from "../resolve-slot-instant";
import type { SlotResolverMedication } from "../resolve-slot-instant";
import type { WorkerScheduleRow } from "../worker-helpers";
import { localHmAsUtc } from "@/lib/timezone";
const TZ = "Europe/Berlin";
function makeSchedule(
overrides: Partial<WorkerScheduleRow> = {},
): WorkerScheduleRow {
return {
id: "sched-1",
windowStart: "07:00",
windowEnd: "08:00",
daysOfWeek: null,
timesOfDay: [],
reminderGraceMinutes: null,
rrule: null,
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
...overrides,
};
}
function makeMedication(
schedules: WorkerScheduleRow[],
overrides: Partial<SlotResolverMedication> = {},
): SlotResolverMedication {
return {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules,
...overrides,
};
}
describe("resolveCanonicalSlotInstant — multi-time", () => {
// A daily 07:00 / 19:00 med. 2026-06-15 is CEST (UTC+2), so 07:00 local
// = 05:00Z and 19:00 local = 17:00Z.
const med = makeMedication([
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
]);
it("snaps an exact-07:00 write to the canonical 07:00 slot instant", () => {
const incoming = new Date("2026-06-15T05:00:00.000Z"); // 07:00 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const canonical = localHmAsUtc(incoming, TZ, 7, 0);
expect(result?.toISOString()).toBe(canonical.toISOString());
});
it("snaps a +1-minute-drifted 07:00 write to the SAME canonical instant", () => {
const incoming = new Date("2026-06-15T05:01:00.000Z"); // 07:01 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const canonical = localHmAsUtc(
new Date("2026-06-15T05:00:00.000Z"),
TZ,
7,
0,
);
expect(result?.toISOString()).toBe(canonical.toISOString());
});
it("snaps a 19:00 write to the canonical 19:00 slot, not the 07:00 slot", () => {
const incoming = new Date("2026-06-15T17:02:00.000Z"); // 19:02 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const canonical = localHmAsUtc(incoming, TZ, 19, 0);
expect(result?.toISOString()).toBe(canonical.toISOString());
});
});
describe("resolveCanonicalSlotInstant — single-time", () => {
it("snaps to the one slot", () => {
const med = makeMedication([
makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }),
]);
const incoming = new Date("2026-06-15T06:10:00.000Z"); // 08:10 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const canonical = localHmAsUtc(incoming, TZ, 8, 0);
expect(result?.toISOString()).toBe(canonical.toISOString());
});
});
describe("resolveCanonicalSlotInstant — exemptions", () => {
it("returns null for a PRN schedule (as-needed never snaps)", () => {
const med = makeMedication([
makeSchedule({ scheduleType: "PRN", timesOfDay: [] }),
]);
const incoming = new Date("2026-06-15T05:00:00.000Z");
expect(
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
).toBeNull();
});
it("returns null for a medication with no schedules", () => {
const med = makeMedication([]);
const incoming = new Date("2026-06-15T05:00:00.000Z");
expect(
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
).toBeNull();
});
it("returns null for an off-slot write beyond tolerance", () => {
// 07:00 / 19:00 schedule; a 13:00-local write is 4–6h from either
// slot — well outside the ±2h default tolerance → unscheduled.
const med = makeMedication([
makeSchedule({ timesOfDay: ["07:00", "19:00"], windowEnd: "07:00" }),
]);
const incoming = new Date("2026-06-15T11:00:00.000Z"); // 13:00 CEST
expect(
resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming }),
).toBeNull();
});
});
describe("resolveCanonicalSlotInstant — DST spring-forward", () => {
it("snaps a write on the 2026-03-29 spring-forward day to the canonical instant", () => {
// 2026-03-29 is the CET→CEST transition. 08:00 local exists (the
// gap is 02:00→03:00). The canonical instant must match localHmAsUtc.
const med = makeMedication([
makeSchedule({ timesOfDay: ["08:00"], windowStart: "08:00", windowEnd: "09:00" }),
]);
const incoming = new Date("2026-03-29T06:05:00.000Z"); // 08:05 CEST
const result = resolveCanonicalSlotInstant({ medication: med, userTz: TZ, incoming });
const canonical = localHmAsUtc(incoming, TZ, 8, 0);
expect(result?.toISOString()).toBe(canonical.toISOString());
});
});
@@ -0,0 +1,61 @@
/**
* v1.8.2 — reminder-worker RED-phase mint guard.
*
* The worker must NOT mint a pending REMINDER row for a slot that the
* user has already actioned (taken / skipped) from ANY source, nor for
* a slot that already carries a pending REMINDER row. Pins the
* duplicate-intake fix on the worker side.
*/
import { describe, expect, it, vi } from "vitest";
import { shouldMintMissedDoseRow } from "../worker-helpers";
const SLOT = {
userId: "u1",
medicationId: "m1",
scheduledFor: new Date("2026-06-15T05:00:00.000Z"),
};
/**
* Fake `count` that resolves the two probes in call order: the first
* call is the pending-REMINDER probe, the second is the actioned probe.
*/
function fakeClient(pendingReminder: number, actioned: number) {
const count = vi
.fn<(args: { where: Record<string, unknown> }) => Promise<number>>()
.mockResolvedValueOnce(pendingReminder)
.mockResolvedValueOnce(actioned);
return { medicationIntakeEvent: { count } };
}
describe("shouldMintMissedDoseRow", () => {
it("mints when the slot is empty (no pending, no actioned row)", async () => {
const client = fakeClient(0, 0);
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(true);
});
it("does NOT mint when a pending REMINDER row already exists", async () => {
const client = fakeClient(1, 0);
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
// short-circuits before the actioned probe
expect(client.medicationIntakeEvent.count).toHaveBeenCalledTimes(1);
});
it("does NOT mint when an actioned (taken) row exists from any source", async () => {
const client = fakeClient(0, 1);
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
// the actioned probe filters takenAt-set OR skipped, live rows only
const actionedWhere = client.medicationIntakeEvent.count.mock.calls[1]?.[0]
.where as Record<string, unknown>;
expect(actionedWhere.deletedAt).toBeNull();
expect(actionedWhere.OR).toEqual([
{ takenAt: { not: null } },
{ skipped: true },
]);
});
it("does NOT mint when a skipped row exists at the slot", async () => {
const client = fakeClient(0, 1);
expect(await shouldMintMissedDoseRow(client, SLOT)).toBe(false);
});
});
@@ -0,0 +1,170 @@
/**
* v1.8.2 — canonical slot-snap for intake writes.
*
* Closes the duplicate-intake-row bug: a twice-daily med ends up with a
* pending REMINDER row AND a separate taken WEB/API row for the same
* dose slot because the unique key carries `source` and the two
* `scheduledFor` instants can drift by a minute between iOS and the
* server's `localHmAsUtc`. The intake write paths use this helper to
* snap an incoming write to the SAME canonical slot instant the
* projector + reminder worker mint with, so the write resolves to one
* row per slot regardless of `source` or sub-minute drift.
*
* Given a medication's schedules, the user's IANA timezone, and an
* incoming instant (the write's `scheduledFor`, or `takenAt` as a
* fallback), `resolveCanonicalSlotInstant` returns the canonical slot
* instant for that dose — byte-identical to the projector's
* `localHmAsUtc(day, userTz, h, m)` row — or `null` when the dose does
* not map to a scheduled slot (PRN / as-needed / off-slot beyond
* tolerance / cyclic off-week). A `null` result is the caller's signal
* to keep the unmodified insert behaviour.
*
* Pure / synchronous: no DB access. The caller `select`s the schedule
* columns the canonical engine consumes and threads `lastIntakeAt` in.
*/
import {
buildCanonicalSchedule,
buildRecurrenceContext,
type WorkerMedicationRow,
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
import {
occurrencesBetween,
type CanonicalSchedule,
type RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import { getLocalDateParts, localHmAsUtc } from "@/lib/timezone";
const ONE_MINUTE_MS = 60_000;
const ONE_HOUR_MS = 60 * ONE_MINUTE_MS;
const ONE_DAY_MS = 24 * ONE_HOUR_MS;
/**
* Default snap tolerance when a schedule has no usable window span. ±2h
* is wide enough to absorb the iOS-vs-server `localHmAsUtc` minute drift
* plus a reasonable "I took it a bit late" margin, while staying narrow
* enough that a clearly off-slot manual log (e.g. a 13:00 entry against a
* 07:00 / 19:00 schedule) is treated as unscheduled and keeps its own
* row rather than colliding into the nearest slot.
*/
const DEFAULT_TOLERANCE_MS = 2 * ONE_HOUR_MS;
/**
* The minimal medication projection the slot resolver needs. Matches the
* worker/projector `select` shape — `oneShot`, the schedule rows, plus
* the recurrence-context anchors.
*/
export interface SlotResolverMedication extends WorkerMedicationRow {
schedules: WorkerScheduleRow[];
}
export interface ResolveSlotInput {
medication: SlotResolverMedication;
userTz: string;
/**
* The write's incoming `scheduledFor`, or `takenAt` as a fallback when
* the client omitted `scheduledFor`. The day-in-tz of this instant
* determines which calendar day's slots are enumerated.
*/
incoming: Date;
/**
* Latest non-tombstoned `takenAt` for the medication. Only consulted by
* rolling schedules; pass `null` when unknown / not loaded.
*/
lastIntakeAt?: Date | null;
}
/**
* Snap `incoming` to the nearest canonical scheduled-slot instant for the
* dose's calendar day (in `userTz`), or return `null` when no slot lands
* within tolerance (treat as unscheduled / PRN — keep insert behaviour).
*
* The returned instant is produced by `localHmAsUtc(day, userTz, h, m)` —
* the exact function the projector (`project-today-intakes.ts`) and the
* reminder worker mint pending rows with — so an existing pending row for
* the slot dedupes on the canonical instant.
*/
export function resolveCanonicalSlotInstant(
input: ResolveSlotInput,
): Date | null {
const { medication, userTz, incoming } = input;
const schedules = medication.schedules ?? [];
if (schedules.length === 0) return null;
const ctx: RecurrenceContext = buildRecurrenceContext({
medication,
userTz,
lastIntakeAt: input.lastIntakeAt ?? null,
});
// The day in userTz implied by the incoming instant. Enumerate that
// day's slots over a window padded by a day on each side so a
// time-of-day near the local-midnight boundary (and any DST shift) is
// still captured — the projector + worker apply the time-of-day to the
// local day, which can land just outside a naive same-UTC-day window.
const parts = getLocalDateParts(incoming, userTz);
const localDayMidnightUtc = localHmAsUtc(incoming, userTz, 0, 0);
const windowStart = new Date(localDayMidnightUtc.getTime() - ONE_DAY_MS);
const windowEnd = new Date(localDayMidnightUtc.getTime() + 2 * ONE_DAY_MS);
let best: { at: Date; toleranceMs: number; deltaMs: number } | null = null;
for (const scheduleRow of schedules) {
const canonical: CanonicalSchedule = buildCanonicalSchedule(scheduleRow);
const occurrences = occurrencesBetween(
canonical,
windowStart,
windowEnd,
ctx,
);
const tolerance = snapToleranceMs(canonical);
for (const occ of occurrences) {
// Only consider slots that land on the SAME local calendar day as
// the incoming instant — a slot on the padded neighbouring day must
// not capture a write meant for a different day.
const occParts = getLocalDateParts(occ.at, userTz);
if (
occParts.year !== parts.year ||
occParts.month !== parts.month ||
occParts.day !== parts.day
) {
continue;
}
const deltaMs = Math.abs(occ.at.getTime() - incoming.getTime());
if (deltaMs > tolerance) continue;
if (best === null || deltaMs < best.deltaMs) {
best = { at: occ.at, toleranceMs: tolerance, deltaMs };
}
}
}
return best ? best.at : null;
}
/**
* Tolerance = ± half the schedule's window span (so two adjacent slots
* never overlap their capture zones), falling back to ±2h when the
* window is degenerate (`windowStart === windowEnd`) or unparseable.
*/
function snapToleranceMs(schedule: CanonicalSchedule): number {
const startMin = hhmmToMinutes(schedule.windowStart);
const endMin = hhmmToMinutes(schedule.windowEnd);
if (startMin === null || endMin === null) return DEFAULT_TOLERANCE_MS;
let span = endMin - startMin;
if (span < 0) span += 24 * 60; // overnight window
if (span === 0) return DEFAULT_TOLERANCE_MS;
const halfSpanMs = (span / 2) * ONE_MINUTE_MS;
// Never go below the minute-drift floor: even a tight 30-minute window
// must absorb the iOS-vs-server sub-minute `scheduledFor` drift.
return Math.max(halfSpanMs, ONE_MINUTE_MS);
}
function hhmmToMinutes(hhmm: string): number | null {
const [hStr, mStr] = hhmm.split(":");
const h = Number(hStr);
const m = Number(mStr);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
return h * 60 + m;
}
@@ -0,0 +1,110 @@
/**
* v1.8.2 — shared slot resolution for the intake write paths.
*
* Both `POST /api/medications/[id]/intake` (source WEB) and
* `POST /api/medications/intake/bulk` (source API) must resolve an
* incoming dose write to the canonical scheduled-slot instant so the
* write updates the pending REMINDER row the projector/worker minted
* rather than inserting a second row that differs only by `source` (and
* by a sub-minute `scheduledFor` drift). This module loads the
* medication's schedules + the rolling-anchor `lastIntakeAt` and runs the
* pure `resolveCanonicalSlotInstant` snap.
*
* Returns `null` when the dose does not map to a scheduled slot
* (PRN / off-slot beyond tolerance / cyclic off-week / no schedules) —
* the caller's signal to keep the unmodified insert behaviour.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { resolveCanonicalSlotInstant } from "@/lib/medications/scheduling/resolve-slot-instant";
import type { WorkerScheduleRow } from "@/lib/medications/scheduling/worker-helpers";
type PrismaLike = Pick<PrismaClient, "medication" | "medicationIntakeEvent">;
const SCHEDULE_SELECT = {
id: true,
windowStart: true,
windowEnd: true,
daysOfWeek: true,
timesOfDay: true,
reminderGraceMinutes: true,
rrule: true,
rollingIntervalDays: true,
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
} as const;
const MEDICATION_SELECT = {
id: true,
startsOn: true,
endsOn: true,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_SELECT },
} as const;
export interface ResolveSlotForWriteInput {
userId: string;
medicationId: string;
userTz: string;
/**
* The write's `scheduledFor`, or `takenAt` as the fallback when the
* client omitted `scheduledFor`.
*/
incoming: Date;
/** Inject a Prisma client/tx in tests; defaults to the app client. */
client?: PrismaLike;
}
/**
* Resolve the canonical scheduled-slot instant for an intake write, or
* `null` when the dose is unscheduled (PRN / off-slot / no schedules).
*
* Loads the medication's schedule rows (and, when any schedule is
* rolling, the latest non-tombstoned `takenAt` to anchor the next-due
* computation byte-identically to the projector + worker).
*/
export async function resolveSlotInstantForWrite(
input: ResolveSlotForWriteInput,
): Promise<Date | null> {
const client = input.client ?? defaultPrisma;
const medication = await 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 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 resolveCanonicalSlotInstant({
medication: {
id: medication.id,
startsOn: medication.startsOn,
endsOn: medication.endsOn,
oneShot: medication.oneShot,
createdAt: medication.createdAt,
schedules: medication.schedules as WorkerScheduleRow[],
},
userTz: input.userTz,
incoming: input.incoming,
lastIntakeAt,
});
}
@@ -127,3 +127,63 @@ export function scheduleEmitsInWindow(
): boolean {
return occurrencesBetween(schedule, windowStart, windowEnd, ctx).length > 0;
}
/**
* Minimal Prisma surface the missed-dose guard needs — just a `count`
* over `MedicationIntakeEvent`. Keeps the helper unit-testable with a
* tiny fake and the worker passing its real client.
*/
interface IntakeCountClient {
medicationIntakeEvent: {
count: (args: { where: Record<string, unknown> }) => Promise<number>;
};
}
/**
* v1.8.2 — decide whether the reminder worker should mint a RED-phase
* pending `REMINDER` row for the given slot.
*
* Returns `false` (skip the mint) when the slot already carries either:
* - an existing pending `REMINDER` row (P2002-collision avoidance; the
* `deletedAt: null` filter is intentionally omitted because a
* tombstoned REMINDER row still occupies the `(userId, medicationId,
* scheduledFor, source)` unique slot), OR
* - an ACTIONED row — `takenAt` set OR `skipped` — from ANY source,
* restricted to live rows (`deletedAt: null`). The intake write
* paths snap a "Genommen" / "Übersprungen" write onto this exact
* canonical slot instant via a source-agnostic update, so a dose the
* user acted on before the RED phase opens already has a live
* taken/skipped row here. Without this arm the worker would mint a
* pending REMINDER row alongside the user's WEB/API taken row — the
* duplicate-intake bug — because the two differ by `source` and the
* unique index would not collide.
*
* `scheduledFor` must be the canonical `localHmAsUtc` slot instant the
* projector + write paths use, so the existence probes match byte-for-byte.
*/
export async function shouldMintMissedDoseRow(
client: IntakeCountClient,
slot: { userId: string; medicationId: string; scheduledFor: Date },
): Promise<boolean> {
const existingPendingReminder = await client.medicationIntakeEvent.count({
where: {
medicationId: slot.medicationId,
userId: slot.userId,
scheduledFor: slot.scheduledFor,
takenAt: null,
source: "REMINDER",
},
});
if (existingPendingReminder > 0) return false;
const existingActioned = await client.medicationIntakeEvent.count({
where: {
medicationId: slot.medicationId,
userId: slot.userId,
scheduledFor: slot.scheduledFor,
deletedAt: null,
OR: [{ takenAt: { not: null } }, { skipped: true }],
},
});
return existingActioned === 0;
}
@@ -41,6 +41,7 @@ import {
buildRecurrenceContext,
scheduleEmitsInWindow,
} from "@/lib/medications/scheduling/worker-helpers";
import { localHmAsUtc } from "@/lib/timezone";
const TEST_USER_ID = "user-medication-cadence-worker";
@@ -712,3 +713,77 @@ describe("legacy daysOfWeek fallback", () => {
expect(scheduleEmitsInWindow(schedule, ctx, on.start, on.end)).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────
// 8. v1.8.2 — per-med intake POST collapses onto the canonical slot
// ────────────────────────────────────────────────────────────────────
describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => {
it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Bisoprolol",
dose: "2.5mg",
active: true,
schedules: {
create: {
windowStart: "07:00",
windowEnd: "07:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
const slot = localHmAsUtc(new Date(), "Europe/Berlin", 7, 0);
const pending = await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: med.id,
scheduledFor: slot,
takenAt: null,
skipped: false,
source: "REMINDER",
},
});
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
const { POST } = await import("@/app/api/medications/[id]/intake/route");
// +1 minute drift between the iOS write and the server's localHmAsUtc.
const drifted = new Date(slot.getTime() + 60_000);
const res = await POST(
new NextRequest(`http://localhost/api/medications/${med.id}/intake`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scheduledFor: drifted.toISOString(),
takenAt: drifted.toISOString(),
skipped: false,
}),
}),
{ params: Promise.resolve({ id: med.id }) },
);
expect(res.status).toBe(201);
const json = (await res.json()) as { data: { id: string } };
expect(json.data.id).toBe(pending.id); // updated the SAME row
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: med.id },
});
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(pending.id);
expect(rows[0]?.takenAt).not.toBeNull();
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
});
});
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { localHmAsUtc } from "@/lib/timezone";
const TEST_USER_ID = "user-medications-intake-bulk";
const OTHER_USER_ID = "user-medications-intake-other";
@@ -160,6 +161,203 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
]);
});
// v1.8.2 — duplicate-intake slot collapse. A scheduled med carries a
// pending REMINDER row at the canonical `localHmAsUtc` slot instant;
// an iOS "Genommen" write (source API) must UPDATE that row, not insert
// a second source-API row that differs only by source + sub-minute
// drift.
describe("v1.8.2 — source-agnostic slot collapse", () => {
const TZ = "Europe/Berlin";
async function makeScheduledMed(timesOfDay: string[]): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Ramipril",
dose: "5mg",
active: true,
schedules: {
create: {
windowStart: timesOfDay[0],
windowEnd: timesOfDay[0],
timesOfDay,
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
it("collapses an API taken-write onto a pre-existing pending REMINDER slot row (exact instant)", async () => {
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
// The projector/worker minted this pending REMINDER row.
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
const pending = await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot,
takenAt: null,
skipped: false,
source: "REMINDER",
},
});
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
medicationId: medId,
scheduledFor: slot.toISOString(),
takenAt: slot.toISOString(),
},
],
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: { inserted: number; updated: number; entries: Array<{ status: string }> };
};
expect(json.data.inserted).toBe(0);
expect(json.data.updated).toBe(1);
expect(json.data.entries[0]?.status).toBe("updated");
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId },
});
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(pending.id);
expect(rows[0]?.takenAt).not.toBeNull();
expect(rows[0]?.source).toBe("REMINDER"); // original source preserved
});
it("collapses even when the write's scheduledFor drifts by 1 minute", async () => {
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["07:00"]);
const slot = localHmAsUtc(new Date(), TZ, 7, 0);
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot,
takenAt: null,
skipped: false,
source: "REMINDER",
},
});
const drifted = new Date(slot.getTime() + 60_000); // +1 min
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
medicationId: medId,
scheduledFor: drifted.toISOString(),
takenAt: drifted.toISOString(),
},
],
}),
);
const json = (await res.json()) as {
data: { inserted: number; updated: number };
};
expect(json.data.updated).toBe(1);
expect(json.data.inserted).toBe(0);
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId },
});
expect(rows).toHaveLength(1);
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
});
it("does NOT collapse PRN doses — two as-needed logs keep two rows", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Ibuprofen",
dose: "400mg",
active: true,
schedules: {
create: {
windowStart: "00:00",
windowEnd: "00:00",
timesOfDay: [],
daysOfWeek: null,
scheduleType: "PRN",
},
},
},
});
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const t1 = new Date("2026-06-15T09:00:00.000Z");
const t2 = new Date("2026-06-15T15:00:00.000Z");
await POST(
makeRequest({
entries: [
{ medicationId: med.id, scheduledFor: t1.toISOString(), takenAt: t1.toISOString() },
],
}),
);
const res = await POST(
makeRequest({
entries: [
{ medicationId: med.id, scheduledFor: t2.toISOString(), takenAt: t2.toISOString() },
],
}),
);
const json = (await res.json()) as {
data: { inserted: number; updated: number };
};
expect(json.data.inserted).toBe(1);
expect(json.data.updated).toBe(0);
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: med.id },
});
expect(rows).toHaveLength(2);
});
it("creates exactly one row for a fresh scheduled dose with no pre-existing slot row", async () => {
const prisma = getPrismaClient();
const medId = await makeScheduledMed(["19:00"]);
const slot = localHmAsUtc(new Date(), TZ, 19, 0);
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const res = await POST(
makeRequest({
entries: [
{
medicationId: medId,
scheduledFor: slot.toISOString(),
takenAt: slot.toISOString(),
},
],
}),
);
const json = (await res.json()) as {
data: { inserted: number; updated: number };
};
expect(json.data.inserted).toBe(1);
expect(json.data.updated).toBe(0);
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: medId },
});
expect(rows).toHaveLength(1);
// snapped to the canonical slot instant
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
});
});
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const body = {