diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5f1922c9a..54ec2d2f8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -53956,8 +53956,10 @@ components: canStartCourse: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, - not one-time, has a course and every course has ended, and the caller is the record's owner (starting a - course is not delegable, so a delegate always reads false)." + not one-time, has a course and every course's last day is before today (a course ended today with `POST + /api/medications/{id}/courses/end` is over, but a course starting today would share its last day, so the + start is offered from the next day), and the caller is the record's owner (starting a course is not + delegable, so a delegate always reads false)." nextDueScheduleId: description: The schedule `nextDueAt` belongs to; null when nothing is due. type: @@ -54417,8 +54419,10 @@ components: canStartCourse: type: boolean description: "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, - not one-time, has a course and every course has ended, and the caller is the record's owner (starting a - course is not delegable, so a delegate always reads false)." + not one-time, has a course and every course's last day is before today (a course ended today with `POST + /api/medications/{id}/courses/end` is over, but a course starting today would share its last day, so the + start is offered from the next day), and the caller is the record's owner (starting a course is not + delegable, so a delegate always reads false)." courseStatus: type: string enum: diff --git a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts index 0ff0530eb..9f46728c0 100644 --- a/src/lib/ai/coach/snapshot-blocks/compliance-block.ts +++ b/src/lib/ai/coach/snapshot-blocks/compliance-block.ts @@ -24,7 +24,7 @@ import { } from "@/lib/analytics/compliance"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { annotate } from "@/lib/logging/context"; -import { courseStatusOn } from "@/lib/medications/course-window"; +import { courseStatusAt } from "@/lib/medications/course-window"; import { isoWeekKey, tzDayKey, tzWeekday } from "../snapshot-series"; import type { RegisterBlock } from "../series-condense"; import type { CoachProvenance, CoachProvenanceMetric } from "../types"; @@ -58,11 +58,12 @@ function courseSummary( now: Date, userTz: string, ): { courses?: { takenInCourses: number; noCourseRunning: number } } { - const todayKey = tzDayKey(now, userTz); const several = meds.filter((m) => (m.courses?.length ?? 0) >= 2); if (several.length === 0) return {}; const idle = several.filter((m) => - (m.courses ?? []).every((c) => courseStatusOn(c, todayKey) !== "CURRENT"), + (m.courses ?? []).every( + (c) => courseStatusAt(c, now, userTz) !== "CURRENT", + ), ).length; return { courses: { takenInCourses: several.length, noCourseRunning: idle }, diff --git a/src/lib/medications/__tests__/course-fields-status.test.ts b/src/lib/medications/__tests__/course-fields-status.test.ts new file mode 100644 index 000000000..0df4b2034 --- /dev/null +++ b/src/lib/medications/__tests__/course-fields-status.test.ts @@ -0,0 +1,39 @@ +/** + * #1280 — the course rows a medication read publishes answer "where does + * today sit" the way the medication's own window does, including after a + * move to a zone whose calendar day is behind the one the course ended on. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import { buildCourseFields } from "@/lib/medications/courses"; +import { courseStatusAt, dateOfDayKey } from "@/lib/medications/course-window"; + +describe("the published course status", () => { + it("is ENDED for a course ended at an instant, read from further west", () => { + const endedAt = new Date("2026-06-11T22:30:00.000Z"); + const row = { + id: "course-1", + startsOn: dateOfDayKey("2026-06-10"), + endsOn: dateOfDayKey("2026-06-12"), + endedAt, + noteEncrypted: null, + }; + const now = new Date("2026-06-11T23:30:00.000Z"); + const zone = "America/New_York"; + const fields = buildCourseFields( + { active: true, oneShot: false }, + [row], + new Map(), + now, + zone, + ); + expect(courseStatusAt(row, now, zone)).toBe("ENDED"); + expect(fields.courses[0].status).toBe("ENDED"); + expect(fields.previousCourseEndedOn).toBe("2026-06-12"); + // Not offered: a course starting today (the 11th there) would begin + // before the ended one's last day. + expect(fields.canStartCourse).toBe(false); + }); +}); diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts index 7b30b7c13..f28f1e1ec 100644 --- a/src/lib/medications/__tests__/course-window.test.ts +++ b/src/lib/medications/__tests__/course-window.test.ts @@ -211,14 +211,11 @@ describe("ending a course now (#1280)", () => { ); }); - 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, - ); + it("offers a new course from the day after the ended one, as validation accepts it", () => { + const base = { active: true, oneShot: false, courses: [MARCH, ended] }; + // On the day it was ended a course starting today would share that day, + // which the validation refuses, so it is not offered yet. + expect(canStartCourse({ ...base, todayKey: "2026-06-12" })).toBe(false); expect( validateCourses( [MARCH, ended, course("2026-06-12", null)], @@ -226,6 +223,45 @@ describe("ending a course now (#1280)", () => { false, ), ).toBe("overlap"); + // The next day it is, and a course starting that day is accepted. + expect(canStartCourse({ ...base, todayKey: "2026-06-13" })).toBe(true); + expect( + validateCourses( + [MARCH, ended, course("2026-06-13", null)], + "2026-06-13", + false, + ), + ).toBe(null); + }); + + it("reads an ended course as over after a move west, as the window does", () => { + // Ended at 00:30 in Warsaw on the 12th, read from New York where it is + // still the evening of the 11th. + const endedAt = new Date("2026-06-11T22:30:00.000Z"); + const westward = { ...JUNE, endsOn: d("2026-06-12"), endedAt }; + const now = new Date("2026-06-11T23:30:00.000Z"); + const zone = "America/New_York"; + expect(courseStatusAt(westward, now, zone)).toBe("ENDED"); + // The course it ended is history to every rule that asks about today. + expect(previousCourseEndedOn([MARCH, westward], "2026-06-11")).toEqual( + d("2026-06-12"), + ); + expect( + validateCourses( + [westward, course("2026-06-13", null)], + "2026-06-11", + false, + ), + ).toBe(null); + // And no course may start before the day after its last one. + expect( + canStartCourse({ + active: true, + oneShot: false, + courses: [MARCH, westward], + todayKey: "2026-06-11", + }), + ).toBe(false); }); }); diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts index a97d9711c..ffd3f1cb9 100644 --- a/src/lib/medications/course-window.ts +++ b/src/lib/medications/course-window.ts @@ -95,10 +95,12 @@ export function projectCourseWindow(courses: readonly CourseSpan[]): { } /** - * 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). + * Where the day `dayKey` sits in one course. Both ends inclusive, except + * that a course ended with "End course" is ENDED on its `endsOn` day + * already. For a day in the past (the doctor report's last day); a question + * about NOW goes to {@link courseStatusAt}, which also holds when the person + * has since moved to a zone whose calendar is a day behind the one the + * course ended on. */ export function courseStatusOn( course: CourseSpan, @@ -176,6 +178,20 @@ export type CourseRefusal = /** A one-time medication has more than one course. */ | "oneShot"; +/** + * #1280 — whether a course is over as of today. A course ended with "End + * course" is, whatever day it is where the person now reads it: its instant + * is written as the moment of the request, so it lies in the past of every + * later question. Otherwise its last day is before today. + * + * The day-key form of {@link courseStatusAt}'s ENDED, for the rules that are + * handed a day (`validateCourses`, `previousCourseEndedOn`). + */ +export function courseIsOver(course: CourseSpan, todayKey: string): boolean { + if (course.endedAt) return true; + return course.endsOn !== null && todayKey > dayKeyOfDate(course.endsOn); +} + /** * Whether a course set may be stored. Adjacent courses (one ends the day * before the next starts) are fine; sharing a day is an overlap. @@ -202,7 +218,7 @@ export function validateCourses( } } for (let i = 0; i < sorted.length - 1; i += 1) { - if (courseStatusOn(sorted[i], todayKey) !== "ENDED") return "currentOpen"; + if (!courseIsOver(sorted[i], todayKey)) return "currentOpen"; } return null; } @@ -235,17 +251,22 @@ export function previousCourseEndedOn( courses: readonly CourseSpan[], todayKey: string, ): Date | null { - const ended = sortCourses(courses).filter( - (c) => courseStatusOn(c, todayKey) === "ENDED", - ); + const ended = sortCourses(courses).filter((c) => courseIsOver(c, todayKey)); return ended.length === 0 ? null : ended[ended.length - 1].endsOn; } /** - * Whether a new course may start: the medication is active and not a - * one-time one, it has at least one course, and every course has ended. A - * medication with no course is chronic and running, so it ends its course - * first ("End course" writes one). + * Whether a new course may start TODAY: the medication is active and not a + * one-time one, it has at least one course, and every course's last day is + * before today. A medication with no course is chronic and running, so it + * ends its course first ("End course" writes one). + * + * #1280 — the last day must be BEFORE today, not merely ended. A course + * ended with "End course" is over from that instant, but its last day is + * today, and a course starting today would share it: `validateCourses` + * refuses that as an overlap, and the engine has no way to start a course at + * an instant. So the start is offered from the next day, the first day the + * validation accepts, and the flag, the validation and the engine agree. */ export function canStartCourse(args: { active: boolean; @@ -255,7 +276,7 @@ export function canStartCourse(args: { }): boolean { if (!args.active || args.oneShot || args.courses.length === 0) return false; return args.courses.every( - (c) => courseStatusOn(c, args.todayKey) === "ENDED", + (c) => c.endsOn !== null && dayKeyOfDate(c.endsOn) < args.todayKey, ); } diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts index 398ab281f..258adb02b 100644 --- a/src/lib/medications/courses.ts +++ b/src/lib/medications/courses.ts @@ -25,7 +25,6 @@ import { courseEndNow, courseFromWindow, courseStatusAt, - courseStatusOn, dateOfDayKey, dayKeyOfDate, previousCourseEndedOn, @@ -686,7 +685,9 @@ export function buildCourseFields( startsOn: dayKeyOfDate(c.startsOn), endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null, endedAt: c.endedAt ? c.endedAt.toISOString() : null, - status: courseStatusOn(c, todayKey), + // The instant-aware reading the medication's own `courseStatus` uses, + // so a course and its medication never disagree about now. + status: courseStatusAt(c, now, timeZone), takenDoses: takenByCourse.get(c.id) ?? 0, note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null, })), diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index 314a72e40..793d919a7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -110,7 +110,7 @@ const medicationCourseFields = { canStartCourse: z .boolean() .describe( - "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course has ended, and the caller is the record's owner (starting a course is not delegable, so a delegate always reads false).", + "v1.40 — whether `POST /api/medications/{id}/courses` may start a new course now: the medication is active, not one-time, has a course and every course's last day is before today (a course ended today with `POST /api/medications/{id}/courses/end` is over, but a course starting today would share its last day, so the start is offered from the next day), and the caller is the record's owner (starting a course is not delegable, so a delegate always reads false).", ), }; diff --git a/tests/integration/medication-course-end.test.ts b/tests/integration/medication-course-end.test.ts index 28c76232f..32cbacbac 100644 --- a/tests/integration/medication-course-end.test.ts +++ b/tests/integration/medication-course-end.test.ts @@ -268,13 +268,35 @@ describe("POST /courses/end ends the course now", () => { canEndCourse: false, intakeActionable: false, remindersActive: false, - canStartCourse: true, + // A course starting today would share the ended one's last day, which + // the start refuses as an overlap; it is offered from tomorrow. + canStartCourse: false, }); expect(read.courses[0]).toMatchObject({ status: "ENDED", endsOn: "2026-06-10", endedAt: ENDED_AT, }); + const startToday = await call( + "@/app/api/medications/[id]/courses/route", + "POST", + `/api/medications/${id}/courses`, + { id }, + { startsOn: "2026-06-10" }, + ); + expect(startToday.status).toBe(422); + expect(startToday.body.meta.errorCode).toBe("medications.course.overlap"); + at("2026-06-11T07:00:00.000Z"); + expect((await detail(id)).canStartCourse).toBe(true); + const startTomorrow = await call( + "@/app/api/medications/[id]/courses/route", + "POST", + `/api/medications/${id}/courses`, + { id }, + { startsOn: "2026-06-11" }, + ); + expect(startTomorrow.status, JSON.stringify(startTomorrow.body)).toBe(201); + at(ENDED_AT); // A second press finds nothing running. const again = await endCourse(id);