mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
5bfafa6f51
commit
bd78cd7f7a
26 files changed
+1806
-223
No files matched your search
@@ -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" } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user