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] 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")} -

-
- -
- - {/* 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")} +

+
+ + + + {/* 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 && ( + + + + + + ) : null} ); }