From a4cccc11ba2bc649ca737a48d68a2c3bddb0a24d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 15:00:37 +0200 Subject: [PATCH] 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. --- src/app/api/medications/[id]/route.ts | 15 +- src/lib/dashboard/meds-today.ts | 13 +- .../jobs/__tests__/intake-auto-take.test.ts | 182 ++++++++++ src/lib/jobs/intake-auto-take.ts | 163 +++------ src/lib/mcp/tools.ts | 15 +- src/lib/medications/auto-take.ts | 336 ++++++++++++++++++ src/lib/medications/list-read.ts | 12 +- src/lib/medications/scheduling/recurrence.ts | 19 + .../medication-auto-mark-taken.test.ts | 86 +++++ 9 files changed, 718 insertions(+), 123 deletions(-) create mode 100644 src/lib/medications/auto-take.ts diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index dc5d768f8..500bc360f 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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, }); diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index 017008912..24382dfe5 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -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, }); diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index 82198030f..969e84758 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -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()]); + }); +}); diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 1f08876e9..e65338966 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -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 | 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; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 771b10be5..d00699618 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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, }); diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts new file mode 100644 index 000000000..d34a0e3ad --- /dev/null +++ b/src/lib/medications/auto-take.ts @@ -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 | 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, + input: { + userId: string; + medicationIds: readonly string[]; + now: Date; + userTz: string; + lastIntakeAt: (medicationId: string) => Date | null; + }, +): Promise<(medicationId: string) => Date | null> { + const out = new Map(); + 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(); + 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; +} diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index cbbfbd456..3c4fb67b7 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -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, }); diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 31f1b8177..568336b39 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -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. * diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index d82a6ceb1..fb36186c1 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -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();