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.
This commit is contained in:
haritowa committed 2026-10-24 16:57:20 +00:00
1 parent 19ae91f80a
commit 92911283be
4 files changed
+167 -95

No files matched your search

@@ -927,6 +927,7 @@ export function MedicationDetailTabs({
medicationId={id}
medicationName={medication.name}
active={medication.active}
courseStatus={served.courseStatus}
/>
</div>
{/* v1.40 (#1024) — the courses: when it was taken, and a new
@@ -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);
});
});
@@ -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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={new QueryClient()}>
<LifecycleManageBody
medicationId="med-1"
medicationName="Example"
active={true}
courseStatus={courseStatus}
/>
</QueryClientProvider>
</I18nProvider>,
);
}
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"');
});
});
@@ -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({
/>
</label>
<Separator />
{canEndCourse(courseStatus) ? (
<>
<Separator />
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.zone.end.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zone.end.helper")}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => setEndDialogOpen(true)}
className="min-h-11 font-semibold sm:min-h-9"
data-slot="destructive-zone-end"
>
<Archive aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.zone.end.button")}
</Button>
</div>
{/* Tier 2 — Beenden confirm */}
<AlertDialog open={endDialogOpen} onOpenChange={setEndDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zone.end.dialogTitle", {
name: medicationName,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zone.end.dialogBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
<div className="flex items-center justify-between gap-3">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.zone.end.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zone.end.helper")}
</p>
</div>
<Button
variant="destructive"
onClick={(e) => {
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 && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
<Archive aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.zone.end.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</div>
{/* Tier 2 — Beenden confirm */}
<AlertDialog open={endDialogOpen} onOpenChange={setEndDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.zone.end.dialogTitle", {
name: medicationName,
})}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.zone.end.dialogBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void tier2End();
}}
disabled={tier2Busy}
aria-busy={tier2Busy || undefined}
className="font-semibold"
>
{tier2Busy && (
<Loader2
aria-hidden="true"
className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.zone.end.button")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : null}
</div>
);
}