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 1/5] 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 `
-
+ {canEndCourse(courseStatus) ? (
+ <>
+
-
+
-
-
-
+
+
+
+ {/* 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 3/5] 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 4/5] 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 (