feat(medications): let a course end at an instant

A course's end date is a calendar day and counts in full: the doses of that
day stay due, reminded and expected until local midnight. That is right for
an end date set in the editor and wrong for "End course", whose dialog
promises that nothing more is due.

A course now carries `endedAt` (migration 0482, additive, nullable), the
instant it was ended on its `endsOn` day, projected onto the medication next
to `startsOn` / `endsOn` by the one course writer. From that instant:

- the schedule engine emits no slot, so a dose later that day is not due,
  not reminded, not projected onto the today list and never expected by
  compliance, while a dose earlier that day stays inside the course;
- the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose
  actions, `remindersActive` and the reminder gate stop at once, follow-ups
  of an earlier dose included;
- every engine context builder, compliance context, dose history, Coach
  snapshot, doctor report and the adherence storyline read it, through one
  select and one picker for the medication window.

Any write that moves a course's `endsOn` clears the instant, so an end date
chosen in the editor keeps meaning the whole day. Backups carry it in both
purposes and a restore projects it again. `courseStatusAt` moves next to the
other course rules.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 22:20:06 +02:00
1 parent a773d839d4
commit 23a67f61e6
68 files changed
+813 -180

No files matched your search

@@ -210,6 +210,8 @@ function makePrisma() {
id: "course-march",
startsOn: new Date("2026-03-01T00:00:00.000Z"),
endsOn: new Date("2026-03-07T00:00:00.000Z"),
// #1280 — ended with "End course" after the morning dose.
endedAt: new Date("2026-03-07T09:30:00.000Z"),
noteEncrypted: encryptToBytes("the March flu"),
createdAt: new Date("2026-03-01T08:00:00.000Z"),
updatedAt: new Date("2026-03-01T08:05:00.000Z"),
@@ -218,6 +220,7 @@ function makePrisma() {
id: "course-june",
startsOn: new Date("2026-06-10T00:00:00.000Z"),
endsOn: null,
endedAt: null,
noteEncrypted: null,
createdAt: new Date("2026-06-10T08:00:00.000Z"),
updatedAt: new Date("2026-06-10T08:05:00.000Z"),
@@ -806,12 +809,14 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
endedAt: "2026-03-07T09:30:00.000Z",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
endedAt: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
@@ -823,6 +828,7 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
expect(march).toMatchObject({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
endedAt: "2026-03-07T09:30:00.000Z",
note: null,
createdAt: "2026-03-01T08:00:00.000Z",
updatedAt: "2026-03-01T08:05:00.000Z",
@@ -123,6 +123,9 @@ export const medicationCourseSchema = z
.object({
startsOn: z.iso.date(),
endsOn: z.iso.date().nullable(),
// #1280 — the instant "End course" ended the course, on its `endsOn`
// day. A file without it (any written before) restores the full-day end.
endedAt: isoDateTime.nullable().optional(),
note: z.string().max(280).nullable().optional(),
// The note's ciphertext, disaster-recovery files only. A file without it
// (any written before) carries the readable note, which the restore seals.
+4
View File
@@ -228,6 +228,10 @@ function serialiseMedicationHistory(
courses: m.courses.map((c) => ({
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
// #1280 — when "End course" ended it. Both purposes: without it the
// restored course runs to midnight of its last day and expects the
// doses the person had stopped.
endedAt: c.endedAt ? c.endedAt.toISOString() : null,
...(disasterRecovery
? {
note: null,
+8 -1
View File
@@ -349,6 +349,7 @@ async function restoreCourses(
const courseRows: Array<{
startsOn: Date;
endsOn: Date | null;
endedAt?: Date | null;
note: string | null;
noteEncrypted?: string | null;
createdAt?: Date;
@@ -358,6 +359,7 @@ async function restoreCourses(
? m.courses.map((c) => ({
startsOn: dateOfDayKey(c.startsOn),
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
endedAt: c.endedAt ? new Date(c.endedAt) : null,
note: c.note ?? null,
...(c.noteEncrypted !== undefined
? { noteEncrypted: c.noteEncrypted }
@@ -380,6 +382,7 @@ async function restoreCourses(
userId: ownerId,
startsOn: c.startsOn,
endsOn: c.endsOn,
endedAt: c.endedAt ?? null,
// Ciphertext verbatim from a disaster-recovery file; the readable note
// of a portable or older file sealed here.
noteEncrypted:
@@ -397,7 +400,11 @@ async function restoreCourses(
const window = projectCourseWindow(courseRows);
await tx.medication.update({
where: { id: created.id },
data: { startsOn: window.startsOn, endsOn: window.endsOn },
data: {
startsOn: window.startsOn,
endsOn: window.endsOn,
endedAt: window.endedAt,
},
});
}