mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
0375c2adc6
commit
a4cccc11ba
9 files changed
+718
-123
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user