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[] = [];