mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(medications): never mint a slot from before the live schedule era
The projector floored today's slots only on an empty archived era, the reminder worker and the next-due reads on the newest revision boundary, and the auto-miss pass on the creation instant alone. Switching tracking back on and then moving a dose time the same afternoon therefore put the morning dose on the card, and the next day's pass stamped it a miss; a same-day times edit on any medication did the same. One helper now names the live era start (the newest active revision's boundary) and every path floors on it: the projector, the worker, the doses card, the medication list, the MCP schedule tool and the auto-miss pass, which leaves a placeholder minted after the switch for a slot before it.
This commit is contained in:
1 parent
1d1570733e
commit
37c6b0e144
8 files changed
+154
-49
No files matched your search
@@ -47,6 +47,7 @@ import {
|
||||
TRACKED_INTAKE_EVENT_WHERE,
|
||||
TRACKED_INTAKE_WHERE,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
export interface MedsTodayDueCandidate {
|
||||
medicationId: string;
|
||||
@@ -198,11 +199,8 @@ export async function buildMedsTodayBlock(
|
||||
if (list) list.push(mark);
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
||||
}
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil)
|
||||
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
// The live era start per medication (see `live-era.ts`).
|
||||
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
|
||||
|
||||
const dueCandidates: MedsTodayDueCandidate[] = [];
|
||||
for (const m of medications) {
|
||||
|
||||
@@ -67,6 +67,10 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
|
||||
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
export const INTAKE_AUTO_SKIP_QUEUE = "intake-auto-skip";
|
||||
|
||||
@@ -249,16 +253,24 @@ export async function runIntakeAutoSkipPass(
|
||||
schedules: {
|
||||
select: { rrule: true, rollingIntervalDays: true, doseWindows: true },
|
||||
},
|
||||
scheduleRevisions: LIVE_ERA_REVISION_ARGS,
|
||||
},
|
||||
});
|
||||
|
||||
// Group medications by their derived delay so one `updateMany` covers
|
||||
// each distinct cutoff instead of one query per medication.
|
||||
const medsByDelay = new Map<number, Array<{ id: string; createdAt: Date }>>();
|
||||
const medsByDelay = new Map<
|
||||
number,
|
||||
Array<{ id: string; createdAt: Date; eraStart: Date | null }>
|
||||
>();
|
||||
for (const medication of medications) {
|
||||
const delayMs = medicationAutoMissDelayMs(medication.schedules);
|
||||
const group = medsByDelay.get(delayMs) ?? [];
|
||||
group.push({ id: medication.id, createdAt: medication.createdAt });
|
||||
group.push({
|
||||
id: medication.id,
|
||||
createdAt: medication.createdAt,
|
||||
eraStart: liveEraStart(medication.scheduleRevisions),
|
||||
});
|
||||
medsByDelay.set(delayMs, group);
|
||||
}
|
||||
|
||||
@@ -272,9 +284,22 @@ export async function runIntakeAutoSkipPass(
|
||||
scheduledFor: { lt: new Date(nowMs - delayMs) },
|
||||
// Per medication, only slots from its creation on: a placeholder
|
||||
// for a slot before it was never an expected dose (see above).
|
||||
//
|
||||
// And only slots the schedule in force at the time expected: a
|
||||
// placeholder anchored before the live era start but minted after it
|
||||
// came from the NEW schedule projected back over the part of the
|
||||
// day that belonged to the previous era (or to none, while intake
|
||||
// tracking was off). Earlier placeholders minted before the switch
|
||||
// were real expectations of the old era and still resolve.
|
||||
OR: group.map((m) => ({
|
||||
medicationId: m.id,
|
||||
scheduledFor: { gte: m.createdAt },
|
||||
...(m.eraStart && {
|
||||
NOT: {
|
||||
scheduledFor: { lt: m.eraStart },
|
||||
createdAt: { gte: m.eraStart },
|
||||
},
|
||||
}),
|
||||
})),
|
||||
},
|
||||
// `syncVersion` bumps so delta-sync clients pick up the terminal
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day";
|
||||
import { getWorkerPrisma, parseTimeToMinutes } from "./shared";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart as liveEraStartOf,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
export interface ReminderCheckPayload {
|
||||
triggeredAt: string;
|
||||
@@ -174,12 +178,7 @@ export async function handleReminderCheck(
|
||||
include: {
|
||||
schedules: true,
|
||||
phaseConfig: true,
|
||||
scheduleRevisions: {
|
||||
where: { supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
take: 1,
|
||||
select: { validUntil: true },
|
||||
},
|
||||
scheduleRevisions: LIVE_ERA_REVISION_ARGS,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -249,7 +248,7 @@ export async function handleReminderCheck(
|
||||
// skips and USER_PIN decisions are mutations, whose Prisma @updatedAt
|
||||
// timestamp records the decision. Ordinary takes use their takenAt.
|
||||
const liveEraStart =
|
||||
med.scheduleRevisions?.[0]?.validUntil.getTime() ?? null;
|
||||
liveEraStartOf(med.scheduleRevisions)?.getTime() ?? null;
|
||||
const liveEraEvents =
|
||||
liveEraStart === null
|
||||
? todayEvents
|
||||
|
||||
@@ -73,6 +73,7 @@ import { listTargetsBySource } from "@/lib/links";
|
||||
import { encounterKindEnum } from "@/lib/validations/encounters";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
/**
|
||||
* Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every
|
||||
@@ -1532,11 +1533,8 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
if (list) list.push(mark);
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
||||
}
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil)
|
||||
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
// The live era start per medication (see `live-era.ts`).
|
||||
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
|
||||
|
||||
const rows = medications.map((m) => {
|
||||
const display = computeDisplayDue({
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
isRecordOnly,
|
||||
scheduleWireFields,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
export type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
|
||||
@@ -164,11 +165,8 @@ export async function buildMedicationsList(
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
||||
}
|
||||
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil)
|
||||
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
// The live era start per medication (see `live-era.ts`).
|
||||
const eraStartByMedId = liveEraStartsByMedication(eraFloors);
|
||||
|
||||
const lastTakenAtByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isBeforeLiveEra,
|
||||
liveEraStart,
|
||||
liveEraStartsByMedication,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
const d = (iso: string) => new Date(iso);
|
||||
|
||||
describe("live era start", () => {
|
||||
it("is null without a revision", () => {
|
||||
expect(liveEraStart(undefined)).toBeNull();
|
||||
expect(liveEraStart([])).toBeNull();
|
||||
expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), null)).toBe(false);
|
||||
});
|
||||
|
||||
it("is the newest revision boundary, and floors earlier slots", () => {
|
||||
const start = liveEraStart([
|
||||
{ validUntil: d("2026-06-01T00:00:00Z") },
|
||||
{ validUntil: d("2026-06-10T12:30:00Z") },
|
||||
]);
|
||||
expect(start?.toISOString()).toBe("2026-06-10T12:30:00.000Z");
|
||||
expect(isBeforeLiveEra(d("2026-06-10T06:00:00Z"), start)).toBe(true);
|
||||
expect(isBeforeLiveEra(d("2026-06-10T12:30:00Z"), start)).toBe(false);
|
||||
expect(isBeforeLiveEra(d("2026-06-11T06:00:00Z"), start)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps a groupBy of active revisions", () => {
|
||||
const map = liveEraStartsByMedication([
|
||||
{ medicationId: "a", _max: { validUntil: d("2026-06-10T12:30:00Z") } },
|
||||
{ medicationId: "b", _max: { validUntil: null } },
|
||||
]);
|
||||
expect([...map.keys()]).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* The start of a medication's LIVE schedule era: the instant from which its
|
||||
* current `MedicationSchedule` rows are in force.
|
||||
*
|
||||
* A schedule replace, or switching intake tracking off or back on, archives
|
||||
* the previous era as a `MedicationScheduleRevision` ending "now". The newest
|
||||
* active revision's `validUntil` is therefore where the live rows began. A
|
||||
* slot the live rows would produce BEFORE that instant was never expected:
|
||||
* the day's earlier doses belonged to the previous era (or to no era at all,
|
||||
* while tracking was off). Minting it would put a dose on the card that the
|
||||
* person could not have taken on this schedule, and the auto-miss pass would
|
||||
* later stamp it a miss.
|
||||
*
|
||||
* Every path that mints or displays a slot from the live rows floors on this
|
||||
* one instant: the today projector, the reminder worker, the next-due read
|
||||
* (doses card, medication list, MCP) and the auto-miss pass. `null` means the
|
||||
* medication has never had a schedule revision: the live rows have been in
|
||||
* force since it was created, and the callers' own creation handling applies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prisma relation args selecting the newest ACTIVE revision's boundary.
|
||||
* Superseded revisions are audit records whose `validUntil` may sit past a
|
||||
* later correction, so only active rows count.
|
||||
*/
|
||||
export const LIVE_ERA_REVISION_ARGS = {
|
||||
where: { supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
take: 1,
|
||||
select: { validUntil: true },
|
||||
} as const;
|
||||
|
||||
/** The live era start from revisions read with {@link LIVE_ERA_REVISION_ARGS}. */
|
||||
export function liveEraStart(
|
||||
revisions: readonly { validUntil: Date }[] | null | undefined,
|
||||
): Date | null {
|
||||
if (!revisions || revisions.length === 0) return null;
|
||||
let newest = revisions[0].validUntil;
|
||||
for (const r of revisions) {
|
||||
if (r.validUntil.getTime() > newest.getTime()) newest = r.validUntil;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live era starts per medication from a `medicationScheduleRevision.groupBy`
|
||||
* over active revisions with `_max: { validUntil: true }`.
|
||||
*/
|
||||
export function liveEraStartsByMedication(
|
||||
rows: readonly {
|
||||
medicationId: string;
|
||||
_max: { validUntil: Date | null };
|
||||
}[],
|
||||
): Map<string, Date> {
|
||||
const out = new Map<string, Date>();
|
||||
for (const row of rows) {
|
||||
if (row._max.validUntil) out.set(row.medicationId, row._max.validUntil);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when a slot anchored at `slot` predates the live era. */
|
||||
export function isBeforeLiveEra(slot: Date, eraStart: Date | null): boolean {
|
||||
return eraStart !== null && slot.getTime() < eraStart.getTime();
|
||||
}
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
|
||||
import {
|
||||
isBeforeLiveEra,
|
||||
LIVE_ERA_REVISION_ARGS,
|
||||
liveEraStart,
|
||||
} from "@/lib/medications/scheduling/live-era";
|
||||
|
||||
export interface ProjectTodayIntakesResult {
|
||||
projected: number;
|
||||
@@ -61,18 +66,10 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
endsOn: true,
|
||||
oneShot: true,
|
||||
createdAt: true,
|
||||
// The newest active schedule revision. When it archived an EMPTY
|
||||
// payload, the stretch up to its `validUntil` expected no dose at all
|
||||
// (intake tracking was off, or the medication was as-needed), so the
|
||||
// live schedule starts there and no slot anchored before it is
|
||||
// projected. Turning tracking back on at 15:00 therefore does not put
|
||||
// the morning dose on the doses card.
|
||||
scheduleRevisions: {
|
||||
where: { supersededByRevisionId: null },
|
||||
orderBy: { validUntil: "desc" },
|
||||
take: 1,
|
||||
select: { validUntil: true, payload: true },
|
||||
},
|
||||
// The live era start: no slot anchored before it is projected. Turning
|
||||
// tracking back on at 15:00, or moving a dose time at 14:30, therefore
|
||||
// never puts a dose from earlier that day on the card.
|
||||
scheduleRevisions: LIVE_ERA_REVISION_ARGS,
|
||||
schedules: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -112,13 +109,7 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
|
||||
for (const med of activeMedications) {
|
||||
if (med.schedules.length === 0) continue;
|
||||
const latestRevision = med.scheduleRevisions?.[0];
|
||||
const expectedNothingUntil =
|
||||
latestRevision &&
|
||||
Array.isArray(latestRevision.payload) &&
|
||||
latestRevision.payload.length === 0
|
||||
? latestRevision.validUntil.getTime()
|
||||
: null;
|
||||
const eraStart = liveEraStart(med.scheduleRevisions);
|
||||
|
||||
// Rolling cadence anchors off the last logged intake. One findFirst
|
||||
// per medication, scoped to `takenAt IS NOT NULL` — byte-identical to
|
||||
@@ -172,12 +163,7 @@ export async function projectTodayIntakesAndRecompute(input: {
|
||||
const [h, m] = slotTime.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
|
||||
const scheduledFor = localHmAsUtc(now, userTz, h, m);
|
||||
if (
|
||||
expectedNothingUntil !== null &&
|
||||
scheduledFor.getTime() < expectedNothingUntil
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (isBeforeLiveEra(scheduledFor, eraStart)) continue;
|
||||
projected.push({
|
||||
medicationId: schedule.medicationId,
|
||||
scheduledFor,
|
||||
|
||||
Reference in new issue
Block a user