From 23a67f61e6c76ee67b5af144ea71d62056269662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 22:20:06 +0200 Subject: [PATCH] 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 --- docs/api/openapi.yaml | 58 ++++++++-- .../migration.sql | 29 +++++ prisma/schema.prisma | 13 +++ .../recurrence-course-anchor-guard.test.ts | 27 ++++- src/__tests__/tz-day-paths-harness.test.ts | 1 + .../medications/[id]/dose-history/route.ts | 2 +- src/lib/__tests__/doctor-report-data.test.ts | 4 + src/lib/ai/coach/__tests__/snapshot.test.ts | 1 + .../compliance-block-pre-creation.test.ts | 1 + src/lib/ai/coach/snapshot.ts | 4 +- .../__tests__/compliance-courses.test.ts | 3 + .../__tests__/compliance-skip-parity.test.ts | 1 + .../__tests__/compliance-bundle.test.ts | 49 +++++++-- .../__tests__/compliance-pause-era.test.ts | 2 + .../__tests__/compliance.property.test.ts | 1 + .../compliance/__tests__/compliance.test.ts | 9 ++ src/lib/analytics/compliance/adapters.ts | 1 + src/lib/analytics/compliance/types.ts | 13 ++- src/lib/dashboard/meds-today.ts | 4 +- src/lib/doctor-report-types.ts | 4 +- src/lib/doctor-report/collect.ts | 9 +- .../__tests__/full-backup-payload.test.ts | 6 ++ src/lib/export/medications-backup-schema.ts | 3 + src/lib/export/medications-backup.ts | 4 + src/lib/export/medications-restore.ts | 9 +- .../adherence-storyline-engine-parity.test.ts | 14 +++ .../insights/derived/adherence-storyline.ts | 2 + .../jobs/__tests__/intake-auto-take.test.ts | 1 + src/lib/jobs/intake-auto-take.ts | 6 +- src/lib/mcp/tools/get-medication-schedule.ts | 4 +- .../__tests__/auto-mark-taken.test.ts | 25 +++++ .../compliance-payload-heatmap.test.ts | 1 + .../compliance-payload-pre-creation.test.ts | 1 + .../__tests__/course-window.test.ts | 88 +++++++++++++-- .../__tests__/intake-actionable.test.ts | 50 ++++++++- .../__tests__/intake-tracking.test.ts | 1 + src/lib/medications/auto-mark-taken.ts | 13 ++- src/lib/medications/auto-take.ts | 4 +- src/lib/medications/compliance-payload.ts | 2 + src/lib/medications/course-window.ts | 101 ++++++++++++++++-- src/lib/medications/courses.ts | 85 +++++++++++---- src/lib/medications/intake-actionable.ts | 6 +- src/lib/medications/intake-slot-dedup.ts | 13 +-- src/lib/medications/list-read.ts | 4 +- src/lib/medications/read-medication.ts | 4 +- .../__tests__/attribute-intake.test.ts | 1 + .../scheduling/__tests__/next-due.test.ts | 1 + .../scheduling/__tests__/recurrence.test.ts | 68 ++++++++++++ .../__tests__/resolve-slot-instant.test.ts | 1 + .../rolling-taken-dose-not-redue.test.ts | 1 + .../__tests__/schedule-eras.test.ts | 2 + src/lib/medications/scheduling/cadence.ts | 22 ++-- .../scheduling/project-today-intakes.ts | 7 +- src/lib/medications/scheduling/recurrence.ts | 52 ++++----- src/lib/medications/scheduling/slot-upsert.ts | 8 +- .../medications/scheduling/worker-helpers.ts | 3 + src/lib/openapi/routes/medications/schemas.ts | 21 +++- src/lib/targets/types.ts | 4 +- .../export-disaster-recovery.json | 28 ++--- .../backup-parity/export-portable.json | 16 +-- .../restore-disaster-recovery.json | 29 ++--- .../backup-parity/restore-portable.json | 17 +-- .../backup-parity/restore-skips.json | 1 + .../backup-parity/restore-version-1.json | 4 + .../backup-parity/seeded-account.json | 15 +-- tests/integration/backup-fixture.ts | 6 +- .../intake-late-take-future-slot.test.ts | 1 + .../medication-auto-mark-taken.test.ts | 2 + 68 files changed, 813 insertions(+), 180 deletions(-) create mode 100644 prisma/migrations/0482_medication_course_ended_at/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b92152575..061a59857 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -42034,6 +42034,11 @@ components: format: date pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" + endedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" note: anyOf: - type: string @@ -53787,9 +53792,10 @@ components: type: boolean description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or - later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and - `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local - reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored." + later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), + it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A + client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, + not stored." nextDueAt: anyOf: - type: string @@ -53813,6 +53819,15 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. + endedAt: + description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it + on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. + Read-only — written by the course writer only." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / @@ -53910,7 +53925,8 @@ components: - ENDED description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all - day. Read-only — computed, not stored." + day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only — + computed, not stored." intakeActionable: type: boolean description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is @@ -54037,13 +54053,23 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ - type: "null" description: Last day, inclusive. NULL = open (only the latest course). + endedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: '#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is + due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an + end date set in the editor). Any edit that moves `endsOn` clears it.' status: type: string enum: - UPCOMING - CURRENT - ENDED - description: Where today, on the person's clock, sits in this course. + description: Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` + day already. takenDoses: type: integer minimum: 0 @@ -54058,6 +54084,7 @@ components: - id - startsOn - endsOn + - endedAt - status - takenDoses - note @@ -54244,6 +54271,15 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. + endedAt: + description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it + on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. + Read-only — written by the course writer only." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / @@ -54336,7 +54372,8 @@ components: - ENDED description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all - day. Read-only — computed, not stored." + day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only — + computed, not stored." intakeActionable: type: boolean description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is @@ -93793,6 +93830,15 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true. + endedAt: + description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it + on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. + Read-only — written by the course writer only." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" oneShot: type: boolean description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / diff --git a/prisma/migrations/0482_medication_course_ended_at/migration.sql b/prisma/migrations/0482_medication_course_ended_at/migration.sql new file mode 100644 index 000000000..0b7385f57 --- /dev/null +++ b/prisma/migrations/0482_medication_course_ended_at/migration.sql @@ -0,0 +1,29 @@ +-- "End course" ends a course at the moment it is pressed (#1280). +-- +-- A course's `ends_on` is a calendar day and counts in full: the doses of +-- that day stay due, reminded and expected until local midnight. That is the +-- right reading for an end date written in the course editor ("the last day +-- of the pack"), and the wrong one for "End course", whose dialog promises +-- that nothing more is due. `ended_at` records the instant the course was +-- ended that way: it lies on the `ends_on` day, a dose slot at or after it +-- is not part of the course, and the course reads as ended from then on. +-- NULL keeps the full-day reading every existing course has. +-- +-- `medications.ended_at` is the projection of the latest course's value, +-- written next to `starts_on` / `ends_on` by the one course writer, so the +-- engines that read the medication's window read it too. +-- +-- Nullable, no default, no backfill: no existing course was ended this way. +-- Reversibility: dropping both columns restores the full-day reading. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "medication_courses" + ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6); + ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6); +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5ecaa161..a7399eef4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2903,6 +2903,12 @@ model Medication { /// minting slots past this date. NULL = no end date (chronic). /// When `oneShot` is true, `endsOn` always equals `startsOn`. endsOn DateTime? @map("ends_on") @db.Date + /// #1280 — the instant the latest course was ended with "End course", the + /// projection of `MedicationCourse.endedAt` written next to `startsOn` / + /// `endsOn` by `src/lib/medications/courses.ts` only. On the `endsOn` day; + /// no dose slot at or after it is due, reminded or expected, and the course + /// reads as ended from it on. NULL = the course runs to the end of `endsOn`. + endedAt DateTime? @map("ended_at") @db.Timestamptz(6) /// v1.5 — single-administration medication (flu shot, vaccine, /// post-op single dose, etc.). When true the medication has at /// most one MedicationIntakeEvent and `active` flips to false once @@ -3157,6 +3163,13 @@ model MedicationCourse { startsOn DateTime @map("starts_on") @db.Date /// Last day, inclusive. NULL = open (only the latest course may be open). endsOn DateTime? @map("ends_on") @db.Date + /// #1280 — set by "End course": the instant the course ended, on the + /// `endsOn` day. A dose slot at or after it is not part of the course (not + /// due, not reminded, not expected by compliance), so the course ends when + /// the person ends it rather than at local midnight. NULL = the whole + /// `endsOn` day counts, the reading of an end date set in the editor. Any + /// later write that moves `endsOn` clears it. + endedAt DateTime? @map("ended_at") @db.Timestamptz(6) /// Why this course, in the person's words. AES-256-GCM at rest. noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts index c7e9fd1ca..6ed1d14b8 100644 --- a/src/__tests__/recurrence-course-anchor-guard.test.ts +++ b/src/__tests__/recurrence-course-anchor-guard.test.ts @@ -15,6 +15,12 @@ * which it is. The scan asserts a floor on what it found, so a matcher that * stopped matching fails instead of agreeing with an empty tree. * + * #1280 — the same list carries the instant "End course" ended a course: + * every medication builder threads `endedAt` into the context, or the + * engine would keep a dose due after the course was ended. A builder passes + * when its block names `endedAt` or spreads a window that carries it + * (`...window` from `mintWindow`, `...medicationWindow(row)`). + * * Limit, stated: a literal passed straight to the engine without the * `RecurrenceContext` annotation is invisible here. Every medication path * today goes through one of the annotated builders below. @@ -69,19 +75,22 @@ function blockAt(text: string, open: number): string { interface Builder { file: string; declaresCourseStart: boolean; + threadsEndedAt: boolean; } +const THREADS_ENDED_AT = /\bendedAt\b|\.\.\.\s*(window\b|medicationWindow\()/; + function builders(): Builder[] { const out: Builder[] = []; for (const file of sourceFiles(SRC)) { const text = readSourceText(file); for (const m of text.matchAll(DECLARATION)) { const open = m.index! + m[0].length - 1; + const block = blockAt(text, open); out.push({ file: relative(ROOT, file), - declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test( - blockAt(text, open), - ), + declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(block), + threadsEndedAt: THREADS_ENDED_AT.test(block), }); } } @@ -114,6 +123,18 @@ describe("the rolling course anchor is opt-in", () => { expect(missing).toEqual([]); }); + it("has every medication builder thread the instant a course was ended", () => { + const medication = builders().filter((b) => + MEDICATION_BUILDERS.includes(b.file), + ); + expect(medication.length).toBeGreaterThanOrEqual(3); + expect(medication.filter((b) => !b.threadsEndedAt)).toEqual([]); + // The matcher, held against what it must and must not accept. + expect(THREADS_ENDED_AT.test("endedAt: row.endedAt,")).toBe(true); + expect(THREADS_ENDED_AT.test("...medicationWindow(row),")).toBe(true); + expect(THREADS_ENDED_AT.test("endsOn: row.endsOn,")).toBe(false); + }); + it("keeps every other builder on the default (the last intake anchors)", () => { const declaring = builders().filter( (b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart, diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts index 598ea1108..edd870974 100644 --- a/src/__tests__/tz-day-paths-harness.test.ts +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -155,6 +155,7 @@ describe.each(EDGES)("day paths with the host in $zone", (edge) => { startsOn: null, // `endsOn` is a @db.Date column: the calendar date as UTC midnight. endsOn: dayKeyAsUtcMidnight(edge.localDay), + endedAt: null, }, edge.now, edge.zone, diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 61b323b4d..900a7cded 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -234,7 +234,7 @@ export const GET = apiHandler( createdAt: medication.createdAt, }; const ctx = buildRecurrenceContext({ - medication: bandMedication, + medication: { ...bandMedication, endedAt: medication.endedAt }, userTz, lastIntakeAt, }); diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 47bb06580..2d656fd0a 100644 --- a/src/lib/__tests__/doctor-report-data.test.ts +++ b/src/lib/__tests__/doctor-report-data.test.ts @@ -317,6 +317,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger" trackIntake: true, startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-04-01T00:00:00.000Z"), schedules: [dailySchedule()], @@ -354,6 +355,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger" trackIntake: true, startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-04-01T00:00:00.000Z"), schedules: [dailySchedule()], @@ -429,6 +431,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger" trackIntake: true, startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-04-01T00:00:00.000Z"), schedules: [dailySchedule()], @@ -460,6 +463,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger" trackIntake: false, startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-04-01T00:00:00.000Z"), // The schedule stays on record; it must not produce a rate. diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 561a2fe47..0067ddfc8 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -706,6 +706,7 @@ describe("buildCoachSnapshot", () => { name: "Testdrug", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt, schedules: [schedule], diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts index cf920a749..7650a2c3f 100644 --- a/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts +++ b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts @@ -37,6 +37,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) { { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: d1(16, 5), schedules: [ diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 2bc7a4abc..b572218a7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -17,6 +17,7 @@ */ import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances"; import { prisma } from "@/lib/db"; +import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window"; import { extractFeatures } from "@/lib/insights/features"; import type { CoachDataCluster } from "@/lib/validations/coach-prefs"; import type { UnitPreferences } from "@/lib/measurements/display-transform"; @@ -511,8 +512,7 @@ async function buildCoachSnapshotImpl( where: { userId, asNeeded: false, ...TRACKED_INTAKE_WHERE }, select: { id: true, - startsOn: true, - endsOn: true, + ...MEDICATION_WINDOW_SELECT, oneShot: true, createdAt: true, schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, diff --git a/src/lib/analytics/__tests__/compliance-courses.test.ts b/src/lib/analytics/__tests__/compliance-courses.test.ts index 16602da2f..081911517 100644 --- a/src/lib/analytics/__tests__/compliance-courses.test.ts +++ b/src/lib/analytics/__tests__/compliance-courses.test.ts @@ -58,6 +58,7 @@ const MEDICATION = { // The row projects the latest course. startsOn: COURSES[1].startsOn, endsOn: COURSES[1].endsOn, + endedAt: null, oneShot: false, createdAt: new Date("2026-03-15T10:00:00.000Z"), }; @@ -124,6 +125,7 @@ describe("the streak across two courses (v1.40, #1024)", () => { const A = { startsOn: dateOfDayKey("2026-06-01"), endsOn: dateOfDayKey("2026-06-10"), + endedAt: null, }; const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null }; const NOW_B = new Date("2026-06-27T18:00:00.000Z"); @@ -141,6 +143,7 @@ describe("the streak across two courses (v1.40, #1024)", () => { { startsOn: B.startsOn, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-05-20T10:00:00.000Z"), courses: [A, B], diff --git a/src/lib/analytics/__tests__/compliance-skip-parity.test.ts b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts index 21a9c57de..c7e6e9678 100644 --- a/src/lib/analytics/__tests__/compliance-skip-parity.test.ts +++ b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts @@ -61,6 +61,7 @@ const medication = { active: true, startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-01-01T00:00:00.000Z"), schedules: [twiceDaily], diff --git a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts index 49b82e255..6a54235c7 100644 --- a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts @@ -136,7 +136,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi const schedules = [dailySchedule()]; const events = dailyFixtureEvents(); const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, lastNonSkippedTakenAt(events), TZ, ); @@ -191,6 +197,7 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi { startsOn: new Date(NOW.getTime() - 380 * DAY_MS), endsOn: null, + endedAt: null, oneShot: false, createdAt, }, @@ -223,7 +230,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi it("empty schedule list: zeros short-circuit matches calculateCompliance", () => { const createdAt = new Date(NOW.getTime() - 10 * DAY_MS); const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, null, TZ, ); @@ -250,7 +263,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); const schedules = [weeklyMondaySchedule()]; const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, null, TZ, ); @@ -300,7 +319,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () = const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); const schedules = [weeklyMondaySchedule()]; const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, null, TZ, ); @@ -383,7 +408,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { const schedules = [dailySchedule()]; const events = dailyFixtureEvents(); const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, lastNonSkippedTakenAt(events), TZ, ); @@ -407,7 +438,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { const schedules = [dailySchedule()]; const events = dailyFixtureEvents(); const ctx = buildComplianceMedicationContext( - { startsOn: null, endsOn: null, oneShot: false, createdAt }, + { + startsOn: null, + endsOn: null, + endedAt: null, + oneShot: false, + createdAt, + }, lastNonSkippedTakenAt(events), TZ, ); diff --git a/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts index 6cc5cff8c..18e8e6000 100644 --- a/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts @@ -78,6 +78,7 @@ describe("H-MED1 — paused days drop out of the compliance denominator", () => const medBase = { startsOn: createdAt, endsOn, + endedAt: null, oneShot: false, createdAt, }; @@ -209,6 +210,7 @@ describe("M-MED3 — the compliance rate honours archived schedule eras", () => const medBase = { startsOn: createdAt, endsOn, + endedAt: null, oneShot: false, createdAt, }; diff --git a/src/lib/analytics/compliance/__tests__/compliance.property.test.ts b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts index f4152968e..d018c01ec 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.property.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts @@ -55,6 +55,7 @@ function context(tz: string) { { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2024-06-01T00:00:00Z"), }, diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts index 8ca4f82c6..1f6a49ddf 100644 --- a/src/lib/analytics/compliance/__tests__/compliance.test.ts +++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts @@ -417,6 +417,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => { return { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2024-12-01T00:00:00Z"), lastIntakeAt: null, @@ -1002,6 +1003,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => { { startsOn: overrides.startsOn ?? null, endsOn: overrides.endsOn ?? null, + endedAt: null, oneShot: overrides.oneShot ?? false, createdAt: overrides.createdAt ?? new Date("2024-12-01T00:00:00Z"), }, @@ -1121,6 +1123,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => { { startsOn: medCtx.startsOn, endsOn: medCtx.endsOn, + endedAt: null, oneShot: medCtx.oneShot, createdAt: medCtx.createdAt, lastIntakeAt: medCtx.lastIntakeAt, @@ -1466,6 +1469,7 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { { startsOn: overrides.startsOn ?? null, endsOn: overrides.endsOn ?? null, + endedAt: null, oneShot: overrides.oneShot ?? false, // Created well before the windows so no day is excluded. createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"), @@ -2102,6 +2106,7 @@ describe("expectedSlotsBetween", () => { { startsOn: overrides.startsOn ?? null, endsOn: overrides.endsOn ?? null, + endedAt: null, oneShot: overrides.oneShot ?? false, createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"), }, @@ -2317,6 +2322,7 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", () { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2025-01-08T00:00:00Z"), }, @@ -2370,6 +2376,7 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", ( { startsOn: null, endsOn: null, + endedAt: null, oneShot: false, // Created well before the window so age never clamps the denominator. createdAt: new Date("2025-01-01T00:00:00Z"), @@ -2717,6 +2724,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { return { startsOn: new Date("2026-06-01T00:00:00Z"), endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-06-01T00:00:00Z"), lastIntakeAt: null, @@ -3048,6 +3056,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => { id: "x", startsOn: ctxFor().startsOn, endsOn: null, + endedAt: null, oneShot: false, createdAt: ctxFor().createdAt, }, diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts index 5d0aa907c..ecd8e9938 100644 --- a/src/lib/analytics/compliance/adapters.ts +++ b/src/lib/analytics/compliance/adapters.ts @@ -65,6 +65,7 @@ export function toRecurrenceCtx( id: idTag, startsOn: window.startsOn, endsOn: window.endsOn, + endedAt: ctx.endedAt ?? null, oneShot: ctx.oneShot, createdAt: ctx.createdAt, }, diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts index f4b298447..622a6996a 100644 --- a/src/lib/analytics/compliance/types.ts +++ b/src/lib/analytics/compliance/types.ts @@ -144,7 +144,7 @@ export const SCHEDULE_COMPLIANCE_SELECT = { * sibling of {@link SCHEDULE_COMPLIANCE_SELECT}. */ export const COURSES_COMPLIANCE_SELECT = { - select: { startsOn: true, endsOn: true }, + select: { startsOn: true, endsOn: true, endedAt: true }, orderBy: { startsOn: "asc" }, } as const; @@ -158,6 +158,13 @@ export const COURSES_COMPLIANCE_SELECT = { export interface ComplianceMedicationContext { startsOn: Date | null; endsOn: Date | null; + /** + * #1280 — the instant "End course" ended the latest course: no slot at or + * after it is expected. Earlier courses carry their own in `courses`. + * Every production context comes from `buildComplianceMedicationContext`, + * whose input requires it. + */ + endedAt?: Date | null; oneShot: boolean; createdAt: Date; lastIntakeAt: Date | null; @@ -222,7 +229,7 @@ export function slotInsideCourses( ): boolean { const courses = ctx.courses; if (!courses || courses.length < 2) return true; - return isInsideCourses(userDayKey(at, ctx.timeZone), courses); + return isInsideCourses(userDayKey(at, ctx.timeZone), courses, at); } /** @@ -245,6 +252,7 @@ export function buildComplianceMedicationContext( med: { startsOn: Date | null; endsOn: Date | null; + endedAt: Date | null; oneShot: boolean; createdAt: Date; /** v1.16.3 — thread the archived eras when the bundle carries them. */ @@ -260,6 +268,7 @@ export function buildComplianceMedicationContext( return { startsOn: med.startsOn, endsOn: med.endsOn, + endedAt: med.endedAt, oneShot: med.oneShot, createdAt: med.createdAt, lastIntakeAt, diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index f8b9d54af..9a444e09c 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -35,6 +35,7 @@ * summary, never an overdue state, until a fresh block arrives. */ import type { PrismaClient } from "@/generated/prisma/client"; +import { medicationWindow } from "@/lib/medications/course-window"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { @@ -220,8 +221,7 @@ export async function buildMedsTodayBlock( const display = computeDisplayDue({ medication: { id: m.id, - startsOn: m.startsOn, - endsOn: m.endsOn, + ...medicationWindow(m), oneShot: m.oneShot, createdAt: m.createdAt, }, diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index 22e8a5aab..ec7c151c4 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -615,6 +615,8 @@ export interface DoctorReportComplianceMedication { trackIntake: boolean; startsOn: Date | null; endsOn: Date | null; + /** #1280 — the instant "End course" ended the latest course. */ + endedAt: Date | null; oneShot: boolean; createdAt: Date; schedules: ComplianceSchedule[]; @@ -622,7 +624,7 @@ export interface DoctorReportComplianceMedication { /** v1.25 — pause eras so paused days drop out of the denominator. */ pauseEras?: MedicationPauseEraLike[]; /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ - courses?: { startsOn: Date; endsOn: Date | null }[]; + courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[]; } /** A window-bounded intake row keyed to its medication. */ diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index eb19713ef..79b431564 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -15,7 +15,11 @@ * * Pure data assembly — no auth, no rate limit, no audit. Idempotent. */ -import { courseStatusOn, dayKeyOfDate } from "@/lib/medications/course-window"; +import { + courseStatusOn, + dayKeyOfDate, + medicationWindow, +} from "@/lib/medications/course-window"; import { prisma } from "@/lib/db"; import { getEffectiveRange, @@ -484,8 +488,7 @@ export async function collectDoctorReportData( name: m.name, asNeeded: m.asNeeded, trackIntake: m.trackIntake, - startsOn: m.startsOn, - endsOn: m.endsOn, + ...medicationWindow(m), oneShot: m.oneShot, createdAt: m.createdAt, schedules: m.schedules, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index f0734dfb7..fe9b5f872 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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", diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts index 4b43396fd..632d729c3 100644 --- a/src/lib/export/medications-backup-schema.ts +++ b/src/lib/export/medications-backup-schema.ts @@ -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. diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts index 34b8f98eb..be78b9be1 100644 --- a/src/lib/export/medications-backup.ts +++ b/src/lib/export/medications-backup.ts @@ -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, diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts index d71a2505f..80e1c72b3 100644 --- a/src/lib/export/medications-restore.ts +++ b/src/lib/export/medications-restore.ts @@ -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, + }, }); } diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts index cf100babc..c82ea1bf4 100644 --- a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts +++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts @@ -210,4 +210,18 @@ describe("adherence storyline course window", () => { ); expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString()); }); + + it("leaves out a course ended with End course on its last day (#1280)", async () => { + await buildAdherenceStoryline( + "user-1", + "UTC", + new Date("2026-06-21T12:00:00.000Z"), + ); + // The candidate read is the one that bounds the window by `endsOn`. + const where = mocks.medicationFindMany.mock.calls + .map(([args]) => (args as { where?: Record }).where) + .reverse() + .find((w) => w && "OR" in w); + expect(where).toHaveProperty("endedAt", null); + }); }); diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index 637e6f746..7dd40f298 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -184,6 +184,8 @@ export async function buildAdherenceStoryline( { endsOn: null }, { endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } }, ], + // #1280 — a course ended with "End course" is over from that instant. + endedAt: null, }, select: { name: true, treatmentClass: true }, }); diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts index bee2b010c..9fdc31a95 100644 --- a/src/lib/jobs/__tests__/intake-auto-take.test.ts +++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts @@ -108,6 +108,7 @@ function medication( userId: overrides.userId ?? "u1", startsOn: overrides.startsOn ?? null, endsOn: overrides.endsOn ?? null, + endedAt: null, oneShot: overrides.oneShot ?? false, createdAt: overrides.createdAt ?? new Date("2026-01-01T00:00:00Z"), autoMarkTakenSince: diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 2b7c7cc9f..1e6998558 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -53,6 +53,7 @@ * owns alone, so the two never write the same slot. */ import type { PrismaClient } from "@/generated/prisma/client"; +import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; @@ -154,6 +155,8 @@ interface AutoTakeMedication { userId: string; startsOn: Date | null; endsOn: Date | null; + /** #1280 — no slot at or after the instant the course was ended is filled. */ + endedAt: Date | null; oneShot: boolean; createdAt: Date; autoMarkTakenSince: Date | null; @@ -262,8 +265,7 @@ export async function runIntakeAutoTakePass( select: { id: true, userId: true, - startsOn: true, - endsOn: true, + ...MEDICATION_WINDOW_SELECT, oneShot: true, createdAt: true, autoMarkTakenSince: true, diff --git a/src/lib/mcp/tools/get-medication-schedule.ts b/src/lib/mcp/tools/get-medication-schedule.ts index c69a3a525..51d2bb535 100644 --- a/src/lib/mcp/tools/get-medication-schedule.ts +++ b/src/lib/mcp/tools/get-medication-schedule.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { medicationWindow } from "@/lib/medications/course-window"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -152,8 +153,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { const display = computeDisplayDue({ medication: { id: m.id, - startsOn: m.startsOn, - endsOn: m.endsOn, + ...medicationWindow(m), oneShot: m.oneShot, createdAt: m.createdAt, }, diff --git a/src/lib/medications/__tests__/auto-mark-taken.test.ts b/src/lib/medications/__tests__/auto-mark-taken.test.ts index 7c072ee97..433d4ae18 100644 --- a/src/lib/medications/__tests__/auto-mark-taken.test.ts +++ b/src/lib/medications/__tests__/auto-mark-taken.test.ts @@ -25,6 +25,7 @@ describe("sendsMedicationReminders", () => { trackIntake: (bits & 8) !== 0, asNeeded: (bits & 16) !== 0, endsOn: ended ? new Date("2026-06-09T00:00:00.000Z") : null, + endedAt: null, }; expect(sendsMedicationReminders(med, at), JSON.stringify(med)).toBe( med.notificationsEnabled && @@ -44,6 +45,7 @@ describe("sendsMedicationReminders", () => { active: true, trackIntake: true, asNeeded: false, + endedAt: null, }; expect( sendsMedicationReminders( @@ -66,6 +68,29 @@ describe("sendsMedicationReminders", () => { ).toBe(false); }); + it("stops at the instant End course ended the course (#1280)", () => { + const med = { + notificationsEnabled: true, + autoMarkTaken: false, + active: true, + trackIntake: true, + asNeeded: false, + endsOn: new Date("2026-06-10T00:00:00.000Z"), + endedAt: new Date("2026-06-10T09:00:00.000Z"), + }; + // 10:00Z is after the instant: no reminder for the rest of the day, + // follow-ups of an earlier dose included. + expect(sendsMedicationReminders(med, at)).toBe(false); + expect( + sendsMedicationReminders(med, { + ...at, + now: new Date("2026-06-10T08:59:00.000Z"), + }), + ).toBe(true); + // The same last day without the instant reminds all day. + expect(sendsMedicationReminders({ ...med, endedAt: null }, at)).toBe(true); + }); + it("is the decision at every gate and on every published DTO", () => { const src = (rel: string) => readFileSync(join(process.cwd(), "src", rel), "utf8"); diff --git a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts index 1039e91a5..1d6503741 100644 --- a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts +++ b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts @@ -55,6 +55,7 @@ it("counts an off-schedule row as taken only when it has a taken time", async () createdAt: new Date("2026-01-01T00:00:00Z"), startsOn: null, endsOn: null, + endedAt: null, oneShot: false, asNeeded: false, trackIntake: true, diff --git a/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts index 3c44e767b..2dacdc3b6 100644 --- a/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts +++ b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts @@ -67,6 +67,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) { createdAt: d1(16, 5), startsOn: new Date("2026-09-23T00:00:00Z"), endsOn: new Date("2026-09-28T00:00:00Z"), + endedAt: null, oneShot: false, asNeeded: false, trackIntake: true, diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts index f85f7ce91..9f3c83e00 100644 --- a/src/lib/medications/__tests__/course-window.test.ts +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { canStartCourse, courseFromWindow, + courseStatusAt, courseStatusOn, dateOfDayKey, isInsideCourses, @@ -26,11 +27,25 @@ describe("projectCourseWindow", () => { expect(projectCourseWindow([JUNE, MARCH])).toEqual({ startsOn: d("2026-06-10"), endsOn: d("2026-06-16"), + endedAt: null, }); }); it("is empty with no course (chronic since creation)", () => { - expect(projectCourseWindow([])).toEqual({ startsOn: null, endsOn: null }); + expect(projectCourseWindow([])).toEqual({ + startsOn: null, + endsOn: null, + endedAt: null, + }); + }); + + it("carries the instant the latest course was ended", () => { + const endedAt = new Date("2026-06-16T10:00:00.000Z"); + expect(projectCourseWindow([MARCH, { ...JUNE, endedAt }])).toEqual({ + startsOn: d("2026-06-10"), + endsOn: d("2026-06-16"), + endedAt, + }); }); it.each([ @@ -116,17 +131,76 @@ describe("courseStatusOn", () => { }); describe("isInsideCourses", () => { + /** A slot at noon UTC of a day, the instant the day key belongs to. */ + const noon = (key: string) => new Date(`${key}T12:00:00.000Z`); + const inside = (key: string, courses: CourseSpan[]) => + isInsideCourses(key, courses, noon(key)); + it("admits the course days and not the gap", () => { const courses = [MARCH, JUNE]; - expect(isInsideCourses("2026-03-01", courses)).toBe(true); - expect(isInsideCourses("2026-03-07", courses)).toBe(true); - expect(isInsideCourses("2026-03-08", courses)).toBe(false); - expect(isInsideCourses("2026-06-16", courses)).toBe(true); - expect(isInsideCourses("2026-06-17", courses)).toBe(false); + expect(inside("2026-03-01", courses)).toBe(true); + expect(inside("2026-03-07", courses)).toBe(true); + expect(inside("2026-03-08", courses)).toBe(false); + expect(inside("2026-06-16", courses)).toBe(true); + expect(inside("2026-06-17", courses)).toBe(false); }); it("places no restriction without courses", () => { - expect(isInsideCourses("2026-03-08", [])).toBe(true); + expect(inside("2026-03-08", [])).toBe(true); + }); + + it("stops an ended course at the instant it was ended (#1280)", () => { + // MARCH ended with "End course" at 10:00Z on its last day: the morning + // slot belongs to it, the evening one does not. + const ended = { ...MARCH, endedAt: new Date("2026-03-07T10:00:00.000Z") }; + const courses = [ended, JUNE]; + const day = "2026-03-07"; + expect( + isInsideCourses(day, courses, new Date("2026-03-07T06:00:00.000Z")), + ).toBe(true); + expect( + isInsideCourses(day, courses, new Date("2026-03-07T10:00:00.000Z")), + ).toBe(false); + expect( + isInsideCourses(day, courses, new Date("2026-03-07T18:00:00.000Z")), + ).toBe(false); + // The days before the last one are untouched. + expect(inside("2026-03-06", courses)).toBe(true); + }); +}); + +describe("ending a course now (#1280)", () => { + const ENDED_AT = new Date("2026-06-12T10:00:00.000Z"); + const ended = { ...JUNE, endsOn: d("2026-06-12"), endedAt: ENDED_AT }; + + it("reads ENDED on the last day already, by day and by instant", () => { + expect(courseStatusOn(ended, "2026-06-12")).toBe("ENDED"); + expect(courseStatusOn(ended, "2026-06-11")).toBe("CURRENT"); + // The same end date set in the editor keeps the whole day. + expect(courseStatusOn({ ...ended, endedAt: null }, "2026-06-12")).toBe( + "CURRENT", + ); + const at = (iso: string) => courseStatusAt(ended, new Date(iso), "UTC"); + expect(at("2026-06-12T09:59:59.999Z")).toBe("CURRENT"); + expect(at("2026-06-12T10:00:00.000Z")).toBe("ENDED"); + expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED"); + }); + + it("lets a new course start once the ended one is behind it", () => { + const base = { active: true, oneShot: false, todayKey: "2026-06-12" }; + expect(canStartCourse({ ...base, courses: [MARCH, ended] })).toBe(true); + // The next course may start tomorrow, not on the ended course's day. + const next = course("2026-06-13", null); + expect(validateCourses([MARCH, ended, next], "2026-06-12", false)).toBe( + null, + ); + expect( + validateCourses( + [MARCH, ended, course("2026-06-12", null)], + "2026-06-12", + false, + ), + ).toBe("overlap"); }); }); diff --git a/src/lib/medications/__tests__/intake-actionable.test.ts b/src/lib/medications/__tests__/intake-actionable.test.ts index 93112e2f4..5c145d1d0 100644 --- a/src/lib/medications/__tests__/intake-actionable.test.ts +++ b/src/lib/medications/__tests__/intake-actionable.test.ts @@ -32,6 +32,7 @@ const base = { trackIntake: true, startsOn: day("2026-09-01"), endsOn: day("2026-09-20"), + endedAt: null, }; describe.each(ZONES)("course window in %s", (tz) => { @@ -42,13 +43,19 @@ describe.each(ZONES)("course window in %s", (tz) => { [23, 59], ] as const) { const r = resolveIntakeActionability(base, at(tz, 20, h, m), tz); - expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: true }); + expect(r).toEqual({ + courseStatus: "CURRENT", + intakeActionable: true, + }); } }); it("is ended from local midnight of the day after endsOn", () => { const r = resolveIntakeActionability(base, at(tz, 21, 0, 0), tz); - expect(r).toEqual({ courseStatus: "ENDED", intakeActionable: false }); + expect(r).toEqual({ + courseStatus: "ENDED", + intakeActionable: false, + }); }); it("is upcoming until local midnight of startsOn", () => { @@ -80,7 +87,10 @@ describe("resolveIntakeActionability", () => { it("refuses a paused or archived medication inside its course", () => { const r = resolveIntakeActionability({ ...base, active: false }, now, tz); - expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false }); + expect(r).toEqual({ + courseStatus: "CURRENT", + intakeActionable: false, + }); }); it("refuses a record-only medication inside its course", () => { @@ -89,7 +99,10 @@ describe("resolveIntakeActionability", () => { now, tz, ); - expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false }); + expect(r).toEqual({ + courseStatus: "CURRENT", + intakeActionable: false, + }); }); it("reports ENDED for a course that is also paused", () => { @@ -101,3 +114,32 @@ describe("resolveIntakeActionability", () => { expect(r.courseStatus).toBe("ENDED"); }); }); + +describe("a course ended with End course (#1280)", () => { + const ended = { + ...base, + endsOn: day("2026-09-20"), + endedAt: new Date("2026-09-20T10:00:00.000Z"), + }; + + it("is running until the instant and ended from it, not from midnight", () => { + const before = resolveIntakeActionability( + ended, + new Date("2026-09-20T09:59:00.000Z"), + "UTC", + ); + expect(before).toEqual({ + courseStatus: "CURRENT", + intakeActionable: true, + }); + const after = resolveIntakeActionability( + ended, + new Date("2026-09-20T10:00:00.000Z"), + "UTC", + ); + expect(after).toEqual({ + courseStatus: "ENDED", + intakeActionable: false, + }); + }); +}); diff --git a/src/lib/medications/__tests__/intake-tracking.test.ts b/src/lib/medications/__tests__/intake-tracking.test.ts index 518ba224f..643859e19 100644 --- a/src/lib/medications/__tests__/intake-tracking.test.ts +++ b/src/lib/medications/__tests__/intake-tracking.test.ts @@ -93,6 +93,7 @@ describe("buildCompliancePayload", () => { createdAt: new Date("2026-01-01T00:00:00Z"), startsOn: null, endsOn: null, + endedAt: null, oneShot: false, asNeeded: false, trackIntake: false, diff --git a/src/lib/medications/auto-mark-taken.ts b/src/lib/medications/auto-mark-taken.ts index b8644d5a5..8c8c3e355 100644 --- a/src/lib/medications/auto-mark-taken.ts +++ b/src/lib/medications/auto-mark-taken.ts @@ -12,10 +12,12 @@ * worker expands no slot past it), it has doses that fall due (intake * tracked, not as-needed), and its doses are not recorded automatically * (`autoMarkTaken`). A course that has not started yet still reminds once - * it does. The person's own reminder preference stays stored untouched: + * it does. #1280 — a course ended with "End course" stops reminding at the + * instant it was ended (`endedAt`), including the follow-up nags of a dose + * that fell due before it. The person's own reminder preference stays stored untouched: * switching auto-mark-taken off restores whatever they had chosen. */ -import { courseStatusAt } from "@/lib/medications/scheduling/recurrence"; +import { courseStatusAt } from "@/lib/medications/course-window"; export interface ReminderGateMedication { notificationsEnabled: boolean; @@ -24,6 +26,7 @@ export interface ReminderGateMedication { trackIntake: boolean; asNeeded: boolean; endsOn: Date | null; + endedAt: Date | null; } export function sendsMedicationReminders( @@ -37,7 +40,11 @@ export function sendsMedicationReminders( medication.trackIntake && !medication.asNeeded && courseStatusAt( - { startsOn: null, endsOn: medication.endsOn }, + { + startsOn: null, + endsOn: medication.endsOn, + endedAt: medication.endedAt, + }, at.now, at.timeZone, ) !== "ENDED" diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts index 271f0b3b8..b8c12d3b3 100644 --- a/src/lib/medications/auto-take.ts +++ b/src/lib/medications/auto-take.ts @@ -14,6 +14,7 @@ * effective `lastIntakeAt`. */ import type { PrismaClient } from "@/generated/prisma/client"; +import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { @@ -361,8 +362,7 @@ export async function loadAutoTakeRollingAnchors( }, select: { id: true, - startsOn: true, - endsOn: true, + ...MEDICATION_WINDOW_SELECT, oneShot: true, createdAt: true, autoMarkTakenSince: true, diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 992e7825b..c7042e23b 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -92,6 +92,8 @@ export interface ComplianceMedicationInput { createdAt: Date; startsOn: Date | null; endsOn: Date | null; + /** #1280 — the instant "End course" ended the latest course. */ + endedAt: Date | null; oneShot: boolean; asNeeded: boolean; /** v1.39.1 (#1033) — intake tracking off: no adherence, heatmap empty. */ diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts index 97f3fc997..333feff9a 100644 --- a/src/lib/medications/course-window.ts +++ b/src/lib/medications/course-window.ts @@ -15,13 +15,27 @@ * projection written at the last course write stays correct as days pass. A * write that would break the invariant is refused (`validateCourses`). * + * #1280 — "End course" ends a course at an instant, `endedAt`, which lies on + * its `endsOn` day. A slot at or after it is not part of the course, and the + * course reads as ENDED from then on. An end date set in the editor carries + * no `endedAt` and keeps the full-day reading. The instant is what keeps + * compliance honest both ways: a dose taken earlier that day stays inside + * the course and counts, and a later slot of the same day is neither due nor + * missed. + * * Pure: no database, no clock (callers pass `today`). */ import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; export interface CourseSpan { startsOn: Date; endsOn: Date | null; + /** + * #1280 — the instant "End course" ended this course, on its `endsOn` day. + * Absent or null: the course runs to the end of `endsOn`. + */ + endedAt?: Date | null; } export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED"; @@ -32,6 +46,26 @@ export const dayKeyOfDate = dateOnlyKey; /** A `YYYY-MM-DD` key as the `@db.Date` value Prisma writes. */ export const dateOfDayKey = dayKeyAsUtcMidnight; +/** + * The medication's window as the engines read it: the latest course's + * `startsOn` / `endsOn` and, since #1280, the instant "End course" ended it. + * One select and one picker, so a reader cannot take the dates and drop the + * instant. + */ +export const MEDICATION_WINDOW_SELECT = { + startsOn: true, + endsOn: true, + endedAt: true, +} as const; + +export function medicationWindow(m: { + startsOn: Date | null; + endsOn: Date | null; + endedAt: Date | null; +}): { startsOn: Date | null; endsOn: Date | null; endedAt: Date | null } { + return { startsOn: m.startsOn, endsOn: m.endsOn, endedAt: m.endedAt }; +} + /** Courses ascending by start. */ export function sortCourses(courses: readonly T[]): T[] { return [...courses].sort( @@ -40,24 +74,71 @@ export function sortCourses(courses: readonly T[]): T[] { } /** - * The window `Medication.startsOn/endsOn` carries: the latest course, or - * nothing at all when the medication has no course (chronic since creation). + * The window `Medication.startsOn/endsOn/endedAt` carries: the latest course, + * or nothing at all when the medication has no course (chronic since + * creation). */ export function projectCourseWindow(courses: readonly CourseSpan[]): { startsOn: Date | null; endsOn: Date | null; + endedAt: Date | null; } { - if (courses.length === 0) return { startsOn: null, endsOn: null }; + if (courses.length === 0) { + return { startsOn: null, endsOn: null, endedAt: null }; + } const latest = sortCourses(courses)[courses.length - 1]; - return { startsOn: latest.startsOn, endsOn: latest.endsOn }; + return { + startsOn: latest.startsOn, + endsOn: latest.endsOn, + endedAt: latest.endedAt ?? null, + }; } -/** Where `todayKey` sits in one course. Both ends inclusive. */ +/** + * Where `todayKey` sits in one course. Both ends inclusive, except that a + * course ended with "End course" is ENDED on its `endsOn` day already: the + * instant it ended lies on that day, and every read that asks about that day + * comes after the instant (it is written as the moment of the request). + */ export function courseStatusOn( course: CourseSpan, todayKey: string, ): CourseStatus { if (todayKey < dayKeyOfDate(course.startsOn)) return "UPCOMING"; + if (course.endsOn) { + const endKey = dayKeyOfDate(course.endsOn); + if (todayKey > endKey) return "ENDED"; + if (course.endedAt && todayKey === endKey) return "ENDED"; + } + return "CURRENT"; +} + +/** + * Where `now` sits in a medication's window (`Medication.startsOn/endsOn/ + * endedAt`, the projection of its latest course), in calendar days on the + * person's clock. `startsOn` / `endsOn` are calendar dates and are read as + * such; only `now` is converted to its local day. Both ends are inclusive: + * a course that ends today is CURRENT for the whole of today, one that + * starts today is CURRENT from local midnight. A medication without dates is + * CURRENT. #1280 — a course ended with "End course" is ENDED from the + * instant it was ended (`endedAt`), not from the next local midnight. + */ +export function courseStatusAt( + course: { + startsOn: Date | null; + endsOn: Date | null; + endedAt?: Date | null; + }, + now: Date, + timeZone: string, +): CourseStatus { + if (course.endedAt && now.getTime() >= course.endedAt.getTime()) { + return "ENDED"; + } + const todayKey = userDayKey(now, timeZone); + if (course.startsOn && todayKey < dayKeyOfDate(course.startsOn)) { + return "UPCOMING"; + } if (course.endsOn && todayKey > dayKeyOfDate(course.endsOn)) return "ENDED"; return "CURRENT"; } @@ -104,18 +185,22 @@ export function validateCourses( } /** - * Whether an instant's local day (`dayKey`) falls inside any course. No - * courses means no restriction (chronic since creation). + * Whether a slot at `at`, whose local day is `dayKey`, falls inside any + * course. No courses means no restriction (chronic since creation). A course + * ended with "End course" stops at its `endedAt`: a slot at or after it is + * outside, even on the `endsOn` day. */ export function isInsideCourses( dayKey: string, courses: readonly CourseSpan[], + at: Date, ): boolean { if (courses.length === 0) return true; return courses.some( (c) => dayKey >= dayKeyOfDate(c.startsOn) && - (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)), + (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)) && + (!c.endedAt || at.getTime() < c.endedAt.getTime()), ); } diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index da6766994..2e6ce8772 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -9,6 +9,10 @@ * columns for a medication that has courses, so reminders, the next-due line * and `intakeActionable` — which read the columns — can never disagree with * the course list. + * + * #1280 — a course may carry `endedAt`, the instant it was ended at. Every + * write that moves a course's `endsOn` clears it: an end date chosen in the + * editor means the whole day. */ import type { Prisma } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -99,9 +103,19 @@ export interface CourseRow { id: string; startsOn: Date; endsOn: Date | null; + endedAt: Date | null; noteEncrypted: Uint8Array | null; } +/** The course columns every course read and write selects. */ +const COURSE_ROW_SELECT = { + id: true, + startsOn: true, + endsOn: true, + endedAt: true, + noteEncrypted: true, +} as const; + async function lockMedication(tx: Tx, medicationId: string) { await tx.$queryRaw` SELECT pg_advisory_xact_lock( @@ -119,6 +133,7 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) { oneShot: true, startsOn: true, endsOn: true, + endedAt: true, createdAt: true, }, }); @@ -126,12 +141,16 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) { const courses = await tx.medicationCourse.findMany({ where: { medicationId }, orderBy: [{ startsOn: "asc" }, { id: "asc" }], - select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + select: COURSE_ROW_SELECT, }); return { medication, courses }; } -type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null }; +type ProjectedWindow = { + startsOn: Date | null; + endsOn: Date | null; + endedAt: Date | null; +}; /** * Write the medication's window from its courses. The only writer. @@ -148,13 +167,17 @@ async function project(tx: Tx, medicationId: string): Promise { }), tx.medicationCourse.findMany({ where: { medicationId }, - select: { startsOn: true, endsOn: true }, + select: { startsOn: true, endsOn: true, endedAt: true }, }), ]); const window = projectCourseWindow(courses); await tx.medication.update({ where: { id: medicationId }, - data: { startsOn: window.startsOn, endsOn: window.endsOn }, + data: { + startsOn: window.startsOn, + endsOn: window.endsOn, + endedAt: window.endedAt, + }, }); if (before && windowWidens(before, window)) { await moveAutoTakeFloorToNow(tx, medicationId); @@ -244,7 +267,7 @@ export async function createCourse( endsOn: args.endsOn, noteEncrypted: encryptNote(args.note) ?? null, }, - select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + select: COURSE_ROW_SELECT, }); await project(tx, args.medicationId); return created; @@ -269,9 +292,11 @@ export async function updateCourse( ); const current = courses.find((c) => c.id === args.courseId); if (!current) throw new CourseWriteError("notFound"); + const endsOn = args.endsOn === undefined ? current.endsOn : args.endsOn; const next = { startsOn: args.startsOn ?? current.startsOn, - endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn, + endsOn, + endedAt: keptEndedAt(current, endsOn), }; refuseIfInvalid( courses.map((c) => (c.id === current.id ? next : c)), @@ -284,9 +309,10 @@ export async function updateCourse( data: { startsOn: next.startsOn, endsOn: next.endsOn, + endedAt: next.endedAt, ...(noteEncrypted !== undefined && { noteEncrypted }), }, - select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true }, + select: COURSE_ROW_SELECT, }); await project(tx, args.medicationId); return updated; @@ -385,14 +411,15 @@ async function writeCurrentWindow( ); if (latest) { + const edited = { ...next, endedAt: keptEndedAt(latest, next.endsOn) }; refuseIfInvalid( - courses.map((c) => (c.id === latest.id ? next : c)), + courses.map((c) => (c.id === latest.id ? edited : c)), todayKey, medication.oneShot, ); await tx.medicationCourse.update({ where: { id: latest.id }, - data: next, + data: edited, }); } else { refuseIfInvalid([next], todayKey, medication.oneShot); @@ -407,6 +434,22 @@ async function writeCurrentWindow( return project(tx, args.medicationId); } +/** + * #1280 — the `endedAt` a course keeps through an edit: its own while its + * `endsOn` stays the day it was ended on, none once the end moves. An end + * date written in the editor is a whole day; only "End course" ends a course + * at an instant. + */ +function keptEndedAt( + course: { endsOn: Date | null; endedAt: Date | null }, + nextEndsOn: Date | null, +): Date | null { + if (!course.endedAt || !course.endsOn || !nextEndsOn) return null; + return course.endsOn.getTime() === nextEndsOn.getTime() + ? course.endedAt + : null; +} + /** * Whether the window a PUT is about to write would be refused. Checked * BEFORE the medication row is updated, so a refused window leaves the row @@ -424,7 +467,7 @@ export async function checkCurrentWindow( const courses = await prisma.medicationCourse.findMany({ where: { medicationId: args.medicationId, userId: args.userId }, orderBy: [{ startsOn: "asc" }, { id: "asc" }], - select: { id: true, startsOn: true, endsOn: true }, + select: { id: true, startsOn: true, endsOn: true, endedAt: true }, }); const latest = courses.at(-1); if (args.startsOn === null && args.endsOn === null) { @@ -438,7 +481,13 @@ export async function checkCurrentWindow( args.timeZone, ); return validateCourses( - latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next], + latest + ? courses.map((c) => + c.id === latest.id + ? { ...next, endedAt: keptEndedAt(latest, next.endsOn) } + : c, + ) + : [next], todayKey, args.oneShot, ); @@ -450,6 +499,11 @@ export interface MedicationCourseDto { id: string; startsOn: string; endsOn: string | null; + /** + * #1280 — the instant "End course" ended this course (ISO), or null when + * the whole `endsOn` day counts. + */ + endedAt: string | null; status: CourseStatus; /** Doses logged as taken whose slot falls on a day of this course. */ takenDoses: number; @@ -473,13 +527,7 @@ export async function loadCoursesFor( const rows = await prisma.medicationCourse.findMany({ where: { medicationId: { in: medicationIds } }, orderBy: [{ startsOn: "asc" }, { id: "asc" }], - select: { - id: true, - medicationId: true, - startsOn: true, - endsOn: true, - noteEncrypted: true, - }, + select: { ...COURSE_ROW_SELECT, medicationId: true }, }); for (const { medicationId, ...row } of rows) { const list = byMedication.get(medicationId) ?? []; @@ -530,6 +578,7 @@ export function buildCourseFields( id: c.id, startsOn: dayKeyOfDate(c.startsOn), endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, + endedAt: c.endedAt ? c.endedAt.toISOString() : null, status: courseStatusOn(c, todayKey), takenDoses: takenByCourse.get(c.id) ?? 0, note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, diff --git a/src/lib/medications/intake-actionable.ts b/src/lib/medications/intake-actionable.ts index f20072559..cd586a720 100644 --- a/src/lib/medications/intake-actionable.ts +++ b/src/lib/medications/intake-actionable.ts @@ -22,11 +22,14 @@ * * Logging a past dose from the history is a different verb and is not * gated here; an ended course can still be completed after the fact. + * + * #1280 — a course ended at an instant (`endedAt`) is ENDED from it, so the + * action row goes away at once instead of at midnight. */ import { courseStatusAt, type CourseStatus, -} from "@/lib/medications/scheduling/recurrence"; +} from "@/lib/medications/course-window"; import { isRecordOnly } from "@/lib/medications/intake-tracking"; export interface IntakeActionability { @@ -40,6 +43,7 @@ export function resolveIntakeActionability( trackIntake: boolean; startsOn: Date | null; endsOn: Date | null; + endedAt: Date | null; }, now: Date, timeZone: string, diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts index 96d1903b8..982436f60 100644 --- a/src/lib/medications/intake-slot-dedup.ts +++ b/src/lib/medications/intake-slot-dedup.ts @@ -43,6 +43,10 @@ * unregistered queue silently never drains. */ import { prisma } from "@/lib/db"; +import { + MEDICATION_WINDOW_SELECT, + medicationWindow, +} from "@/lib/medications/course-window"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { isP2002 } from "@/lib/prisma-errors"; @@ -201,8 +205,7 @@ export async function dedupeUserIntakeSlots( select: { id: true, userId: true, - startsOn: true, - endsOn: true, + ...MEDICATION_WINDOW_SELECT, oneShot: true, createdAt: true, schedules: { @@ -348,8 +351,7 @@ export async function dedupeUserIntakeSlots( const canonical = resolveCanonicalSlotInstant({ medication: { id: med.id, - startsOn: med.startsOn, - endsOn: med.endsOn, + ...medicationWindow(med), oneShot: med.oneShot, createdAt: med.createdAt, schedules: med.schedules, @@ -551,8 +553,7 @@ async function convergeAdhocRowsOntoPendingSlots(input: { const medication: AttributeIntakeMedication = { id: med.id, - startsOn: med.startsOn, - endsOn: med.endsOn, + ...medicationWindow(med), oneShot: med.oneShot, createdAt: med.createdAt, schedules: med.schedules, diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 9c916274b..1a99bddef 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -13,6 +13,7 @@ * readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard. */ import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; +import { medicationWindow } from "@/lib/medications/course-window"; import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -244,8 +245,7 @@ export async function buildMedicationsList( const display = computeDisplayDue({ medication: { id: m.id, - startsOn: m.startsOn, - endsOn: m.endsOn, + ...medicationWindow(m), oneShot: m.oneShot, createdAt: m.createdAt, }, diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts index 6f7adea8d..c41504c7f 100644 --- a/src/lib/medications/read-medication.ts +++ b/src/lib/medications/read-medication.ts @@ -8,6 +8,7 @@ * medication is not the record's. */ import type { Medication, MedicationSchedule } from "@/generated/prisma/client"; +import { medicationWindow } from "@/lib/medications/course-window"; import { prisma } from "@/lib/db"; import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { toIngredientDtos } from "@/lib/medications/ingredients/composition"; @@ -113,8 +114,7 @@ async function loadDisplayDue( const display = computeDisplayDue({ medication: { id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, + ...medicationWindow(medication), oneShot: medication.oneShot, createdAt: medication.createdAt, }, diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts index 4c35f0cf9..752efc30c 100644 --- a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts +++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts @@ -51,6 +51,7 @@ function med( id: "med-1", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-01-01T00:00:00Z"), schedules, diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 6a6fb298f..a7be8bbbe 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -35,6 +35,7 @@ function makeMedication( id: "med-1", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: d("2026-01-01T00:00:00Z"), ...overrides, diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index a2e7fac2f..4dbd67861 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -1295,3 +1295,71 @@ describe("occurrencesBetween — CYCLIC", () => { ); }); }); + +// ──────────────────────────────────────────────────────────────────── +// #1280 — a course ended with "End course" stops at that instant +// ──────────────────────────────────────────────────────────────────── + +describe("a course ended at an instant (#1280)", () => { + // 08:00 and 20:00 in Berlin (UTC+2 in June), ended at 12:00 local on the + // 10th, which is also its last day. + const schedule = makeSchedule({ + rrule: "FREQ=DAILY", + timesOfDay: ["08:00", "20:00"], + }); + const ended = makeCtx({ + medication: { + endsOn: d("2026-06-10T00:00:00Z"), + endedAt: d("2026-06-10T10:00:00Z"), + }, + }); + const wholeDay = makeCtx({ + medication: { endsOn: d("2026-06-10T00:00:00Z") }, + }); + + it("emits the slot before the instant and none after it", () => { + const from = d("2026-06-09T22:00:00Z"); + const to = d("2026-06-10T21:59:59Z"); + expect( + occurrencesBetween(schedule, from, to, ended).map((o) => + o.at.toISOString(), + ), + ).toEqual(["2026-06-10T06:00:00.000Z"]); + // The same end date without the instant keeps the evening dose. + expect(occurrencesBetween(schedule, from, to, wholeDay)).toHaveLength(2); + }); + + it("has no next dose once the course was ended", () => { + expect( + nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), ended), + ).toBeNull(); + expect( + nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), wholeDay)?.at, + ).toEqual(d("2026-06-10T18:00:00Z")); + }); + + it("stops a rolling cadence whose next dose falls after the instant", () => { + const rolling = makeSchedule({ + rollingIntervalDays: 1, + timesOfDay: ["20:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d("2026-06-09T18:00:00Z"), + medication: { + endsOn: d("2026-06-10T00:00:00Z"), + endedAt: d("2026-06-10T10:00:00Z"), + }, + }); + expect( + occurrencesBetween( + rolling, + d("2026-06-09T22:00:00Z"), + d("2026-06-10T21:59:59Z"), + ctx, + ), + ).toEqual([]); + expect( + nextOccurrenceAfter(rolling, d("2026-06-10T10:30:00Z"), ctx), + ).toBeNull(); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts index 1cc19bba7..136fbd599 100644 --- a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts +++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts @@ -46,6 +46,7 @@ function makeMedication( id: "med-1", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-01-01T00:00:00Z"), schedules, diff --git a/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts index 1ff7974b3..3883bf121 100644 --- a/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts +++ b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts @@ -34,6 +34,7 @@ const medication: WorkerMedicationRow = { id: "med-1", startsOn: new Date("2026-06-02T00:00:00.000Z"), endsOn: null, + endedAt: null, oneShot: false, createdAt: new Date("2026-06-01T00:00:00.000Z"), }; diff --git a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts index 7d02534df..20ebf15f7 100644 --- a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts +++ b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts @@ -77,6 +77,7 @@ const medication = { id: "med-1", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt, }; @@ -384,6 +385,7 @@ describe("write-attribution era pick", () => { id: "med-1", startsOn: null, endsOn: null, + endedAt: null, oneShot: false, createdAt, schedules: [ diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts index c2875337d..44dbcb7f3 100644 --- a/src/lib/medications/scheduling/cadence.ts +++ b/src/lib/medications/scheduling/cadence.ts @@ -128,6 +128,8 @@ export interface ScheduleLike { export interface CadenceEngineContext { startsOn: Date | null; endsOn: Date | null; + /** #1280 — when "End course" ended the latest course (see the engine). */ + endedAt?: Date | null; oneShot: boolean; createdAt: Date; /** Latest non-skipped intake — only read by rolling cadences. */ @@ -150,18 +152,17 @@ export interface CadenceEngineContext { } /** v1.40 (#1024) — the span a timeline expands over (see `courses`). */ -function mintWindow(engineCtx: CadenceEngineContext): { - startsOn: Date | null; - endsOn: Date | null; -} { +function mintWindow(engineCtx: CadenceEngineContext) { const courses = engineCtx.courses; + const endedAt = engineCtx.endedAt ?? null; if (!courses || courses.length < 2) { - return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn }; + return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn, endedAt }; } const sorted = sortCourses(courses); return { startsOn: sorted[0].startsOn, endsOn: sorted[sorted.length - 1].endsOn, + endedAt, }; } @@ -169,7 +170,7 @@ function mintWindow(engineCtx: CadenceEngineContext): { function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean { const courses = engineCtx.courses; if (!courses || courses.length < 2) return true; - return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses); + return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses, at); } /** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */ @@ -201,8 +202,7 @@ function toRecurrenceContext( return { medication: { id: "cadence-med", - startsOn: window.startsOn, - endsOn: window.endsOn, + ...window, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, @@ -908,16 +908,14 @@ function missedFromLedger( const window = mintWindow(engineCtx); const medication: BandMinterMedication = { id: "missed-tally", - startsOn: window.startsOn, - endsOn: window.endsOn, + ...window, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }; const recurrenceCtx: RecurrenceContext = { medication: { id: "missed-tally", - startsOn: window.startsOn, - endsOn: window.endsOn, + ...window, oneShot: engineCtx.oneShot, createdAt: engineCtx.createdAt, }, diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts index 20bfd2354..9ae4bddc4 100644 --- a/src/lib/medications/scheduling/project-today-intakes.ts +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -26,6 +26,7 @@ * Returns the counts so the caller can surface them on `annotate(...)`. */ import { prisma } from "@/lib/db"; +import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window"; import { buildCanonicalSchedule, buildRecurrenceContext, @@ -58,8 +59,7 @@ export async function projectTodayIntakesAndRecompute(input: { where: { userId, active: true, ...TRACKED_INTAKE_WHERE }, select: { id: true, - startsOn: true, - endsOn: true, + ...MEDICATION_WINDOW_SELECT, oneShot: true, createdAt: true, // The live era start: no slot anchored before it is projected. Turning @@ -160,6 +160,9 @@ export async function projectTodayIntakesAndRecompute(input: { if (!Number.isFinite(h) || !Number.isFinite(m)) continue; const scheduledFor = localHmAsUtc(now, userTz, h, m); if (isBeforeLiveEra(scheduledFor, eraStart)) continue; + // #1280 — a course ended with "End course" expects nothing from + // that instant on; the schedule still emits its earlier slots today. + if (med.endedAt && scheduledFor >= med.endedAt) continue; projected.push({ medicationId: schedule.medicationId, scheduledFor, diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index 091ae2e4c..f2477ca41 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -143,6 +143,8 @@ export interface RecurrenceContext { id: string; startsOn: Date | null; endsOn: Date | null; + /** #1280 — when "End course" ended it; see `beforeCourseEnded`. */ + endedAt?: Date | null; oneShot: boolean; createdAt: Date; }; @@ -211,31 +213,6 @@ const ONE_MINUTE_MS = 60_000; // Public API // ──────────────────────────────────────────────────────────────────── -/** - * Where `now` sits relative to a medication's course, in calendar days on - * the user's clock. `startsOn` / `endsOn` are calendar dates and are read as - * such (never shifted through `timeZone`); only `now` is converted to the - * local day it falls on. Both ends are inclusive: a course that ends today - * is CURRENT for the whole of today, one that starts today is CURRENT from - * local midnight. - */ -export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED"; - -export function courseStatusAt( - course: { startsOn: Date | null; endsOn: Date | null }, - now: Date, - timeZone: string, -): CourseStatus { - const today = civilDayOfInstant(now, timeZone).getTime(); - if (course.startsOn && today < civilDayOfDate(course.startsOn).getTime()) { - return "UPCOMING"; - } - if (course.endsOn && today > civilDayOfDate(course.endsOn).getTime()) { - return "ENDED"; - } - return "CURRENT"; -} - /** * Emit every occurrence in `[from, to]` (inclusive of both ends) in * chronological order. Pure: no DB access, no side effects. @@ -273,9 +250,22 @@ export function occurrencesBetween( // independent: the legacy stride filters weeks first, the cyclic gate // filters the surviving slots second. if (schedule.scheduleType === "CYCLIC") { - return slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx)); + slots = slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx)); } - return slots; + // #1280 — a course ended with "End course" stops at that instant. + return slots.filter((s) => beforeCourseEnded(s.at, ctx)); +} + +/** + * #1280 — the cut every expansion path applies on top of the `endsOn` day + * cap: no slot at or after the instant "End course" ended the course. A dose + * later that day is not due, reminded or expected; an earlier one is. Every + * medication context builder threads `endedAt` (the course-anchor guard + * holds the list); a measurement or booster reminder has no course to end. + */ +function beforeCourseEnded(at: Date, ctx: RecurrenceContext): boolean { + const endedAt = ctx.medication.endedAt; + return !endedAt || at.getTime() < endedAt.getTime(); } /** @@ -354,7 +344,7 @@ export function nextOccurrenceAfter( for (const s of slots) { if (s.at.getTime() <= after.getTime()) continue; if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue; - return s; + return beforeCourseEnded(s.at, ctx) ? s : null; } return null; } @@ -405,7 +395,7 @@ export function nextOccurrenceAfter( for (const s of slots) { if (s.at.getTime() < floor.getTime()) continue; if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue; - return s; + return beforeCourseEnded(s.at, ctx) ? s : null; } return null; } @@ -608,6 +598,7 @@ export function advanceRollingOccurrence( return null; } const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone); + if (!beforeCourseEnded(at, ctx)) return null; return buildOccurrence(at, time, schedule); } @@ -698,7 +689,8 @@ export function expandRollingRetrospective( const inWindow = (at: Date): boolean => at.getTime() >= from.getTime() && at.getTime() <= to.getTime() && - at.getTime() <= endsCap; + at.getTime() <= endsCap && + beforeCourseEnded(at, ctx); const slots: Occurrence[] = []; diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts index 01fed425f..19471a7ac 100644 --- a/src/lib/medications/scheduling/slot-upsert.ts +++ b/src/lib/medications/scheduling/slot-upsert.ts @@ -18,6 +18,7 @@ import type { MedicationIntakeEvent, PrismaClient, } from "@/generated/prisma/client"; +import { medicationWindow } from "@/lib/medications/course-window"; import { prisma as defaultPrisma } from "@/lib/db"; import { isP2002 } from "@/lib/prisma-errors"; @@ -472,6 +473,7 @@ const MEDICATION_SELECT = { id: true, startsOn: true, endsOn: true, + endedAt: true, oneShot: true, createdAt: true, schedules: { select: SCHEDULE_SELECT }, @@ -557,8 +559,7 @@ export async function resolveSlotInstantForWrite( return resolveCanonicalSlotInstant({ medication: { id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, + ...medicationWindow(medication), oneShot: medication.oneShot, createdAt: medication.createdAt, schedules: medication.schedules as WorkerScheduleRow[], @@ -612,8 +613,7 @@ async function loadAttributeMedication(input: { return { medication: { id: medication.id, - startsOn: medication.startsOn, - endsOn: medication.endsOn, + ...medicationWindow(medication), oneShot: medication.oneShot, createdAt: medication.createdAt, schedules: medication.schedules as WorkerScheduleRow[], diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts index 7d1a45199..959120ce6 100644 --- a/src/lib/medications/scheduling/worker-helpers.ts +++ b/src/lib/medications/scheduling/worker-helpers.ts @@ -69,6 +69,8 @@ export interface WorkerMedicationRow { id: string; startsOn: Date | null; endsOn: Date | null; + /** #1280 — the instant "End course" ended the course (see the engine). */ + endedAt: Date | null; oneShot: boolean; createdAt: Date; } @@ -149,6 +151,7 @@ export function buildRecurrenceContext(input: { id: input.medication.id, startsOn: input.medication.startsOn, endsOn: input.medication.endsOn, + endedAt: input.medication.endedAt, oneShot: input.medication.oneShot, createdAt: input.medication.createdAt, }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 55e9fbe13..2e6c11d0c 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -66,9 +66,16 @@ export const medicationCourseResource = z .date() .nullable() .describe("Last day, inclusive. NULL = open (only the latest course)."), + endedAt: isoDateTime() + .nullable() + .describe( + '#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.', + ), status: z .enum(["UPCOMING", "CURRENT", "ENDED"]) - .describe("Where today, on the person's clock, sits in this course."), + .describe( + "Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` day already.", + ), takenDoses: z .number() .int() @@ -364,6 +371,12 @@ export const medicationResource = z .describe( "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", ), + endedAt: isoDateTime() + .nullable() + .optional() + .describe( + '#1280 — the instant the latest course was ended with "End course" (the course\'s own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.', + ), oneShot: z .boolean() .describe( @@ -435,12 +448,12 @@ export const medicationListEntry = medicationResource remindersActive: z .boolean() .describe( - "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", + "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", ), courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( - "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.", + 'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.', ), intakeActionable: z .boolean() @@ -506,7 +519,7 @@ export const medicationDetailEntry = medicationResource courseStatus: z .enum(["UPCOMING", "CURRENT", "ENDED"]) .describe( - "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.", + 'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.', ), intakeActionable: z .boolean() diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts index 5ae330480..2ffc716b4 100644 --- a/src/lib/targets/types.ts +++ b/src/lib/targets/types.ts @@ -72,12 +72,14 @@ export interface TargetMedication { createdAt: Date; startsOn: Date | null; endsOn: Date | null; + /** #1280 — the instant "End course" ended the latest course. */ + endedAt: Date | null; oneShot: boolean; schedules: ComplianceSchedule[]; scheduleRevisions?: ScheduleRevisionLike[]; pauseEras?: MedicationPauseEraLike[]; /** v1.40 (#1024) — the courses, so a gap between two expects nothing. */ - courses?: { startsOn: Date; endsOn: Date | null }[]; + courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[]; } export interface TargetIntakeEvent extends IntakeEvent { diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index b14db8aff..c741d2b27 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -335,7 +335,7 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "2026-05-01T11:51:00.000Z", + "updatedAt": "2026-05-01T11:52:00.000Z", "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", @@ -344,7 +344,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "id": "", @@ -428,18 +428,20 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": null, "noteEncrypted": "", - "updatedAt": "2026-05-02T00:47:00.000Z", - "createdAt": "2026-05-02T00:46:00.000Z" + "updatedAt": "2026-05-02T00:48:00.000Z", + "createdAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, "noteEncrypted": null, - "updatedAt": "2026-05-02T00:47:01.000Z", - "createdAt": "2026-05-02T00:46:01.000Z" + "updatedAt": "2026-05-02T00:48:01.000Z", + "createdAt": "2026-05-02T00:47:01.000Z" } ], "doseChanges": [ @@ -2772,8 +2774,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "2026-05-01T11:51:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", + "updatedAt": "2026-05-01T11:52:00.000Z", "schedules": [ { "id": "", @@ -2995,18 +2997,20 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": null, "noteEncrypted": "", - "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "2026-05-02T00:47:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z", + "updatedAt": "2026-05-02T00:48:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, "noteEncrypted": null, - "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "2026-05-02T00:47:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z", + "updatedAt": "2026-05-02T00:48:01.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json index 82a5663bd..436afeeb9 100644 --- a/tests/integration/__golden__/backup-parity/export-portable.json +++ b/tests/integration/__golden__/backup-parity/export-portable.json @@ -199,7 +199,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -250,14 +250,16 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z" } ], "doseChanges": [ @@ -1850,7 +1852,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "2026-06-30T12:34:56.789Z", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -2022,14 +2024,16 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z" } ] } diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index f8e15f9f9..a8d078b9b 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -614,6 +614,7 @@ "snoozedUntil": "2026-06-30T12:34:56.789Z", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "oneShot": true, "asNeeded": true, "trackIntake": false, @@ -633,8 +634,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "2026-05-01T11:51:00.000Z" + "createdAt": "2026-05-01T11:51:00.000Z", + "updatedAt": "2026-05-01T11:52:00.000Z" } ], "MedicationSchedule": [ @@ -805,9 +806,10 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", + "endedAt": null, "noteEncrypted": "", - "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "2026-05-02T00:47:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z", + "updatedAt": "2026-05-02T00:48:00.000Z" }, { "id": "", @@ -815,9 +817,10 @@ "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "noteEncrypted": null, - "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "2026-05-02T00:47:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z", + "updatedAt": "2026-05-02T00:48:01.000Z" } ], "MedicationIngredient": [ @@ -3237,7 +3240,7 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "updatedAt": "2026-05-01T11:51:00.000Z", + "updatedAt": "2026-05-01T11:52:00.000Z", "categoryUpdatedAt": "2026-05-01T21:22:00.000Z", "name": "Round-trip tablet", "dose": "5 mg", @@ -3246,7 +3249,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "id": "", @@ -3330,18 +3333,20 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": null, "noteEncrypted": "", - "updatedAt": "2026-05-02T00:47:00.000Z", - "createdAt": "2026-05-02T00:46:00.000Z" + "updatedAt": "2026-05-02T00:48:00.000Z", + "createdAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, "noteEncrypted": null, - "updatedAt": "2026-05-02T00:47:01.000Z", - "createdAt": "2026-05-02T00:46:01.000Z" + "updatedAt": "2026-05-02T00:48:01.000Z", + "createdAt": "2026-05-02T00:47:01.000Z" } ], "doseChanges": [ diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index ed90139e4..68ae26e99 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -681,6 +681,7 @@ "snoozedUntil": null, "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "oneShot": false, "asNeeded": false, "trackIntake": false, @@ -698,7 +699,7 @@ "reorderLeadDays": null, "externalSource": null, "externalId": null, - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "updatedAt": "" } ], @@ -843,8 +844,9 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", + "endedAt": null, "noteEncrypted": "", - "createdAt": "2026-05-02T00:46:00.000Z", + "createdAt": "2026-05-02T00:47:00.000Z", "updatedAt": "" }, { @@ -853,8 +855,9 @@ "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "noteEncrypted": null, - "createdAt": "2026-05-02T00:46:01.000Z", + "createdAt": "2026-05-02T00:47:01.000Z", "updatedAt": "" } ], @@ -2596,7 +2599,7 @@ "autoMarkTaken": true, "autoMarkTakenSince": "", "category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10", - "createdAt": "2026-05-01T11:50:00.000Z", + "createdAt": "2026-05-01T11:51:00.000Z", "schedules": [ { "windowStart": "08:00", @@ -2647,14 +2650,16 @@ { "startsOn": "2026-03-01", "endsOn": "2026-03-07", + "endedAt": null, "note": "the March flu", - "createdAt": "2026-05-02T00:46:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z" }, { "startsOn": "2026-06-10", "endsOn": "2026-06-16", + "endedAt": "2026-06-16T09:30:00.000Z", "note": null, - "createdAt": "2026-05-02T00:46:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z" } ], "doseChanges": [ diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index d050c0382..1dec697bd 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -570,6 +570,7 @@ "snoozedUntil": null, "startsOn": null, "endsOn": null, + "endedAt": null, "oneShot": false, "asNeeded": false, "trackIntake": true, diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 7c6570625..68895abe8 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -521,6 +521,7 @@ "snoozedUntil": null, "startsOn": null, "endsOn": null, + "endedAt": null, "oneShot": false, "asNeeded": false, "trackIntake": true, @@ -555,6 +556,7 @@ "snoozedUntil": null, "startsOn": "2026-07-01T00:00:00.000Z", "endsOn": "2026-08-01T00:00:00.000Z", + "endedAt": null, "oneShot": false, "asNeeded": false, "trackIntake": true, @@ -652,6 +654,7 @@ "userId": "round-trip-owner", "startsOn": "2026-07-01T00:00:00.000Z", "endsOn": "2026-08-01T00:00:00.000Z", + "endedAt": null, "noteEncrypted": null, "createdAt": "", "updatedAt": "" @@ -1480,6 +1483,7 @@ { "startsOn": "2026-07-01", "endsOn": "2026-08-01", + "endedAt": null, "note": null, "noteEncrypted": null, "updatedAt": "", diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index c4d46b2c9..cd3618a3f 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -212,6 +212,7 @@ "snoozedUntil": "2026-06-30T12:34:56.789Z", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "oneShot": true, "asNeeded": true, "trackIntake": false, @@ -231,8 +232,8 @@ "reorderLeadDays": 3, "externalSource": "WEB", "externalId": "rt-Medication-externalId", - "createdAt": "2026-05-01T11:50:00.000Z", - "updatedAt": "2026-05-01T11:51:00.000Z" + "createdAt": "2026-05-01T11:51:00.000Z", + "updatedAt": "2026-05-01T11:52:00.000Z" } ], "MedicationSchedule": [ @@ -403,9 +404,10 @@ "userId": "round-trip-owner", "startsOn": "2026-03-01T00:00:00.000Z", "endsOn": "2026-03-07T00:00:00.000Z", + "endedAt": null, "noteEncrypted": "", - "createdAt": "2026-05-02T00:46:00.000Z", - "updatedAt": "2026-05-02T00:47:00.000Z" + "createdAt": "2026-05-02T00:47:00.000Z", + "updatedAt": "2026-05-02T00:48:00.000Z" }, { "id": "", @@ -413,9 +415,10 @@ "userId": "round-trip-owner", "startsOn": "2026-06-10T00:00:00.000Z", "endsOn": "2026-06-16T00:00:00.000Z", + "endedAt": "2026-06-16T09:30:00.000Z", "noteEncrypted": null, - "createdAt": "2026-05-02T00:46:01.000Z", - "updatedAt": "2026-05-02T00:47:01.000Z" + "createdAt": "2026-05-02T00:47:01.000Z", + "updatedAt": "2026-05-02T00:48:01.000Z" } ], "MedicationIngredient": [ diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts index ddf0f990e..8b271145c 100644 --- a/tests/integration/backup-fixture.ts +++ b/tests/integration/backup-fixture.ts @@ -190,7 +190,9 @@ export async function seedEveryTwoEndedModel( }); // Two courses with a gap, and the window the row projects from them (the - // latest). The earlier course exists nowhere else once it has ended. + // latest). The earlier course exists nowhere else once it has ended. The + // latest was ended with "End course" (#1280), so the instant has to come + // back with it. const medication = await prisma.medication.create({ data: { userId: OWNER_ID, @@ -198,6 +200,7 @@ export async function seedEveryTwoEndedModel( dose: "5 mg", startsOn: AT("2026-06-10T00:00:00.000Z"), endsOn: AT("2026-06-16T00:00:00.000Z"), + endedAt: AT("2026-06-16T09:30:00.000Z"), schedules: { create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" }, }, @@ -213,6 +216,7 @@ export async function seedEveryTwoEndedModel( userId: OWNER_ID, startsOn: AT("2026-06-10T00:00:00.000Z"), endsOn: AT("2026-06-16T00:00:00.000Z"), + endedAt: AT("2026-06-16T09:30:00.000Z"), }, ], }, diff --git a/tests/integration/intake-late-take-future-slot.test.ts b/tests/integration/intake-late-take-future-slot.test.ts index ddffd0969..797d809d3 100644 --- a/tests/integration/intake-late-take-future-slot.test.ts +++ b/tests/integration/intake-late-take-future-slot.test.ts @@ -217,6 +217,7 @@ async function readDisplayDue(medId: string, now: Date): Promise { id: med.id, startsOn: med.startsOn, endsOn: med.endsOn, + endedAt: null, oneShot: med.oneShot, createdAt: med.createdAt, }, diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts index b30f2e907..6719dd9c2 100644 --- a/tests/integration/medication-auto-mark-taken.test.ts +++ b/tests/integration/medication-auto-mark-taken.test.ts @@ -585,6 +585,7 @@ describe("remindersActive — the published value is the gate", () => { trackIntake: boolean; asNeeded: boolean; endsOn: Date | null; + endedAt: Date | null; }> = []; for (let bits = 0; bits < 64; bits++) { combos.push({ @@ -595,6 +596,7 @@ describe("remindersActive — the published value is the gate", () => { asNeeded: (bits & 16) !== 0, // A course that ended the day before. endsOn: (bits & 32) !== 0 ? new Date("2026-06-09T00:00:00.000Z") : null, + endedAt: null, }); } const ids: string[] = [];