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

+45 -26
View File
@@ -21,10 +21,14 @@
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import {
buildBandsForSchedulesWithEras,
occurrencesAcrossEras,
type ScheduleRevisionLike,
} from "@/lib/medications/scheduling/schedule-eras";
import {
buildCadenceTimeline,
type CadenceEngineContext,
@@ -419,6 +423,13 @@ export interface ComplianceMedicationContext {
createdAt: Date;
lastIntakeAt: Date | null;
timeZone: string;
/**
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present,
* every expected-slot expansion + band mint segments its range so a past
* day counts/mints against the schedule that was live THEN. Optional:
* callers without revisions keep the live-only expansion.
*/
scheduleRevisions?: ScheduleRevisionLike[];
}
/**
@@ -433,6 +444,8 @@ export function buildComplianceMedicationContext(
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
/** v1.16.3 — thread the archived eras when the bundle carries them. */
scheduleRevisions?: ScheduleRevisionLike[];
},
lastIntakeAt: Date | null,
timeZone: string,
@@ -444,6 +457,7 @@ export function buildComplianceMedicationContext(
createdAt: med.createdAt,
lastIntakeAt,
timeZone,
...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }),
};
}
@@ -624,25 +638,28 @@ export function expectedSlotCountForDay(
ctx: ComplianceMedicationContext,
intakes?: ComplianceIntakeInstant[],
): number {
let count = 0;
const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-daily");
const now = new Date();
const retro =
intakes && schedules.some((s) => s.rollingIntervalDays != null)
? { intakeInstants: intakeInstantsAtOrBefore(intakes, now), now }
: undefined;
for (let i = 0; i < schedules.length; i++) {
count += expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-daily-${i}`),
recurrenceCtx,
dayStart,
// v1.16.3 — era-aware: a past day counts the slots of the schedule that
// was live THEN. With no revisions the single live era expands exactly
// the per-schedule loop this used to run.
return occurrencesAcrossEras(
{
from: dayStart,
// occurrencesBetween is inclusive of both ends; subtract 1 ms so a
// slot exactly at the next day's midnight doesn't double-count.
new Date(dayEnd.getTime() - 1),
retro,
).length;
}
return count;
to: new Date(dayEnd.getTime() - 1),
},
ctx.scheduleRevisions ?? [],
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-daily-${i}`)),
(schedule, eraFrom, eraTo) =>
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
{ oneShot: ctx.oneShot },
).length;
}
/**
@@ -686,19 +703,17 @@ export function expectedSlotsBetween(
intakes && schedules.some((s) => s.rollingIntervalDays != null)
? { intakeInstants: intakeInstantsAtOrBefore(intakes, to), now: to }
: undefined;
const all: Occurrence[] = [];
for (let i = 0; i < schedules.length; i++) {
all.push(
...expandComplianceOccurrences(
toCanonicalSchedule(schedules[i], `compliance-slots-${i}`),
recurrenceCtx,
effectiveFrom,
to,
retro,
),
);
}
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
if (effectiveFrom.getTime() > to.getTime()) return [];
// v1.16.3 — era-aware: each archived era contributes the slots of ITS
// schedules; the live rows cover only the range past the newest revision.
return occurrencesAcrossEras(
{ from: effectiveFrom, to },
ctx.scheduleRevisions ?? [],
schedules.map((s, i) => toCanonicalSchedule(s, `compliance-slots-${i}`)),
(schedule, eraFrom, eraTo) =>
expandComplianceOccurrences(schedule, recurrenceCtx, eraFrom, eraTo, retro),
{ oneShot: ctx.oneShot },
);
}
/**
@@ -1261,9 +1276,11 @@ export function buildComplianceLedgerRows(
to,
);
const groups = buildBandsForSchedules({
// v1.16.3 — era-aware mint: archived eras band with THEIR schedules.
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: canonicalSchedules,
revisions: ctx.scheduleRevisions ?? [],
ctx: recurrenceCtx,
userTz: ctx.timeZone,
range: { from, to },
@@ -1504,6 +1521,7 @@ export function calculateCompliance(
createdAt: ctx.createdAt,
lastIntakeAt: ctx.lastIntakeAt,
timeZone: ctx.timeZone,
scheduleRevisions: ctx.scheduleRevisions,
}
: undefined;
@@ -1853,6 +1871,7 @@ function buildTimelineForWindow(
createdAt: ctx.createdAt,
lastIntakeAt: ctx.lastIntakeAt,
timeZone: ctx.timeZone,
scheduleRevisions: ctx.scheduleRevisions,
};
const normalisedEvents: IntakeEventLike[] = events
+5 -1
View File
@@ -265,7 +265,11 @@ export async function generateBloodPressureStatusForUser(
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(
+5 -1
View File
@@ -914,7 +914,11 @@ export async function extractFeatures(
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" } },
},
});
if (medications.length > 0) {
@@ -156,7 +156,11 @@ export async function generateMedicationComplianceStatusForUser(
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" },
});
@@ -0,0 +1,424 @@
/**
* v1.16.3 — schedule-era segmentation (effective dating).
*
* Pins the era-split engine: a schedule replace archives the old state as a
* revision, and every historical surface mints past days against the
* schedule that was live THEN. Covers the boundary rule (anchor decides era,
* tails uncut), DST across an era boundary, a rolling-cadence era, the
* zero-revision pass-through, the write-attribution era pick, and the
* material-change gate the write path uses.
*/
import { describe, expect, it } from "vitest";
import {
attributeTakenToSlot,
type AttributeIntakeMedication,
} from "@/lib/medications/scheduling/attribute-intake";
import { buildBandsForSchedules } from "@/lib/medications/scheduling/band-minter";
import { reconstructDoseHistory } from "@/lib/medications/scheduling/dose-history";
import type {
CanonicalSchedule,
RecurrenceContext,
} from "@/lib/medications/scheduling/recurrence";
import {
buildBandsForSchedulesWithEras,
canonicalSchedulesFromRevision,
schedulesMateriallyDiffer,
segmentRangeIntoEras,
toRevisionPayloadEntry,
type ScheduleRevisionEntry,
type ScheduleRevisionLike,
} from "@/lib/medications/scheduling/schedule-eras";
const TZ = "Europe/Berlin";
function canonical(partial: Partial<CanonicalSchedule>): CanonicalSchedule {
return {
id: "live-1",
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
timesOfDay: [],
daysOfWeek: null,
windowStart: "08:00",
windowEnd: "08:00",
reminderGraceMinutes: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
...partial,
};
}
function entry(partial: Partial<ScheduleRevisionEntry>): ScheduleRevisionEntry {
return {
timesOfDay: ["07:00", "19:00"],
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: null,
dose: null,
reminderGraceMinutes: null,
...partial,
};
}
const createdAt = new Date("2026-05-01T08:00:00.000Z");
// The replace happened on 31 May at 10:00 UTC (12:00 Berlin).
const replaceAt = new Date("2026-05-31T10:00:00.000Z");
const medication = {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
};
const ctx: RecurrenceContext = {
medication,
timeZone: TZ,
lastIntakeAt: null,
};
const oldEraRevision: ScheduleRevisionLike = {
id: "rev-1",
validFrom: createdAt,
validUntil: replaceAt,
payload: [entry({})],
};
const liveSchedule = canonical({
timesOfDay: ["09:00", "21:00"],
windowStart: "09:00",
windowEnd: "21:00",
});
describe("segmentRangeIntoEras", () => {
it("returns a single live era when no revisions exist", () => {
const range = {
from: new Date("2026-05-10T00:00:00.000Z"),
to: new Date("2026-06-10T00:00:00.000Z"),
};
const eras = segmentRangeIntoEras(range, [], [liveSchedule]);
expect(eras).toHaveLength(1);
expect(eras[0].live).toBe(true);
expect(eras[0].from).toEqual(range.from);
expect(eras[0].to).toEqual(range.to);
});
it("splits the range at validUntil with the revision schedules first", () => {
const range = {
from: new Date("2026-05-10T00:00:00.000Z"),
to: new Date("2026-06-10T00:00:00.000Z"),
};
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
expect(eras).toHaveLength(2);
expect(eras[0].live).toBe(false);
expect(eras[0].schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
// Anchor-boundary rule: the archived era ends 1 ms short of validUntil.
expect(eras[0].to.getTime()).toBe(replaceAt.getTime() - 1);
expect(eras[1].live).toBe(true);
expect(eras[1].from).toEqual(replaceAt);
});
it("drops a revision era that does not intersect the range", () => {
const range = {
from: new Date("2026-06-01T00:00:00.000Z"),
to: new Date("2026-06-10T00:00:00.000Z"),
};
const eras = segmentRangeIntoEras(range, [oldEraRevision], [liveSchedule]);
expect(eras).toHaveLength(1);
expect(eras[0].live).toBe(true);
});
});
describe("buildBandsForSchedulesWithEras", () => {
const range = {
from: new Date("2026-05-20T00:00:00.000Z"),
to: new Date("2026-06-05T00:00:00.000Z"),
};
function mintedBands() {
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: [liveSchedule],
revisions: [oldEraRevision],
ctx,
userTz: TZ,
range,
now: range.to,
});
return groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
}
it("mints 07:00/19:00 before the boundary and 09:00/21:00 after", () => {
const bands = mintedBands();
const before = bands.filter((b) => b.at.getTime() < replaceAt.getTime());
const after = bands.filter((b) => b.at.getTime() >= replaceAt.getTime());
expect(before.length).toBeGreaterThan(0);
expect(after.length).toBeGreaterThan(0);
expect(new Set(before.map((b) => b.timeOfDay))).toEqual(
new Set(["07:00", "19:00"]),
);
expect(new Set(after.map((b) => b.timeOfDay))).toEqual(
new Set(["09:00", "21:00"]),
);
// The new times must not exist before the boundary at all.
expect(
before.some((b) => b.timeOfDay === "09:00" || b.timeOfDay === "21:00"),
).toBe(false);
});
it("is a pass-through with zero revisions", () => {
const direct = buildBandsForSchedules({
medication,
schedules: [liveSchedule],
ctx,
userTz: TZ,
range,
now: range.to,
});
const viaEras = buildBandsForSchedulesWithEras({
medication,
schedules: [liveSchedule],
revisions: [],
ctx,
userTz: TZ,
range,
now: range.to,
});
expect(viaEras).toEqual(direct);
});
it("binds an old-era take on-time to the old 07:00 slot and keeps history compliance positive", () => {
const bands = mintedBands();
// A take at 07:05 Berlin (05:05 UTC) on 25 May — inside the old era.
const takenAt = new Date("2026-05-25T05:05:00.000Z");
const rows = reconstructDoseHistory(
bands,
[{ scheduledFor: takenAt, takenAt, skipped: false }],
range.to,
);
const takenRows = rows.filter((r) => r.status === "taken_on_time");
expect(takenRows).toHaveLength(1);
expect(takenRows[0].timeOfDay).toBe("07:00");
const taken = rows.filter(
(r) => r.status === "taken_on_time" || r.status === "taken_late",
).length;
expect(taken).toBeGreaterThan(0);
});
it("keeps local slot times correct across a DST transition inside an era", () => {
// Berlin springs forward on 2026-03-29. Old era covers the transition.
const dstCreated = new Date("2026-03-20T08:00:00.000Z");
const dstReplace = new Date("2026-04-02T10:00:00.000Z");
const dstMed = { ...medication, createdAt: dstCreated };
const dstCtx: RecurrenceContext = { ...ctx, medication: dstMed };
const groups = buildBandsForSchedulesWithEras({
medication: dstMed,
schedules: [liveSchedule],
revisions: [
{
id: "rev-dst",
validFrom: dstCreated,
validUntil: dstReplace,
payload: [entry({ timesOfDay: ["07:00"], windowStart: "07:00", windowEnd: "07:00" })],
},
],
ctx: dstCtx,
userTz: TZ,
range: {
from: new Date("2026-03-25T00:00:00.000Z"),
to: new Date("2026-04-05T00:00:00.000Z"),
},
now: new Date("2026-04-05T00:00:00.000Z"),
});
const bands = groups.flatMap((g) => (g.hasExpectedSlots ? g.bands : []));
// 07:00 Berlin is 06:00 UTC before the transition, 05:00 UTC after.
const mar28 = bands.find((b) => b.at.toISOString().startsWith("2026-03-28"));
const mar30 = bands.find((b) => b.at.toISOString().startsWith("2026-03-30"));
expect(mar28?.at.toISOString()).toBe("2026-03-28T06:00:00.000Z");
expect(mar30?.at.toISOString()).toBe("2026-03-30T05:00:00.000Z");
// After the replace the live 09:00/21:00 schedule mints (09:00 = 07:00Z).
const apr03 = bands.filter((b) => b.at.toISOString().startsWith("2026-04-03"));
expect(apr03.map((b) => b.timeOfDay).sort()).toEqual(["09:00", "21:00"]);
});
it("mints a rolling era from the archived rolling schedule", () => {
// Old era: weekly rolling injection. Live: daily oral.
const rollingRevision: ScheduleRevisionLike = {
id: "rev-roll",
validFrom: createdAt,
validUntil: replaceAt,
payload: [
entry({
timesOfDay: ["08:00"],
windowStart: "08:00",
windowEnd: "08:00",
rrule: null,
rollingIntervalDays: 7,
}),
],
};
const intakeInstants = [
new Date("2026-05-05T06:00:00.000Z"),
new Date("2026-05-12T06:10:00.000Z"),
new Date("2026-05-19T05:55:00.000Z"),
];
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: [liveSchedule],
revisions: [rollingRevision],
ctx,
userTz: TZ,
range: {
from: new Date("2026-05-01T00:00:00.000Z"),
to: new Date("2026-06-05T00:00:00.000Z"),
},
now: new Date("2026-06-05T00:00:00.000Z"),
intakeInstants,
});
const rollingGroup = groups.find((g) => g.scheduleId.startsWith("rev:rev-roll"));
expect(rollingGroup?.family).toBe("weekly");
// Each logged intake anchors one band inside the old era.
expect(rollingGroup?.bands.length).toBeGreaterThanOrEqual(3);
expect(
rollingGroup?.bands.every((b) => b.at.getTime() < replaceAt.getTime()),
).toBe(true);
});
});
describe("write-attribution era pick", () => {
it("attributes an old takenAt against the era live at that instant", () => {
const med: AttributeIntakeMedication = {
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt,
schedules: [
{
id: "live-1",
windowStart: "09:00",
windowEnd: "21:00",
daysOfWeek: null,
timesOfDay: ["09:00", "21:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
},
],
scheduleRevisions: [oldEraRevision],
};
// Editing a take back to 25 May 07:10 Berlin (05:10 UTC) — old era.
const takenAt = new Date("2026-05-25T05:10:00.000Z");
const result = attributeTakenToSlot({
medication: med,
userTz: TZ,
takenAt,
now: new Date("2026-06-05T00:00:00.000Z"),
});
expect(result.slotInstant?.toISOString()).toBe("2026-05-25T05:00:00.000Z");
expect(result.status).toBe("on_time");
// The same instant against the live-only schedules would NOT be a
// 07:00-slot match — the revision is what fixes the attribution.
const liveOnly = attributeTakenToSlot({
medication: { ...med, scheduleRevisions: [] },
userTz: TZ,
takenAt,
now: new Date("2026-06-05T00:00:00.000Z"),
});
expect(liveOnly.slotInstant?.toISOString()).not.toBe(
"2026-05-25T05:00:00.000Z",
);
});
});
describe("canonicalSchedulesFromRevision", () => {
it("rebuilds canonical schedules with synthetic ids and tolerates malformed payloads", () => {
const schedules = canonicalSchedulesFromRevision(oldEraRevision);
expect(schedules).toHaveLength(1);
expect(schedules[0].id).toBe("rev:rev-1:0");
expect(schedules[0].timesOfDay).toEqual(["07:00", "19:00"]);
expect(
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: "garbage" }),
).toEqual([]);
expect(
canonicalSchedulesFromRevision({ ...oldEraRevision, payload: [null] }),
).toHaveLength(1);
});
});
describe("schedulesMateriallyDiffer", () => {
it("ignores label/dose changes and row order", () => {
const a = [entry({ label: "Morgens" }), entry({ timesOfDay: ["12:00"] })];
const b = [
entry({ timesOfDay: ["12:00"], dose: "2" }),
entry({ label: "Abends" }),
];
expect(schedulesMateriallyDiffer(a, b)).toBe(false);
});
it("ignores timesOfDay ordering", () => {
expect(
schedulesMateriallyDiffer(
[entry({ timesOfDay: ["19:00", "07:00"] })],
[entry({ timesOfDay: ["07:00", "19:00"] })],
),
).toBe(false);
});
it("flags a times change, a count change, and a doseWindows change", () => {
expect(
schedulesMateriallyDiffer(
[entry({})],
[entry({ timesOfDay: ["09:00", "21:00"] })],
),
).toBe(true);
expect(schedulesMateriallyDiffer([entry({})], [entry({}), entry({})])).toBe(
true,
);
expect(
schedulesMateriallyDiffer(
[entry({})],
[
entry({
doseWindows: [{ timeOfDay: "07:00", start: "06:30", end: "08:30" }],
}),
],
),
).toBe(true);
});
it("round-trips through toRevisionPayloadEntry", () => {
const row = {
timesOfDay: ["07:00", "19:00"],
windowStart: "07:00",
windowEnd: "19:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED" as const,
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: "Alt",
dose: null,
reminderGraceMinutes: null,
};
expect(
schedulesMateriallyDiffer([toRevisionPayloadEntry(row)], [entry({})]),
).toBe(false);
});
});
@@ -29,11 +29,14 @@ import {
type SlotBand,
} from "@/lib/medications/scheduling/attribution";
import {
buildBandsForMedication,
type BandMinterMedication,
type DoseWindowConfig,
} from "@/lib/medications/scheduling/band-minter";
import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults";
import {
buildBandsForSchedulesWithEras,
type ScheduleRevisionLike,
} from "@/lib/medications/scheduling/schedule-eras";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
@@ -46,6 +49,13 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** The medication projection the attributor reads (matches the write `select`). */
export interface AttributeIntakeMedication extends WorkerMedicationRow {
schedules: WorkerScheduleRow[];
/**
* v1.16.3 — archived schedule eras, `validFrom` ascending. When present
* the day bands around a historical `takenAt` mint from the era that was
* live THEN, so an edit of an old take attributes against the old times.
* Optional: callers without revisions keep the live-only behaviour.
*/
scheduleRevisions?: ScheduleRevisionLike[];
}
export interface AttributeTakenInput {
@@ -145,25 +155,34 @@ export function buildMedicationDayBands(input: {
to: new Date(input.around.getTime() + dayScaleReachDays * ONE_DAY_MS),
};
const bands: SlotBand[] = [];
let hasExpectedSlots = false;
for (const schedule of canonicalSchedules) {
const common = {
// v1.16.3 — bands mint era-aware: a historical `around` inside an archived
// era gets THAT era's schedules. With no revisions the wrapper passes
// straight through to the live-only minter.
const mintEras = (range: { from: Date; to: Date }) =>
buildBandsForSchedulesWithEras({
medication: bandMinterMedication,
schedule,
schedules: canonicalSchedules,
revisions: input.medication.scheduleRevisions ?? [],
ctx,
userTz: input.userTz,
range,
now: input.now ?? input.around,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
};
let result = buildBandsForMedication({ ...common, range: minuteScaleRange });
if (result.family === "weekly") {
result = buildBandsForMedication({ ...common, range: dayScaleRange });
}
if (result.hasExpectedSlots) {
});
let groups = mintEras(minuteScaleRange);
// Day-scale families re-mint over the wide range (the same per-schedule
// narrow-then-wide pass as before, applied per group).
if (groups.some((g) => g.family === "weekly")) {
const wide = mintEras(dayScaleRange).filter((g) => g.family === "weekly");
groups = [...groups.filter((g) => g.family !== "weekly"), ...wide];
}
const bands: SlotBand[] = [];
let hasExpectedSlots = false;
for (const g of groups) {
if (g.hasExpectedSlots) {
hasExpectedSlots = true;
bands.push(...result.bands);
bands.push(...g.bands);
}
}
return { bands, hasExpectedSlots };
+12 -4
View File
@@ -29,10 +29,11 @@
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import type { SlotBand } from "@/lib/medications/scheduling/attribution";
import { type BandMinterMedication } from "@/lib/medications/scheduling/band-minter";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "@/lib/medications/scheduling/band-minter";
buildBandsForSchedulesWithEras,
type ScheduleRevisionLike,
} from "@/lib/medications/scheduling/schedule-eras";
import {
reconstructDoseHistory,
type HistoryIntake,
@@ -125,6 +126,12 @@ export interface CadenceEngineContext {
lastIntakeAt: Date | null;
/** User IANA timezone. Required for the engine to apply HH:mm slots. */
timeZone: string;
/**
* v1.16.3 — archived schedule eras (`validFrom` ascending). When present
* the ledger-band consumers mint past days against the schedule that was
* live THEN. Optional: callers without revisions keep live-only minting.
*/
scheduleRevisions?: ScheduleRevisionLike[];
}
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
@@ -892,9 +899,10 @@ function missedFromLedger(
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= asOf)
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: canonicalSchedules,
revisions: engineCtx.scheduleRevisions ?? [],
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
+4 -5
View File
@@ -20,10 +20,8 @@
*/
import type { SlotBand } from "./attribution";
import {
buildBandsForSchedules,
type BandMinterMedication,
} from "./band-minter";
import { type BandMinterMedication } from "./band-minter";
import { buildBandsForSchedulesWithEras } from "./schedule-eras";
import {
buildCadenceTimeline,
startOfLocalDay,
@@ -234,9 +232,10 @@ function ledgerChipCounts(
.map((e) => e.takenAt as Date)
.sort((a, b) => a.getTime() - b.getTime());
const groups = buildBandsForSchedules({
const groups = buildBandsForSchedulesWithEras({
medication,
schedules: canonicalSchedules,
revisions: engineCtx.scheduleRevisions ?? [],
ctx: recurrenceCtx,
userTz,
range: { from, to: asOf },
@@ -0,0 +1,344 @@
/**
* v1.16.3 — schedule-era segmentation (effective dating).
*
* A wholesale schedule replace used to rewrite history: bands for PAST days
* were minted from the CURRENT `medication_schedules` rows, so after a
* times edit the whole old era read "missed at the new times". The write
* path now archives each superseded schedule state as one
* `MedicationScheduleRevision` row covering `[validFrom, validUntil)`; this
* module turns those revisions into minting eras so every historical
* surface (dose-history ledger, compliance tally, expected-slot counts,
* write/edit attribution) reads a past day against the schedule that was
* live THEN.
*
* Era rules:
* - revisions are chained: `validFrom` = previous revision's `validUntil`
* (or `medication.createdAt` for the first), `validUntil` = the replace
* instant. The LIVE rows cover `[newest validUntil, ∞)`.
* - boundary: a slot belongs to the era its ANCHOR lies in. Eras mint
* with an inclusive sub-range capped 1 ms short of `validUntil`, so an
* anchor exactly at the boundary mints in the NEXT era only. On-time /
* late tails are NOT clipped — a band minted near the end of an era
* keeps its full reach past the boundary.
* - `startsOn` stays the global floor for all eras (the engine applies
* it per occurrence); a times-only edit never moves it.
*
* Pure / synchronous like the band minter: revisions are pre-fetched by the
* caller and threaded in — no DB access here.
*/
import {
buildBandsForSchedules,
type BandMinterMedication,
type DoseWindowConfig,
type ScheduleBandGroup,
} from "@/lib/medications/scheduling/band-minter";
import {
SCHEDULE_TYPES,
type CanonicalSchedule,
type Occurrence,
type RecurrenceContext,
type ScheduleType,
} from "@/lib/medications/scheduling/recurrence";
import { normaliseDoseWindows } from "@/lib/medications/scheduling/worker-helpers";
/** The revision projection the era splitter reads (a Prisma row drops in). */
export interface ScheduleRevisionLike {
id: string;
/** Inclusive start of the archived era. */
validFrom: Date;
/** Exclusive end of the archived era (the replace instant). */
validUntil: Date;
/** JSON array of {@link ScheduleRevisionEntry} snapshots. */
payload: unknown;
}
/**
* One archived schedule row inside a revision's `payload` array. The write
* path snapshots every cadence-relevant column so the era splitter can
* rebuild a full `CanonicalSchedule`; `label` / `dose` ride along for
* display-only consumers but never participate in the material-change
* compare.
*/
export interface ScheduleRevisionEntry {
timesOfDay: string[];
windowStart: string;
windowEnd: string;
daysOfWeek: string | null;
rrule: string | null;
rollingIntervalDays: number | null;
scheduleType: ScheduleType;
cyclicOnWeeks: number | null;
cyclicOffWeeks: number | null;
doseWindows: unknown;
label: string | null;
dose: string | null;
reminderGraceMinutes: number | null;
}
/** One minting era: an inclusive `[from, to]` sub-range + its schedules. */
export interface ScheduleEra {
from: Date;
to: Date;
schedules: CanonicalSchedule[];
/** True for the era backed by the live `medication_schedules` rows. */
live: boolean;
}
/**
* Rebuild the `CanonicalSchedule` list a revision archived. Synthetic ids
* (`rev:<revisionId>:<idx>`) keep the engine occurrence/group plumbing
* stable without colliding with live schedule ids. Defensive parsing: a
* malformed payload entry degrades to an empty/cadence-less schedule the
* band minter already treats as "no slot machinery" rather than throwing
* on a read path.
*/
export function canonicalSchedulesFromRevision(
revision: ScheduleRevisionLike,
options?: { oneShot?: boolean },
): CanonicalSchedule[] {
if (!Array.isArray(revision.payload)) return [];
return revision.payload.map((raw, idx) => {
const e = (raw ?? {}) as Partial<ScheduleRevisionEntry>;
const base: CanonicalSchedule = {
id: `rev:${revision.id}:${idx}`,
rrule: typeof e.rrule === "string" ? e.rrule : null,
rollingIntervalDays:
typeof e.rollingIntervalDays === "number" ? e.rollingIntervalDays : null,
timesOfDay: Array.isArray(e.timesOfDay)
? e.timesOfDay.filter((t): t is string => typeof t === "string")
: [],
daysOfWeek: typeof e.daysOfWeek === "string" ? e.daysOfWeek : null,
windowStart: typeof e.windowStart === "string" ? e.windowStart : "08:00",
windowEnd: typeof e.windowEnd === "string" ? e.windowEnd : "08:00",
reminderGraceMinutes:
typeof e.reminderGraceMinutes === "number"
? e.reminderGraceMinutes
: null,
scheduleType: SCHEDULE_TYPES.includes(e.scheduleType as ScheduleType)
? (e.scheduleType as ScheduleType)
: "SCHEDULED",
cyclicOnWeeks:
typeof e.cyclicOnWeeks === "number" ? e.cyclicOnWeeks : null,
cyclicOffWeeks:
typeof e.cyclicOffWeeks === "number" ? e.cyclicOffWeeks : null,
doseWindows: normaliseDoseWindows(e.doseWindows),
};
// A legacy daily row carrying only `windowStart` surfaces it as the
// single time-of-day so the minter mints its daily band — the same
// normalisation every band-building consumer applies to live rows.
if (
base.timesOfDay.length === 0 &&
base.rrule === null &&
base.rollingIntervalDays === null &&
base.scheduleType !== "PRN" &&
options?.oneShot !== true
) {
return { ...base, timesOfDay: [base.windowStart] };
}
return base;
});
}
/**
* Segment an inclusive minting `range` into eras. Each revision interval
* that intersects the range contributes one era with ITS archived
* schedules; the remainder (from the newest `validUntil` onward) is the
* live era. Eras are returned chronologically; an empty intersection is
* dropped. With no revisions the result is a single live era covering the
* whole range — the zero-revision path is byte-equivalent to not
* segmenting at all.
*/
export function segmentRangeIntoEras(
range: { from: Date; to: Date },
revisions: ScheduleRevisionLike[],
liveSchedules: CanonicalSchedule[],
options?: { oneShot?: boolean },
): ScheduleEra[] {
const eras: ScheduleEra[] = [];
const sorted = [...revisions].sort(
(a, b) => a.validFrom.getTime() - b.validFrom.getTime(),
);
for (const revision of sorted) {
const from = new Date(
Math.max(revision.validFrom.getTime(), range.from.getTime()),
);
// Inclusive sub-range capped 1 ms short of `validUntil` — the anchor
// boundary rule: a slot exactly at the boundary belongs to the era
// that begins there, never to the one ending there.
const to = new Date(
Math.min(revision.validUntil.getTime() - 1, range.to.getTime()),
);
if (from.getTime() > to.getTime()) continue;
eras.push({
from,
to,
schedules: canonicalSchedulesFromRevision(revision, options),
live: false,
});
}
const liveFrom =
sorted.length > 0
? new Date(
Math.max(
sorted[sorted.length - 1].validUntil.getTime(),
range.from.getTime(),
),
)
: range.from;
if (liveFrom.getTime() <= range.to.getTime()) {
eras.push({ from: liveFrom, to: range.to, schedules: liveSchedules, live: true });
}
return eras;
}
/**
* Era-aware drop-in for `buildBandsForSchedules`: segments the range into
* eras and mints each era with the schedules that were live THEN,
* concatenating the per-schedule groups. Archived-era groups carry the
* synthetic `rev:<id>:<idx>` schedule ids. With no revisions this is a
* plain pass-through to `buildBandsForSchedules` — every existing caller
* keeps its exact behaviour until it threads revisions in.
*/
export function buildBandsForSchedulesWithEras(input: {
medication: BandMinterMedication;
schedules: CanonicalSchedule[];
revisions: ScheduleRevisionLike[];
ctx: RecurrenceContext;
userTz: string;
range: { from: Date; to: Date };
now: Date;
windowConfig?: DoseWindowConfig;
intakeInstants?: Date[];
}): ScheduleBandGroup[] {
if (input.revisions.length === 0) {
return buildBandsForSchedules(input);
}
const eras = segmentRangeIntoEras(input.range, input.revisions, input.schedules, {
oneShot: input.medication.oneShot,
});
const groups: ScheduleBandGroup[] = [];
for (const era of eras) {
groups.push(
...buildBandsForSchedules({
medication: input.medication,
schedules: era.schedules,
ctx: input.ctx,
userTz: input.userTz,
range: { from: era.from, to: era.to },
now: input.now,
windowConfig: input.windowConfig,
intakeInstants: input.intakeInstants,
}),
);
}
return groups;
}
/**
* Era-aware occurrence expansion for the count-shaped consumers
* (`expectedSlotCountForDay` / `expectedSlotsBetween`): expands each era's
* schedules over the era's sub-range via the supplied expander and returns
* the chronological union. The expander signature matches the per-schedule
* loop those callers already run, so the era split slots in without
* duplicating their retro-rolling plumbing.
*/
export function occurrencesAcrossEras(
range: { from: Date; to: Date },
revisions: ScheduleRevisionLike[],
liveSchedules: CanonicalSchedule[],
expand: (schedule: CanonicalSchedule, from: Date, to: Date) => Occurrence[],
options?: { oneShot?: boolean },
): Occurrence[] {
const eras = segmentRangeIntoEras(range, revisions, liveSchedules, options);
const all: Occurrence[] = [];
for (const era of eras) {
for (const schedule of era.schedules) {
all.push(...expand(schedule, era.from, era.to));
}
}
return all.sort((a, b) => a.at.getTime() - b.at.getTime());
}
// ────────────────────────────────────────────────────────────────────
// Write-path helpers: snapshot + material-change compare
// ────────────────────────────────────────────────────────────────────
/** The schedule-row projection the snapshot reads (a Prisma row drops in). */
export interface ScheduleSnapshotRow {
timesOfDay: string[];
windowStart: string;
windowEnd: string;
daysOfWeek: string | null;
rrule: string | null;
rollingIntervalDays: number | null;
scheduleType: ScheduleType;
cyclicOnWeeks: number | null;
cyclicOffWeeks: number | null;
doseWindows: unknown;
label: string | null;
dose: string | null;
reminderGraceMinutes: number | null;
}
/** Snapshot one schedule row into a revision payload entry. */
export function toRevisionPayloadEntry(
row: ScheduleSnapshotRow,
): ScheduleRevisionEntry {
return {
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 ?? null,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
};
}
/**
* Normalised cadence fingerprint of one snapshot entry. Sorted
* `timesOfDay`, normalised `doseWindows` sorted by `timeOfDay`, and only
* the fields that change WHICH slots/bands history mints — `label` and
* `dose` are display-only and excluded, so renaming a schedule never
* archives a phantom revision.
*/
function cadenceFingerprint(entry: ScheduleRevisionEntry): string {
const windows = (normaliseDoseWindows(entry.doseWindows) ?? [])
.map((w) => `${w.timeOfDay}>${w.start}-${w.end}`)
.sort();
return JSON.stringify({
timesOfDay: [...entry.timesOfDay].sort(),
windowStart: entry.windowStart,
windowEnd: entry.windowEnd,
daysOfWeek: entry.daysOfWeek,
rrule: entry.rrule,
rollingIntervalDays: entry.rollingIntervalDays,
scheduleType: entry.scheduleType,
cyclicOnWeeks: entry.cyclicOnWeeks,
cyclicOffWeeks: entry.cyclicOffWeeks,
doseWindows: windows,
reminderGraceMinutes: entry.reminderGraceMinutes,
});
}
/**
* True when the two schedule sets differ in any cadence-relevant field —
* the gate for archiving a revision on a wholesale replace. Order- and
* label-insensitive: a no-op edit (the editor echoing the same rows back,
* possibly reordered or relabelled) must NOT mint a revision.
*/
export function schedulesMateriallyDiffer(
before: ScheduleRevisionEntry[],
after: ScheduleRevisionEntry[],
): boolean {
if (before.length !== after.length) return true;
const a = before.map(cadenceFingerprint).sort();
const b = after.map(cadenceFingerprint).sort();
return a.some((fp, i) => fp !== b[i]);
}
@@ -333,6 +333,12 @@ const MEDICATION_SELECT = {
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_SELECT },
// v1.16.3 — archived schedule eras: a write/edit at a historical instant
// attributes against the era that was live at that takenAt.
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: { id: true, validFrom: true, validUntil: true, payload: true },
},
} as const;
export interface ResolveSlotForWriteInput {