mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(medications): offer a new course only on a day the start accepts
canStartCourse answered true on the day a course was ended with End course, while a course starting that day shares its last day and the start refused it with 422 overlap. The start is now offered from the day after the last course's end, the first day validation and the engine accept. A course ended at an instant is over for every rule that asks about today, whatever the zone it is read from: after a move west the course rows read CURRENT while the medication's own status read ENDED. The course rows, the coach summary, the overlap rule and the previous-end read take the instant-aware reading. Refs #1280
This commit is contained in:
1 parent
8b66f647cb
commit
f0b6854a91
8 files changed
+156
-32
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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).",
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user