fix(medications): keep an auto-taken rolling cadence moving

A rolling cadence yields only lastIntakeAt + N, so a slot the auto-take
pass never records (before the switch-on floor, past the catch-up
window, inside a pause, skipped or removed by the person) held it still
for good. Step the cadence forward by N past every such slot, keeping
its rhythm, and feed the stepped anchor to the card, dashboard and
schedule-tool next due so they show the slot the pass will record.
This commit is contained in:
Marc-André Bombeck committed 2026-10-11 15:00:37 +02:00
1 parent 0375c2adc6
commit a4cccc11ba
9 files changed
+718 -123

No files matched your search

+13 -2
View File
@@ -32,6 +32,7 @@ import {
toRevisionPayloadEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import type { Prisma } from "@/generated/prisma/client";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
@@ -179,6 +180,16 @@ export const GET = apiHandler(
// v1.39.1 (#1033) — intake tracking off: nothing is due from the
// stored rows, and no runway derives from them.
const liveSchedules = dueSchedules(medication);
const userTz = user.timezone || DEFAULT_TIMEZONE;
// v1.42.2 — under auto-take a rolling cadence stands where the job will
// record next (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId: user.id,
medicationIds: medication.autoMarkTaken ? [id] : [],
now,
userTz,
lastIntakeAt: () => lastIntake?.takenAt ?? null,
});
const display = computeDisplayDue({
medication: {
id: medication.id,
@@ -189,8 +200,8 @@ export const GET = apiHandler(
},
schedules: liveSchedules,
now,
userTz: user.timezone || DEFAULT_TIMEZONE,
lastIntakeAt: lastIntake?.takenAt ?? null,
userTz,
lastIntakeAt: engineLastIntakeAt(id),
resolvedSlots: resolvedRows.map(toResolvedSlotMark),
eraStart: latestRevision?.validUntil ?? null,
});
+12 -1
View File
@@ -36,6 +36,7 @@
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
@@ -203,6 +204,16 @@ export async function buildMedsTodayBlock(
// The live era start per medication (see `live-era.ts`).
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
// v1.42.2 — under auto-take a rolling cadence stands where the job will
// record next (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId,
medicationIds: medications.filter((m) => m.autoMarkTaken).map((m) => m.id),
now,
userTz,
lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null,
});
const dueCandidates: MedsTodayDueCandidate[] = [];
for (const m of medications) {
const display = computeDisplayDue({
@@ -216,7 +227,7 @@ export async function buildMedsTodayBlock(
schedules: m.schedules,
now,
userTz,
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
lastIntakeAt: engineLastIntakeAt(m.id),
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
@@ -49,6 +49,8 @@ import {
runIntakeAutoTakePass,
} from "../intake-auto-take";
import { autoTakeOwnedFrom } from "../intake-auto-skip";
import { autoTakeRollingAnchor } from "@/lib/medications/auto-take";
import { computeDisplayDue } from "@/lib/medications/scheduling/next-due";
const TZ = "Europe/Berlin";
@@ -710,3 +712,183 @@ describe("runIntakeAutoTakePass — over a fake client", () => {
).toEqual(["2026-06-03T07:00:00.000Z", "2026-06-05T07:00:00.000Z"]);
});
});
// ── a rolling cadence under auto-take never stalls ────────────────────
describe("runIntakeAutoTakePass — rolling cadence keeps moving", () => {
const weekly = () =>
schedule({ rollingIntervalDays: 7, timesOfDay: ["09:00"] });
const autoRecordedSlots = (rows: FakeRow[]) =>
rows
.filter((r) => r.autoRecorded && r.deletedAt === null)
.map((r) => r.scheduledFor.toISOString())
.sort();
it("a first dose before the floor is passed over, the next one recorded", async () => {
// Created at 12:00 CEST, after that day's 09:00 slot, no start date:
// the first rolling dose lies before the floor.
const med = medication({
createdAt: new Date("2026-06-10T10:00:00Z"),
autoMarkTakenSince: new Date("2026-06-10T10:00:00Z"),
schedules: [weekly()],
});
const rows: FakeRow[] = [];
const client = fakeClient([med], rows);
const early = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-12T08:00:00Z"),
});
expect(early.recordedCount).toBe(0);
const due = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-17T07:20:00Z"),
});
expect(due.recordedCount).toBe(1);
expect(autoRecordedSlots(rows)).toEqual(["2026-06-17T07:00:00.000Z"]);
});
it("switched on while the weekly dose is overdue: the overdue dose stays, the cadence moves on", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-10T12:00:00Z"),
schedules: [weekly()],
});
const rows: FakeRow[] = [
row({
id: "t0",
scheduledFor: new Date("2026-06-01T07:00:00Z"),
takenAt: new Date("2026-06-01T07:00:00Z"),
source: "WEB",
}),
];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-15T08:00:00Z"),
});
// 06-08 lies before the switch-on and is never backfilled.
expect(result.recordedCount).toBe(1);
expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]);
});
it("a skipped rolling auto dose counts as handled: the next one is recorded", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-01T00:00:00Z"),
schedules: [weekly()],
});
const rows: FakeRow[] = [
row({
id: "t0",
scheduledFor: new Date("2026-06-01T07:00:00Z"),
takenAt: new Date("2026-06-01T07:00:00Z"),
source: "WEB",
}),
row({
id: "s1",
scheduledFor: new Date("2026-06-08T07:00:00Z"),
skipped: true,
source: "WEB",
}),
];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-15T08:00:00Z"),
});
expect(result.recordedCount).toBe(1);
expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]);
expect(rows.find((r) => r.id === "s1")).toMatchObject({
skipped: true,
takenAt: null,
});
});
it("a removed rolling auto dose stays removed and the next one is recorded", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-01T00:00:00Z"),
schedules: [weekly()],
});
const rows: FakeRow[] = [
row({
id: "t0",
scheduledFor: new Date("2026-06-01T07:00:00Z"),
takenAt: new Date("2026-06-01T07:00:00Z"),
source: "WEB",
}),
row({
id: "gone",
scheduledFor: new Date("2026-06-08T07:00:00Z"),
takenAt: new Date("2026-06-08T07:00:00Z"),
autoRecorded: true,
deletedAt: new Date("2026-06-08T09:00:00Z"),
}),
];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-15T08:00:00Z"),
});
expect(result.recordedCount).toBe(1);
expect(autoRecordedSlots(rows)).toEqual(["2026-06-15T07:00:00.000Z"]);
expect(
rows.filter(
(r) => r.scheduledFor.toISOString() === "2026-06-08T07:00:00.000Z",
),
).toHaveLength(1);
});
it("a worker down for more than a week catches up on the rhythm, not the stale slot", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-05-01T00:00:00Z"),
schedules: [weekly()],
});
const rows: FakeRow[] = [
row({
id: "t0",
scheduledFor: new Date("2026-05-25T07:00:00Z"),
takenAt: new Date("2026-05-25T07:00:00Z"),
source: "WEB",
}),
];
const client = fakeClient([med], rows);
// Ten days after the 06-01 dose fell due: it is outside the catch-up
// window, 06-08 is inside it.
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-11T08:00:00Z"),
});
expect(result.recordedCount).toBe(1);
expect(autoRecordedSlots(rows)).toEqual(["2026-06-08T07:00:00.000Z"]);
});
it("the card's next due is the slot the job records", async () => {
const med = medication({
createdAt: new Date("2026-06-10T10:00:00Z"),
autoMarkTakenSince: new Date("2026-06-10T10:00:00Z"),
schedules: [weekly()],
});
const now = new Date("2026-06-12T08:00:00Z");
const anchor = autoTakeRollingAnchor({
medication: med,
userTz: TZ,
floor: autoTakeFloor({
autoMarkTakenSince: med.autoMarkTakenSince!,
createdAt: med.createdAt,
now,
}),
now,
lastIntakeAt: null,
rows: [],
});
const display = computeDisplayDue({
medication: med,
schedules: med.schedules,
now,
userTz: TZ,
lastIntakeAt: anchor,
resolvedSlots: [],
});
expect(display?.at.toISOString()).toBe("2026-06-17T07:00:00.000Z");
const rows: FakeRow[] = [];
await runIntakeAutoTakePass(fakeClient([med], rows), {
nowMs: Date.parse("2026-06-17T07:20:00Z"),
});
expect(autoRecordedSlots(rows)).toEqual([display!.at.toISOString()]);
});
});
+45 -118
View File
@@ -17,6 +17,14 @@
* 3. Records each remaining slot as taken AT its scheduled instant, marked
* `autoRecorded`, unless the person already acted on it.
*
* A rolling cadence ("every N days from the last dose") yields only its next
* slot, so a slot this pass never records would hold it still for good. Under
* auto-take the cadence therefore steps forward by N, keeping its rhythm,
* past every slot before the floor, inside a pause era, or already handled
* by the person (skipped, auto-missed, removed); see `autoTakeRollingAnchor`
* in `src/lib/medications/auto-take.ts`, which the medication card's next
* due applies too, so the card shows the slot this pass will record.
*
* The floor is the latest of `autoMarkTakenSince` (the option is never
* applied to history before it was switched on), the medication's creation,
* and a 7-day catch-up window (a worker that was down for a weekend still
@@ -47,7 +55,16 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import {
autoTakeEligibleWhere,
autoTakeFloor,
autoTakeRollingAnchor,
autoTakeRowWindow,
decideSlot,
isInPauseEra,
type SlotDecision,
type SlotRow,
} from "@/lib/medications/auto-take";
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
import {
@@ -71,6 +88,14 @@ import {
} from "@/lib/rollups/medication-compliance-rollups";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
export {
autoTakeFloor,
decideSlot,
INTAKE_AUTO_TAKE_LOOKBACK_MS,
isInPauseEra,
type SlotDecision,
} from "@/lib/medications/auto-take";
export const INTAKE_AUTO_TAKE_QUEUE = "intake-auto-take";
/**
@@ -83,9 +108,6 @@ const MINUTE_MS = 60 * 1000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** How far back one pass catches up on slots it has not filled yet. */
export const INTAKE_AUTO_TAKE_LOOKBACK_MS = 7 * DAY_MS;
/**
* A rolling cadence produces its next slot only once the previous one is
* taken, so a pass that catches up re-expands after each recorded rolling
@@ -131,95 +153,6 @@ const SCHEDULE_SELECT = {
doseWindows: true,
} as const;
/** One slot row as the occupancy check reads it. */
interface SlotRow {
id: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
deletedAt: Date | null;
}
export type SlotDecision =
| { kind: "create" }
| { kind: "convert"; rowId: string }
| {
kind: "leave";
reason: "actioned" | "removed" | "taken_nearby";
};
/**
* Decide what the pass does with one slot, given every row of the
* medication around it (live and tombstoned). Pure, so the precedence rules
* are tested without a database.
*
* `band` is the slot's attribution band when the cadence has one (a take
* anywhere inside it belongs to this slot); `null` for a rolling slot, whose
* next occurrence is by definition later than every taken dose.
*/
export function decideSlot(
slotAt: Date,
rows: readonly SlotRow[],
band: Pick<SlotBand, "earlyStart" | "overdueEnd"> | null,
): SlotDecision {
const t = slotAt.getTime();
const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t);
const liveOnSlot = onSlot.filter((r) => r.deletedAt === null);
if (liveOnSlot.some((r) => r.takenAt !== null || r.skipped || r.autoMissed)) {
return { kind: "leave", reason: "actioned" };
}
if (liveOnSlot.length === 0 && onSlot.length > 0) {
return { kind: "leave", reason: "removed" };
}
if (band) {
const from = band.earlyStart.getTime();
const to = band.overdueEnd.getTime();
const takenNearby = rows.some(
(r) =>
r.deletedAt === null &&
!r.skipped &&
r.takenAt !== null &&
r.takenAt.getTime() >= from &&
r.takenAt.getTime() <= to,
);
if (takenNearby) return { kind: "leave", reason: "taken_nearby" };
}
const pending = [...liveOnSlot].sort(
(a, b) => a.id.localeCompare(b.id), // deterministic pick on duplicates
)[0];
return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" };
}
/** True when `at` falls inside a `[pausedAt, resumedAt ?? now)` pause era. */
export function isInPauseEra(
at: Date,
eras: readonly { pausedAt: Date; resumedAt: Date | null }[],
now: Date,
): boolean {
const t = at.getTime();
return eras.some(
(era) =>
t >= era.pausedAt.getTime() && t < (era.resumedAt ?? now).getTime(),
);
}
/** The slot floor for one medication: see the module comment. */
export function autoTakeFloor(input: {
autoMarkTakenSince: Date;
createdAt: Date;
now: Date;
}): Date {
return new Date(
Math.max(
input.autoMarkTakenSince.getTime(),
input.createdAt.getTime(),
input.now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS,
),
);
}
interface AutoTakeMedication {
id: string;
userId: string;
@@ -327,15 +260,9 @@ export async function runIntakeAutoTakePass(
const now = new Date(options.nowMs ?? Date.now());
const medications = (await prisma.medication.findMany({
where: {
autoMarkTaken: true,
autoMarkTakenSince: { not: null, lte: now },
active: true,
pausedAt: null,
asNeeded: false,
// A record-only medication expects no dose, so none is recorded.
...TRACKED_INTAKE_WHERE,
},
// Opted in, active, not paused, not as-needed, intake tracked (a
// record-only medication expects no dose, so none is recorded).
where: autoTakeEligibleWhere(now),
select: {
id: true,
userId: true,
@@ -451,20 +378,7 @@ async function autoTakeMedication(
where: {
userId: medication.userId,
medicationId: medication.id,
OR: [
{
scheduledFor: {
gte: new Date(floor.getTime() - DAY_MS),
lte: new Date(now.getTime() + DAY_MS),
},
},
{
takenAt: {
gte: new Date(floor.getTime() - DAY_MS),
lte: new Date(now.getTime() + DAY_MS),
},
},
],
...autoTakeRowWindow(floor, now),
},
select: {
id: true,
@@ -492,12 +406,25 @@ async function autoTakeMedication(
}
for (let round = 0; round < MAX_EXPANSION_ROUNDS; round++) {
// A rolling cadence steps past the slots this pass will never record
// (before the floor, paused, skipped, removed), so it cannot stall on
// one; the card's next due reads the same anchor.
const anchor = rolling
? autoTakeRollingAnchor({
medication,
userTz,
floor,
now,
lastIntakeAt,
rows,
})
: lastIntakeAt;
const slots = dueAutoTakeSlots({
medication,
userTz,
floor,
now,
lastIntakeAt,
lastIntakeAt: anchor,
}).filter((slot) => !handled.has(slot.at.getTime()));
if (slots.length === 0) break;
const bands = bandsByAnchor({
@@ -505,7 +432,7 @@ async function autoTakeMedication(
userTz,
floor,
now,
lastIntakeAt,
lastIntakeAt: anchor,
});
let recordedRolling = false;
+14 -1
View File
@@ -65,6 +65,7 @@ import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
computeDisplayDue,
OVERDUE_LOOKBACK_MS,
@@ -2010,6 +2011,18 @@ export const MCP_TOOLS: McpToolDefinition[] = [
// The live era start per medication (see `live-era.ts`).
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
// v1.42.2 — under auto-take a rolling cadence stands where the job
// will record next (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId: ctx.userId,
medicationIds: medications
.filter((m) => m.autoMarkTaken)
.map((m) => m.id),
now,
userTz,
lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null,
});
const rows = medications.map((m) => {
const display = computeDisplayDue({
medication: {
@@ -2024,7 +2037,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
schedules: dueSchedules(m),
now,
userTz,
lastIntakeAt: lastTakenAtByMedId.get(m.id) ?? null,
lastIntakeAt: engineLastIntakeAt(m.id),
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
+336
View File
@@ -0,0 +1,336 @@
/**
* v1.42.2 — the pure rules of auto-mark-taken that both the auto-take job
* and the read paths (the medication card's next due, the dashboard, the
* assistant tools) apply, so the card shows the slot the job will record.
*
* - `autoTakeFloor`: the earliest slot the job may record.
* - `decideSlot`: what the job does with one slot given its rows.
* - `autoTakeRollingAnchor`: where a rolling cadence stands for an
* auto-taken medication. The engine yields only `lastIntakeAt + N`; a
* slot the job will never record (before the floor, inside a pause era,
* skipped, auto-missed, or removed by the person) would otherwise hold
* the cadence still forever. The anchor steps forward by N past every
* such slot, keeping the rhythm, and is fed back to the engine as the
* effective `lastIntakeAt`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import {
advanceRollingOccurrence,
nextOccurrenceAfter,
rollingIntakeAnchorFor,
type Occurrence,
} from "@/lib/medications/scheduling/recurrence";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
type WorkerMedicationRow,
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
const DAY_MS = 24 * 60 * 60 * 1000;
/** How far back one pass catches up on slots it has not filled yet. */
export const INTAKE_AUTO_TAKE_LOOKBACK_MS = 7 * DAY_MS;
/**
* Upper bound on single steps past handled rolling slots. Slots before the
* floor are crossed in one stride; only slots inside the catch-up window are
* stepped one by one, and there are at most a handful.
*/
const MAX_ROLLING_STEPS = 64;
/** The slot floor for one medication: see `intake-auto-take.ts`. */
export function autoTakeFloor(input: {
autoMarkTakenSince: Date;
createdAt: Date;
now: Date;
}): Date {
return new Date(
Math.max(
input.autoMarkTakenSince.getTime(),
input.createdAt.getTime(),
input.now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS,
),
);
}
/** True when `at` falls inside a `[pausedAt, resumedAt ?? now)` pause era. */
export function isInPauseEra(
at: Date,
eras: readonly { pausedAt: Date; resumedAt: Date | null }[],
now: Date,
): boolean {
const t = at.getTime();
return eras.some(
(era) =>
t >= era.pausedAt.getTime() && t < (era.resumedAt ?? now).getTime(),
);
}
/** One slot row as the occupancy check reads it. */
export interface SlotRow {
id: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
deletedAt: Date | null;
}
export type SlotDecision =
| { kind: "create" }
| { kind: "convert"; rowId: string }
| {
kind: "leave";
reason: "actioned" | "removed" | "taken_nearby";
};
/**
* Decide what the pass does with one slot, given every row of the
* medication around it (live and tombstoned). Pure, so the precedence rules
* are tested without a database.
*
* `band` is the slot's attribution band when the cadence has one (a take
* anywhere inside it belongs to this slot); `null` for a rolling slot, whose
* next occurrence is by definition later than every taken dose.
*/
export function decideSlot(
slotAt: Date,
rows: readonly SlotRow[],
band: Pick<SlotBand, "earlyStart" | "overdueEnd"> | null,
): SlotDecision {
const t = slotAt.getTime();
const onSlot = rows.filter((r) => r.scheduledFor.getTime() === t);
const liveOnSlot = onSlot.filter((r) => r.deletedAt === null);
if (liveOnSlot.some((r) => r.takenAt !== null || r.skipped || r.autoMissed)) {
return { kind: "leave", reason: "actioned" };
}
if (liveOnSlot.length === 0 && onSlot.length > 0) {
return { kind: "leave", reason: "removed" };
}
if (band) {
const from = band.earlyStart.getTime();
const to = band.overdueEnd.getTime();
const takenNearby = rows.some(
(r) =>
r.deletedAt === null &&
!r.skipped &&
r.takenAt !== null &&
r.takenAt.getTime() >= from &&
r.takenAt.getTime() <= to,
);
if (takenNearby) return { kind: "leave", reason: "taken_nearby" };
}
const pending = [...liveOnSlot].sort(
(a, b) => a.id.localeCompare(b.id), // deterministic pick on duplicates
)[0];
return pending ? { kind: "convert", rowId: pending.id } : { kind: "create" };
}
export interface AutoTakeRollingMedication extends WorkerMedicationRow {
schedules: WorkerScheduleRow[];
pauseEras: { pausedAt: Date; resumedAt: Date | null }[];
}
/**
* The effective `lastIntakeAt` for a rolling cadence under auto-take: the
* real one when its next slot is one the job will record, otherwise an
* anchor stepped forward by N past every slot it will not record. `null`
* input stays `null` only when the course-start slot is recordable.
*
* A slot is passed over when it lies before `floor` (the option is never
* applied to history before it was switched on, and the catch-up window is
* bounded), inside a pause era, or, for a slot whose time has come, when the
* person already handled it: skipped, auto-missed, taken, or removed.
*
* Returns the input unchanged for a medication without a rolling schedule.
* When the course ends before a recordable slot, returns the last passed
* slot as the anchor, so the engine yields nothing.
*/
export function autoTakeRollingAnchor(input: {
medication: AutoTakeRollingMedication;
userTz: string;
floor: Date;
now: Date;
lastIntakeAt: Date | null;
rows: readonly SlotRow[];
}): Date | null {
const { medication, userTz, floor, now, rows } = input;
const scheduleRow = medication.schedules.find(
(s) => s.rollingIntervalDays !== null && s.rollingIntervalDays > 0,
);
if (!scheduleRow || medication.oneShot) return input.lastIntakeAt;
const schedule = buildCanonicalSchedule(scheduleRow);
const n = schedule.rollingIntervalDays!;
const ctx = buildRecurrenceContext({
medication,
userTz,
lastIntakeAt: input.lastIntakeAt,
});
// The engine's identity: `lastIntakeAt + N`, or the course start.
let occ: Occurrence | null = nextOccurrenceAfter(schedule, floor, ctx);
if (!occ) return input.lastIntakeAt;
let last: Occurrence | null = null;
// Cross a long stretch before the floor in one stride, stopping one
// interval short so the single steps below land on the first slot at or
// after the floor, whatever DST did to the hours in between.
const strides = Math.floor(
(floor.getTime() - occ.at.getTime()) / (n * DAY_MS),
);
if (strides >= 2) {
last = occ;
occ = advanceRollingOccurrence(
{ ...schedule, rollingIntervalDays: (strides - 1) * n },
occ,
ctx,
);
}
const passOver = (at: Date): boolean => {
if (at.getTime() < floor.getTime()) return true;
if (isInPauseEra(at, medication.pauseEras, now)) return true;
if (at.getTime() > now.getTime()) return false;
return decideSlot(at, rows, null).kind === "leave";
};
let stepped = last !== null;
for (let step = 0; occ && step < MAX_ROLLING_STEPS; step++) {
if (!passOver(occ.at)) break;
last = occ;
stepped = true;
occ = advanceRollingOccurrence(schedule, occ, ctx);
}
if (!stepped) return input.lastIntakeAt;
if (occ === null) return last!.at;
return rollingIntakeAnchorFor(schedule, occ, ctx) ?? input.lastIntakeAt;
}
/**
* The row window the job reads around one medication, and the read paths
* read for the same purpose: every row (tombstones included) whose slot or
* take falls within a day of `[floor, now]`.
*/
export function autoTakeRowWindow(floor: Date, now: Date) {
const gte = new Date(floor.getTime() - DAY_MS);
const lte = new Date(now.getTime() + DAY_MS);
return {
OR: [{ scheduledFor: { gte, lte } }, { takenAt: { gte, lte } }],
};
}
/** The medications the auto-take job works on (see `intake-auto-take.ts`). */
export function autoTakeEligibleWhere(now: Date) {
return {
autoMarkTaken: true,
autoMarkTakenSince: { not: null, lte: now },
active: true,
pausedAt: null,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
};
}
const ROLLING_SCHEDULE_SELECT = {
id: true,
windowStart: true,
windowEnd: true,
daysOfWeek: true,
timesOfDay: true,
reminderGraceMinutes: true,
rrule: true,
rollingIntervalDays: true,
scheduleType: true,
cyclicOnWeeks: true,
cyclicOffWeeks: true,
doseWindows: true,
} as const;
/**
* For the read paths: resolves the `lastIntakeAt` to hand the engine for
* each medication. An auto-taken rolling medication among `medicationIds`
* gets its stepped anchor; every other one its real last intake. At most
* one medication query and one row query, and none when `medicationIds` is
* empty.
*/
export async function loadAutoTakeRollingAnchors(
prisma: Pick<PrismaClient, "medication" | "medicationIntakeEvent">,
input: {
userId: string;
medicationIds: readonly string[];
now: Date;
userTz: string;
lastIntakeAt: (medicationId: string) => Date | null;
},
): Promise<(medicationId: string) => Date | null> {
const out = new Map<string, Date | null>();
const resolve = (id: string) =>
out.has(id) ? (out.get(id) ?? null) : input.lastIntakeAt(id);
if (input.medicationIds.length === 0) return resolve;
const { now } = input;
const medications = await prisma.medication.findMany({
where: {
userId: input.userId,
id: { in: [...input.medicationIds] },
...autoTakeEligibleWhere(now),
schedules: { some: { rollingIntervalDays: { not: null } } },
},
select: {
id: true,
startsOn: true,
endsOn: true,
oneShot: true,
createdAt: true,
autoMarkTakenSince: true,
schedules: { select: ROLLING_SCHEDULE_SELECT },
pauseEras: { select: { pausedAt: true, resumedAt: true } },
},
});
if (medications.length === 0) return resolve;
const floors = new Map<string, Date>();
let earliest = now;
for (const m of medications) {
const floor = autoTakeFloor({
autoMarkTakenSince: m.autoMarkTakenSince!,
createdAt: m.createdAt,
now,
});
floors.set(m.id, floor);
if (floor.getTime() < earliest.getTime()) earliest = floor;
}
const rows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: input.userId,
medicationId: { in: medications.map((m) => m.id) },
...autoTakeRowWindow(earliest, now),
},
select: {
id: true,
medicationId: true,
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
deletedAt: true,
},
});
for (const m of medications) {
const anchor = autoTakeRollingAnchor({
medication: m as AutoTakeRollingMedication,
userTz: input.userTz,
floor: floors.get(m.id)!,
now,
lastIntakeAt: input.lastIntakeAt(m.id),
rows: rows.filter((r) => r.medicationId === m.id),
});
out.set(m.id, anchor);
}
return resolve;
}
+11 -1
View File
@@ -13,6 +13,7 @@
* readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard.
*/
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { resolveCourseFields } from "@/lib/medications/courses";
@@ -220,6 +221,15 @@ export async function buildMedicationsList(
const now = new Date();
// v1.40 (#1024) — every medication's courses, two queries for the list.
const courseFieldsByMed = await resolveCourseFields(medications, now, userTz);
// v1.42.2 — a rolling cadence under auto-take stands where the auto-take
// job will record next, so the card shows that slot (see `auto-take.ts`).
const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, {
userId,
medicationIds: medications.filter((m) => m.autoMarkTaken).map((m) => m.id),
now,
userTz,
lastIntakeAt: (id) => lastTakenAtDateByMedicationId[id] ?? null,
});
return medications.map((m) => {
// v1.39.1 (#1033) — intake tracking off: the stored rows are a record,
@@ -241,7 +251,7 @@ export async function buildMedicationsList(
schedules: liveSchedules,
now,
userTz,
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
lastIntakeAt: engineLastIntakeAt(m.id),
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
eraStart: eraStartByMedId.get(m.id) ?? null,
});
@@ -593,6 +593,25 @@ export function advanceRollingOccurrence(
return buildOccurrence(at, time, schedule);
}
/**
* The intake instant that makes `occurrence` the next rolling dose: the
* schedule's time on the local day N days earlier. Fed back as
* `lastIntakeAt`, the engine then yields exactly `occurrence`, which lets a
* caller that stepped a rolling cadence forward (past slots it will never
* record) hand the stepped position to every consumer of the engine.
*/
export function rollingIntakeAnchorFor(
schedule: CanonicalSchedule,
occurrence: Occurrence,
ctx: RecurrenceContext,
): Date | null {
const n = schedule.rollingIntervalDays;
if (n === null || n <= 0) return null;
const time = schedule.timesOfDay[0] ?? schedule.windowStart;
const day = addCivilDays(civilDayOfInstant(occurrence.at, ctx.timeZone), -n);
return applyTimeOfDayToCivilDay(day, time, ctx.timeZone);
}
/**
* v1.13.x compliance — RETROSPECTIVE rolling expansion.
*
@@ -380,6 +380,92 @@ describe("auto-mark-taken pass", () => {
});
});
describe("rolling cadence under auto-take", () => {
it("never stalls on a removed dose, and the card shows the slot the pass records", async () => {
const prisma = getPrismaClient();
const userId = await makeUser();
// Weekly at 09:00 local. Last taken Mon 06-01; the 06-08 dose the pass
// recorded was removed by the person (not taken that week).
const medication = await prisma.medication.create({
data: {
userId,
name: "Weekly injection",
dose: "1 pen",
createdAt: new Date("2026-05-01T00:00:00.000Z"),
autoMarkTaken: true,
autoMarkTakenSince: new Date("2026-05-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00"],
rollingIntervalDays: 7,
scheduleType: "SCHEDULED",
},
},
},
select: { id: true },
});
await prisma.medicationIntakeEvent.createMany({
data: [
{
userId,
medicationId: medication.id,
scheduledFor: new Date("2026-06-01T07:00:00.000Z"),
takenAt: new Date("2026-06-01T07:00:00.000Z"),
source: "WEB",
},
{
userId,
medicationId: medication.id,
scheduledFor: new Date("2026-06-08T07:00:00.000Z"),
takenAt: new Date("2026-06-08T07:00:00.000Z"),
autoRecorded: true,
source: "REMINDER",
deletedAt: new Date("2026-06-08T12:00:00.000Z"),
},
],
});
const detail = await import("@/app/api/medications/[id]/route");
const list = await import("@/app/api/medications/route");
const nextDue = async () => {
const res = await (detail.GET as Handler)(
new NextRequest(`http://localhost/api/medications/${medication.id}`),
{ params: Promise.resolve({ id: medication.id }) },
);
const listRes = await (list.GET as Handler)(
new NextRequest("http://localhost/api/medications"),
{ params: Promise.resolve({}) },
);
const listed = (
(await listRes.json()) as {
data: Array<{ id: string; nextDueAt: string | null }>;
}
).data.find((m) => m.id === medication.id);
const own = ((await res.json()) as { data: { nextDueAt: string | null } })
.data.nextDueAt;
expect(listed?.nextDueAt).toBe(own);
return own;
};
at("2026-06-10T08:00:00.000Z");
expect(await nextDue()).toBe("2026-06-15T07:00:00.000Z");
at("2026-06-15T07:20:00.000Z");
const result = await runIntakeAutoTakePass(prisma, {
nowMs: Date.now(),
});
expect(result.recordedCount).toBe(1);
const recorded = (await liveRows(medication.id)).filter(
(r) => r.autoRecorded,
);
expect(recorded.map((r) => r.scheduledFor.toISOString())).toEqual([
"2026-06-15T07:00:00.000Z",
]);
});
});
describe("remindersActive — the published value is the gate", () => {
it("equals sendsMedicationReminders() on the list and detail reads, for every combination", async () => {
const prisma = getPrismaClient();