feat(medications): end a course now through its own action

"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.

End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.

The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.

Refs #1280
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 22:24:15 +02:00
1 parent 23a67f61e6
commit 275367f6e9
15 files changed
+990 -124

No files matched your search

@@ -193,6 +193,7 @@ export interface MedicationDetailSnapshot {
/** v1.40 (#1024) — the server-resolved courses. */
courses?: MedicationCourseWire[];
canStartCourse?: boolean;
canEndCourse?: boolean;
recordedSchedules?: ScheduleSnapshot[];
schedules: ScheduleSnapshot[];
/** v1.43 — the composition per unit, as the server delivered it. */
@@ -3,13 +3,19 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
import {
canEndCourse,
endCourseBody,
LifecycleManageBody,
} from "../destructive-zone-section";
courseEndNow,
dayKeyOfDate,
} from "@/lib/medications/course-window";
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
import { LifecycleManageBody } from "../destructive-zone-section";
/** The end day End writes, as the `YYYY-MM-DD` an `endsOn` field carries. */
function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
return { endsOn: dayKeyOfDate(courseEndNow(now, timeZone).endsOn) };
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -22,8 +28,9 @@ vi.mock("@/hooks/use-auth", () => ({
/**
* "End medication" sent `new Date().toISOString()`, a full instant, against an
* `endsOn` that only takes `YYYY-MM-DD`, so every press answered 422 and the
* toast said the medication could not be ended. The body now carries the
* person's own calendar day.
* toast said the medication could not be ended. End now asks the server to
* end the course (`POST /courses/end`), which writes the person's own
* calendar day (`courseEndNow`) as the last day.
*/
describe("endCourseBody", () => {
it("is a date the update schema accepts", () => {
@@ -58,10 +65,12 @@ describe("endCourseBody", () => {
* Ending writes today as the end of the latest course. On a course that has
* already ended that would reopen it up to today, and on one that has not
* started it would become a one-day course today, so End is only offered while
* the course runs. A medication with no dates is CURRENT.
* the course runs. The server resolves that as `canEndCourse` (a medication
* with no dates is CURRENT); the controls render from it, and without it
* (a read that does not carry it) End stays hidden.
*/
describe("End is offered only for a running course", () => {
function render(courseStatus?: "UPCOMING" | "CURRENT" | "ENDED") {
function render(served: boolean | undefined) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={new QueryClient()}>
@@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => {
id: "med-1",
name: "Example",
active: true,
courseStatus,
canEndCourse: served,
}}
/>
</QueryClientProvider>
@@ -80,12 +89,13 @@ describe("End is offered only for a running course", () => {
it.each([
["CURRENT", true],
[undefined, true],
[undefined, false],
["ENDED", false],
["UPCOMING", false],
] as const)("course %s → End shown: %s", (status, shown) => {
expect(canEndCourse(status)).toBe(shown);
const html = render(status);
const served = status === undefined ? undefined : canEndCourse(status);
expect(served ?? false).toBe(shown);
const html = render(served);
expect(html.includes('data-slot="destructive-zone-end"')).toBe(shown);
// Pausing stays available whatever the course.
expect(html).toContain('id="medication-detail-pause-switch"');
@@ -13,8 +13,10 @@
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
* AT hit target.
* - Tier 2 Beenden: PUT `{ endsOn: <today YYYY-MM-DD> }`. AlertDialog
* confirm; Cancel autofocus stays default.
* - Tier 2 Beenden: POST `/courses/end` (#1280), no body: the server ends
* the running course at that moment, on the person's own day. Offered
* while the read serves `canEndCourse`. AlertDialog confirm; Cancel
* autofocus stays default.
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
* `intakeCount === 0`. AlertDialog confirm.
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
@@ -46,33 +48,9 @@ import {
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys";
import { apiDelete, apiPut } from "@/lib/api/api-fetch";
import type { CourseStatus } from "@/lib/medications/course-window";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
/**
* The PUT body that ends a course today: the person's own calendar day as
* `YYYY-MM-DD`, the only shape `endsOn` accepts. A full ISO instant was
* refused with 422 on every press, and its date was the UTC one, a day off
* around midnight for anyone not on UTC.
*/
export function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
return { endsOn: userDayKey(now, timeZone) };
}
/**
* Whether "End" may be offered. Only a course that is running today can be
* ended today: the PUT rewrites the latest course's end, so pressing it on an
* ended course would reopen it up to today, and on a course that has not
* started it would turn into a one-day course today. Unknown (an older
* server) keeps the button.
*/
export function canEndCourse(courseStatus: CourseStatus | undefined): boolean {
return courseStatus === undefined || courseStatus === "CURRENT";
}
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -90,8 +68,12 @@ export function LifecycleManageBody({
id: string;
name: string;
active: boolean;
/** Where today sits in the course (server-resolved). */
courseStatus?: CourseStatus;
/**
* Whether a course is running that End may end now (server-resolved).
* Ending writes today as the course's last day, so on an ended course it
* would reopen it and on one not yet started invent a one-day course.
*/
canEndCourse?: boolean;
};
onAfterAction?: () => void;
}) {
@@ -158,7 +140,7 @@ export function LifecycleManageBody({
/>
</label>
{canEndCourse(medication.courseStatus) && (
{medication.canEndCourse === true && (
<EndCourseControl
medicationId={medicationId}
medicationName={medication.name}
@@ -185,8 +167,6 @@ function EndCourseControl({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [tier2Busy, setTier2Busy] = useState(false);
@@ -195,10 +175,7 @@ function EndCourseControl({
if (tier2Busy) return;
setTier2Busy(true);
try {
await apiPut(
`/api/medications/${medicationId}`,
endCourseBody(new Date(), userTz),
);
await apiPost(`/api/medications/${medicationId}/courses/end`);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
@@ -207,6 +184,9 @@ function EndCourseControl({
showErrorToast(t("medications.detail.zone.end.failed"), {
cause: failure,
});
// A 409 means the course is no longer running (ended in another tab):
// refetch, so the served `canEndCourse` takes the button away.
void invalidateMedicationReads(queryClient);
} finally {
setTier2Busy(false);
}