From 19ae91f80ae6b624a34830b54b1b62b19151185c Mon Sep 17 00:00:00 2001
From: haritowa <6107211+haritowa@users.noreply.github.com>
Date: Wed, 21 Oct 2026 13:11:53 +0000
Subject: [PATCH 01/11] fix(medications): end a course on the person's day, as
a date
"End medication" sent new Date().toISOString(), a full instant, against
an endsOn that only accepts YYYY-MM-DD, so every press answered 422 and
the toast said the medication could not be ended. The instant's date was
also the UTC one, a day off around midnight for anyone not on UTC.
The button now sends the person's calendar day in their timezone
(userDayKey), the same day key the medication card uses.
---
.../destructive-zone-section.test.ts | 40 +++++++++++++++++++
.../sections/destructive-zone-section.tsx | 22 ++++++++--
2 files changed, 59 insertions(+), 3 deletions(-)
create mode 100644 src/components/medications/sections/__tests__/destructive-zone-section.test.ts
diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.ts b/src/components/medications/sections/__tests__/destructive-zone-section.test.ts
new file mode 100644
index 000000000..b1702880f
--- /dev/null
+++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
+
+import { endCourseBody } from "../destructive-zone-section";
+
+/**
+ * "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.
+ */
+describe("endCourseBody", () => {
+ it("is a date the update schema accepts", () => {
+ const body = endCourseBody(
+ new Date("2026-10-21T09:30:00.000Z"),
+ "Europe/Berlin",
+ );
+ expect(body).toEqual({ endsOn: "2026-10-21" });
+ expect(updateMedicationSchema.safeParse(body).success).toBe(true);
+ });
+
+ it("is the person's day, not the UTC one", () => {
+ // 23:30 UTC is already the next day in Warsaw and still the same day in
+ // New York.
+ const lateUtc = new Date("2026-10-20T23:30:00.000Z");
+ expect(endCourseBody(lateUtc, "Europe/Warsaw").endsOn).toBe("2026-10-21");
+ expect(endCourseBody(lateUtc, "America/New_York").endsOn).toBe(
+ "2026-10-20",
+ );
+ });
+
+ it("is what the update schema refused before: a full instant is not a date", () => {
+ expect(
+ updateMedicationSchema.safeParse({
+ endsOn: new Date("2026-10-21T09:30:00.000Z").toISOString(),
+ }).success,
+ ).toBe(false);
+ });
+});
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index b8e247d76..ef5853c38 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -13,7 +13,7 @@
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `` so the entire row is the
* AT hit target.
- * - Tier 2 Beenden: PUT `{ endsOn: }`. AlertDialog
+ * - Tier 2 Beenden: PUT `{ endsOn: }`. AlertDialog
* confirm; Cancel autofocus stays default.
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
* `intakeCount === 0`. AlertDialog confirm.
@@ -46,9 +46,21 @@ 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 { 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) };
+}
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -71,6 +83,8 @@ export function LifecycleManageBody({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const userTz = user?.timezone || DEFAULT_TIMEZONE;
const [paused, setPaused] = useState(!active);
const [tier1Busy, setTier1Busy] = useState(false);
@@ -107,8 +121,10 @@ export function LifecycleManageBody({
if (tier2Busy) return;
setTier2Busy(true);
try {
- const today = new Date().toISOString();
- await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
+ await apiPut(
+ `/api/medications/${medicationId}`,
+ endCourseBody(new Date(), userTz),
+ );
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.zone.end.toast"));
setEndDialogOpen(false);
From 92911283be71e0a945819e5072dddecb12c81c19 Mon Sep 17 00:00:00 2001
From: haritowa <6107211+haritowa@users.noreply.github.com>
Date: Thu, 22 Oct 2026 16:45:02 +0000
Subject: [PATCH 02/11] fix(medications): offer "End" only for a course that is
running
Ending sends today as the end of the medication's latest course. While
the PUT was refused this did nothing; now that it succeeds, pressing End
on a course that had already ended reopened it up to today (doses due
again, the days between counted as missed), and on a course that had not
started yet it became a one-day course today.
LifecycleManageBody now takes the server-resolved courseStatus and shows
End only while the course is CURRENT (a medication with no dates is
CURRENT). Pausing stays available whatever the course.
---
.../detail/medication-detail-tabs.tsx | 1 +
.../destructive-zone-section.test.ts | 40 ------
.../destructive-zone-section.test.tsx | 91 ++++++++++++
.../sections/destructive-zone-section.tsx | 130 ++++++++++--------
4 files changed, 167 insertions(+), 95 deletions(-)
delete mode 100644 src/components/medications/sections/__tests__/destructive-zone-section.test.ts
create mode 100644 src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 39a7a8b7b..ba0e5da5d 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -927,6 +927,7 @@ export function MedicationDetailTabs({
medicationId={id}
medicationName={medication.name}
active={medication.active}
+ courseStatus={served.courseStatus}
/>
{/* v1.40 (#1024) — the courses: when it was taken, and a new
diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.ts b/src/components/medications/sections/__tests__/destructive-zone-section.test.ts
deleted file mode 100644
index b1702880f..000000000
--- a/src/components/medications/sections/__tests__/destructive-zone-section.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { describe, expect, it } from "vitest";
-
-import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
-
-import { endCourseBody } from "../destructive-zone-section";
-
-/**
- * "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.
- */
-describe("endCourseBody", () => {
- it("is a date the update schema accepts", () => {
- const body = endCourseBody(
- new Date("2026-10-21T09:30:00.000Z"),
- "Europe/Berlin",
- );
- expect(body).toEqual({ endsOn: "2026-10-21" });
- expect(updateMedicationSchema.safeParse(body).success).toBe(true);
- });
-
- it("is the person's day, not the UTC one", () => {
- // 23:30 UTC is already the next day in Warsaw and still the same day in
- // New York.
- const lateUtc = new Date("2026-10-20T23:30:00.000Z");
- expect(endCourseBody(lateUtc, "Europe/Warsaw").endsOn).toBe("2026-10-21");
- expect(endCourseBody(lateUtc, "America/New_York").endsOn).toBe(
- "2026-10-20",
- );
- });
-
- it("is what the update schema refused before: a full instant is not a date", () => {
- expect(
- updateMedicationSchema.safeParse({
- endsOn: new Date("2026-10-21T09:30:00.000Z").toISOString(),
- }).success,
- ).toBe(false);
- });
-});
diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
new file mode 100644
index 000000000..3e71ba1da
--- /dev/null
+++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
@@ -0,0 +1,91 @@
+import { describe, expect, it, vi } from "vitest";
+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";
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "test-user", username: "tester", role: "USER" },
+ isAuthenticated: true,
+ isLoading: false,
+ }),
+}));
+
+/**
+ * "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.
+ */
+describe("endCourseBody", () => {
+ it("is a date the update schema accepts", () => {
+ const body = endCourseBody(
+ new Date("2026-10-21T09:30:00.000Z"),
+ "Europe/Berlin",
+ );
+ expect(body).toEqual({ endsOn: "2026-10-21" });
+ expect(updateMedicationSchema.safeParse(body).success).toBe(true);
+ });
+
+ it("is the person's day, not the UTC one", () => {
+ // 23:30 UTC is already the next day in Warsaw and still the same day in
+ // New York.
+ const lateUtc = new Date("2026-10-20T23:30:00.000Z");
+ expect(endCourseBody(lateUtc, "Europe/Warsaw").endsOn).toBe("2026-10-21");
+ expect(endCourseBody(lateUtc, "America/New_York").endsOn).toBe(
+ "2026-10-20",
+ );
+ });
+
+ it("is what the update schema refused before: a full instant is not a date", () => {
+ expect(
+ updateMedicationSchema.safeParse({
+ endsOn: new Date("2026-10-21T09:30:00.000Z").toISOString(),
+ }).success,
+ ).toBe(false);
+ });
+});
+
+/**
+ * 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.
+ */
+describe("End is offered only for a running course", () => {
+ function render(courseStatus?: "UPCOMING" | "CURRENT" | "ENDED") {
+ return renderToStaticMarkup(
+
+
+
+
+ ,
+ );
+ }
+
+ it.each([
+ ["CURRENT", true],
+ [undefined, true],
+ ["ENDED", false],
+ ["UPCOMING", false],
+ ] as const)("course %s → End shown: %s", (status, shown) => {
+ expect(canEndCourse(status)).toBe(shown);
+ const html = render(status);
+ expect(html.includes('data-slot="destructive-zone-end"')).toBe(shown);
+ // Pausing stays available whatever the course.
+ expect(html).toContain('id="medication-detail-pause-switch"');
+ });
+});
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index ef5853c38..e39b51b44 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -62,6 +62,19 @@ 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: "UPCOMING" | "CURRENT" | "ENDED" | undefined,
+): boolean {
+ return courseStatus === undefined || courseStatus === "CURRENT";
+}
+
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
const PAUSE_HELPER_ID = "medication-detail-pause-helper";
@@ -74,11 +87,14 @@ export function LifecycleManageBody({
medicationId,
medicationName,
active,
+ courseStatus,
onAfterAction,
}: {
medicationId: string;
medicationName: string;
active: boolean;
+ /** Where today sits in the course (server-resolved). */
+ courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
onAfterAction?: () => void;
}) {
const { t } = useTranslations();
@@ -168,65 +184,69 @@ export function LifecycleManageBody({
/>
-
+ {canEndCourse(courseStatus) ? (
+ <>
+
-
-
-
- {t("medications.detail.zone.end.title")}
-
-
- {t("medications.detail.zone.end.helper")}
-
-
-
setEndDialogOpen(true)}
- className="min-h-11 font-semibold sm:min-h-9"
- data-slot="destructive-zone-end"
- >
-
- {t("medications.detail.zone.end.button")}
-
-
-
- {/* Tier 2 — Beenden confirm */}
-
-
-
-
- {t("medications.detail.zone.end.dialogTitle", {
- name: medicationName,
- })}
-
-
- {t("medications.detail.zone.end.dialogBody")}
-
-
-
- {t("common.cancel")}
-
+
+
+ {t("medications.detail.zone.end.title")}
+
+
+ {t("medications.detail.zone.end.helper")}
+
+
+ {
- e.preventDefault();
- void tier2End();
- }}
- disabled={tier2Busy}
- aria-busy={tier2Busy || undefined}
- className="font-semibold"
+ size="sm"
+ onClick={() => setEndDialogOpen(true)}
+ className="min-h-11 font-semibold sm:min-h-9"
+ data-slot="destructive-zone-end"
>
- {tier2Busy && (
-
- )}
+
{t("medications.detail.zone.end.button")}
-
-
-
-
+
+
+
+ {/* Tier 2 — Beenden confirm */}
+
+
+
+
+ {t("medications.detail.zone.end.dialogTitle", {
+ name: medicationName,
+ })}
+
+
+ {t("medications.detail.zone.end.dialogBody")}
+
+
+
+ {t("common.cancel")}
+ {
+ e.preventDefault();
+ void tier2End();
+ }}
+ disabled={tier2Busy}
+ aria-busy={tier2Busy || undefined}
+ className="font-semibold"
+ >
+ {tier2Busy && (
+
+ )}
+ {t("medications.detail.zone.end.button")}
+
+
+
+
+ >
+ ) : null}
);
}
From 9b8c76390185ff237b5e0e11750fe4c677a82510 Mon Sep 17 00:00:00 2001
From: haritowa <6107211+haritowa@users.noreply.github.com>
Date: Thu, 22 Oct 2026 18:18:07 +0000
Subject: [PATCH 03/11] refactor(medications): tidy the End guard after review
The course-status prop uses the shared CourseStatus type instead of a
spelled-out union, and the End confirmation's open state is dropped when
End stops being offered, so a dialog closed by a refetch cannot pop up
by itself once a later course offers End again.
---
.../sections/destructive-zone-section.tsx | 20 ++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index e39b51b44..f627a22b4 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -50,6 +50,7 @@ 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";
/**
@@ -69,9 +70,7 @@ export function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
* started it would turn into a one-day course today. Unknown (an older
* server) keeps the button.
*/
-export function canEndCourse(
- courseStatus: "UPCOMING" | "CURRENT" | "ENDED" | undefined,
-): boolean {
+export function canEndCourse(courseStatus: CourseStatus | undefined): boolean {
return courseStatus === undefined || courseStatus === "CURRENT";
}
@@ -94,19 +93,30 @@ export function LifecycleManageBody({
medicationName: string;
active: boolean;
/** Where today sits in the course (server-resolved). */
- courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
+ courseStatus?: CourseStatus;
onAfterAction?: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
+ const canEnd = canEndCourse(courseStatus);
const [paused, setPaused] = useState(!active);
const [tier1Busy, setTier1Busy] = useState(false);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [tier2Busy, setTier2Busy] = useState(false);
+ // A refetch can stop offering End while its confirmation is open (the
+ // course ended in another tab). Drop the open state with it, or the dialog
+ // would pop up by itself once a later course offers End again. Adjusted
+ // during render, React's pattern for state that follows a prop.
+ const [offeredEnd, setOfferedEnd] = useState(canEnd);
+ if (offeredEnd !== canEnd) {
+ setOfferedEnd(canEnd);
+ if (!canEnd) setEndDialogOpen(false);
+ }
+
async function tier1Toggle(pauseNext: boolean) {
if (tier1Busy) return;
setTier1Busy(true);
@@ -184,7 +194,7 @@ export function LifecycleManageBody({
/>
- {canEndCourse(courseStatus) ? (
+ {canEnd ? (
<>
From fa20b1fe3a50fc44b6a81ee2ef3c86811c28f019 Mon Sep 17 00:00:00 2001
From: haritowa <6107211+haritowa@users.noreply.github.com>
Date: Sat, 24 Oct 2026 17:04:26 +0000
Subject: [PATCH 04/11] refactor(medications): give End its own component
End and its confirmation now live in EndCourseControl, which renders only
while the course runs. When End stops being offered, the open dialog and
its state unmount with it, so the state no longer has to be reset during
render.
---
.../sections/destructive-zone-section.tsx | 214 ++++++++++--------
1 file changed, 115 insertions(+), 99 deletions(-)
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index f627a22b4..541d4ad8e 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -98,24 +98,9 @@ export function LifecycleManageBody({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
- const { user } = useAuth();
- const userTz = user?.timezone || DEFAULT_TIMEZONE;
- const canEnd = canEndCourse(courseStatus);
const [paused, setPaused] = useState(!active);
const [tier1Busy, setTier1Busy] = useState(false);
- const [endDialogOpen, setEndDialogOpen] = useState(false);
- const [tier2Busy, setTier2Busy] = useState(false);
-
- // A refetch can stop offering End while its confirmation is open (the
- // course ended in another tab). Drop the open state with it, or the dialog
- // would pop up by itself once a later course offers End again. Adjusted
- // during render, React's pattern for state that follows a prop.
- const [offeredEnd, setOfferedEnd] = useState(canEnd);
- if (offeredEnd !== canEnd) {
- setOfferedEnd(canEnd);
- if (!canEnd) setEndDialogOpen(false);
- }
async function tier1Toggle(pauseNext: boolean) {
if (tier1Busy) return;
@@ -143,27 +128,6 @@ export function LifecycleManageBody({
}
}
- async function tier2End() {
- if (tier2Busy) return;
- setTier2Busy(true);
- try {
- await apiPut(
- `/api/medications/${medicationId}`,
- endCourseBody(new Date(), userTz),
- );
- await invalidateMedicationReads(queryClient);
- toast.success(t("medications.detail.zone.end.toast"));
- setEndDialogOpen(false);
- onAfterAction?.();
- } catch (failure) {
- showErrorToast(t("medications.detail.zone.end.failed"), {
- cause: failure,
- });
- } finally {
- setTier2Busy(false);
- }
- }
-
return (
- {canEnd ? (
- <>
-
-
-
-
-
- {t("medications.detail.zone.end.title")}
-
-
- {t("medications.detail.zone.end.helper")}
-
-
-
setEndDialogOpen(true)}
- className="min-h-11 font-semibold sm:min-h-9"
- data-slot="destructive-zone-end"
- >
-
- {t("medications.detail.zone.end.button")}
-
-
-
- {/* Tier 2 — Beenden confirm */}
-
-
-
-
- {t("medications.detail.zone.end.dialogTitle", {
- name: medicationName,
- })}
-
-
- {t("medications.detail.zone.end.dialogBody")}
-
-
-
- {t("common.cancel")}
- {
- e.preventDefault();
- void tier2End();
- }}
- disabled={tier2Busy}
- aria-busy={tier2Busy || undefined}
- className="font-semibold"
- >
- {tier2Busy && (
-
- )}
- {t("medications.detail.zone.end.button")}
-
-
-
-
- >
- ) : null}
+ {canEndCourse(courseStatus) && (
+
+ )}
);
}
+/**
+ * Tier 2: End the course today, behind a confirmation. Rendered only while
+ * the course runs, so when End stops being offered (the course ended in
+ * another tab) the open dialog and its state go away with it.
+ */
+function EndCourseControl({
+ medicationId,
+ medicationName,
+ onAfterAction,
+}: {
+ medicationId: string;
+ medicationName: string;
+ onAfterAction?: () => void;
+}) {
+ 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);
+
+ async function tier2End() {
+ if (tier2Busy) return;
+ setTier2Busy(true);
+ try {
+ await apiPut(
+ `/api/medications/${medicationId}`,
+ endCourseBody(new Date(), userTz),
+ );
+ await invalidateMedicationReads(queryClient);
+ toast.success(t("medications.detail.zone.end.toast"));
+ setEndDialogOpen(false);
+ onAfterAction?.();
+ } catch (failure) {
+ showErrorToast(t("medications.detail.zone.end.failed"), {
+ cause: failure,
+ });
+ } finally {
+ setTier2Busy(false);
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+ {t("medications.detail.zone.end.title")}
+
+
+ {t("medications.detail.zone.end.helper")}
+
+
+
setEndDialogOpen(true)}
+ className="min-h-11 font-semibold sm:min-h-9"
+ data-slot="destructive-zone-end"
+ >
+
+ {t("medications.detail.zone.end.button")}
+
+
+
+ {/* Tier 2 — Beenden confirm */}
+
+
+
+
+ {t("medications.detail.zone.end.dialogTitle", {
+ name: medicationName,
+ })}
+
+
+ {t("medications.detail.zone.end.dialogBody")}
+
+
+
+ {t("common.cancel")}
+ {
+ e.preventDefault();
+ void tier2End();
+ }}
+ disabled={tier2Busy}
+ aria-busy={tier2Busy || undefined}
+ className="font-semibold"
+ >
+ {tier2Busy && (
+
+ )}
+ {t("medications.detail.zone.end.button")}
+
+
+
+
+ >
+ );
+}
+
/**
* v1.7.0 — irreversible danger zone: purge intake history + delete the
* medication. v1.16.1 — the body is bare rows (the caller's
From 5e8fb126a33f23e543a6c2f7c5bf951fb3ee8a41 Mon Sep 17 00:00:00 2001
From: haritowa <6107211+haritowa@users.noreply.github.com>
Date: Sat, 24 Oct 2026 17:53:18 +0000
Subject: [PATCH 05/11] refactor(medications): hand the lifecycle controls the
medication
LifecycleManageBody takes the medication (id, name, active, course
status) instead of one prop per field. The call site in
medication-detail-tabs.tsx shrinks to one line, so the course status
reaches the controls without growing a file the size ratchet holds.
---
.../detail/medication-detail-tabs.tsx | 7 +-----
.../destructive-zone-section.test.tsx | 10 ++++----
.../sections/destructive-zone-section.tsx | 24 +++++++++----------
3 files changed, 19 insertions(+), 22 deletions(-)
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index ba0e5da5d..9cefc459a 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -923,12 +923,7 @@ export function MedicationDetailTabs({
defaultOpen={false}
>
-
+
{/* v1.40 (#1024) — the courses: when it was taken, and a new
course once the last one has ended. */}
diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
index 3e71ba1da..5992803ea 100644
--- a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
+++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
@@ -66,10 +66,12 @@ describe("End is offered only for a running course", () => {
,
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index 541d4ad8e..ae1c193c2 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -83,23 +83,23 @@ const PAUSE_HELPER_ID = "medication-detail-pause-helper";
* Bare (no section / card chrome); the caller supplies its own grouping.
*/
export function LifecycleManageBody({
- medicationId,
- medicationName,
- active,
- courseStatus,
+ medication,
onAfterAction,
}: {
- medicationId: string;
- medicationName: string;
- active: boolean;
- /** Where today sits in the course (server-resolved). */
- courseStatus?: CourseStatus;
+ medication: {
+ id: string;
+ name: string;
+ active: boolean;
+ /** Where today sits in the course (server-resolved). */
+ courseStatus?: CourseStatus;
+ };
onAfterAction?: () => void;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
+ const medicationId = medication.id;
- const [paused, setPaused] = useState(!active);
+ const [paused, setPaused] = useState(!medication.active);
const [tier1Busy, setTier1Busy] = useState(false);
async function tier1Toggle(pauseNext: boolean) {
@@ -158,10 +158,10 @@ export function LifecycleManageBody({
/>
- {canEndCourse(courseStatus) && (
+ {canEndCourse(medication.courseStatus) && (
)}
From 23a67f61e6c76ee67b5af144ea71d62056269662 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 24 Oct 2026 22:20:06 +0200
Subject: [PATCH 06/11] feat(medications): let a course end at an instant
A course's end date is a calendar day and counts in full: the doses of that
day stay due, reminded and expected until local midnight. That is right for
an end date set in the editor and wrong for "End course", whose dialog
promises that nothing more is due.
A course now carries `endedAt` (migration 0482, additive, nullable), the
instant it was ended on its `endsOn` day, projected onto the medication next
to `startsOn` / `endsOn` by the one course writer. From that instant:
- the schedule engine emits no slot, so a dose later that day is not due,
not reminded, not projected onto the today list and never expected by
compliance, while a dose earlier that day stays inside the course;
- the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose
actions, `remindersActive` and the reminder gate stop at once, follow-ups
of an earlier dose included;
- every engine context builder, compliance context, dose history, Coach
snapshot, doctor report and the adherence storyline read it, through one
select and one picker for the medication window.
Any write that moves a course's `endsOn` clears the instant, so an end date
chosen in the editor keeps meaning the whole day. Backups carry it in both
purposes and a restore projects it again. `courseStatusAt` moves next to the
other course rules.
Refs #1280
---
docs/api/openapi.yaml | 58 ++++++++--
.../migration.sql | 29 +++++
prisma/schema.prisma | 13 +++
.../recurrence-course-anchor-guard.test.ts | 27 ++++-
src/__tests__/tz-day-paths-harness.test.ts | 1 +
.../medications/[id]/dose-history/route.ts | 2 +-
src/lib/__tests__/doctor-report-data.test.ts | 4 +
src/lib/ai/coach/__tests__/snapshot.test.ts | 1 +
.../compliance-block-pre-creation.test.ts | 1 +
src/lib/ai/coach/snapshot.ts | 4 +-
.../__tests__/compliance-courses.test.ts | 3 +
.../__tests__/compliance-skip-parity.test.ts | 1 +
.../__tests__/compliance-bundle.test.ts | 49 +++++++--
.../__tests__/compliance-pause-era.test.ts | 2 +
.../__tests__/compliance.property.test.ts | 1 +
.../compliance/__tests__/compliance.test.ts | 9 ++
src/lib/analytics/compliance/adapters.ts | 1 +
src/lib/analytics/compliance/types.ts | 13 ++-
src/lib/dashboard/meds-today.ts | 4 +-
src/lib/doctor-report-types.ts | 4 +-
src/lib/doctor-report/collect.ts | 9 +-
.../__tests__/full-backup-payload.test.ts | 6 ++
src/lib/export/medications-backup-schema.ts | 3 +
src/lib/export/medications-backup.ts | 4 +
src/lib/export/medications-restore.ts | 9 +-
.../adherence-storyline-engine-parity.test.ts | 14 +++
.../insights/derived/adherence-storyline.ts | 2 +
.../jobs/__tests__/intake-auto-take.test.ts | 1 +
src/lib/jobs/intake-auto-take.ts | 6 +-
src/lib/mcp/tools/get-medication-schedule.ts | 4 +-
.../__tests__/auto-mark-taken.test.ts | 25 +++++
.../compliance-payload-heatmap.test.ts | 1 +
.../compliance-payload-pre-creation.test.ts | 1 +
.../__tests__/course-window.test.ts | 88 +++++++++++++--
.../__tests__/intake-actionable.test.ts | 50 ++++++++-
.../__tests__/intake-tracking.test.ts | 1 +
src/lib/medications/auto-mark-taken.ts | 13 ++-
src/lib/medications/auto-take.ts | 4 +-
src/lib/medications/compliance-payload.ts | 2 +
src/lib/medications/course-window.ts | 101 ++++++++++++++++--
src/lib/medications/courses.ts | 85 +++++++++++----
src/lib/medications/intake-actionable.ts | 6 +-
src/lib/medications/intake-slot-dedup.ts | 13 +--
src/lib/medications/list-read.ts | 4 +-
src/lib/medications/read-medication.ts | 4 +-
.../__tests__/attribute-intake.test.ts | 1 +
.../scheduling/__tests__/next-due.test.ts | 1 +
.../scheduling/__tests__/recurrence.test.ts | 68 ++++++++++++
.../__tests__/resolve-slot-instant.test.ts | 1 +
.../rolling-taken-dose-not-redue.test.ts | 1 +
.../__tests__/schedule-eras.test.ts | 2 +
src/lib/medications/scheduling/cadence.ts | 22 ++--
.../scheduling/project-today-intakes.ts | 7 +-
src/lib/medications/scheduling/recurrence.ts | 52 ++++-----
src/lib/medications/scheduling/slot-upsert.ts | 8 +-
.../medications/scheduling/worker-helpers.ts | 3 +
src/lib/openapi/routes/medications/schemas.ts | 21 +++-
src/lib/targets/types.ts | 4 +-
.../export-disaster-recovery.json | 28 ++---
.../backup-parity/export-portable.json | 16 +--
.../restore-disaster-recovery.json | 29 ++---
.../backup-parity/restore-portable.json | 17 +--
.../backup-parity/restore-skips.json | 1 +
.../backup-parity/restore-version-1.json | 4 +
.../backup-parity/seeded-account.json | 15 +--
tests/integration/backup-fixture.ts | 6 +-
.../intake-late-take-future-slot.test.ts | 1 +
.../medication-auto-mark-taken.test.ts | 2 +
68 files changed, 813 insertions(+), 180 deletions(-)
create mode 100644 prisma/migrations/0482_medication_course_ended_at/migration.sql
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b92152575..061a59857 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -42034,6 +42034,11 @@ components:
format: date
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
- type: "null"
+ endedAt:
+ anyOf:
+ - type: string
+ minLength: 1
+ - type: "null"
note:
anyOf:
- type: string
@@ -53787,9 +53792,10 @@ components:
type: boolean
description: "v1.42.2 — whether the server sends dose reminders for this medication: true only when
`notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or
- later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and
- `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local
- reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored."
+ later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`),
+ it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A
+ client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed,
+ not stored."
nextDueAt:
anyOf:
- type: string
@@ -53813,6 +53819,15 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
+ endedAt:
+ description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
+ on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
+ Read-only — written by the course writer only."
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
oneShot:
type: boolean
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
@@ -53910,7 +53925,8 @@ components:
- ENDED
description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after
`endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all
- day. Read-only — computed, not stored."
+ day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
+ computed, not stored."
intakeActionable:
type: boolean
description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is
@@ -54037,13 +54053,23 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$
- type: "null"
description: Last day, inclusive. NULL = open (only the latest course).
+ endedAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ description: '#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is
+ due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an
+ end date set in the editor). Any edit that moves `endsOn` clears it.'
status:
type: string
enum:
- UPCOMING
- CURRENT
- ENDED
- description: Where today, on the person's clock, sits in this course.
+ description: Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn`
+ day already.
takenDoses:
type: integer
minimum: 0
@@ -54058,6 +54084,7 @@ components:
- id
- startsOn
- endsOn
+ - endedAt
- status
- takenDoses
- note
@@ -54244,6 +54271,15 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
+ endedAt:
+ description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
+ on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
+ Read-only — written by the course writer only."
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
oneShot:
type: boolean
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
@@ -54336,7 +54372,8 @@ components:
- ENDED
description: "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after
`endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all
- day. Read-only — computed, not stored."
+ day, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
+ computed, not stored."
intakeActionable:
type: boolean
description: "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is
@@ -93793,6 +93830,15 @@ components:
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.
+ endedAt:
+ description: "#1280 — the instant the latest course was ended with \"End course\" (the course's own `endedAt`). From it
+ on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`.
+ Read-only — written by the course writer only."
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
oneShot:
type: boolean
description: v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` /
diff --git a/prisma/migrations/0482_medication_course_ended_at/migration.sql b/prisma/migrations/0482_medication_course_ended_at/migration.sql
new file mode 100644
index 000000000..0b7385f57
--- /dev/null
+++ b/prisma/migrations/0482_medication_course_ended_at/migration.sql
@@ -0,0 +1,29 @@
+-- "End course" ends a course at the moment it is pressed (#1280).
+--
+-- A course's `ends_on` is a calendar day and counts in full: the doses of
+-- that day stay due, reminded and expected until local midnight. That is the
+-- right reading for an end date written in the course editor ("the last day
+-- of the pack"), and the wrong one for "End course", whose dialog promises
+-- that nothing more is due. `ended_at` records the instant the course was
+-- ended that way: it lies on the `ends_on` day, a dose slot at or after it
+-- is not part of the course, and the course reads as ended from then on.
+-- NULL keeps the full-day reading every existing course has.
+--
+-- `medications.ended_at` is the projection of the latest course's value,
+-- written next to `starts_on` / `ends_on` by the one course writer, so the
+-- engines that read the medication's window read it too.
+--
+-- Nullable, no default, no backfill: no existing course was ended this way.
+-- Reversibility: dropping both columns restores the full-day reading.
+--
+-- @retry-on-lock-timeout
+
+DO $migration$
+BEGIN
+ PERFORM set_config('lock_timeout', '5s', true);
+ ALTER TABLE "medication_courses"
+ ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6);
+ ALTER TABLE "medications"
+ ADD COLUMN IF NOT EXISTS "ended_at" TIMESTAMPTZ(6);
+END
+$migration$;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b5ecaa161..a7399eef4 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2903,6 +2903,12 @@ model Medication {
/// minting slots past this date. NULL = no end date (chronic).
/// When `oneShot` is true, `endsOn` always equals `startsOn`.
endsOn DateTime? @map("ends_on") @db.Date
+ /// #1280 — the instant the latest course was ended with "End course", the
+ /// projection of `MedicationCourse.endedAt` written next to `startsOn` /
+ /// `endsOn` by `src/lib/medications/courses.ts` only. On the `endsOn` day;
+ /// no dose slot at or after it is due, reminded or expected, and the course
+ /// reads as ended from it on. NULL = the course runs to the end of `endsOn`.
+ endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
/// v1.5 — single-administration medication (flu shot, vaccine,
/// post-op single dose, etc.). When true the medication has at
/// most one MedicationIntakeEvent and `active` flips to false once
@@ -3157,6 +3163,13 @@ model MedicationCourse {
startsOn DateTime @map("starts_on") @db.Date
/// Last day, inclusive. NULL = open (only the latest course may be open).
endsOn DateTime? @map("ends_on") @db.Date
+ /// #1280 — set by "End course": the instant the course ended, on the
+ /// `endsOn` day. A dose slot at or after it is not part of the course (not
+ /// due, not reminded, not expected by compliance), so the course ends when
+ /// the person ends it rather than at local midnight. NULL = the whole
+ /// `endsOn` day counts, the reading of an end date set in the editor. Any
+ /// later write that moves `endsOn` clears it.
+ endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
/// Why this course, in the person's words. AES-256-GCM at rest.
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
diff --git a/src/__tests__/recurrence-course-anchor-guard.test.ts b/src/__tests__/recurrence-course-anchor-guard.test.ts
index c7e9fd1ca..6ed1d14b8 100644
--- a/src/__tests__/recurrence-course-anchor-guard.test.ts
+++ b/src/__tests__/recurrence-course-anchor-guard.test.ts
@@ -15,6 +15,12 @@
* which it is. The scan asserts a floor on what it found, so a matcher that
* stopped matching fails instead of agreeing with an empty tree.
*
+ * #1280 — the same list carries the instant "End course" ended a course:
+ * every medication builder threads `endedAt` into the context, or the
+ * engine would keep a dose due after the course was ended. A builder passes
+ * when its block names `endedAt` or spreads a window that carries it
+ * (`...window` from `mintWindow`, `...medicationWindow(row)`).
+ *
* Limit, stated: a literal passed straight to the engine without the
* `RecurrenceContext` annotation is invisible here. Every medication path
* today goes through one of the annotated builders below.
@@ -69,19 +75,22 @@ function blockAt(text: string, open: number): string {
interface Builder {
file: string;
declaresCourseStart: boolean;
+ threadsEndedAt: boolean;
}
+const THREADS_ENDED_AT = /\bendedAt\b|\.\.\.\s*(window\b|medicationWindow\()/;
+
function builders(): Builder[] {
const out: Builder[] = [];
for (const file of sourceFiles(SRC)) {
const text = readSourceText(file);
for (const m of text.matchAll(DECLARATION)) {
const open = m.index! + m[0].length - 1;
+ const block = blockAt(text, open);
out.push({
file: relative(ROOT, file),
- declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(
- blockAt(text, open),
- ),
+ declaresCourseStart: /rollingAnchor\s*:\s*"courseStart"/.test(block),
+ threadsEndedAt: THREADS_ENDED_AT.test(block),
});
}
}
@@ -114,6 +123,18 @@ describe("the rolling course anchor is opt-in", () => {
expect(missing).toEqual([]);
});
+ it("has every medication builder thread the instant a course was ended", () => {
+ const medication = builders().filter((b) =>
+ MEDICATION_BUILDERS.includes(b.file),
+ );
+ expect(medication.length).toBeGreaterThanOrEqual(3);
+ expect(medication.filter((b) => !b.threadsEndedAt)).toEqual([]);
+ // The matcher, held against what it must and must not accept.
+ expect(THREADS_ENDED_AT.test("endedAt: row.endedAt,")).toBe(true);
+ expect(THREADS_ENDED_AT.test("...medicationWindow(row),")).toBe(true);
+ expect(THREADS_ENDED_AT.test("endsOn: row.endsOn,")).toBe(false);
+ });
+
it("keeps every other builder on the default (the last intake anchors)", () => {
const declaring = builders().filter(
(b) => NON_MEDICATION_BUILDERS.includes(b.file) && b.declaresCourseStart,
diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts
index 598ea1108..edd870974 100644
--- a/src/__tests__/tz-day-paths-harness.test.ts
+++ b/src/__tests__/tz-day-paths-harness.test.ts
@@ -155,6 +155,7 @@ describe.each(EDGES)("day paths with the host in $zone", (edge) => {
startsOn: null,
// `endsOn` is a @db.Date column: the calendar date as UTC midnight.
endsOn: dayKeyAsUtcMidnight(edge.localDay),
+ endedAt: null,
},
edge.now,
edge.zone,
diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts
index 61b323b4d..900a7cded 100644
--- a/src/app/api/medications/[id]/dose-history/route.ts
+++ b/src/app/api/medications/[id]/dose-history/route.ts
@@ -234,7 +234,7 @@ export const GET = apiHandler(
createdAt: medication.createdAt,
};
const ctx = buildRecurrenceContext({
- medication: bandMedication,
+ medication: { ...bandMedication, endedAt: medication.endedAt },
userTz,
lastIntakeAt,
});
diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts
index 47bb06580..2d656fd0a 100644
--- a/src/lib/__tests__/doctor-report-data.test.ts
+++ b/src/lib/__tests__/doctor-report-data.test.ts
@@ -317,6 +317,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
trackIntake: true,
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
schedules: [dailySchedule()],
@@ -354,6 +355,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
trackIntake: true,
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
schedules: [dailySchedule()],
@@ -429,6 +431,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
trackIntake: true,
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
schedules: [dailySchedule()],
@@ -460,6 +463,7 @@ describe("buildLedgerCompliance — doctor-report adherence via the dose ledger"
trackIntake: false,
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
// The schedule stays on record; it must not produce a rate.
diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts
index 561a2fe47..0067ddfc8 100644
--- a/src/lib/ai/coach/__tests__/snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot.test.ts
@@ -706,6 +706,7 @@ describe("buildCoachSnapshot", () => {
name: "Testdrug",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt,
schedules: [schedule],
diff --git a/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts
index cf920a749..7650a2c3f 100644
--- a/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts
+++ b/src/lib/ai/coach/snapshot-blocks/__tests__/compliance-block-pre-creation.test.ts
@@ -37,6 +37,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
{
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: d1(16, 5),
schedules: [
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index 2bc7a4abc..b572218a7 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -17,6 +17,7 @@
*/
import { loadFreeWeekdaySpansForPrompt } from "@/lib/profile/life-circumstances";
import { prisma } from "@/lib/db";
+import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
import { extractFeatures } from "@/lib/insights/features";
import type { CoachDataCluster } from "@/lib/validations/coach-prefs";
import type { UnitPreferences } from "@/lib/measurements/display-transform";
@@ -511,8 +512,7 @@ async function buildCoachSnapshotImpl(
where: { userId, asNeeded: false, ...TRACKED_INTAKE_WHERE },
select: {
id: true,
- startsOn: true,
- endsOn: true,
+ ...MEDICATION_WINDOW_SELECT,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
diff --git a/src/lib/analytics/__tests__/compliance-courses.test.ts b/src/lib/analytics/__tests__/compliance-courses.test.ts
index 16602da2f..081911517 100644
--- a/src/lib/analytics/__tests__/compliance-courses.test.ts
+++ b/src/lib/analytics/__tests__/compliance-courses.test.ts
@@ -58,6 +58,7 @@ const MEDICATION = {
// The row projects the latest course.
startsOn: COURSES[1].startsOn,
endsOn: COURSES[1].endsOn,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-03-15T10:00:00.000Z"),
};
@@ -124,6 +125,7 @@ describe("the streak across two courses (v1.40, #1024)", () => {
const A = {
startsOn: dateOfDayKey("2026-06-01"),
endsOn: dateOfDayKey("2026-06-10"),
+ endedAt: null,
};
const B = { startsOn: dateOfDayKey("2026-06-13"), endsOn: null };
const NOW_B = new Date("2026-06-27T18:00:00.000Z");
@@ -141,6 +143,7 @@ describe("the streak across two courses (v1.40, #1024)", () => {
{
startsOn: B.startsOn,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-05-20T10:00:00.000Z"),
courses: [A, B],
diff --git a/src/lib/analytics/__tests__/compliance-skip-parity.test.ts b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts
index 21a9c57de..c7e6e9678 100644
--- a/src/lib/analytics/__tests__/compliance-skip-parity.test.ts
+++ b/src/lib/analytics/__tests__/compliance-skip-parity.test.ts
@@ -61,6 +61,7 @@ const medication = {
active: true,
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
schedules: [twiceDaily],
diff --git a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts
index 49b82e255..6a54235c7 100644
--- a/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts
+++ b/src/lib/analytics/compliance/__tests__/compliance-bundle.test.ts
@@ -136,7 +136,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
lastNonSkippedTakenAt(events),
TZ,
);
@@ -191,6 +197,7 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
{
startsOn: new Date(NOW.getTime() - 380 * DAY_MS),
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt,
},
@@ -223,7 +230,13 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi
it("empty schedule list: zeros short-circuit matches calculateCompliance", () => {
const createdAt = new Date(NOW.getTime() - 10 * DAY_MS);
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
null,
TZ,
);
@@ -250,7 +263,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
null,
TZ,
);
@@ -300,7 +319,13 @@ describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () =
const createdAt = new Date(NOW.getTime() - 60 * DAY_MS);
const schedules = [weeklyMondaySchedule()];
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
null,
TZ,
);
@@ -383,7 +408,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
lastNonSkippedTakenAt(events),
TZ,
);
@@ -407,7 +438,13 @@ describe("tallyLedgerRows — windowed tallies over one shared ledger", () => {
const schedules = [dailySchedule()];
const events = dailyFixtureEvents();
const ctx = buildComplianceMedicationContext(
- { startsOn: null, endsOn: null, oneShot: false, createdAt },
+ {
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ oneShot: false,
+ createdAt,
+ },
lastNonSkippedTakenAt(events),
TZ,
);
diff --git a/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts
index 6cc5cff8c..18e8e6000 100644
--- a/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts
+++ b/src/lib/analytics/compliance/__tests__/compliance-pause-era.test.ts
@@ -78,6 +78,7 @@ describe("H-MED1 — paused days drop out of the compliance denominator", () =>
const medBase = {
startsOn: createdAt,
endsOn,
+ endedAt: null,
oneShot: false,
createdAt,
};
@@ -209,6 +210,7 @@ describe("M-MED3 — the compliance rate honours archived schedule eras", () =>
const medBase = {
startsOn: createdAt,
endsOn,
+ endedAt: null,
oneShot: false,
createdAt,
};
diff --git a/src/lib/analytics/compliance/__tests__/compliance.property.test.ts b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts
index f4152968e..d018c01ec 100644
--- a/src/lib/analytics/compliance/__tests__/compliance.property.test.ts
+++ b/src/lib/analytics/compliance/__tests__/compliance.property.test.ts
@@ -55,6 +55,7 @@ function context(tz: string) {
{
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2024-06-01T00:00:00Z"),
},
diff --git a/src/lib/analytics/compliance/__tests__/compliance.test.ts b/src/lib/analytics/compliance/__tests__/compliance.test.ts
index 8ca4f82c6..1f6a49ddf 100644
--- a/src/lib/analytics/compliance/__tests__/compliance.test.ts
+++ b/src/lib/analytics/compliance/__tests__/compliance.test.ts
@@ -417,6 +417,7 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
return {
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2024-12-01T00:00:00Z"),
lastIntakeAt: null,
@@ -1002,6 +1003,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
+ endedAt: null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2024-12-01T00:00:00Z"),
},
@@ -1121,6 +1123,7 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
{
startsOn: medCtx.startsOn,
endsOn: medCtx.endsOn,
+ endedAt: null,
oneShot: medCtx.oneShot,
createdAt: medCtx.createdAt,
lastIntakeAt: medCtx.lastIntakeAt,
@@ -1466,6 +1469,7 @@ describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => {
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
+ endedAt: null,
oneShot: overrides.oneShot ?? false,
// Created well before the windows so no day is excluded.
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
@@ -2102,6 +2106,7 @@ describe("expectedSlotsBetween", () => {
{
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
+ endedAt: null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2025-01-01T00:00:00Z"),
},
@@ -2317,6 +2322,7 @@ describe("calculateCompliance — autoMissed forgotten doses (BUG #2 / #3)", ()
{
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2025-01-08T00:00:00Z"),
},
@@ -2370,6 +2376,7 @@ describe("calculateCompliance — irregular twice-daily taker (BUG 3 verify)", (
{
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
// Created well before the window so age never clamps the denominator.
createdAt: new Date("2025-01-01T00:00:00Z"),
@@ -2717,6 +2724,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
return {
startsOn: new Date("2026-06-01T00:00:00Z"),
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-06-01T00:00:00Z"),
lastIntakeAt: null,
@@ -3048,6 +3056,7 @@ describe("tallyComplianceFromLedger — the unified % keystone", () => {
id: "x",
startsOn: ctxFor().startsOn,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: ctxFor().createdAt,
},
diff --git a/src/lib/analytics/compliance/adapters.ts b/src/lib/analytics/compliance/adapters.ts
index 5d0aa907c..ecd8e9938 100644
--- a/src/lib/analytics/compliance/adapters.ts
+++ b/src/lib/analytics/compliance/adapters.ts
@@ -65,6 +65,7 @@ export function toRecurrenceCtx(
id: idTag,
startsOn: window.startsOn,
endsOn: window.endsOn,
+ endedAt: ctx.endedAt ?? null,
oneShot: ctx.oneShot,
createdAt: ctx.createdAt,
},
diff --git a/src/lib/analytics/compliance/types.ts b/src/lib/analytics/compliance/types.ts
index f4b298447..622a6996a 100644
--- a/src/lib/analytics/compliance/types.ts
+++ b/src/lib/analytics/compliance/types.ts
@@ -144,7 +144,7 @@ export const SCHEDULE_COMPLIANCE_SELECT = {
* sibling of {@link SCHEDULE_COMPLIANCE_SELECT}.
*/
export const COURSES_COMPLIANCE_SELECT = {
- select: { startsOn: true, endsOn: true },
+ select: { startsOn: true, endsOn: true, endedAt: true },
orderBy: { startsOn: "asc" },
} as const;
@@ -158,6 +158,13 @@ export const COURSES_COMPLIANCE_SELECT = {
export interface ComplianceMedicationContext {
startsOn: Date | null;
endsOn: Date | null;
+ /**
+ * #1280 — the instant "End course" ended the latest course: no slot at or
+ * after it is expected. Earlier courses carry their own in `courses`.
+ * Every production context comes from `buildComplianceMedicationContext`,
+ * whose input requires it.
+ */
+ endedAt?: Date | null;
oneShot: boolean;
createdAt: Date;
lastIntakeAt: Date | null;
@@ -222,7 +229,7 @@ export function slotInsideCourses(
): boolean {
const courses = ctx.courses;
if (!courses || courses.length < 2) return true;
- return isInsideCourses(userDayKey(at, ctx.timeZone), courses);
+ return isInsideCourses(userDayKey(at, ctx.timeZone), courses, at);
}
/**
@@ -245,6 +252,7 @@ export function buildComplianceMedicationContext(
med: {
startsOn: Date | null;
endsOn: Date | null;
+ endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
/** v1.16.3 — thread the archived eras when the bundle carries them. */
@@ -260,6 +268,7 @@ export function buildComplianceMedicationContext(
return {
startsOn: med.startsOn,
endsOn: med.endsOn,
+ endedAt: med.endedAt,
oneShot: med.oneShot,
createdAt: med.createdAt,
lastIntakeAt,
diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts
index f8b9d54af..9a444e09c 100644
--- a/src/lib/dashboard/meds-today.ts
+++ b/src/lib/dashboard/meds-today.ts
@@ -35,6 +35,7 @@
* summary, never an overdue state, until a fresh block arrives.
*/
import type { PrismaClient } from "@/generated/prisma/client";
+import { medicationWindow } from "@/lib/medications/course-window";
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import {
@@ -220,8 +221,7 @@ export async function buildMedsTodayBlock(
const display = computeDisplayDue({
medication: {
id: m.id,
- startsOn: m.startsOn,
- endsOn: m.endsOn,
+ ...medicationWindow(m),
oneShot: m.oneShot,
createdAt: m.createdAt,
},
diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts
index 22e8a5aab..ec7c151c4 100644
--- a/src/lib/doctor-report-types.ts
+++ b/src/lib/doctor-report-types.ts
@@ -615,6 +615,8 @@ export interface DoctorReportComplianceMedication {
trackIntake: boolean;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — the instant "End course" ended the latest course. */
+ endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
schedules: ComplianceSchedule[];
@@ -622,7 +624,7 @@ export interface DoctorReportComplianceMedication {
/** v1.25 — pause eras so paused days drop out of the denominator. */
pauseEras?: MedicationPauseEraLike[];
/** v1.40 (#1024) — the courses, so a gap between two expects nothing. */
- courses?: { startsOn: Date; endsOn: Date | null }[];
+ courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[];
}
/** A window-bounded intake row keyed to its medication. */
diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts
index eb19713ef..79b431564 100644
--- a/src/lib/doctor-report/collect.ts
+++ b/src/lib/doctor-report/collect.ts
@@ -15,7 +15,11 @@
*
* Pure data assembly — no auth, no rate limit, no audit. Idempotent.
*/
-import { courseStatusOn, dayKeyOfDate } from "@/lib/medications/course-window";
+import {
+ courseStatusOn,
+ dayKeyOfDate,
+ medicationWindow,
+} from "@/lib/medications/course-window";
import { prisma } from "@/lib/db";
import {
getEffectiveRange,
@@ -484,8 +488,7 @@ export async function collectDoctorReportData(
name: m.name,
asNeeded: m.asNeeded,
trackIntake: m.trackIntake,
- startsOn: m.startsOn,
- endsOn: m.endsOn,
+ ...medicationWindow(m),
oneShot: m.oneShot,
createdAt: m.createdAt,
schedules: m.schedules,
diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts
index f0734dfb7..fe9b5f872 100644
--- a/src/lib/export/__tests__/full-backup-payload.test.ts
+++ b/src/lib/export/__tests__/full-backup-payload.test.ts
@@ -210,6 +210,8 @@ function makePrisma() {
id: "course-march",
startsOn: new Date("2026-03-01T00:00:00.000Z"),
endsOn: new Date("2026-03-07T00:00:00.000Z"),
+ // #1280 — ended with "End course" after the morning dose.
+ endedAt: new Date("2026-03-07T09:30:00.000Z"),
noteEncrypted: encryptToBytes("the March flu"),
createdAt: new Date("2026-03-01T08:00:00.000Z"),
updatedAt: new Date("2026-03-01T08:05:00.000Z"),
@@ -218,6 +220,7 @@ function makePrisma() {
id: "course-june",
startsOn: new Date("2026-06-10T00:00:00.000Z"),
endsOn: null,
+ endedAt: null,
noteEncrypted: null,
createdAt: new Date("2026-06-10T08:00:00.000Z"),
updatedAt: new Date("2026-06-10T08:05:00.000Z"),
@@ -806,12 +809,14 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
{
startsOn: "2026-03-01",
endsOn: "2026-03-07",
+ endedAt: "2026-03-07T09:30:00.000Z",
note: "the March flu",
createdAt: "2026-03-01T08:00:00.000Z",
},
{
startsOn: "2026-06-10",
endsOn: null,
+ endedAt: null,
note: null,
createdAt: "2026-06-10T08:00:00.000Z",
},
@@ -823,6 +828,7 @@ describe("buildFullBackupPayload — medication courses (v1.40, #1024)", () => {
expect(march).toMatchObject({
startsOn: "2026-03-01",
endsOn: "2026-03-07",
+ endedAt: "2026-03-07T09:30:00.000Z",
note: null,
createdAt: "2026-03-01T08:00:00.000Z",
updatedAt: "2026-03-01T08:05:00.000Z",
diff --git a/src/lib/export/medications-backup-schema.ts b/src/lib/export/medications-backup-schema.ts
index 4b43396fd..632d729c3 100644
--- a/src/lib/export/medications-backup-schema.ts
+++ b/src/lib/export/medications-backup-schema.ts
@@ -123,6 +123,9 @@ export const medicationCourseSchema = z
.object({
startsOn: z.iso.date(),
endsOn: z.iso.date().nullable(),
+ // #1280 — the instant "End course" ended the course, on its `endsOn`
+ // day. A file without it (any written before) restores the full-day end.
+ endedAt: isoDateTime.nullable().optional(),
note: z.string().max(280).nullable().optional(),
// The note's ciphertext, disaster-recovery files only. A file without it
// (any written before) carries the readable note, which the restore seals.
diff --git a/src/lib/export/medications-backup.ts b/src/lib/export/medications-backup.ts
index 34b8f98eb..be78b9be1 100644
--- a/src/lib/export/medications-backup.ts
+++ b/src/lib/export/medications-backup.ts
@@ -228,6 +228,10 @@ function serialiseMedicationHistory(
courses: m.courses.map((c) => ({
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
+ // #1280 — when "End course" ended it. Both purposes: without it the
+ // restored course runs to midnight of its last day and expects the
+ // doses the person had stopped.
+ endedAt: c.endedAt ? c.endedAt.toISOString() : null,
...(disasterRecovery
? {
note: null,
diff --git a/src/lib/export/medications-restore.ts b/src/lib/export/medications-restore.ts
index d71a2505f..80e1c72b3 100644
--- a/src/lib/export/medications-restore.ts
+++ b/src/lib/export/medications-restore.ts
@@ -349,6 +349,7 @@ async function restoreCourses(
const courseRows: Array<{
startsOn: Date;
endsOn: Date | null;
+ endedAt?: Date | null;
note: string | null;
noteEncrypted?: string | null;
createdAt?: Date;
@@ -358,6 +359,7 @@ async function restoreCourses(
? m.courses.map((c) => ({
startsOn: dateOfDayKey(c.startsOn),
endsOn: c.endsOn ? dateOfDayKey(c.endsOn) : null,
+ endedAt: c.endedAt ? new Date(c.endedAt) : null,
note: c.note ?? null,
...(c.noteEncrypted !== undefined
? { noteEncrypted: c.noteEncrypted }
@@ -380,6 +382,7 @@ async function restoreCourses(
userId: ownerId,
startsOn: c.startsOn,
endsOn: c.endsOn,
+ endedAt: c.endedAt ?? null,
// Ciphertext verbatim from a disaster-recovery file; the readable note
// of a portable or older file sealed here.
noteEncrypted:
@@ -397,7 +400,11 @@ async function restoreCourses(
const window = projectCourseWindow(courseRows);
await tx.medication.update({
where: { id: created.id },
- data: { startsOn: window.startsOn, endsOn: window.endsOn },
+ data: {
+ startsOn: window.startsOn,
+ endsOn: window.endsOn,
+ endedAt: window.endedAt,
+ },
});
}
diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
index cf100babc..c82ea1bf4 100644
--- a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
+++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts
@@ -210,4 +210,18 @@ describe("adherence storyline course window", () => {
);
expect(where.OR[1].endsOn.gte.toISOString()).toBe(lastDay.toISOString());
});
+
+ it("leaves out a course ended with End course on its last day (#1280)", async () => {
+ await buildAdherenceStoryline(
+ "user-1",
+ "UTC",
+ new Date("2026-06-21T12:00:00.000Z"),
+ );
+ // The candidate read is the one that bounds the window by `endsOn`.
+ const where = mocks.medicationFindMany.mock.calls
+ .map(([args]) => (args as { where?: Record }).where)
+ .reverse()
+ .find((w) => w && "OR" in w);
+ expect(where).toHaveProperty("endedAt", null);
+ });
});
diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts
index 637e6f746..7dd40f298 100644
--- a/src/lib/insights/derived/adherence-storyline.ts
+++ b/src/lib/insights/derived/adherence-storyline.ts
@@ -184,6 +184,8 @@ export async function buildAdherenceStoryline(
{ endsOn: null },
{ endsOn: { gte: dayKeyAsUtcMidnight(todayKey) } },
],
+ // #1280 — a course ended with "End course" is over from that instant.
+ endedAt: null,
},
select: { name: true, treatmentClass: true },
});
diff --git a/src/lib/jobs/__tests__/intake-auto-take.test.ts b/src/lib/jobs/__tests__/intake-auto-take.test.ts
index bee2b010c..9fdc31a95 100644
--- a/src/lib/jobs/__tests__/intake-auto-take.test.ts
+++ b/src/lib/jobs/__tests__/intake-auto-take.test.ts
@@ -108,6 +108,7 @@ function medication(
userId: overrides.userId ?? "u1",
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
+ endedAt: null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2026-01-01T00:00:00Z"),
autoMarkTakenSince:
diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts
index 2b7c7cc9f..1e6998558 100644
--- a/src/lib/jobs/intake-auto-take.ts
+++ b/src/lib/jobs/intake-auto-take.ts
@@ -53,6 +53,7 @@
* owns alone, so the two never write the same slot.
*/
import type { PrismaClient } from "@/generated/prisma/client";
+import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
@@ -154,6 +155,8 @@ interface AutoTakeMedication {
userId: string;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — no slot at or after the instant the course was ended is filled. */
+ endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
autoMarkTakenSince: Date | null;
@@ -262,8 +265,7 @@ export async function runIntakeAutoTakePass(
select: {
id: true,
userId: true,
- startsOn: true,
- endsOn: true,
+ ...MEDICATION_WINDOW_SELECT,
oneShot: true,
createdAt: true,
autoMarkTakenSince: true,
diff --git a/src/lib/mcp/tools/get-medication-schedule.ts b/src/lib/mcp/tools/get-medication-schedule.ts
index c69a3a525..51d2bb535 100644
--- a/src/lib/mcp/tools/get-medication-schedule.ts
+++ b/src/lib/mcp/tools/get-medication-schedule.ts
@@ -1,4 +1,5 @@
import { z } from "zod/v4";
+import { medicationWindow } from "@/lib/medications/course-window";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -152,8 +153,7 @@ export const getMedicationScheduleTool: McpToolDefinition = {
const display = computeDisplayDue({
medication: {
id: m.id,
- startsOn: m.startsOn,
- endsOn: m.endsOn,
+ ...medicationWindow(m),
oneShot: m.oneShot,
createdAt: m.createdAt,
},
diff --git a/src/lib/medications/__tests__/auto-mark-taken.test.ts b/src/lib/medications/__tests__/auto-mark-taken.test.ts
index 7c072ee97..433d4ae18 100644
--- a/src/lib/medications/__tests__/auto-mark-taken.test.ts
+++ b/src/lib/medications/__tests__/auto-mark-taken.test.ts
@@ -25,6 +25,7 @@ describe("sendsMedicationReminders", () => {
trackIntake: (bits & 8) !== 0,
asNeeded: (bits & 16) !== 0,
endsOn: ended ? new Date("2026-06-09T00:00:00.000Z") : null,
+ endedAt: null,
};
expect(sendsMedicationReminders(med, at), JSON.stringify(med)).toBe(
med.notificationsEnabled &&
@@ -44,6 +45,7 @@ describe("sendsMedicationReminders", () => {
active: true,
trackIntake: true,
asNeeded: false,
+ endedAt: null,
};
expect(
sendsMedicationReminders(
@@ -66,6 +68,29 @@ describe("sendsMedicationReminders", () => {
).toBe(false);
});
+ it("stops at the instant End course ended the course (#1280)", () => {
+ const med = {
+ notificationsEnabled: true,
+ autoMarkTaken: false,
+ active: true,
+ trackIntake: true,
+ asNeeded: false,
+ endsOn: new Date("2026-06-10T00:00:00.000Z"),
+ endedAt: new Date("2026-06-10T09:00:00.000Z"),
+ };
+ // 10:00Z is after the instant: no reminder for the rest of the day,
+ // follow-ups of an earlier dose included.
+ expect(sendsMedicationReminders(med, at)).toBe(false);
+ expect(
+ sendsMedicationReminders(med, {
+ ...at,
+ now: new Date("2026-06-10T08:59:00.000Z"),
+ }),
+ ).toBe(true);
+ // The same last day without the instant reminds all day.
+ expect(sendsMedicationReminders({ ...med, endedAt: null }, at)).toBe(true);
+ });
+
it("is the decision at every gate and on every published DTO", () => {
const src = (rel: string) =>
readFileSync(join(process.cwd(), "src", rel), "utf8");
diff --git a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts
index 1039e91a5..1d6503741 100644
--- a/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts
+++ b/src/lib/medications/__tests__/compliance-payload-heatmap.test.ts
@@ -55,6 +55,7 @@ it("counts an off-schedule row as taken only when it has a taken time", async ()
createdAt: new Date("2026-01-01T00:00:00Z"),
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
asNeeded: false,
trackIntake: true,
diff --git a/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts
index 3c44e767b..2dacdc3b6 100644
--- a/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts
+++ b/src/lib/medications/__tests__/compliance-payload-pre-creation.test.ts
@@ -67,6 +67,7 @@ for (const tz of ["Asia/Kolkata", "America/New_York"]) {
createdAt: d1(16, 5),
startsOn: new Date("2026-09-23T00:00:00Z"),
endsOn: new Date("2026-09-28T00:00:00Z"),
+ endedAt: null,
oneShot: false,
asNeeded: false,
trackIntake: true,
diff --git a/src/lib/medications/__tests__/course-window.test.ts b/src/lib/medications/__tests__/course-window.test.ts
index f85f7ce91..9f3c83e00 100644
--- a/src/lib/medications/__tests__/course-window.test.ts
+++ b/src/lib/medications/__tests__/course-window.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
canStartCourse,
courseFromWindow,
+ courseStatusAt,
courseStatusOn,
dateOfDayKey,
isInsideCourses,
@@ -26,11 +27,25 @@ describe("projectCourseWindow", () => {
expect(projectCourseWindow([JUNE, MARCH])).toEqual({
startsOn: d("2026-06-10"),
endsOn: d("2026-06-16"),
+ endedAt: null,
});
});
it("is empty with no course (chronic since creation)", () => {
- expect(projectCourseWindow([])).toEqual({ startsOn: null, endsOn: null });
+ expect(projectCourseWindow([])).toEqual({
+ startsOn: null,
+ endsOn: null,
+ endedAt: null,
+ });
+ });
+
+ it("carries the instant the latest course was ended", () => {
+ const endedAt = new Date("2026-06-16T10:00:00.000Z");
+ expect(projectCourseWindow([MARCH, { ...JUNE, endedAt }])).toEqual({
+ startsOn: d("2026-06-10"),
+ endsOn: d("2026-06-16"),
+ endedAt,
+ });
});
it.each([
@@ -116,17 +131,76 @@ describe("courseStatusOn", () => {
});
describe("isInsideCourses", () => {
+ /** A slot at noon UTC of a day, the instant the day key belongs to. */
+ const noon = (key: string) => new Date(`${key}T12:00:00.000Z`);
+ const inside = (key: string, courses: CourseSpan[]) =>
+ isInsideCourses(key, courses, noon(key));
+
it("admits the course days and not the gap", () => {
const courses = [MARCH, JUNE];
- expect(isInsideCourses("2026-03-01", courses)).toBe(true);
- expect(isInsideCourses("2026-03-07", courses)).toBe(true);
- expect(isInsideCourses("2026-03-08", courses)).toBe(false);
- expect(isInsideCourses("2026-06-16", courses)).toBe(true);
- expect(isInsideCourses("2026-06-17", courses)).toBe(false);
+ expect(inside("2026-03-01", courses)).toBe(true);
+ expect(inside("2026-03-07", courses)).toBe(true);
+ expect(inside("2026-03-08", courses)).toBe(false);
+ expect(inside("2026-06-16", courses)).toBe(true);
+ expect(inside("2026-06-17", courses)).toBe(false);
});
it("places no restriction without courses", () => {
- expect(isInsideCourses("2026-03-08", [])).toBe(true);
+ expect(inside("2026-03-08", [])).toBe(true);
+ });
+
+ it("stops an ended course at the instant it was ended (#1280)", () => {
+ // MARCH ended with "End course" at 10:00Z on its last day: the morning
+ // slot belongs to it, the evening one does not.
+ const ended = { ...MARCH, endedAt: new Date("2026-03-07T10:00:00.000Z") };
+ const courses = [ended, JUNE];
+ const day = "2026-03-07";
+ expect(
+ isInsideCourses(day, courses, new Date("2026-03-07T06:00:00.000Z")),
+ ).toBe(true);
+ expect(
+ isInsideCourses(day, courses, new Date("2026-03-07T10:00:00.000Z")),
+ ).toBe(false);
+ expect(
+ isInsideCourses(day, courses, new Date("2026-03-07T18:00:00.000Z")),
+ ).toBe(false);
+ // The days before the last one are untouched.
+ expect(inside("2026-03-06", courses)).toBe(true);
+ });
+});
+
+describe("ending a course now (#1280)", () => {
+ const ENDED_AT = new Date("2026-06-12T10:00:00.000Z");
+ const ended = { ...JUNE, endsOn: d("2026-06-12"), endedAt: ENDED_AT };
+
+ it("reads ENDED on the last day already, by day and by instant", () => {
+ expect(courseStatusOn(ended, "2026-06-12")).toBe("ENDED");
+ expect(courseStatusOn(ended, "2026-06-11")).toBe("CURRENT");
+ // The same end date set in the editor keeps the whole day.
+ expect(courseStatusOn({ ...ended, endedAt: null }, "2026-06-12")).toBe(
+ "CURRENT",
+ );
+ const at = (iso: string) => courseStatusAt(ended, new Date(iso), "UTC");
+ expect(at("2026-06-12T09:59:59.999Z")).toBe("CURRENT");
+ expect(at("2026-06-12T10:00:00.000Z")).toBe("ENDED");
+ expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED");
+ });
+
+ it("lets a new course start once the ended one is behind it", () => {
+ const base = { active: true, oneShot: false, todayKey: "2026-06-12" };
+ expect(canStartCourse({ ...base, courses: [MARCH, ended] })).toBe(true);
+ // The next course may start tomorrow, not on the ended course's day.
+ const next = course("2026-06-13", null);
+ expect(validateCourses([MARCH, ended, next], "2026-06-12", false)).toBe(
+ null,
+ );
+ expect(
+ validateCourses(
+ [MARCH, ended, course("2026-06-12", null)],
+ "2026-06-12",
+ false,
+ ),
+ ).toBe("overlap");
});
});
diff --git a/src/lib/medications/__tests__/intake-actionable.test.ts b/src/lib/medications/__tests__/intake-actionable.test.ts
index 93112e2f4..5c145d1d0 100644
--- a/src/lib/medications/__tests__/intake-actionable.test.ts
+++ b/src/lib/medications/__tests__/intake-actionable.test.ts
@@ -32,6 +32,7 @@ const base = {
trackIntake: true,
startsOn: day("2026-09-01"),
endsOn: day("2026-09-20"),
+ endedAt: null,
};
describe.each(ZONES)("course window in %s", (tz) => {
@@ -42,13 +43,19 @@ describe.each(ZONES)("course window in %s", (tz) => {
[23, 59],
] as const) {
const r = resolveIntakeActionability(base, at(tz, 20, h, m), tz);
- expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: true });
+ expect(r).toEqual({
+ courseStatus: "CURRENT",
+ intakeActionable: true,
+ });
}
});
it("is ended from local midnight of the day after endsOn", () => {
const r = resolveIntakeActionability(base, at(tz, 21, 0, 0), tz);
- expect(r).toEqual({ courseStatus: "ENDED", intakeActionable: false });
+ expect(r).toEqual({
+ courseStatus: "ENDED",
+ intakeActionable: false,
+ });
});
it("is upcoming until local midnight of startsOn", () => {
@@ -80,7 +87,10 @@ describe("resolveIntakeActionability", () => {
it("refuses a paused or archived medication inside its course", () => {
const r = resolveIntakeActionability({ ...base, active: false }, now, tz);
- expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
+ expect(r).toEqual({
+ courseStatus: "CURRENT",
+ intakeActionable: false,
+ });
});
it("refuses a record-only medication inside its course", () => {
@@ -89,7 +99,10 @@ describe("resolveIntakeActionability", () => {
now,
tz,
);
- expect(r).toEqual({ courseStatus: "CURRENT", intakeActionable: false });
+ expect(r).toEqual({
+ courseStatus: "CURRENT",
+ intakeActionable: false,
+ });
});
it("reports ENDED for a course that is also paused", () => {
@@ -101,3 +114,32 @@ describe("resolveIntakeActionability", () => {
expect(r.courseStatus).toBe("ENDED");
});
});
+
+describe("a course ended with End course (#1280)", () => {
+ const ended = {
+ ...base,
+ endsOn: day("2026-09-20"),
+ endedAt: new Date("2026-09-20T10:00:00.000Z"),
+ };
+
+ it("is running until the instant and ended from it, not from midnight", () => {
+ const before = resolveIntakeActionability(
+ ended,
+ new Date("2026-09-20T09:59:00.000Z"),
+ "UTC",
+ );
+ expect(before).toEqual({
+ courseStatus: "CURRENT",
+ intakeActionable: true,
+ });
+ const after = resolveIntakeActionability(
+ ended,
+ new Date("2026-09-20T10:00:00.000Z"),
+ "UTC",
+ );
+ expect(after).toEqual({
+ courseStatus: "ENDED",
+ intakeActionable: false,
+ });
+ });
+});
diff --git a/src/lib/medications/__tests__/intake-tracking.test.ts b/src/lib/medications/__tests__/intake-tracking.test.ts
index 518ba224f..643859e19 100644
--- a/src/lib/medications/__tests__/intake-tracking.test.ts
+++ b/src/lib/medications/__tests__/intake-tracking.test.ts
@@ -93,6 +93,7 @@ describe("buildCompliancePayload", () => {
createdAt: new Date("2026-01-01T00:00:00Z"),
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
asNeeded: false,
trackIntake: false,
diff --git a/src/lib/medications/auto-mark-taken.ts b/src/lib/medications/auto-mark-taken.ts
index b8644d5a5..8c8c3e355 100644
--- a/src/lib/medications/auto-mark-taken.ts
+++ b/src/lib/medications/auto-mark-taken.ts
@@ -12,10 +12,12 @@
* worker expands no slot past it), it has doses that fall due (intake
* tracked, not as-needed), and its doses are not recorded automatically
* (`autoMarkTaken`). A course that has not started yet still reminds once
- * it does. The person's own reminder preference stays stored untouched:
+ * it does. #1280 — a course ended with "End course" stops reminding at the
+ * instant it was ended (`endedAt`), including the follow-up nags of a dose
+ * that fell due before it. The person's own reminder preference stays stored untouched:
* switching auto-mark-taken off restores whatever they had chosen.
*/
-import { courseStatusAt } from "@/lib/medications/scheduling/recurrence";
+import { courseStatusAt } from "@/lib/medications/course-window";
export interface ReminderGateMedication {
notificationsEnabled: boolean;
@@ -24,6 +26,7 @@ export interface ReminderGateMedication {
trackIntake: boolean;
asNeeded: boolean;
endsOn: Date | null;
+ endedAt: Date | null;
}
export function sendsMedicationReminders(
@@ -37,7 +40,11 @@ export function sendsMedicationReminders(
medication.trackIntake &&
!medication.asNeeded &&
courseStatusAt(
- { startsOn: null, endsOn: medication.endsOn },
+ {
+ startsOn: null,
+ endsOn: medication.endsOn,
+ endedAt: medication.endedAt,
+ },
at.now,
at.timeZone,
) !== "ENDED"
diff --git a/src/lib/medications/auto-take.ts b/src/lib/medications/auto-take.ts
index 271f0b3b8..b8c12d3b3 100644
--- a/src/lib/medications/auto-take.ts
+++ b/src/lib/medications/auto-take.ts
@@ -14,6 +14,7 @@
* effective `lastIntakeAt`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
+import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import {
@@ -361,8 +362,7 @@ export async function loadAutoTakeRollingAnchors(
},
select: {
id: true,
- startsOn: true,
- endsOn: true,
+ ...MEDICATION_WINDOW_SELECT,
oneShot: true,
createdAt: true,
autoMarkTakenSince: true,
diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts
index 992e7825b..c7042e23b 100644
--- a/src/lib/medications/compliance-payload.ts
+++ b/src/lib/medications/compliance-payload.ts
@@ -92,6 +92,8 @@ export interface ComplianceMedicationInput {
createdAt: Date;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — the instant "End course" ended the latest course. */
+ endedAt: Date | null;
oneShot: boolean;
asNeeded: boolean;
/** v1.39.1 (#1033) — intake tracking off: no adherence, heatmap empty. */
diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts
index 97f3fc997..333feff9a 100644
--- a/src/lib/medications/course-window.ts
+++ b/src/lib/medications/course-window.ts
@@ -15,13 +15,27 @@
* projection written at the last course write stays correct as days pass. A
* write that would break the invariant is refused (`validateCourses`).
*
+ * #1280 — "End course" ends a course at an instant, `endedAt`, which lies on
+ * its `endsOn` day. A slot at or after it is not part of the course, and the
+ * course reads as ENDED from then on. An end date set in the editor carries
+ * no `endedAt` and keeps the full-day reading. The instant is what keeps
+ * compliance honest both ways: a dose taken earlier that day stays inside
+ * the course and counts, and a later slot of the same day is neither due nor
+ * missed.
+ *
* Pure: no database, no clock (callers pass `today`).
*/
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
+import { userDayKey } from "@/lib/tz/format";
export interface CourseSpan {
startsOn: Date;
endsOn: Date | null;
+ /**
+ * #1280 — the instant "End course" ended this course, on its `endsOn` day.
+ * Absent or null: the course runs to the end of `endsOn`.
+ */
+ endedAt?: Date | null;
}
export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED";
@@ -32,6 +46,26 @@ export const dayKeyOfDate = dateOnlyKey;
/** A `YYYY-MM-DD` key as the `@db.Date` value Prisma writes. */
export const dateOfDayKey = dayKeyAsUtcMidnight;
+/**
+ * The medication's window as the engines read it: the latest course's
+ * `startsOn` / `endsOn` and, since #1280, the instant "End course" ended it.
+ * One select and one picker, so a reader cannot take the dates and drop the
+ * instant.
+ */
+export const MEDICATION_WINDOW_SELECT = {
+ startsOn: true,
+ endsOn: true,
+ endedAt: true,
+} as const;
+
+export function medicationWindow(m: {
+ startsOn: Date | null;
+ endsOn: Date | null;
+ endedAt: Date | null;
+}): { startsOn: Date | null; endsOn: Date | null; endedAt: Date | null } {
+ return { startsOn: m.startsOn, endsOn: m.endsOn, endedAt: m.endedAt };
+}
+
/** Courses ascending by start. */
export function sortCourses(courses: readonly T[]): T[] {
return [...courses].sort(
@@ -40,24 +74,71 @@ export function sortCourses(courses: readonly T[]): T[] {
}
/**
- * The window `Medication.startsOn/endsOn` carries: the latest course, or
- * nothing at all when the medication has no course (chronic since creation).
+ * The window `Medication.startsOn/endsOn/endedAt` carries: the latest course,
+ * or nothing at all when the medication has no course (chronic since
+ * creation).
*/
export function projectCourseWindow(courses: readonly CourseSpan[]): {
startsOn: Date | null;
endsOn: Date | null;
+ endedAt: Date | null;
} {
- if (courses.length === 0) return { startsOn: null, endsOn: null };
+ if (courses.length === 0) {
+ return { startsOn: null, endsOn: null, endedAt: null };
+ }
const latest = sortCourses(courses)[courses.length - 1];
- return { startsOn: latest.startsOn, endsOn: latest.endsOn };
+ return {
+ startsOn: latest.startsOn,
+ endsOn: latest.endsOn,
+ endedAt: latest.endedAt ?? null,
+ };
}
-/** Where `todayKey` sits in one course. Both ends inclusive. */
+/**
+ * Where `todayKey` sits in one course. Both ends inclusive, except that a
+ * course ended with "End course" is ENDED on its `endsOn` day already: the
+ * instant it ended lies on that day, and every read that asks about that day
+ * comes after the instant (it is written as the moment of the request).
+ */
export function courseStatusOn(
course: CourseSpan,
todayKey: string,
): CourseStatus {
if (todayKey < dayKeyOfDate(course.startsOn)) return "UPCOMING";
+ if (course.endsOn) {
+ const endKey = dayKeyOfDate(course.endsOn);
+ if (todayKey > endKey) return "ENDED";
+ if (course.endedAt && todayKey === endKey) return "ENDED";
+ }
+ return "CURRENT";
+}
+
+/**
+ * Where `now` sits in a medication's window (`Medication.startsOn/endsOn/
+ * endedAt`, the projection of its latest course), in calendar days on the
+ * person's clock. `startsOn` / `endsOn` are calendar dates and are read as
+ * such; only `now` is converted to its local day. Both ends are inclusive:
+ * a course that ends today is CURRENT for the whole of today, one that
+ * starts today is CURRENT from local midnight. A medication without dates is
+ * CURRENT. #1280 — a course ended with "End course" is ENDED from the
+ * instant it was ended (`endedAt`), not from the next local midnight.
+ */
+export function courseStatusAt(
+ course: {
+ startsOn: Date | null;
+ endsOn: Date | null;
+ endedAt?: Date | null;
+ },
+ now: Date,
+ timeZone: string,
+): CourseStatus {
+ if (course.endedAt && now.getTime() >= course.endedAt.getTime()) {
+ return "ENDED";
+ }
+ const todayKey = userDayKey(now, timeZone);
+ if (course.startsOn && todayKey < dayKeyOfDate(course.startsOn)) {
+ return "UPCOMING";
+ }
if (course.endsOn && todayKey > dayKeyOfDate(course.endsOn)) return "ENDED";
return "CURRENT";
}
@@ -104,18 +185,22 @@ export function validateCourses(
}
/**
- * Whether an instant's local day (`dayKey`) falls inside any course. No
- * courses means no restriction (chronic since creation).
+ * Whether a slot at `at`, whose local day is `dayKey`, falls inside any
+ * course. No courses means no restriction (chronic since creation). A course
+ * ended with "End course" stops at its `endedAt`: a slot at or after it is
+ * outside, even on the `endsOn` day.
*/
export function isInsideCourses(
dayKey: string,
courses: readonly CourseSpan[],
+ at: Date,
): boolean {
if (courses.length === 0) return true;
return courses.some(
(c) =>
dayKey >= dayKeyOfDate(c.startsOn) &&
- (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)),
+ (c.endsOn === null || dayKey <= dayKeyOfDate(c.endsOn)) &&
+ (!c.endedAt || at.getTime() < c.endedAt.getTime()),
);
}
diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts
index da6766994..2e6ce8772 100644
--- a/src/lib/medications/courses.ts
+++ b/src/lib/medications/courses.ts
@@ -9,6 +9,10 @@
* columns for a medication that has courses, so reminders, the next-due line
* and `intakeActionable` — which read the columns — can never disagree with
* the course list.
+ *
+ * #1280 — a course may carry `endedAt`, the instant it was ended at. Every
+ * write that moves a course's `endsOn` clears it: an end date chosen in the
+ * editor means the whole day.
*/
import type { Prisma } from "@/generated/prisma/client";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -99,9 +103,19 @@ export interface CourseRow {
id: string;
startsOn: Date;
endsOn: Date | null;
+ endedAt: Date | null;
noteEncrypted: Uint8Array | null;
}
+/** The course columns every course read and write selects. */
+const COURSE_ROW_SELECT = {
+ id: true,
+ startsOn: true,
+ endsOn: true,
+ endedAt: true,
+ noteEncrypted: true,
+} as const;
+
async function lockMedication(tx: Tx, medicationId: string) {
await tx.$queryRaw`
SELECT pg_advisory_xact_lock(
@@ -119,6 +133,7 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
oneShot: true,
startsOn: true,
endsOn: true,
+ endedAt: true,
createdAt: true,
},
});
@@ -126,12 +141,16 @@ async function loadOwned(tx: Tx, userId: string, medicationId: string) {
const courses = await tx.medicationCourse.findMany({
where: { medicationId },
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
- select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
+ select: COURSE_ROW_SELECT,
});
return { medication, courses };
}
-type ProjectedWindow = { startsOn: Date | null; endsOn: Date | null };
+type ProjectedWindow = {
+ startsOn: Date | null;
+ endsOn: Date | null;
+ endedAt: Date | null;
+};
/**
* Write the medication's window from its courses. The only writer.
@@ -148,13 +167,17 @@ async function project(tx: Tx, medicationId: string): Promise {
}),
tx.medicationCourse.findMany({
where: { medicationId },
- select: { startsOn: true, endsOn: true },
+ select: { startsOn: true, endsOn: true, endedAt: true },
}),
]);
const window = projectCourseWindow(courses);
await tx.medication.update({
where: { id: medicationId },
- data: { startsOn: window.startsOn, endsOn: window.endsOn },
+ data: {
+ startsOn: window.startsOn,
+ endsOn: window.endsOn,
+ endedAt: window.endedAt,
+ },
});
if (before && windowWidens(before, window)) {
await moveAutoTakeFloorToNow(tx, medicationId);
@@ -244,7 +267,7 @@ export async function createCourse(
endsOn: args.endsOn,
noteEncrypted: encryptNote(args.note) ?? null,
},
- select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
+ select: COURSE_ROW_SELECT,
});
await project(tx, args.medicationId);
return created;
@@ -269,9 +292,11 @@ export async function updateCourse(
);
const current = courses.find((c) => c.id === args.courseId);
if (!current) throw new CourseWriteError("notFound");
+ const endsOn = args.endsOn === undefined ? current.endsOn : args.endsOn;
const next = {
startsOn: args.startsOn ?? current.startsOn,
- endsOn: args.endsOn === undefined ? current.endsOn : args.endsOn,
+ endsOn,
+ endedAt: keptEndedAt(current, endsOn),
};
refuseIfInvalid(
courses.map((c) => (c.id === current.id ? next : c)),
@@ -284,9 +309,10 @@ export async function updateCourse(
data: {
startsOn: next.startsOn,
endsOn: next.endsOn,
+ endedAt: next.endedAt,
...(noteEncrypted !== undefined && { noteEncrypted }),
},
- select: { id: true, startsOn: true, endsOn: true, noteEncrypted: true },
+ select: COURSE_ROW_SELECT,
});
await project(tx, args.medicationId);
return updated;
@@ -385,14 +411,15 @@ async function writeCurrentWindow(
);
if (latest) {
+ const edited = { ...next, endedAt: keptEndedAt(latest, next.endsOn) };
refuseIfInvalid(
- courses.map((c) => (c.id === latest.id ? next : c)),
+ courses.map((c) => (c.id === latest.id ? edited : c)),
todayKey,
medication.oneShot,
);
await tx.medicationCourse.update({
where: { id: latest.id },
- data: next,
+ data: edited,
});
} else {
refuseIfInvalid([next], todayKey, medication.oneShot);
@@ -407,6 +434,22 @@ async function writeCurrentWindow(
return project(tx, args.medicationId);
}
+/**
+ * #1280 — the `endedAt` a course keeps through an edit: its own while its
+ * `endsOn` stays the day it was ended on, none once the end moves. An end
+ * date written in the editor is a whole day; only "End course" ends a course
+ * at an instant.
+ */
+function keptEndedAt(
+ course: { endsOn: Date | null; endedAt: Date | null },
+ nextEndsOn: Date | null,
+): Date | null {
+ if (!course.endedAt || !course.endsOn || !nextEndsOn) return null;
+ return course.endsOn.getTime() === nextEndsOn.getTime()
+ ? course.endedAt
+ : null;
+}
+
/**
* Whether the window a PUT is about to write would be refused. Checked
* BEFORE the medication row is updated, so a refused window leaves the row
@@ -424,7 +467,7 @@ export async function checkCurrentWindow(
const courses = await prisma.medicationCourse.findMany({
where: { medicationId: args.medicationId, userId: args.userId },
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
- select: { id: true, startsOn: true, endsOn: true },
+ select: { id: true, startsOn: true, endsOn: true, endedAt: true },
});
const latest = courses.at(-1);
if (args.startsOn === null && args.endsOn === null) {
@@ -438,7 +481,13 @@ export async function checkCurrentWindow(
args.timeZone,
);
return validateCourses(
- latest ? courses.map((c) => (c.id === latest.id ? next : c)) : [next],
+ latest
+ ? courses.map((c) =>
+ c.id === latest.id
+ ? { ...next, endedAt: keptEndedAt(latest, next.endsOn) }
+ : c,
+ )
+ : [next],
todayKey,
args.oneShot,
);
@@ -450,6 +499,11 @@ export interface MedicationCourseDto {
id: string;
startsOn: string;
endsOn: string | null;
+ /**
+ * #1280 — the instant "End course" ended this course (ISO), or null when
+ * the whole `endsOn` day counts.
+ */
+ endedAt: string | null;
status: CourseStatus;
/** Doses logged as taken whose slot falls on a day of this course. */
takenDoses: number;
@@ -473,13 +527,7 @@ export async function loadCoursesFor(
const rows = await prisma.medicationCourse.findMany({
where: { medicationId: { in: medicationIds } },
orderBy: [{ startsOn: "asc" }, { id: "asc" }],
- select: {
- id: true,
- medicationId: true,
- startsOn: true,
- endsOn: true,
- noteEncrypted: true,
- },
+ select: { ...COURSE_ROW_SELECT, medicationId: true },
});
for (const { medicationId, ...row } of rows) {
const list = byMedication.get(medicationId) ?? [];
@@ -530,6 +578,7 @@ export function buildCourseFields(
id: c.id,
startsOn: dayKeyOfDate(c.startsOn),
endsOn: c.endsOn ? dayKeyOfDate(c.endsOn) : null,
+ endedAt: c.endedAt ? c.endedAt.toISOString() : null,
status: courseStatusOn(c, todayKey),
takenDoses: takenByCourse.get(c.id) ?? 0,
note: c.noteEncrypted ? decryptFromBytes(c.noteEncrypted) : null,
diff --git a/src/lib/medications/intake-actionable.ts b/src/lib/medications/intake-actionable.ts
index f20072559..cd586a720 100644
--- a/src/lib/medications/intake-actionable.ts
+++ b/src/lib/medications/intake-actionable.ts
@@ -22,11 +22,14 @@
*
* Logging a past dose from the history is a different verb and is not
* gated here; an ended course can still be completed after the fact.
+ *
+ * #1280 — a course ended at an instant (`endedAt`) is ENDED from it, so the
+ * action row goes away at once instead of at midnight.
*/
import {
courseStatusAt,
type CourseStatus,
-} from "@/lib/medications/scheduling/recurrence";
+} from "@/lib/medications/course-window";
import { isRecordOnly } from "@/lib/medications/intake-tracking";
export interface IntakeActionability {
@@ -40,6 +43,7 @@ export function resolveIntakeActionability(
trackIntake: boolean;
startsOn: Date | null;
endsOn: Date | null;
+ endedAt: Date | null;
},
now: Date,
timeZone: string,
diff --git a/src/lib/medications/intake-slot-dedup.ts b/src/lib/medications/intake-slot-dedup.ts
index 96d1903b8..982436f60 100644
--- a/src/lib/medications/intake-slot-dedup.ts
+++ b/src/lib/medications/intake-slot-dedup.ts
@@ -43,6 +43,10 @@
* unregistered queue silently never drains.
*/
import { prisma } from "@/lib/db";
+import {
+ MEDICATION_WINDOW_SELECT,
+ medicationWindow,
+} from "@/lib/medications/course-window";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { restoreForIntake } from "@/lib/medications/inventory/consumption";
import { isP2002 } from "@/lib/prisma-errors";
@@ -201,8 +205,7 @@ export async function dedupeUserIntakeSlots(
select: {
id: true,
userId: true,
- startsOn: true,
- endsOn: true,
+ ...MEDICATION_WINDOW_SELECT,
oneShot: true,
createdAt: true,
schedules: {
@@ -348,8 +351,7 @@ export async function dedupeUserIntakeSlots(
const canonical = resolveCanonicalSlotInstant({
medication: {
id: med.id,
- startsOn: med.startsOn,
- endsOn: med.endsOn,
+ ...medicationWindow(med),
oneShot: med.oneShot,
createdAt: med.createdAt,
schedules: med.schedules,
@@ -551,8 +553,7 @@ async function convergeAdhocRowsOntoPendingSlots(input: {
const medication: AttributeIntakeMedication = {
id: med.id,
- startsOn: med.startsOn,
- endsOn: med.endsOn,
+ ...medicationWindow(med),
oneShot: med.oneShot,
createdAt: med.createdAt,
schedules: med.schedules,
diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts
index 9c916274b..1a99bddef 100644
--- a/src/lib/medications/list-read.ts
+++ b/src/lib/medications/list-read.ts
@@ -13,6 +13,7 @@
* readers. Mirrors `src/lib/dashboard/snapshot-read.ts` for the dashboard.
*/
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
+import { medicationWindow } from "@/lib/medications/course-window";
import { loadAutoTakeRollingAnchors } from "@/lib/medications/auto-take";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -244,8 +245,7 @@ export async function buildMedicationsList(
const display = computeDisplayDue({
medication: {
id: m.id,
- startsOn: m.startsOn,
- endsOn: m.endsOn,
+ ...medicationWindow(m),
oneShot: m.oneShot,
createdAt: m.createdAt,
},
diff --git a/src/lib/medications/read-medication.ts b/src/lib/medications/read-medication.ts
index 6f7adea8d..c41504c7f 100644
--- a/src/lib/medications/read-medication.ts
+++ b/src/lib/medications/read-medication.ts
@@ -8,6 +8,7 @@
* medication is not the record's.
*/
import type { Medication, MedicationSchedule } from "@/generated/prisma/client";
+import { medicationWindow } from "@/lib/medications/course-window";
import { prisma } from "@/lib/db";
import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken";
import { toIngredientDtos } from "@/lib/medications/ingredients/composition";
@@ -113,8 +114,7 @@ async function loadDisplayDue(
const display = computeDisplayDue({
medication: {
id: medication.id,
- startsOn: medication.startsOn,
- endsOn: medication.endsOn,
+ ...medicationWindow(medication),
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
diff --git a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts
index 4c35f0cf9..752efc30c 100644
--- a/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts
+++ b/src/lib/medications/scheduling/__tests__/attribute-intake.test.ts
@@ -51,6 +51,7 @@ function med(
id: "med-1",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules,
diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts
index 6a6fb298f..a7be8bbbe 100644
--- a/src/lib/medications/scheduling/__tests__/next-due.test.ts
+++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts
@@ -35,6 +35,7 @@ function makeMedication(
id: "med-1",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: d("2026-01-01T00:00:00Z"),
...overrides,
diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts
index a2e7fac2f..4dbd67861 100644
--- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts
+++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts
@@ -1295,3 +1295,71 @@ describe("occurrencesBetween — CYCLIC", () => {
);
});
});
+
+// ────────────────────────────────────────────────────────────────────
+// #1280 — a course ended with "End course" stops at that instant
+// ────────────────────────────────────────────────────────────────────
+
+describe("a course ended at an instant (#1280)", () => {
+ // 08:00 and 20:00 in Berlin (UTC+2 in June), ended at 12:00 local on the
+ // 10th, which is also its last day.
+ const schedule = makeSchedule({
+ rrule: "FREQ=DAILY",
+ timesOfDay: ["08:00", "20:00"],
+ });
+ const ended = makeCtx({
+ medication: {
+ endsOn: d("2026-06-10T00:00:00Z"),
+ endedAt: d("2026-06-10T10:00:00Z"),
+ },
+ });
+ const wholeDay = makeCtx({
+ medication: { endsOn: d("2026-06-10T00:00:00Z") },
+ });
+
+ it("emits the slot before the instant and none after it", () => {
+ const from = d("2026-06-09T22:00:00Z");
+ const to = d("2026-06-10T21:59:59Z");
+ expect(
+ occurrencesBetween(schedule, from, to, ended).map((o) =>
+ o.at.toISOString(),
+ ),
+ ).toEqual(["2026-06-10T06:00:00.000Z"]);
+ // The same end date without the instant keeps the evening dose.
+ expect(occurrencesBetween(schedule, from, to, wholeDay)).toHaveLength(2);
+ });
+
+ it("has no next dose once the course was ended", () => {
+ expect(
+ nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), ended),
+ ).toBeNull();
+ expect(
+ nextOccurrenceAfter(schedule, d("2026-06-10T10:30:00Z"), wholeDay)?.at,
+ ).toEqual(d("2026-06-10T18:00:00Z"));
+ });
+
+ it("stops a rolling cadence whose next dose falls after the instant", () => {
+ const rolling = makeSchedule({
+ rollingIntervalDays: 1,
+ timesOfDay: ["20:00"],
+ });
+ const ctx = makeCtx({
+ lastIntakeAt: d("2026-06-09T18:00:00Z"),
+ medication: {
+ endsOn: d("2026-06-10T00:00:00Z"),
+ endedAt: d("2026-06-10T10:00:00Z"),
+ },
+ });
+ expect(
+ occurrencesBetween(
+ rolling,
+ d("2026-06-09T22:00:00Z"),
+ d("2026-06-10T21:59:59Z"),
+ ctx,
+ ),
+ ).toEqual([]);
+ expect(
+ nextOccurrenceAfter(rolling, d("2026-06-10T10:30:00Z"), ctx),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts
index 1cc19bba7..136fbd599 100644
--- a/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts
+++ b/src/lib/medications/scheduling/__tests__/resolve-slot-instant.test.ts
@@ -46,6 +46,7 @@ function makeMedication(
id: "med-1",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
schedules,
diff --git a/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts
index 1ff7974b3..3883bf121 100644
--- a/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts
+++ b/src/lib/medications/scheduling/__tests__/rolling-taken-dose-not-redue.test.ts
@@ -34,6 +34,7 @@ const medication: WorkerMedicationRow = {
id: "med-1",
startsOn: new Date("2026-06-02T00:00:00.000Z"),
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt: new Date("2026-06-01T00:00:00.000Z"),
};
diff --git a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts
index 7d02534df..20ebf15f7 100644
--- a/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts
+++ b/src/lib/medications/scheduling/__tests__/schedule-eras.test.ts
@@ -77,6 +77,7 @@ const medication = {
id: "med-1",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt,
};
@@ -384,6 +385,7 @@ describe("write-attribution era pick", () => {
id: "med-1",
startsOn: null,
endsOn: null,
+ endedAt: null,
oneShot: false,
createdAt,
schedules: [
diff --git a/src/lib/medications/scheduling/cadence.ts b/src/lib/medications/scheduling/cadence.ts
index c2875337d..44dbcb7f3 100644
--- a/src/lib/medications/scheduling/cadence.ts
+++ b/src/lib/medications/scheduling/cadence.ts
@@ -128,6 +128,8 @@ export interface ScheduleLike {
export interface CadenceEngineContext {
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — when "End course" ended the latest course (see the engine). */
+ endedAt?: Date | null;
oneShot: boolean;
createdAt: Date;
/** Latest non-skipped intake — only read by rolling cadences. */
@@ -150,18 +152,17 @@ export interface CadenceEngineContext {
}
/** v1.40 (#1024) — the span a timeline expands over (see `courses`). */
-function mintWindow(engineCtx: CadenceEngineContext): {
- startsOn: Date | null;
- endsOn: Date | null;
-} {
+function mintWindow(engineCtx: CadenceEngineContext) {
const courses = engineCtx.courses;
+ const endedAt = engineCtx.endedAt ?? null;
if (!courses || courses.length < 2) {
- return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn };
+ return { startsOn: engineCtx.startsOn, endsOn: engineCtx.endsOn, endedAt };
}
const sorted = sortCourses(courses);
return {
startsOn: sorted[0].startsOn,
endsOn: sorted[sorted.length - 1].endsOn,
+ endedAt,
};
}
@@ -169,7 +170,7 @@ function mintWindow(engineCtx: CadenceEngineContext): {
function onCourseDay(engineCtx: CadenceEngineContext, at: Date): boolean {
const courses = engineCtx.courses;
if (!courses || courses.length < 2) return true;
- return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses);
+ return isInsideCourses(userDayKey(at, engineCtx.timeZone), courses, at);
}
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
@@ -201,8 +202,7 @@ function toRecurrenceContext(
return {
medication: {
id: "cadence-med",
- startsOn: window.startsOn,
- endsOn: window.endsOn,
+ ...window,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
@@ -908,16 +908,14 @@ function missedFromLedger(
const window = mintWindow(engineCtx);
const medication: BandMinterMedication = {
id: "missed-tally",
- startsOn: window.startsOn,
- endsOn: window.endsOn,
+ ...window,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
};
const recurrenceCtx: RecurrenceContext = {
medication: {
id: "missed-tally",
- startsOn: window.startsOn,
- endsOn: window.endsOn,
+ ...window,
oneShot: engineCtx.oneShot,
createdAt: engineCtx.createdAt,
},
diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts
index 20bfd2354..9ae4bddc4 100644
--- a/src/lib/medications/scheduling/project-today-intakes.ts
+++ b/src/lib/medications/scheduling/project-today-intakes.ts
@@ -26,6 +26,7 @@
* Returns the counts so the caller can surface them on `annotate(...)`.
*/
import { prisma } from "@/lib/db";
+import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
@@ -58,8 +59,7 @@ export async function projectTodayIntakesAndRecompute(input: {
where: { userId, active: true, ...TRACKED_INTAKE_WHERE },
select: {
id: true,
- startsOn: true,
- endsOn: true,
+ ...MEDICATION_WINDOW_SELECT,
oneShot: true,
createdAt: true,
// The live era start: no slot anchored before it is projected. Turning
@@ -160,6 +160,9 @@ export async function projectTodayIntakesAndRecompute(input: {
if (!Number.isFinite(h) || !Number.isFinite(m)) continue;
const scheduledFor = localHmAsUtc(now, userTz, h, m);
if (isBeforeLiveEra(scheduledFor, eraStart)) continue;
+ // #1280 — a course ended with "End course" expects nothing from
+ // that instant on; the schedule still emits its earlier slots today.
+ if (med.endedAt && scheduledFor >= med.endedAt) continue;
projected.push({
medicationId: schedule.medicationId,
scheduledFor,
diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts
index 091ae2e4c..f2477ca41 100644
--- a/src/lib/medications/scheduling/recurrence.ts
+++ b/src/lib/medications/scheduling/recurrence.ts
@@ -143,6 +143,8 @@ export interface RecurrenceContext {
id: string;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — when "End course" ended it; see `beforeCourseEnded`. */
+ endedAt?: Date | null;
oneShot: boolean;
createdAt: Date;
};
@@ -211,31 +213,6 @@ const ONE_MINUTE_MS = 60_000;
// Public API
// ────────────────────────────────────────────────────────────────────
-/**
- * Where `now` sits relative to a medication's course, in calendar days on
- * the user's clock. `startsOn` / `endsOn` are calendar dates and are read as
- * such (never shifted through `timeZone`); only `now` is converted to the
- * local day it falls on. Both ends are inclusive: a course that ends today
- * is CURRENT for the whole of today, one that starts today is CURRENT from
- * local midnight.
- */
-export type CourseStatus = "UPCOMING" | "CURRENT" | "ENDED";
-
-export function courseStatusAt(
- course: { startsOn: Date | null; endsOn: Date | null },
- now: Date,
- timeZone: string,
-): CourseStatus {
- const today = civilDayOfInstant(now, timeZone).getTime();
- if (course.startsOn && today < civilDayOfDate(course.startsOn).getTime()) {
- return "UPCOMING";
- }
- if (course.endsOn && today > civilDayOfDate(course.endsOn).getTime()) {
- return "ENDED";
- }
- return "CURRENT";
-}
-
/**
* Emit every occurrence in `[from, to]` (inclusive of both ends) in
* chronological order. Pure: no DB access, no side effects.
@@ -273,9 +250,22 @@ export function occurrencesBetween(
// independent: the legacy stride filters weeks first, the cyclic gate
// filters the surviving slots second.
if (schedule.scheduleType === "CYCLIC") {
- return slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx));
+ slots = slots.filter((s) => isInCyclicOnWeek(s.at, schedule, ctx));
}
- return slots;
+ // #1280 — a course ended with "End course" stops at that instant.
+ return slots.filter((s) => beforeCourseEnded(s.at, ctx));
+}
+
+/**
+ * #1280 — the cut every expansion path applies on top of the `endsOn` day
+ * cap: no slot at or after the instant "End course" ended the course. A dose
+ * later that day is not due, reminded or expected; an earlier one is. Every
+ * medication context builder threads `endedAt` (the course-anchor guard
+ * holds the list); a measurement or booster reminder has no course to end.
+ */
+function beforeCourseEnded(at: Date, ctx: RecurrenceContext): boolean {
+ const endedAt = ctx.medication.endedAt;
+ return !endedAt || at.getTime() < endedAt.getTime();
}
/**
@@ -354,7 +344,7 @@ export function nextOccurrenceAfter(
for (const s of slots) {
if (s.at.getTime() <= after.getTime()) continue;
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
- return s;
+ return beforeCourseEnded(s.at, ctx) ? s : null;
}
return null;
}
@@ -405,7 +395,7 @@ export function nextOccurrenceAfter(
for (const s of slots) {
if (s.at.getTime() < floor.getTime()) continue;
if (cyclic && !isInCyclicOnWeek(s.at, schedule, ctx)) continue;
- return s;
+ return beforeCourseEnded(s.at, ctx) ? s : null;
}
return null;
}
@@ -608,6 +598,7 @@ export function advanceRollingOccurrence(
return null;
}
const at = applyTimeOfDayToCivilDay(nextDay, time, ctx.timeZone);
+ if (!beforeCourseEnded(at, ctx)) return null;
return buildOccurrence(at, time, schedule);
}
@@ -698,7 +689,8 @@ export function expandRollingRetrospective(
const inWindow = (at: Date): boolean =>
at.getTime() >= from.getTime() &&
at.getTime() <= to.getTime() &&
- at.getTime() <= endsCap;
+ at.getTime() <= endsCap &&
+ beforeCourseEnded(at, ctx);
const slots: Occurrence[] = [];
diff --git a/src/lib/medications/scheduling/slot-upsert.ts b/src/lib/medications/scheduling/slot-upsert.ts
index 01fed425f..19471a7ac 100644
--- a/src/lib/medications/scheduling/slot-upsert.ts
+++ b/src/lib/medications/scheduling/slot-upsert.ts
@@ -18,6 +18,7 @@ import type {
MedicationIntakeEvent,
PrismaClient,
} from "@/generated/prisma/client";
+import { medicationWindow } from "@/lib/medications/course-window";
import { prisma as defaultPrisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
@@ -472,6 +473,7 @@ const MEDICATION_SELECT = {
id: true,
startsOn: true,
endsOn: true,
+ endedAt: true,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_SELECT },
@@ -557,8 +559,7 @@ export async function resolveSlotInstantForWrite(
return resolveCanonicalSlotInstant({
medication: {
id: medication.id,
- startsOn: medication.startsOn,
- endsOn: medication.endsOn,
+ ...medicationWindow(medication),
oneShot: medication.oneShot,
createdAt: medication.createdAt,
schedules: medication.schedules as WorkerScheduleRow[],
@@ -612,8 +613,7 @@ async function loadAttributeMedication(input: {
return {
medication: {
id: medication.id,
- startsOn: medication.startsOn,
- endsOn: medication.endsOn,
+ ...medicationWindow(medication),
oneShot: medication.oneShot,
createdAt: medication.createdAt,
schedules: medication.schedules as WorkerScheduleRow[],
diff --git a/src/lib/medications/scheduling/worker-helpers.ts b/src/lib/medications/scheduling/worker-helpers.ts
index 7d1a45199..959120ce6 100644
--- a/src/lib/medications/scheduling/worker-helpers.ts
+++ b/src/lib/medications/scheduling/worker-helpers.ts
@@ -69,6 +69,8 @@ export interface WorkerMedicationRow {
id: string;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — the instant "End course" ended the course (see the engine). */
+ endedAt: Date | null;
oneShot: boolean;
createdAt: Date;
}
@@ -149,6 +151,7 @@ export function buildRecurrenceContext(input: {
id: input.medication.id,
startsOn: input.medication.startsOn,
endsOn: input.medication.endsOn,
+ endedAt: input.medication.endedAt,
oneShot: input.medication.oneShot,
createdAt: input.medication.createdAt,
},
diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts
index 55e9fbe13..2e6c11d0c 100644
--- a/src/lib/openapi/routes/medications/schemas.ts
+++ b/src/lib/openapi/routes/medications/schemas.ts
@@ -66,9 +66,16 @@ export const medicationCourseResource = z
.date()
.nullable()
.describe("Last day, inclusive. NULL = open (only the latest course)."),
+ endedAt: isoDateTime()
+ .nullable()
+ .describe(
+ '#1280 — the instant "End course" ended this course, on its `endsOn` day. No dose slot at or after it is due, reminded or expected, and the course is ENDED from it on. NULL when the whole `endsOn` day counts (an end date set in the editor). Any edit that moves `endsOn` clears it.',
+ ),
status: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
- .describe("Where today, on the person's clock, sits in this course."),
+ .describe(
+ "Where today, on the person's clock, sits in this course. A course with `endedAt` is ENDED on its `endsOn` day already.",
+ ),
takenDoses: z
.number()
.int()
@@ -364,6 +371,12 @@ export const medicationResource = z
.describe(
"v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.",
),
+ endedAt: isoDateTime()
+ .nullable()
+ .optional()
+ .describe(
+ '#1280 — the instant the latest course was ended with "End course" (the course\'s own `endedAt`). From it on no dose is due or reminded and `courseStatus` is ENDED; NULL when the course runs to the end of `endsOn`. Read-only — written by the course writer only.',
+ ),
oneShot: z
.boolean()
.describe(
@@ -435,12 +448,12 @@ export const medicationListEntry = medicationResource
remindersActive: z
.boolean()
.describe(
- "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
+ "v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset, and no `endedAt` has passed), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.",
),
courseStatus: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
.describe(
- "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.",
+ 'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
),
intakeActionable: z
.boolean()
@@ -506,7 +519,7 @@ export const medicationDetailEntry = medicationResource
courseStatus: z
.enum(["UPCOMING", "CURRENT", "ENDED"])
.describe(
- "v1.39.4 — where today (on the user's clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day. Read-only — computed, not stored.",
+ 'v1.39.4 — where today (on the user\'s clock) sits in the course: UPCOMING before `startsOn`, ENDED after `endsOn`, CURRENT otherwise. Calendar days, both ends inclusive, so a course that ends today is CURRENT all day, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.',
),
intakeActionable: z
.boolean()
diff --git a/src/lib/targets/types.ts b/src/lib/targets/types.ts
index 5ae330480..2ffc716b4 100644
--- a/src/lib/targets/types.ts
+++ b/src/lib/targets/types.ts
@@ -72,12 +72,14 @@ export interface TargetMedication {
createdAt: Date;
startsOn: Date | null;
endsOn: Date | null;
+ /** #1280 — the instant "End course" ended the latest course. */
+ endedAt: Date | null;
oneShot: boolean;
schedules: ComplianceSchedule[];
scheduleRevisions?: ScheduleRevisionLike[];
pauseEras?: MedicationPauseEraLike[];
/** v1.40 (#1024) — the courses, so a gap between two expects nothing. */
- courses?: { startsOn: Date; endsOn: Date | null }[];
+ courses?: { startsOn: Date; endsOn: Date | null; endedAt: Date | null }[];
}
export interface TargetIntakeEvent extends IntakeEvent {
diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json
index b14db8aff..c741d2b27 100644
--- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json
+++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json
@@ -335,7 +335,7 @@
"reorderLeadDays": 3,
"externalSource": "WEB",
"externalId": "rt-Medication-externalId",
- "updatedAt": "2026-05-01T11:51:00.000Z",
+ "updatedAt": "2026-05-01T11:52:00.000Z",
"categoryUpdatedAt": "2026-05-01T21:22:00.000Z",
"name": "Round-trip tablet",
"dose": "5 mg",
@@ -344,7 +344,7 @@
"autoMarkTaken": true,
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"schedules": [
{
"id": "",
@@ -428,18 +428,20 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": null,
"noteEncrypted": "",
- "updatedAt": "2026-05-02T00:47:00.000Z",
- "createdAt": "2026-05-02T00:46:00.000Z"
+ "updatedAt": "2026-05-02T00:48:00.000Z",
+ "createdAt": "2026-05-02T00:47:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
"noteEncrypted": null,
- "updatedAt": "2026-05-02T00:47:01.000Z",
- "createdAt": "2026-05-02T00:46:01.000Z"
+ "updatedAt": "2026-05-02T00:48:01.000Z",
+ "createdAt": "2026-05-02T00:47:01.000Z"
}
],
"doseChanges": [
@@ -2772,8 +2774,8 @@
"reorderLeadDays": 3,
"externalSource": "WEB",
"externalId": "rt-Medication-externalId",
- "createdAt": "2026-05-01T11:50:00.000Z",
- "updatedAt": "2026-05-01T11:51:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
+ "updatedAt": "2026-05-01T11:52:00.000Z",
"schedules": [
{
"id": "",
@@ -2995,18 +2997,20 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": null,
"noteEncrypted": "",
- "createdAt": "2026-05-02T00:46:00.000Z",
- "updatedAt": "2026-05-02T00:47:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z",
+ "updatedAt": "2026-05-02T00:48:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
"noteEncrypted": null,
- "createdAt": "2026-05-02T00:46:01.000Z",
- "updatedAt": "2026-05-02T00:47:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z",
+ "updatedAt": "2026-05-02T00:48:01.000Z"
}
]
}
diff --git a/tests/integration/__golden__/backup-parity/export-portable.json b/tests/integration/__golden__/backup-parity/export-portable.json
index 82a5663bd..436afeeb9 100644
--- a/tests/integration/__golden__/backup-parity/export-portable.json
+++ b/tests/integration/__golden__/backup-parity/export-portable.json
@@ -199,7 +199,7 @@
"autoMarkTaken": true,
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"schedules": [
{
"windowStart": "08:00",
@@ -250,14 +250,16 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": "the March flu",
- "createdAt": "2026-05-02T00:46:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
- "createdAt": "2026-05-02T00:46:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z"
}
],
"doseChanges": [
@@ -1850,7 +1852,7 @@
"autoMarkTaken": true,
"autoMarkTakenSince": "2026-06-30T12:34:56.789Z",
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"schedules": [
{
"windowStart": "08:00",
@@ -2022,14 +2024,16 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": "the March flu",
- "createdAt": "2026-05-02T00:46:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
- "createdAt": "2026-05-02T00:46:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z"
}
]
}
diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
index f8e15f9f9..a8d078b9b 100644
--- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
+++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json
@@ -614,6 +614,7 @@
"snoozedUntil": "2026-06-30T12:34:56.789Z",
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"oneShot": true,
"asNeeded": true,
"trackIntake": false,
@@ -633,8 +634,8 @@
"reorderLeadDays": 3,
"externalSource": "WEB",
"externalId": "rt-Medication-externalId",
- "createdAt": "2026-05-01T11:50:00.000Z",
- "updatedAt": "2026-05-01T11:51:00.000Z"
+ "createdAt": "2026-05-01T11:51:00.000Z",
+ "updatedAt": "2026-05-01T11:52:00.000Z"
}
],
"MedicationSchedule": [
@@ -805,9 +806,10 @@
"userId": "round-trip-owner",
"startsOn": "2026-03-01T00:00:00.000Z",
"endsOn": "2026-03-07T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": "",
- "createdAt": "2026-05-02T00:46:00.000Z",
- "updatedAt": "2026-05-02T00:47:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z",
+ "updatedAt": "2026-05-02T00:48:00.000Z"
},
{
"id": "",
@@ -815,9 +817,10 @@
"userId": "round-trip-owner",
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"noteEncrypted": null,
- "createdAt": "2026-05-02T00:46:01.000Z",
- "updatedAt": "2026-05-02T00:47:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z",
+ "updatedAt": "2026-05-02T00:48:01.000Z"
}
],
"MedicationIngredient": [
@@ -3237,7 +3240,7 @@
"reorderLeadDays": 3,
"externalSource": "WEB",
"externalId": "rt-Medication-externalId",
- "updatedAt": "2026-05-01T11:51:00.000Z",
+ "updatedAt": "2026-05-01T11:52:00.000Z",
"categoryUpdatedAt": "2026-05-01T21:22:00.000Z",
"name": "Round-trip tablet",
"dose": "5 mg",
@@ -3246,7 +3249,7 @@
"autoMarkTaken": true,
"autoMarkTakenSince": "",
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"schedules": [
{
"id": "",
@@ -3330,18 +3333,20 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": null,
"noteEncrypted": "",
- "updatedAt": "2026-05-02T00:47:00.000Z",
- "createdAt": "2026-05-02T00:46:00.000Z"
+ "updatedAt": "2026-05-02T00:48:00.000Z",
+ "createdAt": "2026-05-02T00:47:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
"noteEncrypted": null,
- "updatedAt": "2026-05-02T00:47:01.000Z",
- "createdAt": "2026-05-02T00:46:01.000Z"
+ "updatedAt": "2026-05-02T00:48:01.000Z",
+ "createdAt": "2026-05-02T00:47:01.000Z"
}
],
"doseChanges": [
diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json
index ed90139e4..68ae26e99 100644
--- a/tests/integration/__golden__/backup-parity/restore-portable.json
+++ b/tests/integration/__golden__/backup-parity/restore-portable.json
@@ -681,6 +681,7 @@
"snoozedUntil": null,
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -698,7 +699,7 @@
"reorderLeadDays": null,
"externalSource": null,
"externalId": null,
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"updatedAt": ""
}
],
@@ -843,8 +844,9 @@
"userId": "round-trip-owner",
"startsOn": "2026-03-01T00:00:00.000Z",
"endsOn": "2026-03-07T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": "",
- "createdAt": "2026-05-02T00:46:00.000Z",
+ "createdAt": "2026-05-02T00:47:00.000Z",
"updatedAt": ""
},
{
@@ -853,8 +855,9 @@
"userId": "round-trip-owner",
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"noteEncrypted": null,
- "createdAt": "2026-05-02T00:46:01.000Z",
+ "createdAt": "2026-05-02T00:47:01.000Z",
"updatedAt": ""
}
],
@@ -2596,7 +2599,7 @@
"autoMarkTaken": true,
"autoMarkTakenSince": "",
"category": "custom:5f0c6a52-6a43-4f63-9b39-0c1f4d7e2a10",
- "createdAt": "2026-05-01T11:50:00.000Z",
+ "createdAt": "2026-05-01T11:51:00.000Z",
"schedules": [
{
"windowStart": "08:00",
@@ -2647,14 +2650,16 @@
{
"startsOn": "2026-03-01",
"endsOn": "2026-03-07",
+ "endedAt": null,
"note": "the March flu",
- "createdAt": "2026-05-02T00:46:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z"
},
{
"startsOn": "2026-06-10",
"endsOn": "2026-06-16",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"note": null,
- "createdAt": "2026-05-02T00:46:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z"
}
],
"doseChanges": [
diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json
index d050c0382..1dec697bd 100644
--- a/tests/integration/__golden__/backup-parity/restore-skips.json
+++ b/tests/integration/__golden__/backup-parity/restore-skips.json
@@ -570,6 +570,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json
index 7c6570625..68895abe8 100644
--- a/tests/integration/__golden__/backup-parity/restore-version-1.json
+++ b/tests/integration/__golden__/backup-parity/restore-version-1.json
@@ -521,6 +521,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -555,6 +556,7 @@
"snoozedUntil": null,
"startsOn": "2026-07-01T00:00:00.000Z",
"endsOn": "2026-08-01T00:00:00.000Z",
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -652,6 +654,7 @@
"userId": "round-trip-owner",
"startsOn": "2026-07-01T00:00:00.000Z",
"endsOn": "2026-08-01T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -1480,6 +1483,7 @@
{
"startsOn": "2026-07-01",
"endsOn": "2026-08-01",
+ "endedAt": null,
"note": null,
"noteEncrypted": null,
"updatedAt": "",
diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json
index c4d46b2c9..cd3618a3f 100644
--- a/tests/integration/__golden__/backup-parity/seeded-account.json
+++ b/tests/integration/__golden__/backup-parity/seeded-account.json
@@ -212,6 +212,7 @@
"snoozedUntil": "2026-06-30T12:34:56.789Z",
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"oneShot": true,
"asNeeded": true,
"trackIntake": false,
@@ -231,8 +232,8 @@
"reorderLeadDays": 3,
"externalSource": "WEB",
"externalId": "rt-Medication-externalId",
- "createdAt": "2026-05-01T11:50:00.000Z",
- "updatedAt": "2026-05-01T11:51:00.000Z"
+ "createdAt": "2026-05-01T11:51:00.000Z",
+ "updatedAt": "2026-05-01T11:52:00.000Z"
}
],
"MedicationSchedule": [
@@ -403,9 +404,10 @@
"userId": "round-trip-owner",
"startsOn": "2026-03-01T00:00:00.000Z",
"endsOn": "2026-03-07T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": "",
- "createdAt": "2026-05-02T00:46:00.000Z",
- "updatedAt": "2026-05-02T00:47:00.000Z"
+ "createdAt": "2026-05-02T00:47:00.000Z",
+ "updatedAt": "2026-05-02T00:48:00.000Z"
},
{
"id": "",
@@ -413,9 +415,10 @@
"userId": "round-trip-owner",
"startsOn": "2026-06-10T00:00:00.000Z",
"endsOn": "2026-06-16T00:00:00.000Z",
+ "endedAt": "2026-06-16T09:30:00.000Z",
"noteEncrypted": null,
- "createdAt": "2026-05-02T00:46:01.000Z",
- "updatedAt": "2026-05-02T00:47:01.000Z"
+ "createdAt": "2026-05-02T00:47:01.000Z",
+ "updatedAt": "2026-05-02T00:48:01.000Z"
}
],
"MedicationIngredient": [
diff --git a/tests/integration/backup-fixture.ts b/tests/integration/backup-fixture.ts
index ddf0f990e..8b271145c 100644
--- a/tests/integration/backup-fixture.ts
+++ b/tests/integration/backup-fixture.ts
@@ -190,7 +190,9 @@ export async function seedEveryTwoEndedModel(
});
// Two courses with a gap, and the window the row projects from them (the
- // latest). The earlier course exists nowhere else once it has ended.
+ // latest). The earlier course exists nowhere else once it has ended. The
+ // latest was ended with "End course" (#1280), so the instant has to come
+ // back with it.
const medication = await prisma.medication.create({
data: {
userId: OWNER_ID,
@@ -198,6 +200,7 @@ export async function seedEveryTwoEndedModel(
dose: "5 mg",
startsOn: AT("2026-06-10T00:00:00.000Z"),
endsOn: AT("2026-06-16T00:00:00.000Z"),
+ endedAt: AT("2026-06-16T09:30:00.000Z"),
schedules: {
create: { windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
},
@@ -213,6 +216,7 @@ export async function seedEveryTwoEndedModel(
userId: OWNER_ID,
startsOn: AT("2026-06-10T00:00:00.000Z"),
endsOn: AT("2026-06-16T00:00:00.000Z"),
+ endedAt: AT("2026-06-16T09:30:00.000Z"),
},
],
},
diff --git a/tests/integration/intake-late-take-future-slot.test.ts b/tests/integration/intake-late-take-future-slot.test.ts
index ddffd0969..797d809d3 100644
--- a/tests/integration/intake-late-take-future-slot.test.ts
+++ b/tests/integration/intake-late-take-future-slot.test.ts
@@ -217,6 +217,7 @@ async function readDisplayDue(medId: string, now: Date): Promise {
id: med.id,
startsOn: med.startsOn,
endsOn: med.endsOn,
+ endedAt: null,
oneShot: med.oneShot,
createdAt: med.createdAt,
},
diff --git a/tests/integration/medication-auto-mark-taken.test.ts b/tests/integration/medication-auto-mark-taken.test.ts
index b30f2e907..6719dd9c2 100644
--- a/tests/integration/medication-auto-mark-taken.test.ts
+++ b/tests/integration/medication-auto-mark-taken.test.ts
@@ -585,6 +585,7 @@ describe("remindersActive — the published value is the gate", () => {
trackIntake: boolean;
asNeeded: boolean;
endsOn: Date | null;
+ endedAt: Date | null;
}> = [];
for (let bits = 0; bits < 64; bits++) {
combos.push({
@@ -595,6 +596,7 @@ describe("remindersActive — the published value is the gate", () => {
asNeeded: (bits & 16) !== 0,
// A course that ended the day before.
endsOn: (bits & 32) !== 0 ? new Date("2026-06-09T00:00:00.000Z") : null,
+ endedAt: null,
});
}
const ids: string[] = [];
From 275367f6e9d8a60b348d85b6ec6c0fe6e49a769e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 24 Oct 2026 22:24:15 +0200
Subject: [PATCH 07/11] 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
---
docs/api/openapi.yaml | 229 +++++---
.../aging-fixture-dates-guard.test.ts | 5 +
.../api/medications/[id]/courses/end/route.ts | 78 +++
.../detail/medication-detail-tabs.tsx | 1 +
.../destructive-zone-section.test.tsx | 36 +-
.../sections/destructive-zone-section.tsx | 52 +-
.../__tests__/course-window.test.ts | 25 +
.../__tests__/intake-actionable.test.ts | 8 +
src/lib/medications/course-window.ts | 23 +
src/lib/medications/courses.ts | 102 +++-
src/lib/medications/intake-actionable.ts | 9 +-
src/lib/openapi/error-codes.ts | 1 +
src/lib/openapi/routes/medications/paths.ts | 39 ++
src/lib/openapi/routes/medications/schemas.ts | 18 +-
.../integration/medication-course-end.test.ts | 488 ++++++++++++++++++
15 files changed, 990 insertions(+), 124 deletions(-)
create mode 100644 src/app/api/medications/[id]/courses/end/route.ts
create mode 100644 tests/integration/medication-course-end.test.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 061a59857..d9ce7210b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -6861,6 +6861,49 @@ paths:
"409": *a7
"422": *a4
"429": *a5
+ /api/medications/{id}/courses/end:
+ post:
+ tags:
+ - Medications
+ summary: End the running course now
+ description: "#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the
+ moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by
+ compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending
+ placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets
+ the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or
+ the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409
+ `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`.
+ Owner-only."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ - *a6
+ responses:
+ "200":
+ description: The medication's window after ending.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EndMedicationCourseResponse"
+ "401": *a1
+ "403": *a9
+ "404":
+ description: Medication not found (`medications.course.notFound`), or another user's.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "409":
+ description: "No course is running now (`medications.course.notRunning`): it has ended already or has not started."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422": *a4
+ "429": *a5
/api/medications/{id}/courses/{courseId}:
patch:
tags:
@@ -38495,55 +38538,56 @@ components:
medication.intake.notTracked, medication.link.invalid; medications — medications.category.invalid,
medications.category.limitReached, medications.category.unknown, medications.course.currentOpen,
medications.course.invalid, medications.course.invalidRange, medications.course.notFound,
- medications.course.oneShot, medications.course.overlap, medications.course.windowRequired,
- medications.ingredients.extract.budgetExceeded, medications.ingredients.extract.failed,
- medications.ingredients.extract.fileTooLarge, medications.ingredients.extract.fileType,
- medications.ingredients.extract.rateLimited, medications.ingredients.invalid,
- medications.ingredients.unitMismatch, medications.intake.bulk.apple_health_not_mirrored,
- medications.intake.bulk.invalid, medications.intake.bulk.too_large,
- medications.intake.force_slot.invalid, medications.intake.force_slot.occupied,
- medications.intake.injection_site.disallowed, medications.intake.taken_at.before_start,
- medications.mirror.limit_exceeded, medications.notFound; mentalHealth — mentalHealth.rateLimited; module
- — module.disabled; modules — modules.body.invalid_json, modules.invalid; mood —
- mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, mood.create.external_id_not_delegable,
- mood.create.invalid, mood.duplicate_timestamp, mood.linkedContext.invalid, mood.list.invalid,
- mood.not_found, mood.ratedFactor.out_of_range, mood.restore.invalid, mood.update.invalid;
- notification-prefs — notification-prefs.body.invalid_json; notification —
- notification.action.already_used, notification.action.expired, notification.action.invalid,
- notification.action.module_disabled; nutrient — nutrient.batch.invalid, nutrient.batch.too_large,
- nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day;
- onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid,
- onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch,
- onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited,
- onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown,
- passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner —
- practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited,
- profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved;
- profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported,
- profile_claim.sso_unavailable, profile_claim.taken; record_write — record_write.rate_limited;
- report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
- report-selection.leaves.unknown; route — route.retired; search — search.invalid; security_activity —
- security_activity.invalid; share-link — share-link.document.decryptFailed, share-link.document.notFound,
- share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound,
- share-link.report.rateLimited, share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf;
- sharing — sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending,
- sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid,
- sharing.invite.invalid_scope, sharing.invite.manage_browser_only, sharing.invite.no_account,
- sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
- sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
- sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
- source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
- symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
- symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
- tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
- tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
- vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
- vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
- vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
- vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
- vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
- workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
- — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
+ medications.course.notRunning, medications.course.oneShot, medications.course.overlap,
+ medications.course.windowRequired, medications.ingredients.extract.budgetExceeded,
+ medications.ingredients.extract.failed, medications.ingredients.extract.fileTooLarge,
+ medications.ingredients.extract.fileType, medications.ingredients.extract.rateLimited,
+ medications.ingredients.invalid, medications.ingredients.unitMismatch,
+ medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid,
+ medications.intake.bulk.too_large, medications.intake.force_slot.invalid,
+ medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed,
+ medications.intake.taken_at.before_start, medications.mirror.limit_exceeded, medications.notFound;
+ mentalHealth — mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json,
+ modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large,
+ mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp,
+ mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range,
+ mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json;
+ notification — notification.action.already_used, notification.action.expired,
+ notification.action.invalid, notification.action.module_disabled; nutrient — nutrient.batch.invalid,
+ nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid,
+ nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited,
+ onboarding.complete.invalid, onboarding.complete.recordNotFound,
+ onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid,
+ onboarding.restart.rateLimited, onboarding.tour.invalid; passkey — passkey.challenge.expired,
+ passkey.response.invalid, passkey.unknown, passkey.verification.failed; personal_records —
+ personal_records.invalid_query; practitioner — practitioner.invalid; profile —
+ profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired,
+ profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
+ profile_claim.oidc_only_unsupported, profile_claim.sso_unavailable, profile_claim.taken; record_write —
+ record_write.rate_limited; report-selection — report-selection.body.invalid_json,
+ report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; search —
+ search.invalid; security_activity — security_activity.invalid; share-link —
+ share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
+ share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
+ share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
+ sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
+ sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
+ sharing.invite.manage_browser_only, sharing.invite.no_account, sharing.invite.refused,
+ sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended, sharing.renounce.not_found,
+ sharing.revoke.already_ended, sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
+ sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
+ source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
+ symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
+ timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
+ tokens.measurements.ceiling_reached, tokens.mirror.not_measurement_token,
+ tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid,
+ vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found,
+ vaccination.custom.invalid, vaccination.custom.name-taken, vaccination.custom.not-found,
+ vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
+ vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
+ workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
+ workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict,
+ ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
health_score_config_conflict, insights_layout_conflict, invalid_base_updated_at,
json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, missing_required_columns,
modules_conflict, mood_tag_layout_conflict, not_configured, notification_prefs_conflict, oidc_only,
@@ -53820,9 +53864,9 @@ components:
- 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."
+ description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (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
@@ -53925,13 +53969,19 @@ 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, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
- computed, not stored."
+ day, unless it was ended with `POST /api/medications/{id}/courses/end`: 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
tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not
re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored."
+ canEndCourse:
+ type: boolean
+ description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a
+ course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End
+ course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when
+ it is false. Read-only — computed, not stored.'
lastTakenAt:
anyOf:
- type: string
@@ -54017,6 +54067,7 @@ components:
- nextDueScheduleId
- courseStatus
- intakeActionable
+ - canEndCourse
- lastTakenAt
- todayEventCount
- stockUnitsRemaining
@@ -54059,9 +54110,9 @@ components:
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.'
+ description: "#1280 — the instant `POST /api/medications/{id}/courses/end` 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:
@@ -54272,9 +54323,9 @@ components:
- 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."
+ description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (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
@@ -54372,13 +54423,19 @@ 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, unless it was ended with \"End course\": then it is ENDED from that instant (`endedAt`). Read-only —
- computed, not stored."
+ day, unless it was ended with `POST /api/medications/{id}/courses/end`: 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
tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not
re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored."
+ canEndCourse:
+ type: boolean
+ description: '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a
+ course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End
+ course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when
+ it is false. Read-only — computed, not stored.'
stockUnitsRemaining:
description: "v1.37.19 — usable inventory units left (same semantics as the list entry's field): NULL = inventory
tracking off; 0 = tracking on, supply ran out."
@@ -54445,6 +54502,7 @@ components:
- canStartCourse
- courseStatus
- intakeActionable
+ - canEndCourse
- stockUnitsRemaining
- stockDosesRemaining
- runwayDays
@@ -54725,6 +54783,47 @@ components:
- data
- error
additionalProperties: false
+ EndMedicationCourseResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ startsOn:
+ anyOf:
+ - type: string
+ 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"
+ endsOn:
+ anyOf:
+ - type: string
+ 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
+ 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"
+ required:
+ - startsOn
+ - endsOn
+ - endedAt
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
UpdateMedicationCourseResponse:
type: object
properties:
@@ -93831,9 +93930,9 @@ components:
- 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."
+ description: "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (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
diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts
index b4db923dd..ba785e961 100644
--- a/src/__tests__/aging-fixture-dates-guard.test.ts
+++ b/src/__tests__/aging-fixture-dates-guard.test.ts
@@ -73,6 +73,11 @@ const ALLOWLIST: Record = {
max: 2,
reason: "the cases pin the clock with fake timers around the fixed dates",
},
+ "tests/integration/medication-course-end.test.ts": {
+ max: 1,
+ reason:
+ "the cases pin the clock with fake timers; the history read starts on the fixed day the course was ended",
+ },
"tests/integration/symptoms.test.ts": {
max: 1,
reason:
diff --git a/src/app/api/medications/[id]/courses/end/route.ts b/src/app/api/medications/[id]/courses/end/route.ts
new file mode 100644
index 000000000..3a3088282
--- /dev/null
+++ b/src/app/api/medications/[id]/courses/end/route.ts
@@ -0,0 +1,78 @@
+/**
+ * #1280 — "End course": end the running course now.
+ *
+ * POST /api/medications/{id}/courses/end — no body. The course's last day
+ * becomes today on the person's clock and the moment is recorded as
+ * its `endedAt`, so a dose later today is not due, not reminded and
+ * not expected, while one taken earlier today still counts. The
+ * medication reads as ENDED at once.
+ *
+ * An action of its own rather than `PUT { endsOn }`: an end date set through
+ * the PUT or the course editor keeps meaning the whole day. Refused with 409
+ * `medications.course.notRunning` while no course is running, the same rule
+ * the reads publish as `canEndCourse`.
+ *
+ * Owner-only, like the other course routes.
+ */
+import { NextRequest } from "next/server";
+
+import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { auditLog } from "@/lib/auth/audit";
+import { invalidateUserMedications } from "@/lib/cache/invalidate";
+import { withIdempotency } from "@/lib/idempotency";
+import { annotate } from "@/lib/logging/context";
+import {
+ CourseWriteError,
+ courseRefusalResponse,
+ endCurrentCourse,
+} from "@/lib/medications/courses";
+import { dayKeyOfDate } from "@/lib/medications/course-window";
+import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
+
+export const dynamic = "force-dynamic";
+
+type RouteParams = { params: Promise<{ id: string }> };
+
+export const POST = apiHandler(
+ withIdempotency<[NextRequest, RouteParams]>(postEndCourse),
+);
+
+async function postEndCourse(
+ request: NextRequest,
+ { params }: RouteParams,
+): Promise {
+ const { user } = await requireAuth();
+ const { id } = await params;
+
+ try {
+ const window = await endCurrentCourse({
+ userId: user.id,
+ medicationId: id,
+ timeZone: user.timezone || DEFAULT_TIMEZONE,
+ });
+ invalidateUserMedications(user.id, { evict: true });
+ const body = {
+ startsOn: window.startsOn ? dayKeyOfDate(window.startsOn) : null,
+ endsOn: window.endsOn ? dayKeyOfDate(window.endsOn) : null,
+ endedAt: window.endedAt ? window.endedAt.toISOString() : null,
+ };
+ await auditLog("medication.course.end", {
+ userId: user.id,
+ ipAddress: getClientIp(request),
+ details: { medicationId: id, ...body },
+ });
+ annotate({
+ action: { name: "medication.course.end" },
+ meta: { medicationId: id },
+ });
+ return apiSuccess(body);
+ } catch (err) {
+ if (err instanceof CourseWriteError) {
+ const r = courseRefusalResponse(err.refusal);
+ annotate({ meta: { "medication.course.refused": err.refusal } });
+ return apiError(r.message, r.status, { errorCode: r.errorCode });
+ }
+ throw err;
+ }
+}
diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx
index 9cefc459a..12f6c37de 100644
--- a/src/components/medications/detail/medication-detail-tabs.tsx
+++ b/src/components/medications/detail/medication-detail-tabs.tsx
@@ -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. */
diff --git a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
index 5992803ea..ca6b40a6e 100644
--- a/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
+++ b/src/components/medications/sections/__tests__/destructive-zone-section.test.tsx
@@ -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(
@@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => {
id: "med-1",
name: "Example",
active: true,
- courseStatus,
+ canEndCourse: served,
}}
/>
@@ -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"');
diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx
index ae1c193c2..f048f06a6 100644
--- a/src/components/medications/sections/destructive-zone-section.tsx
+++ b/src/components/medications/sections/destructive-zone-section.tsx
@@ -13,8 +13,10 @@
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `` so the entire row is the
* AT hit target.
- * - Tier 2 Beenden: PUT `{ endsOn: }`. 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({
/>
- {canEndCourse(medication.courseStatus) && (
+ {medication.canEndCourse === true && (
{
expect(at("2026-06-12T22:00:00.000Z")).toBe("ENDED");
});
+ it("offers End only while a course runs", () => {
+ expect(canEndCourse("CURRENT")).toBe(true);
+ expect(canEndCourse("ENDED")).toBe(false);
+ expect(canEndCourse("UPCOMING")).toBe(false);
+ // No dates at all is running.
+ expect(
+ canEndCourse(
+ courseStatusAt({ startsOn: null, endsOn: null }, ENDED_AT, "UTC"),
+ ),
+ ).toBe(true);
+ });
+
+ it("ends on the person's own day, at the moment itself", () => {
+ const late = new Date("2026-06-11T23:30:00.000Z");
+ expect(courseEndNow(late, "Europe/Warsaw")).toEqual({
+ endsOn: d("2026-06-12"),
+ endedAt: late,
+ });
+ expect(courseEndNow(late, "America/New_York").endsOn).toEqual(
+ d("2026-06-11"),
+ );
+ });
+
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);
diff --git a/src/lib/medications/__tests__/intake-actionable.test.ts b/src/lib/medications/__tests__/intake-actionable.test.ts
index 5c145d1d0..5fdfac777 100644
--- a/src/lib/medications/__tests__/intake-actionable.test.ts
+++ b/src/lib/medications/__tests__/intake-actionable.test.ts
@@ -46,6 +46,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
+ canEndCourse: true,
});
}
});
@@ -55,6 +56,7 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(r).toEqual({
courseStatus: "ENDED",
intakeActionable: false,
+ canEndCourse: false,
});
});
@@ -63,10 +65,12 @@ describe.each(ZONES)("course window in %s", (tz) => {
expect(resolveIntakeActionability(med, at(tz, 24, 23, 59), tz)).toEqual({
courseStatus: "UPCOMING",
intakeActionable: false,
+ canEndCourse: false,
});
expect(resolveIntakeActionability(med, at(tz, 25, 0, 0), tz)).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
+ canEndCourse: true,
});
});
});
@@ -90,6 +94,7 @@ describe("resolveIntakeActionability", () => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: false,
+ canEndCourse: true,
});
});
@@ -102,6 +107,7 @@ describe("resolveIntakeActionability", () => {
expect(r).toEqual({
courseStatus: "CURRENT",
intakeActionable: false,
+ canEndCourse: true,
});
});
@@ -131,6 +137,7 @@ describe("a course ended with End course (#1280)", () => {
expect(before).toEqual({
courseStatus: "CURRENT",
intakeActionable: true,
+ canEndCourse: true,
});
const after = resolveIntakeActionability(
ended,
@@ -140,6 +147,7 @@ describe("a course ended with End course (#1280)", () => {
expect(after).toEqual({
courseStatus: "ENDED",
intakeActionable: false,
+ canEndCourse: false,
});
});
});
diff --git a/src/lib/medications/course-window.ts b/src/lib/medications/course-window.ts
index 333feff9a..a97d9711c 100644
--- a/src/lib/medications/course-window.ts
+++ b/src/lib/medications/course-window.ts
@@ -143,6 +143,29 @@ export function courseStatusAt(
return "CURRENT";
}
+/**
+ * #1280 — the end "End course" writes: the person's own calendar day as the
+ * course's last day (never the UTC one, a day off around midnight), and the
+ * moment itself as `endedAt`.
+ */
+export function courseEndNow(
+ now: Date,
+ timeZone: string,
+): { endsOn: Date; endedAt: Date } {
+ return { endsOn: dateOfDayKey(userDayKey(now, timeZone)), endedAt: now };
+}
+
+/**
+ * #1280 — whether "End course" may be pressed: a course is running now. On
+ * a course that has ended, ending it again would reopen it up to today; on
+ * one that has not started, it would turn into a one-day course today. The
+ * medication read wire publishes the answer as `canEndCourse`, and the End
+ * action refuses when it is false.
+ */
+export function canEndCourse(courseStatus: CourseStatus): boolean {
+ return courseStatus === "CURRENT";
+}
+
export type CourseRefusal =
/** `endsOn` before `startsOn`. */
| "invalidRange"
diff --git a/src/lib/medications/courses.ts b/src/lib/medications/courses.ts
index 2e6ce8772..73471fead 100644
--- a/src/lib/medications/courses.ts
+++ b/src/lib/medications/courses.ts
@@ -10,16 +10,21 @@
* 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.
+ * #1280 — "End course" (`endCurrentCourse`) ends the running course at the
+ * moment it is pressed: `endsOn` becomes today and `endedAt` records the
+ * instant, so no slot after it is due, reminded or expected. Every other
+ * write that moves a course's `endsOn` clears `endedAt`: 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";
import { prisma } from "@/lib/db";
import {
+ canEndCourse,
canStartCourse,
+ courseEndNow,
courseFromWindow,
+ courseStatusAt,
courseStatusOn,
dateOfDayKey,
dayKeyOfDate,
@@ -43,7 +48,9 @@ export type CourseWriteRefusal =
/** Clearing the window of a medication that has several courses. */
| "windowRequired"
/** The course or the medication is not the caller's. */
- | "notFound";
+ | "notFound"
+ /** #1280 — "End course" on a medication with no course running now. */
+ | "notRunning";
export class CourseWriteError extends Error {
constructor(readonly refusal: CourseWriteRefusal) {
@@ -89,6 +96,12 @@ export function courseRefusalResponse(refusal: CourseWriteRefusal): {
errorCode: "medications.course.invalidRange",
message: "A course cannot end before it starts",
};
+ case "notRunning":
+ return {
+ status: 409,
+ errorCode: "medications.course.notRunning",
+ message: "No course is running to end",
+ };
case "windowRequired":
return {
status: 422,
@@ -450,6 +463,87 @@ function keptEndedAt(
: null;
}
+/**
+ * #1280 — "End course": end the running course now.
+ *
+ * The course's last day becomes today on the person's clock and `endedAt`
+ * records the moment. Doses earlier today stay inside the course and count;
+ * a slot later today is not due, not reminded and not expected, and the
+ * course reads as ENDED at once (`courseStatusAt`). A medication without a
+ * course row (chronic since creation, or a window never materialised) gets
+ * the course its window describes, ended now.
+ *
+ * The pending placeholders already minted for slots at or after that moment
+ * (the today projector mints the whole day) are tombstoned, as a schedule
+ * replace does, so no list shows them and the auto-miss pass never stamps
+ * them a miss. An actioned row is history and stays.
+ *
+ * Refused with `notRunning` unless a course is running now
+ * (`canEndCourse`): ending an ended course would reopen it up to today, and
+ * ending one that has not started would invent a one-day course.
+ */
+export async function endCurrentCourse(
+ args: WriteContext,
+): Promise {
+ const now = args.now ?? new Date();
+ const todayKey = userDayKey(now, args.timeZone);
+ const { endsOn: today, endedAt } = courseEndNow(now, args.timeZone);
+ return prisma.$transaction(async (tx) => {
+ await lockMedication(tx, args.medicationId);
+ const { medication, courses } = await loadOwned(
+ tx,
+ args.userId,
+ args.medicationId,
+ );
+ if (!canEndCourse(courseStatusAt(medication, now, args.timeZone))) {
+ throw new CourseWriteError("notRunning");
+ }
+ const latest = sortCourses(courses).at(-1);
+ if (latest) {
+ const ended = { startsOn: latest.startsOn, endsOn: today, endedAt };
+ refuseIfInvalid(
+ courses.map((c) => (c.id === latest.id ? ended : c)),
+ todayKey,
+ medication.oneShot,
+ );
+ await tx.medicationCourse.update({
+ where: { id: latest.id },
+ data: { endsOn: today, endedAt },
+ });
+ } else {
+ const implicit = windowAsCourse(
+ medication.startsOn,
+ today,
+ undefined,
+ medication.createdAt,
+ args.timeZone,
+ );
+ refuseIfInvalid([implicit], todayKey, medication.oneShot);
+ await tx.medicationCourse.create({
+ data: {
+ medicationId: args.medicationId,
+ userId: args.userId,
+ ...implicit,
+ endedAt,
+ },
+ });
+ }
+ await tx.medicationIntakeEvent.updateMany({
+ where: {
+ userId: args.userId,
+ medicationId: args.medicationId,
+ deletedAt: null,
+ takenAt: null,
+ skipped: false,
+ autoMissed: false,
+ scheduledFor: { gte: now },
+ },
+ data: { deletedAt: now, syncVersion: { increment: 1 } },
+ });
+ return project(tx, args.medicationId);
+ });
+}
+
/**
* 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
diff --git a/src/lib/medications/intake-actionable.ts b/src/lib/medications/intake-actionable.ts
index cd586a720..4aaf27bcf 100644
--- a/src/lib/medications/intake-actionable.ts
+++ b/src/lib/medications/intake-actionable.ts
@@ -23,10 +23,13 @@
* 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.
+ * #1280 — `canEndCourse` rides along: "End course" is offered exactly while
+ * the course is CURRENT, from the same resolution. A course ended with it is
+ * ENDED from that instant (`endedAt`), so the action row and the End button
+ * go away at once instead of at midnight.
*/
import {
+ canEndCourse,
courseStatusAt,
type CourseStatus,
} from "@/lib/medications/course-window";
@@ -35,6 +38,7 @@ import { isRecordOnly } from "@/lib/medications/intake-tracking";
export interface IntakeActionability {
courseStatus: CourseStatus;
intakeActionable: boolean;
+ canEndCourse: boolean;
}
export function resolveIntakeActionability(
@@ -55,5 +59,6 @@ export function resolveIntakeActionability(
medication.active &&
!isRecordOnly(medication) &&
courseStatus === "CURRENT",
+ canEndCourse: canEndCourse(courseStatus),
};
}
diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts
index 7433c5490..eefaaf11d 100644
--- a/src/lib/openapi/error-codes.ts
+++ b/src/lib/openapi/error-codes.ts
@@ -312,6 +312,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> =
"medications.course.invalid",
"medications.course.invalidRange",
"medications.course.notFound",
+ "medications.course.notRunning",
"medications.course.oneShot",
"medications.course.overlap",
"medications.course.windowRequired",
diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts
index d5ef6928f..0f8961e85 100644
--- a/src/lib/openapi/routes/medications/paths.ts
+++ b/src/lib/openapi/routes/medications/paths.ts
@@ -1178,6 +1178,45 @@ export const medicationPaths: NonNullable = {
},
},
},
+ "/api/medications/{id}/courses/end": {
+ post: {
+ parameters: [idempotencyKeyParameter],
+ tags: ["Medications"],
+ summary: "End the running course now",
+ description:
+ "#1280 — \"End course\". No body. The running course's last day becomes today on the person's clock and the moment is stored as its `endedAt`: a dose slot later today is not due, not reminded and not expected by compliance, a dose taken earlier today still counts, and `courseStatus` reads ENDED at once. Pending placeholders for slots at or after the moment are removed (tombstoned for sync). A medication with no dates gets the course its window describes, from creation to today. An end date set through `PUT /api/medications/{id}` or the course editor keeps meaning the whole day; only this action ends a course at an instant. Refused with 409 `medications.course.notRunning` while no course is running — the rule the reads publish as `canEndCourse`. Owner-only.",
+ requestParams: { path: z.object({ id: z.string() }) },
+ responses: {
+ ...idempotentWrite(),
+ "200": {
+ description: "The medication's window after ending.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({
+ startsOn: z.iso.date().nullable(),
+ endsOn: z.iso.date().nullable(),
+ endedAt: isoDateTime().nullable(),
+ }),
+ "EndMedicationCourseResponse",
+ ),
+ },
+ },
+ },
+ "404": {
+ description:
+ "Medication not found (`medications.course.notFound`), or another user's.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ "409": {
+ description:
+ "No course is running now (`medications.course.notRunning`): it has ended already or has not started.",
+ content: { "application/json": { schema: errorEnvelope } },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/medications/{id}/courses/{courseId}": {
patch: {
tags: ["Medications"],
diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts
index 2e6c11d0c..6a5d44487 100644
--- a/src/lib/openapi/routes/medications/schemas.ts
+++ b/src/lib/openapi/routes/medications/schemas.ts
@@ -69,7 +69,7 @@ export const medicationCourseResource = z
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.',
+ "#1280 — the instant `POST /api/medications/{id}/courses/end` 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"])
@@ -375,7 +375,7 @@ export const medicationResource = z
.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.',
+ "#1280 — the instant the latest course was ended with `POST /api/medications/{id}/courses/end` (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()
@@ -453,13 +453,18 @@ export const medicationListEntry = 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, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). 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 `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
),
intakeActionable: z
.boolean()
.describe(
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
),
+ canEndCourse: z
+ .boolean()
+ .describe(
+ '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
+ ),
lastTakenAt: isoDateTime()
.nullable()
.describe(
@@ -519,13 +524,18 @@ 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, unless it was ended with "End course": then it is ENDED from that instant (`endedAt`). 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 `POST /api/medications/{id}/courses/end`: then it is ENDED from that instant (`endedAt`). Read-only — computed, not stored.",
),
intakeActionable: z
.boolean()
.describe(
"v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.",
),
+ canEndCourse: z
+ .boolean()
+ .describe(
+ '#1280 — whether `POST /api/medications/{id}/courses/end` may end the course now: true exactly while a course is running (`courseStatus` CURRENT; a medication with no dates is running). Clients offer "End course" from this and do not re-derive it; the action refuses with 409 `medications.course.notRunning` when it is false. Read-only — computed, not stored.',
+ ),
stockUnitsRemaining: z
.number()
.nullable()
diff --git a/tests/integration/medication-course-end.test.ts b/tests/integration/medication-course-end.test.ts
new file mode 100644
index 000000000..f60717e17
--- /dev/null
+++ b/tests/integration/medication-course-end.test.ts
@@ -0,0 +1,488 @@
+/**
+ * "End course" (#1280) against a real Postgres, through the shipped routes,
+ * the reminder worker, the today projector and the auto-miss pass.
+ *
+ * What the file pins:
+ * - the medication's PUT takes `endsOn` as a date (the request the End
+ * button used to send as a full instant was refused with 422), and an
+ * end date set that way keeps meaning the whole day;
+ * - `POST /courses/end` ends the running course at that moment: the last
+ * day is today on the person's clock and `endedAt` the instant, and the
+ * reads serve it ENDED with `canEndCourse` false at once;
+ * - a dose slot later that day is not due (no placeholder on the today
+ * list), not reminded and never missed, while a dose taken earlier that
+ * day still counts in compliance;
+ * - `canEndCourse` is true only while a course runs, and the action refuses
+ * an ended or upcoming course with 409;
+ * - an edit that moves the end clears the instant.
+ *
+ * The person lives in Europe/Berlin (UTC+2 in June). The medication doses at
+ * 08:00 and 20:00 local, 06:00Z and 18:00Z.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { NextRequest } from "next/server";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => cookieJar.set(name, value),
+ delete: (name: string) => cookieJar.delete(name),
+ })),
+ };
+});
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+vi.mock("@/lib/notifications/dispatcher", () => ({
+ dispatchNotification: vi.fn().mockResolvedValue({
+ dispatched: true,
+ channelsAttempted: 1,
+ channelsSucceeded: 1,
+ }),
+}));
+vi.mock("@/lib/notifications/medication-intake-sync", () => ({
+ queueMedicationIntakeSync: vi.fn(),
+}));
+
+const { dispatchNotification } = await import("@/lib/notifications/dispatcher");
+const { runIntakeAutoSkipPass } = await import("@/lib/jobs/intake-auto-skip");
+const { handleReminderCheck } =
+ await import("@/lib/jobs/reminder/medication-reminder-check");
+
+const TZ = "Europe/Berlin";
+const MORNING = "2026-06-10T06:00:00.000Z";
+const EVENING = "2026-06-10T18:00:00.000Z";
+const ENDED_AT = "2026-06-10T10:00:00.000Z";
+
+type Handler = (
+ request: NextRequest,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ context: { params: Promise },
+) => Promise;
+
+function at(iso: string): void {
+ vi.setSystemTime(new Date(iso));
+}
+
+function req(method: string, path: string, body?: unknown) {
+ return new NextRequest(`http://localhost${path}`, {
+ method,
+ headers: { "content-type": "application/json" },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ });
+}
+
+async function call(
+ modulePath: string,
+ method: "GET" | "POST" | "PUT" | "PATCH",
+ path: string,
+ params: Record,
+ body?: unknown,
+) {
+ const route = (await import(/* @vite-ignore */ modulePath)) as Record<
+ string,
+ Handler
+ >;
+ const res = await route[method](req(method, path, body), {
+ params: Promise.resolve(params),
+ });
+ return { status: res.status, body: await res.json() };
+}
+
+const endCourse = (id: string) =>
+ call(
+ "@/app/api/medications/[id]/courses/end/route",
+ "POST",
+ `/api/medications/${id}/courses/end`,
+ { id },
+ );
+const putMedication = (id: string, body: unknown) =>
+ call(
+ "@/app/api/medications/[id]/route",
+ "PUT",
+ `/api/medications/${id}`,
+ { id },
+ body,
+ );
+const detail = async (id: string) =>
+ (
+ await call(
+ "@/app/api/medications/[id]/route",
+ "GET",
+ `/api/medications/${id}`,
+ { id },
+ )
+ ).body.data;
+
+async function makeUser(): Promise {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username: "course-end",
+ email: "course-end@example.test",
+ timezone: TZ,
+ locale: "en",
+ },
+ });
+ const session = await prisma.session.create({
+ data: {
+ userId: user.id,
+ expiresAt: new Date("2030-01-01T00:00:00.000Z"),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return user.id;
+}
+
+/** Twice a day, 08:00 and 20:00, created early on the 10th. */
+async function seedTwiceDaily(
+ userId: string,
+ window: { startsOn?: Date; endsOn?: Date } = {},
+): Promise {
+ const medication = await getPrismaClient().medication.create({
+ data: {
+ userId,
+ name: "Amoxicillin",
+ dose: "500 mg",
+ createdAt: new Date("2026-06-10T03:00:00.000Z"),
+ notificationsEnabled: true,
+ ...window,
+ schedules: {
+ create: {
+ windowStart: "08:00",
+ windowEnd: "08:00",
+ timesOfDay: ["08:00", "20:00"],
+ rrule: "FREQ=DAILY",
+ scheduleType: "SCHEDULED",
+ },
+ },
+ },
+ select: { id: true },
+ });
+ return medication.id;
+}
+
+async function takeMorningDose(userId: string, medicationId: string) {
+ await getPrismaClient().medicationIntakeEvent.create({
+ data: {
+ userId,
+ medicationId,
+ scheduledFor: new Date(MORNING),
+ takenAt: new Date("2026-06-10T06:10:00.000Z"),
+ source: "WEB",
+ },
+ });
+}
+
+async function liveRowsOn(medicationId: string, scheduledFor: string) {
+ return getPrismaClient().medicationIntakeEvent.findMany({
+ where: {
+ medicationId,
+ deletedAt: null,
+ scheduledFor: new Date(scheduledFor),
+ },
+ });
+}
+
+function dispatchedFor(medicationId: string): number {
+ return vi
+ .mocked(dispatchNotification)
+ .mock.calls.filter(
+ ([arg]) =>
+ (arg as { metadata?: { medicationId?: string } }).metadata
+ ?.medicationId === medicationId,
+ ).length;
+}
+
+beforeEach(async () => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ vi.mocked(dispatchNotification).mockClear();
+ cookieJar.clear();
+ headerJar.clear();
+ await truncateAllTables(getPrismaClient());
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("the medication PUT takes the end as a date (#1280)", () => {
+ it("answers 200 for a date and 422 for the full instant the button used to send", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+
+ const instant = await putMedication(id, { endsOn: ENDED_AT });
+ expect(instant.status).toBe(422);
+
+ const date = await putMedication(id, { endsOn: "2026-06-10" });
+ expect(date.status, JSON.stringify(date.body)).toBe(200);
+ // An end date written this way is the whole day: still running, still
+ // offering End, no instant recorded.
+ const read = await detail(id);
+ expect(read.courseStatus).toBe("CURRENT");
+ expect(read.canEndCourse).toBe(true);
+ expect(read.endedAt).toBeNull();
+ expect(read.courses[0]).toMatchObject({
+ endsOn: "2026-06-10",
+ endedAt: null,
+ });
+ });
+});
+
+describe("POST /courses/end ends the course now", () => {
+ it("records today and the instant, and the reads serve it ended at once", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ expect((await detail(id)).canEndCourse).toBe(true);
+
+ const res = await endCourse(id);
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(res.body.data).toEqual({
+ startsOn: "2026-06-10",
+ endsOn: "2026-06-10",
+ endedAt: ENDED_AT,
+ });
+
+ const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
+ where: { medicationId: id },
+ });
+ expect(course.endedAt?.toISOString()).toBe(ENDED_AT);
+ const read = await detail(id);
+ expect(read).toMatchObject({
+ endedAt: ENDED_AT,
+ courseStatus: "ENDED",
+ canEndCourse: false,
+ intakeActionable: false,
+ remindersActive: false,
+ canStartCourse: true,
+ });
+ expect(read.courses[0]).toMatchObject({
+ status: "ENDED",
+ endsOn: "2026-06-10",
+ endedAt: ENDED_AT,
+ });
+
+ // A second press finds nothing running.
+ const again = await endCourse(id);
+ expect(again.status).toBe(409);
+ expect(again.body.meta.errorCode).toBe("medications.course.notRunning");
+ });
+
+ it("drops the evening dose: not listed, not reminded, never missed", async () => {
+ at("2026-06-10T09:00:00.000Z");
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ // The today list mints both placeholders before the course is ended.
+ const today = () =>
+ call(
+ "@/app/api/medications/intake/route",
+ "GET",
+ "/api/medications/intake?scope=today",
+ {},
+ );
+ expect((await today()).status).toBe(200);
+ expect(await liveRowsOn(id, EVENING)).toHaveLength(1);
+
+ at(ENDED_AT);
+ expect((await endCourse(id)).status).toBe(200);
+ expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
+
+ // The today list does not bring it back.
+ at("2026-06-10T12:00:00.000Z");
+ expect((await today()).status).toBe(200);
+ expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
+
+ // Through the evening window and its follow-ups: no reminder, no
+ // missed-dose placeholder.
+ for (const tick of [
+ "2026-06-10T18:00:00.000Z",
+ "2026-06-10T18:45:00.000Z",
+ "2026-06-10T19:30:00.000Z",
+ "2026-06-10T21:00:00.000Z",
+ ]) {
+ at(tick);
+ await handleReminderCheck([]);
+ }
+ expect(dispatchedFor(id)).toBe(0);
+ expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
+
+ // Two days on, the auto-miss pass stamps the morning dose, which fell
+ // due while the course ran and was never taken, and nothing after the
+ // end.
+ await runIntakeAutoSkipPass(getPrismaClient(), {
+ nowMs: Date.parse("2026-06-12T12:00:00.000Z"),
+ });
+ const missed = await getPrismaClient().medicationIntakeEvent.findMany({
+ where: { medicationId: id, autoMissed: true },
+ select: { scheduledFor: true },
+ });
+ expect(missed.map((r) => r.scheduledFor.toISOString())).toEqual([MORNING]);
+ });
+
+ it("lists no evening dose when the day's list is first read after the end", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ expect((await endCourse(id)).status).toBe(200);
+ const today = await call(
+ "@/app/api/medications/intake/route",
+ "GET",
+ "/api/medications/intake?scope=today",
+ {},
+ );
+ expect(today.status).toBe(200);
+ expect(await liveRowsOn(id, MORNING)).toHaveLength(1);
+ expect(await liveRowsOn(id, EVENING)).toHaveLength(0);
+ });
+
+ it("keeps the morning dose taken before the end in compliance", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ await takeMorningDose(userId, id);
+ expect((await endCourse(id)).status).toBe(200);
+
+ at("2026-06-11T08:00:00.000Z");
+ const compliance = await call(
+ "@/app/api/medications/[id]/compliance/route",
+ "GET",
+ `/api/medications/${id}/compliance`,
+ { id },
+ );
+ expect(compliance.status).toBe(200);
+ expect(compliance.body.data.compliance7).toMatchObject({
+ totalExpected: 1,
+ taken: 1,
+ missed: 0,
+ rate: 100,
+ });
+
+ const history = await call(
+ "@/app/api/medications/[id]/dose-history/route",
+ "GET",
+ `/api/medications/${id}/dose-history?from=2026-06-10T00:00:00.000Z`,
+ { id },
+ );
+ expect(history.status).toBe(200);
+ const slots = (
+ history.body.data.rows as Array<{
+ kind: string;
+ at: string;
+ status: string;
+ }>
+ ).filter((r) => r.kind === "slot");
+ expect(slots.map((r) => [r.at, r.status])).toEqual([
+ [MORNING, "taken_on_time"],
+ ]);
+ });
+
+ it("ends a medication without dates from its creation day", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ expect(
+ await getPrismaClient().medicationCourse.count({
+ where: { medicationId: id },
+ }),
+ ).toBe(0);
+ expect((await endCourse(id)).status).toBe(200);
+ const row = await getPrismaClient().medication.findUniqueOrThrow({
+ where: { id },
+ select: { startsOn: true, endsOn: true, endedAt: true },
+ });
+ expect(row.startsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
+ expect(row.endsOn?.toISOString().slice(0, 10)).toBe("2026-06-10");
+ expect(row.endedAt?.toISOString()).toBe(ENDED_AT);
+ });
+});
+
+describe("canEndCourse follows the course", () => {
+ it.each([
+ ["running (no dates)", {}, true],
+ [
+ "running (dated)",
+ { startsOn: new Date("2026-06-01T00:00:00.000Z") },
+ true,
+ ],
+ ["upcoming", { startsOn: new Date("2026-06-20T00:00:00.000Z") }, false],
+ [
+ "ended",
+ {
+ startsOn: new Date("2026-05-01T00:00:00.000Z"),
+ endsOn: new Date("2026-05-31T00:00:00.000Z"),
+ },
+ false,
+ ],
+ ] as const)("%s → %s", async (_label, window, expected) => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId, window);
+ expect((await detail(id)).canEndCourse).toBe(expected);
+ const list = await call(
+ "@/app/api/medications/route",
+ "GET",
+ "/api/medications",
+ {},
+ );
+ const entry = (
+ list.body.data as Array<{ id: string; canEndCourse: boolean }>
+ ).find((m) => m.id === id);
+ expect(entry?.canEndCourse).toBe(expected);
+
+ const res = await endCourse(id);
+ expect(res.status).toBe(expected ? 200 : 409);
+ });
+});
+
+describe("an edit that moves the end clears the instant", () => {
+ it("reopens the course to the end of its new last day", async () => {
+ at(ENDED_AT);
+ const userId = await makeUser();
+ const id = await seedTwiceDaily(userId);
+ expect((await endCourse(id)).status).toBe(200);
+ const course = await getPrismaClient().medicationCourse.findFirstOrThrow({
+ where: { medicationId: id },
+ });
+
+ // Same last day, only the note: the instant stays.
+ const note = await call(
+ "@/app/api/medications/[id]/courses/[courseId]/route",
+ "PATCH",
+ `/api/medications/${id}/courses/${course.id}`,
+ { id, courseId: course.id },
+ { note: "Stopped after the morning dose" },
+ );
+ expect(note.status, JSON.stringify(note.body)).toBe(200);
+ expect((await detail(id)).endedAt).toBe(ENDED_AT);
+
+ // A later last day: a whole-day end again, running once more.
+ const moved = await call(
+ "@/app/api/medications/[id]/courses/[courseId]/route",
+ "PATCH",
+ `/api/medications/${id}/courses/${course.id}`,
+ { id, courseId: course.id },
+ { endsOn: "2026-06-12" },
+ );
+ expect(moved.status, JSON.stringify(moved.body)).toBe(200);
+ const read = await detail(id);
+ expect(read).toMatchObject({
+ endedAt: null,
+ courseStatus: "CURRENT",
+ canEndCourse: true,
+ });
+ });
+});
From 3df06b137d7364535148b88d8ce9bdf42ce4535e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 24 Oct 2026 22:25:18 +0200
Subject: [PATCH 08/11] chore(ratchet): lower the size baselines the course end
shrank
Refs #1280
---
src/__tests__/baselines/source-file-lines.json | 8 ++++----
src/__tests__/baselines/source-function-lines.json | 12 ++++++------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json
index 3721a2163..89987a027 100644
--- a/src/__tests__/baselines/source-file-lines.json
+++ b/src/__tests__/baselines/source-file-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 88,
- "total": 98280,
+ "total": 98267,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -13,7 +13,7 @@
"components/insights/health-score-card.tsx": 1023,
"components/insights/insights-tab-strip.tsx": 1086,
"components/measurements/measurement-form.tsx": 815,
- "components/medications/detail/medication-detail-tabs.tsx": 1160,
+ "components/medications/detail/medication-detail-tabs.tsx": 1157,
"components/medications/medication-table.tsx": 853,
"components/medications/wizard/wizard-payload.ts": 1414,
"components/mood/mood-list.tsx": 1408,
@@ -77,8 +77,8 @@
"lib/mcp/rich-reads.ts": 1467,
"lib/measurements/dense-intraday-retention.ts": 1057,
"lib/measurements/import-apple-health-export.ts": 1722,
- "lib/medications/scheduling/cadence.ts": 978,
- "lib/medications/scheduling/recurrence.ts": 1108,
+ "lib/medications/scheduling/cadence.ts": 976,
+ "lib/medications/scheduling/recurrence.ts": 1100,
"lib/medications/scheduling/slot-upsert.ts": 912,
"lib/notifications/senders/apns.ts": 1100,
"lib/oura/sync.ts": 900,
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index 3a8b2011a..09e7d4fc7 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 351,
- "total": 113740,
+ "total": 113731,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -152,7 +152,7 @@
"components/measurement-reminders/vorsorge-section.tsx :: VorsorgeSection": 352,
"components/measurements/measurement-form.tsx :: MeasurementForm": 500,
"components/medications/detail/efficacy/efficacy-chart.tsx :: EfficacyChart": 361,
- "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 771,
+ "components/medications/detail/medication-detail-tabs.tsx :: MedicationDetailTabs": 767,
"components/medications/dose-history-add-dialog.tsx :: LedgerAddDialog": 223,
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405,
@@ -248,7 +248,7 @@
"lib/ai/anthropic-client.ts :: generateCompletion": 294,
"lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218,
"lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218,
- "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 882,
+ "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 881,
"lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438,
"lib/ai/coach/turn/model.ts :: runTurnModel": 540,
"lib/ai/coach/turn/pipeline.ts :: runTurn": 274,
@@ -276,7 +276,7 @@
"lib/doctor-report-pdf/clinical-records-notes-section.ts :: buildClinicalRecordsNotesSection": 648,
"lib/doctor-report-pdf/measurements-charts-section.ts :: buildMeasurementsChartsSection": 384,
"lib/doctor-report-pdf/medication-mood-wellness-section.ts :: buildMedicationMoodWellnessSection": 509,
- "lib/doctor-report/collect.ts :: collectDoctorReportData": 668,
+ "lib/doctor-report/collect.ts :: collectDoctorReportData": 667,
"lib/doctor-report/dense-buckets.ts :: loadDenseMeasurementBuckets": 210,
"lib/documents/fenced-chat.ts :: streamFencedReply": 309,
"lib/documents/ingest.ts :: ingestDocument": 272,
@@ -333,8 +333,8 @@
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
- "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
- "lib/medications/list-read.ts :: buildMedicationsList": 309,
+ "lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 319,
+ "lib/medications/list-read.ts :: buildMedicationsList": 308,
"lib/medications/scheduling/dose-history.ts :: reconstructDoseHistory": 218,
"lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272,
"lib/notifications/dispatcher.ts :: dispatchNotification": 378,
From fd2c20dffa581916b73b7072095813568d62fdb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 24 Oct 2026 22:28:20 +0200
Subject: [PATCH 09/11] test(modules): list the course end route with its
sibling course routes
Ending the running course writes the same course rows the other course
routes do, so it follows the same data-layer reasoning: the medications
module gates the surface, not the row store.
Refs #1280
---
src/app/api/__tests__/module-route-gate-inventory.test.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts
index 9758c75e4..4ab806ca6 100644
--- a/src/app/api/__tests__/module-route-gate-inventory.test.ts
+++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts
@@ -285,6 +285,8 @@ const EXEMPT_ROUTES: ReadonlyArray = [
// v1.40 (#1024) — a medication's courses, same again.
"src/app/api/medications/[id]/courses/route.ts",
"src/app/api/medications/[id]/courses/[courseId]/route.ts",
+ // #1280 — ending the running course is a write to the same course rows.
+ "src/app/api/medications/[id]/courses/end/route.ts",
"src/app/api/medications/compliance/route.ts",
"src/app/api/medications/intake/route.ts",
"src/app/api/medications/intake/bulk/route.ts",
From 8649e0413ff5de58e9357a4a79126ff3a2298eb9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 24 Oct 2026 22:37:43 +0200
Subject: [PATCH 10/11] test(parity): add the course end fields to the
medication route snapshots
The medication reads now carry `endedAt` and `canEndCourse`, and every
course its `endedAt`; the row dumps carry the new columns. Additions only.
Refs #1280
---
.../medication-detail.01-rolling.json.snap | 11 +++++++
.../medication-detail.02-daily.json.snap | 11 +++++++
...medication-detail.03-record-only.json.snap | 11 +++++++
.../medication-detail.04-not-found.json.snap | 9 ++++++
.../medication-detail.05a-reader.json.snap | 11 +++++++
.../medication-detail.05b-anonymous.json.snap | 9 ++++++
...edication-detail.11-plain-fields.json.snap | 11 +++++++
...ation-detail.12-schedule-replace.json.snap | 11 +++++++
...dication-detail.13-schedule-echo.json.snap | 11 +++++++
...edication-detail.14-grace-bridge.json.snap | 11 +++++++
.../medication-detail.15a-pause.json.snap | 11 +++++++
.../medication-detail.15b-resume.json.snap | 11 +++++++
...cation-detail.16-auto-mark-taken.json.snap | 11 +++++++
...ation-detail.17a-custom-category.json.snap | 11 +++++++
...ion-detail.17b-native-other-kept.json.snap | 11 +++++++
.../medication-detail.17c-web-other.json.snap | 11 +++++++
...tion-detail.17d-unknown-category.json.snap | 9 ++++++
...-detail.18a-record-schedule-kept.json.snap | 11 +++++++
...edication-detail.18b-tracking-on.json.snap | 11 +++++++
...dication-detail.18c-tracking-off.json.snap | 11 +++++++
....19a-scheduled-without-schedules.json.snap | 9 ++++++
.../medication-detail.19b-scheduled.json.snap | 11 +++++++
....19c-as-needed-keeping-schedules.json.snap | 9 ++++++
.../medication-detail.19d-as-needed.json.snap | 11 +++++++
...etail.20a-one-shot-two-schedules.json.snap | 9 ++++++
...n-detail.20b-one-shot-recurrence.json.snap | 9 ++++++
.../medication-detail.20c-one-shot.json.snap | 13 ++++++++
...cation-detail.21a-course-overlap.json.snap | 10 +++++++
...etail.21b-course-window-required.json.snap | 10 +++++++
...medication-detail.21c-course-end.json.snap | 14 +++++++++
...tion-detail.22-composition-units.json.snap | 12 ++++++++
.../medication-detail.23a-invalid.json.snap | 10 +++++++
.../medication-detail.23b-not-found.json.snap | 10 +++++++
.../medication-detail.24a-writer.json.snap | 10 +++++++
.../medication-detail.24b-manager.json.snap | 12 ++++++++
.../medication-detail.24c-anonymous.json.snap | 10 +++++++
.../medication-detail.31-delete.json.snap | 9 ++++++
.../medication-detail.32-not-found.json.snap | 9 ++++++
.../medication-detail.33a-manager.json.snap | 9 ++++++
.../medication-detail.33b-anonymous.json.snap | 9 ++++++
.../medication-intake-bulk.01-mixed.json.snap | 4 +++
...ation-intake-bulk.02-mixed-again.json.snap | 4 +++
...ation-intake-bulk.03a-background.json.snap | 4 +++
...ication-intake-bulk.03b-backfill.json.snap | 4 +++
...cation-intake-bulk.04a-too-large.json.snap | 4 +++
...dication-intake-bulk.04b-invalid.json.snap | 4 +++
...take-bulk.04c-apple-not-mirrored.json.snap | 4 +++
...edication-intake-bulk.05a-native.json.snap | 4 +++
...ion-intake-bulk.05b-narrow-token.json.snap | 4 +++
...ication-intake-bulk.05c-delegate.json.snap | 4 +++
...cation-intake-bulk.05d-anonymous.json.snap | 4 +++
...on-intake-event.01-take-in-place.json.snap | 2 ++
...cation-intake-event.02-skip-flip.json.snap | 2 ++
...ion-intake-event.03-move-by-band.json.snap | 2 ++
...on-intake-event.04-pin-free-slot.json.snap | 2 ++
...n-intake-event.05-pin-not-a-slot.json.snap | 2 ++
...ion-intake-event.06-pin-occupied.json.snap | 2 ++
...medication-intake-event.07-unpin.json.snap | 2 ++
...on-intake-event.08-explicit-slot.json.snap | 2 ++
...ion-intake-event.09-before-start.json.snap | 2 ++
...dication-intake-event.10-invalid.json.snap | 2 ++
...tion-intake-event.11a-tombstoned.json.snap | 2 ++
...ntake-event.11b-other-medication.json.snap | 2 ++
...dication-intake-event.12a-writer.json.snap | 2 ++
...ication-intake-event.12b-manager.json.snap | 2 ++
...cation-intake-event.13-anonymous.json.snap | 2 ++
...edication-intake-event.21-delete.json.snap | 2 ++
...-intake-event.22-delete-one-shot.json.snap | 2 ++
...intake-event.23-other-medication.json.snap | 2 ++
...dication-intake-event.24a-writer.json.snap | 2 ++
...ication-intake-event.24b-manager.json.snap | 2 ++
...cation-intake-event.25-anonymous.json.snap | 2 ++
...edication-intake-status.01-today.json.snap | 4 +++
...ntake-status.02a-compliance-cold.json.snap | 4 +++
...ntake-status.02b-compliance-warm.json.snap | 4 +++
...ication-intake-status.03-invalid.json.snap | 4 +++
...ication-intake-status.04a-reader.json.snap | 4 +++
...tion-intake-status.04b-anonymous.json.snap | 4 +++
...n-intake-status.11-take-in-place.json.snap | 4 +++
...-intake-status.12-take-slot-move.json.snap | 4 +++
...medication-intake-status.13a-pin.json.snap | 4 +++
...intake-status.13b-pin-not-a-slot.json.snap | 4 +++
...medication-intake-status.14-skip.json.snap | 4 +++
...n-intake-status.15a-snooze-until.json.snap | 4 +++
...intake-status.15b-snooze-default.json.snap | 4 +++
...cation-intake-status.16a-unknown.json.snap | 4 +++
...cation-intake-status.16b-foreign.json.snap | 4 +++
...ion-intake-status.16c-tombstoned.json.snap | 4 +++
...take-status.17a-record-only-open.json.snap | 4 +++
...tatus.17b-record-only-correction.json.snap | 4 +++
...take-status.18a-site-not-allowed.json.snap | 4 +++
...edication-intake-status.18b-site.json.snap | 4 +++
...ication-intake-status.19-invalid.json.snap | 4 +++
...on-intake-status.20a-writer-take.json.snap | 4 +++
...status.20b-writer-snooze-refused.json.snap | 4 +++
...us.20c-writer-correction-refused.json.snap | 4 +++
...ication-intake-status.20d-reader.json.snap | 4 +++
...tion-intake-status.20e-anonymous.json.snap | 4 +++
...edication-intake.01-take-in-band.json.snap | 5 ++++
...ation-intake.02a-take-standalone.json.snap | 5 ++++
...ntake.02b-take-standalone-repeat.json.snap | 5 ++++
...ation-intake.03a-skip-named-slot.json.snap | 5 ++++
...ication-intake.03b-skip-slotless.json.snap | 5 ++++
...delegate-skip-standalone-refused.json.snap | 5 ++++
...intake.03d-owner-skip-standalone.json.snap | 5 ++++
.../medication-intake.04a-pin.json.snap | 5 ++++
...cation-intake.04b-pin-not-a-slot.json.snap | 5 ++++
...dication-intake.04c-pin-occupied.json.snap | 5 ++++
...ication-intake.05-named-backfill.json.snap | 5 ++++
.../medication-intake.06a-body-key.json.snap | 5 ++++
...ation-intake.06b-body-key-replay.json.snap | 5 ++++
...medication-intake.07a-header-key.json.snap | 5 ++++
...ion-intake.07b-header-key-replay.json.snap | 5 ++++
.../medication-intake.08-native.json.snap | 5 ++++
...intake.09a-delegate-skip-refused.json.snap | 5 ++++
...ation-intake.09b-owner-skip-flip.json.snap | 5 ++++
...edication-intake.10a-writer-take.json.snap | 5 ++++
.../medication-intake.10b-reader.json.snap | 5 ++++
.../medication-intake.10c-anonymous.json.snap | 5 ++++
...tion-intake.11a-site-not-allowed.json.snap | 5 ++++
...ication-intake.11b-site-and-dose.json.snap | 5 ++++
.../medication-intake.12-one-shot.json.snap | 5 ++++
.../medication-intake.13-invalid.json.snap | 5 ++++
.../medication-intake.14a-not-found.json.snap | 5 ++++
...edication-intake.14b-record-only.json.snap | 5 ++++
.../medication-intake.21-default.json.snap | 5 ++++
...ication-intake.22-completed-page.json.snap | 5 ++++
.../medication-intake.23a-skipped.json.snap | 5 ++++
.../medication-intake.23b-taken.json.snap | 5 ++++
.../medication-intake.24-invalid.json.snap | 5 ++++
.../medication-intake.25a-not-found.json.snap | 5 ++++
.../medication-intake.25b-reader.json.snap | 5 ++++
.../medication-intake.25c-anonymous.json.snap | 5 ++++
.../medications.01-list.json.snap | 3 ++
.../medications.02a-list-reader.json.snap | 3 ++
.../medications.02b-list-anonymous.json.snap | 1 +
.../medications.11-plain.json.snap | 4 +++
.../medications.12-full.json.snap | 7 +++++
.../medications.13a-as-needed.json.snap | 7 +++++
.../medications.13b-one-shot.json.snap | 10 +++++++
...ications.13c-prn-and-legacy-days.json.snap | 10 +++++++
.../medications.13d-ended-window.json.snap | 13 ++++++++
...medications.14a-unknown-category.json.snap | 10 +++++++
...ons.14b-course-ends-before-start.json.snap | 10 +++++++
...tions.14c-one-shot-two-schedules.json.snap | 10 +++++++
...ications.14d-one-shot-recurrence.json.snap | 10 +++++++
...ications.14e-unstable-concept-id.json.snap | 10 +++++++
.../medications.14f-invalid.json.snap | 10 +++++++
.../medications.15a-mirror.json.snap | 13 ++++++++
.../medications.15b-mirror-replay.json.snap | 13 ++++++++
.../medications.16-mirror-cap.json.snap | 30 +++++++++++++++++++
...medications.17a-idempotent-first.json.snap | 14 +++++++++
...edications.17b-idempotent-replay.json.snap | 14 +++++++++
.../medications.18a-writer.json.snap | 15 ++++++++++
.../medications.18b-reader.json.snap | 13 ++++++++
.../medications.19-anonymous.json.snap | 13 ++++++++
156 files changed, 1027 insertions(+)
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.01-rolling.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.01-rolling.json.snap
index 7d5a09722..fadfcd7b5 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.01-rolling.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.01-rolling.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": 3,
"stockDosesRemaining": 3,
"runwayDays": 21,
@@ -116,6 +118,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -150,6 +153,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -184,6 +188,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -218,6 +223,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -252,6 +258,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -286,6 +293,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -320,6 +328,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -492,6 +501,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -502,6 +512,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.02-daily.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.02-daily.json.snap
index 618a09d7a..908bc834c 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.02-daily.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.02-daily.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -89,6 +90,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -127,6 +129,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -161,6 +164,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -195,6 +199,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -229,6 +234,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -263,6 +269,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -297,6 +304,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -331,6 +339,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -503,6 +512,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -513,6 +523,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.03-record-only.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.03-record-only.json.snap
index 9b038c771..308885b93 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.03-record-only.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.03-record-only.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -69,6 +70,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -107,6 +109,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -141,6 +144,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -175,6 +179,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -209,6 +214,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -243,6 +249,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -277,6 +284,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -311,6 +319,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -483,6 +492,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -493,6 +503,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.04-not-found.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.04-not-found.json.snap
index 4be62bf07..3a838452f 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.04-not-found.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.04-not-found.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -410,6 +417,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -420,6 +428,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.05a-reader.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.05a-reader.json.snap
index 7d5a09722..fadfcd7b5 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.05a-reader.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.05a-reader.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": 3,
"stockDosesRemaining": 3,
"runwayDays": 21,
@@ -116,6 +118,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -150,6 +153,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -184,6 +188,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -218,6 +223,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -252,6 +258,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -286,6 +293,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -320,6 +328,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -492,6 +501,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -502,6 +512,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.05b-anonymous.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.05b-anonymous.json.snap
index 408d19cc6..3d27bb59f 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.05b-anonymous.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.05b-anonymous.json.snap
@@ -37,6 +37,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -71,6 +72,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -105,6 +107,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -139,6 +142,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -173,6 +177,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -207,6 +212,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -241,6 +247,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -413,6 +420,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -423,6 +431,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap
index a51c9f9c7..e29ffb88c 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.11-plain-fields.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -89,6 +90,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -127,6 +129,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -161,6 +164,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -195,6 +199,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -229,6 +234,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -263,6 +269,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -297,6 +304,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -331,6 +339,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -503,6 +512,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -513,6 +523,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap
index 633798338..ccbedd002 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.12-schedule-replace.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -135,6 +137,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -169,6 +172,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -203,6 +207,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -237,6 +242,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -271,6 +277,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -305,6 +312,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -339,6 +347,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -563,6 +572,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -573,6 +583,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap
index 798ac25e4..e65487eca 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.13-schedule-echo.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -561,6 +570,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -571,6 +581,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap
index b2c594dfe..758c2b692 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.14-grace-bridge.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -561,6 +570,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -571,6 +581,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap
index 341c62676..fb0924bcd 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.15a-pause.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -570,6 +579,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -580,6 +590,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap
index eb29dbaf6..a2e1884af 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.15b-resume.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -570,6 +579,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -580,6 +590,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap
index d9b77b14f..199a3e754 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.16-auto-mark-taken.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -570,6 +579,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -580,6 +590,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap
index 1aff35567..9ea6183b1 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17a-custom-category.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -570,6 +579,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -580,6 +590,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap
index d6d69d3b3..7253a5ac5 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17b-native-other-kept.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -135,6 +137,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -169,6 +172,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -203,6 +207,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -237,6 +242,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -271,6 +277,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -305,6 +312,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -339,6 +347,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -572,6 +581,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -582,6 +592,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap
index 8298a4f74..a0a2f9f89 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17c-web-other.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -95,6 +96,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -133,6 +135,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -167,6 +170,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -201,6 +205,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -235,6 +240,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -269,6 +275,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -303,6 +310,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -337,6 +345,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -570,6 +579,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -580,6 +590,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.17d-unknown-category.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.17d-unknown-category.json.snap
index 42df8b7a2..257b03adb 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.17d-unknown-category.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.17d-unknown-category.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -471,6 +478,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -481,6 +489,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap
index b8c24ee3e..23d2b7dd8 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18a-record-schedule-kept.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -69,6 +70,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -111,6 +113,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -145,6 +148,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -179,6 +183,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -213,6 +218,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -247,6 +253,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -281,6 +288,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -315,6 +323,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -548,6 +557,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -558,6 +568,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap
index fb5603aae..3d331df26 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18b-tracking-on.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -109,6 +111,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -143,6 +146,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -177,6 +181,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -211,6 +216,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -245,6 +251,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -279,6 +286,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -313,6 +321,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -556,6 +565,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -566,6 +576,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap
index 725e80fbf..243bfedfe 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.18c-tracking-off.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -69,6 +70,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": false,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -110,6 +112,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -144,6 +147,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -178,6 +182,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -212,6 +217,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -246,6 +252,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -280,6 +287,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -314,6 +322,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -585,6 +594,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -595,6 +605,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19a-scheduled-without-schedules.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19a-scheduled-without-schedules.json.snap
index a114b01cb..1b9000bc5 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.19a-scheduled-without-schedules.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19a-scheduled-without-schedules.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -509,6 +516,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -519,6 +527,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap
index fe0b6e967..43a995ddf 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19b-scheduled.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -108,6 +110,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -142,6 +145,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -176,6 +180,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -210,6 +215,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -244,6 +250,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -278,6 +285,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -312,6 +320,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -613,6 +622,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -623,6 +633,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19c-as-needed-keeping-schedules.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19c-as-needed-keeping-schedules.json.snap
index fbcedbd50..302d9b2d6 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.19c-as-needed-keeping-schedules.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19c-as-needed-keeping-schedules.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -539,6 +546,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -549,6 +557,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap
index 582f4f13f..ab4e2d3fb 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.19d-as-needed.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -46,6 +47,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -86,6 +88,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -120,6 +123,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -154,6 +158,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -188,6 +193,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -222,6 +228,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -256,6 +263,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -290,6 +298,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -599,6 +608,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -609,6 +619,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.20a-one-shot-two-schedules.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.20a-one-shot-two-schedules.json.snap
index bf46e1b89..c865ac348 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.20a-one-shot-two-schedules.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.20a-one-shot-two-schedules.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -547,6 +554,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -557,6 +565,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.20b-one-shot-recurrence.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.20b-one-shot-recurrence.json.snap
index a7639f5bc..5c7c0c0ca 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.20b-one-shot-recurrence.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.20b-one-shot-recurrence.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -547,6 +554,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -557,6 +565,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap
index 0560579b9..45d3302dd 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.20c-one-shot.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -64,6 +65,7 @@
"id": "",
"startsOn": "2026-10-20",
"endsOn": "2026-10-20",
+ "endedAt": null,
"status": "UPCOMING",
"takenDoses": 0,
"note": null
@@ -77,6 +79,7 @@
"nextDueOverdue": false,
"courseStatus": "UPCOMING",
"intakeActionable": false,
+ "canEndCourse": false,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -117,6 +120,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -151,6 +155,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -185,6 +190,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -219,6 +225,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -253,6 +260,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -287,6 +295,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -321,6 +330,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -658,6 +668,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -668,6 +679,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -678,6 +690,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.21a-course-overlap.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.21a-course-overlap.json.snap
index 95ee616b8..643118922 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.21a-course-overlap.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.21a-course-overlap.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -575,6 +582,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -585,6 +593,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -595,6 +604,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.21b-course-window-required.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.21b-course-window-required.json.snap
index 29c9db892..203769d88 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.21b-course-window-required.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.21b-course-window-required.json.snap
@@ -34,6 +34,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -68,6 +69,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -102,6 +104,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -136,6 +139,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -170,6 +174,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -204,6 +209,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -238,6 +244,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -575,6 +582,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -585,6 +593,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -595,6 +604,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap
index 7d472d215..32048150f 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.21c-course-end.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -64,6 +65,7 @@
"id": "",
"startsOn": "2026-08-01",
"endsOn": "2026-08-31",
+ "endedAt": null,
"status": "ENDED",
"takenDoses": 0,
"note": null
@@ -72,6 +74,7 @@
"id": "",
"startsOn": "2026-10-01",
"endsOn": "2026-12-31",
+ "endedAt": null,
"status": "CURRENT",
"takenDoses": 0,
"note": null
@@ -85,6 +88,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": null,
"stockDosesRemaining": null,
"runwayDays": null,
@@ -123,6 +127,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -157,6 +162,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -191,6 +197,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -225,6 +232,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -259,6 +267,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -293,6 +302,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -327,6 +337,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -664,6 +675,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -674,6 +686,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -684,6 +697,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap
index 02ed0c383..093b55a10 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.22-composition-units.json.snap
@@ -15,6 +15,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -68,6 +69,7 @@
"nextDueOverdue": false,
"courseStatus": "CURRENT",
"intakeActionable": true,
+ "canEndCourse": true,
"stockUnitsRemaining": 3,
"stockDosesRemaining": 1,
"runwayDays": 10,
@@ -123,6 +125,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -157,6 +160,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -191,6 +195,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -225,6 +230,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -259,6 +265,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -293,6 +300,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -327,6 +335,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -664,6 +673,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -674,6 +684,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -684,6 +695,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
diff --git a/tests/integration/routes/parity/__snapshots__/medication-detail.23a-invalid.json.snap b/tests/integration/routes/parity/__snapshots__/medication-detail.23a-invalid.json.snap
index 0967c7eaa..b7f9aba85 100644
--- a/tests/integration/routes/parity/__snapshots__/medication-detail.23a-invalid.json.snap
+++ b/tests/integration/routes/parity/__snapshots__/medication-detail.23a-invalid.json.snap
@@ -53,6 +53,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": true,
"trackIntake": true,
@@ -87,6 +88,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"oneShot": true,
"asNeeded": false,
"trackIntake": true,
@@ -121,6 +123,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -155,6 +158,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -189,6 +193,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": false,
@@ -223,6 +228,7 @@
"snoozedUntil": null,
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -257,6 +263,7 @@
"snoozedUntil": null,
"startsOn": null,
"endsOn": null,
+ "endedAt": null,
"oneShot": false,
"asNeeded": false,
"trackIntake": true,
@@ -594,6 +601,7 @@
"userId": "",
"startsOn": "2026-08-01T00:00:00.000Z",
"endsOn": "2026-08-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -604,6 +612,7 @@
"userId": "",
"startsOn": "2026-10-01T00:00:00.000Z",
"endsOn": "2026-12-31T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "",
"updatedAt": ""
@@ -614,6 +623,7 @@
"userId": "",
"startsOn": "2026-10-20T00:00:00.000Z",
"endsOn": "2026-10-20T00:00:00.000Z",
+ "endedAt": null,
"noteEncrypted": null,
"createdAt": "