feat(medications): schedules remember their history

Editing a schedule's times no longer rewrites the past. Each material
change archives the outgoing cadence as a dated revision, and every
band consumer — history, compliance, write attribution, the auto-miss
cutoff — mints past periods from the schedule that was valid at the
time, so a dose era taken at its then-correct times stays on time
forever. The repair script can infer historical eras from consistent
old anchors behind an explicit flag. The welcome tour scrolls its
target into view, flips above it when space below runs out and falls
back to a centred sheet on any viewport, so no step can strand its
buttons below the fold. The avatar menu drops the about entry — that
lives in settings.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 22:21:44 +02:00
1 parent 5bfafa6f51
commit bd78cd7f7a
26 files changed
+1806 -223

No files matched your search

+5 -1
View File
@@ -84,7 +84,11 @@ export const GET = apiHandler(async () => {
}),
prisma.medication.findMany({
where: { userId: user.id, active: true },
include: { schedules: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
}),
]);
+5 -1
View File
@@ -261,7 +261,11 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
where: { userId, active: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
const categoryMap = await getMedicationCategories(
medications.map((m) => m.id),
+5 -1
View File
@@ -1007,7 +1007,11 @@ async function buildTargetsResponse(user: AuthedUser) {
where: { userId, active: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
orderBy: { name: "asc" },
});
@@ -13,8 +13,13 @@ vi.mock("@/lib/db", () => ({
medicationSchedule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
update: vi.fn(),
},
medicationScheduleRevision: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
medicationIntakeEvent: {
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
@@ -101,6 +106,15 @@ beforeEach(() => {
vi.mocked(prisma.medicationSchedule.deleteMany).mockResolvedValue({
count: 0,
} as never);
// v1.16.3 — the schedule-replace branch archives the previous rows as a
// revision when a cadence field changes; default to "no previous rows".
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue(
{} as never,
);
});
describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -202,6 +216,125 @@ describe("PUT /api/medications/[id] — v1.5 scheduling primitives", () => {
});
});
describe("PUT /api/medications/[id] — schedule replace archives a revision (v1.16.3)", () => {
const PREVIOUS_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "07:00",
windowEnd: "19:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["07:00", "19:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
schedules: [],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(
undefined,
);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
PREVIOUS_ROW,
] as never);
});
it("archives the previous rows when the dose times change", async () => {
const res = await PUT(
putReq({
schedules: [
{
windowStart: "09:00",
windowEnd: "21:00",
timesOfDay: ["09:00", "21:00"],
rrule: "FREQ=DAILY",
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(calls).toHaveLength(1);
const data = calls[0][0].data as {
medicationId: string;
validFrom: Date;
validUntil: Date;
payload: Array<{ timesOfDay: string[] }>;
};
expect(data.medicationId).toBe("m1");
// First revision chains from medication.createdAt.
expect(data.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
expect(data.validUntil.getTime()).toBeLessThanOrEqual(Date.now());
expect(data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
});
it("does NOT archive a revision on a no-op echo of the same schedule", async () => {
const res = await PUT(
putReq({
schedules: [
{
windowStart: "07:00",
windowEnd: "19:00",
timesOfDay: ["19:00", "07:00"], // reordered — still no-op
rrule: "FREQ=DAILY",
label: "Renamed", // display-only — never a cadence change
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
});
it("chains validFrom from the newest existing revision", async () => {
vi.mocked(prisma.medicationScheduleRevision.findFirst).mockResolvedValue({
validUntil: new Date("2026-06-01T12:00:00.000Z"),
} as never);
const res = await PUT(
putReq({
schedules: [
{
windowStart: "10:00",
windowEnd: "22:00",
timesOfDay: ["10:00", "22:00"],
rrule: "FREQ=DAILY",
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const calls = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(calls).toHaveLength(1);
const data = calls[0][0].data as { validFrom: Date };
expect(data.validFrom.toISOString()).toBe("2026-06-01T12:00:00.000Z");
});
});
describe("PUT /api/medications/[id] — schedule replace migrates open slots", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -57,7 +57,11 @@ export const GET = apiHandler(
where: { id },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } },
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware chips/tallies.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
if (!med) {
return apiError("Medication not found", 404);
@@ -126,6 +130,7 @@ export const GET = apiHandler(
createdAt: med.createdAt,
lastIntakeAt,
timeZone: userTz,
scheduleRevisions: med.scheduleRevisions,
};
const timeline = buildCadenceTimeline(
@@ -246,7 +246,11 @@ export const GET = apiHandler(
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
if (!medication) {
@@ -29,10 +29,10 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { lastNonSkippedTakenAt } from "@/lib/analytics/compliance";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandFamily,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
import { buildBandsForSchedulesWithEras } from "@/lib/medications/scheduling/schedule-eras";
import {
reconstructDoseHistory,
type DoseHistoryRow,
@@ -123,7 +123,12 @@ export const GET = apiHandler(
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras: past days mint against the
// schedule that was live THEN.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
if (!medication) {
return apiError("Medication not found", 404);
@@ -205,9 +210,10 @@ export const GET = apiHandler(
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
const groups = buildBandsForSchedulesWithEras({
medication: bandMedication,
schedules: canonicalSchedules,
revisions: medication.scheduleRevisions ?? [],
ctx,
userTz,
range: { from, to },
+152 -69
View File
@@ -17,6 +17,11 @@ import {
setMedicationCategory,
} from "@/lib/medication-category";
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
import {
schedulesMateriallyDiffer,
toRevisionPayloadEntry,
} from "@/lib/medications/scheduling/schedule-eras";
import type { Prisma } from "@/generated/prisma/client";
import { computeNextDueAt } from "@/lib/medications/scheduling/next-due";
import {
dayKeyForScheduledFor,
@@ -174,7 +179,7 @@ export const PUT = apiHandler(
if (guard) return guard;
const existing = await prisma.medication.findUnique({
where: { id },
select: { active: true },
select: { active: true, createdAt: true },
});
if (!existing) {
return apiError("Medication not found", 404);
@@ -280,8 +285,151 @@ export const PUT = apiHandler(
? { pausedAt: new Date() }
: {};
// v1.16.3 — normalise the incoming schedules ONCE: the same values feed
// the create payload below AND the revision compare/snapshot (so the
// material-change gate sees exactly what will be persisted).
const normalisedSchedules = schedules?.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN carries no cadence, so never default it.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
oneShot !== true &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write timesOfDay from windowStart.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0 ? s.timesOfDay : [s.windowStart];
// Invariant 4 — window / times consistency. See
// `reconcileScheduleWindow`: a times-only edit that echoes the
// stale window back gets the window pulled to the min/max of
// the new times.
const window = reconcileScheduleWindow(
s.windowStart,
s.windowEnd,
effectiveTimesOfDay,
);
const serializedDaysOfWeek = serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
});
return {
createData: {
windowStart: window.windowStart,
windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializedDaysOfWeek,
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
},
// Mirror of the row the create above will persist (defaults applied),
// shaped for the cadence compare.
snapshot: toRevisionPayloadEntry({
timesOfDay: effectiveTimesOfDay,
windowStart: window.windowStart,
windowEnd: window.windowEnd,
daysOfWeek: serializedDaysOfWeek,
rrule: defaultedRrule ?? null,
rollingIntervalDays: s.rollingIntervalDays ?? null,
scheduleType: s.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: s.cyclicOnWeeks ?? null,
cyclicOffWeeks: s.cyclicOffWeeks ?? null,
doseWindows: s.doseWindows ?? null,
label: s.label ?? null,
dose: s.dose ?? null,
reminderGraceMinutes: s.reminderGraceMinutes ?? null,
}),
};
});
// If schedules provided, replace all
if (schedules) {
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
if (
previousRows.length > 0 &&
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
)
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: previousEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: previousEntries.length },
});
}
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
@@ -366,74 +514,9 @@ export const PUT = apiHandler(
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
...(schedules && {
...(normalisedSchedules && {
schedules: {
create: schedules.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN carries no cadence, so never default it.
const hasLegacyDays = (s.daysOfWeek?.length ?? 0) > 0;
const isPrn = s.scheduleType === "PRN";
const defaultedRrule =
oneShot !== true &&
!isPrn &&
s.rrule === undefined &&
s.rollingIntervalDays === undefined &&
!hasLegacyDays
? "FREQ=DAILY"
: s.rrule;
// Invariant 3 — dual-write timesOfDay from windowStart.
const effectiveTimesOfDay =
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart];
// Invariant 4 — window / times consistency. See
// `reconcileScheduleWindow`: a times-only edit that echoes the
// stale window back gets the window pulled to the min/max of
// the new times.
const window = reconcileScheduleWindow(
s.windowStart,
s.windowEnd,
effectiveTimesOfDay,
);
return {
windowStart: window.windowStart,
windowEnd: window.windowEnd,
label: s.label ?? null,
dose: s.dose ?? null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: s.daysOfWeek ?? [],
intervalWeeks: s.intervalWeeks ?? 1,
}),
// v1.5 first-class times-of-day.
timesOfDay: effectiveTimesOfDay,
...(s.reminderGraceMinutes !== undefined && {
reminderGraceMinutes: s.reminderGraceMinutes,
}),
...(defaultedRrule !== undefined && { rrule: defaultedRrule }),
...(s.rollingIntervalDays !== undefined && {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
...(s.cyclicOffWeeks !== undefined && {
cyclicOffWeeks: s.cyclicOffWeeks,
}),
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
};
}),
create: normalisedSchedules.map((n) => n.createData),
},
}),
};
+5 -1
View File
@@ -258,7 +258,11 @@ async function buildScheduleAnchoredComplianceBuckets(
const medications = await prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware expected counts.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
});
const events = await prisma.medicationIntakeEvent.findMany({