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) ? ( + <> + -
-
-

- {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} ); } 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 (
- {canEnd ? ( - <> - - -
-
-

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

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