Merge pull request #1281

End a medication course on the person's day, as a date, and offer End
only while the course is running (#1280).
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 21:32:39 +02:00
commit a773d839d4
3 files changed
+186 -35

No files matched your search

@@ -923,11 +923,7 @@ export function MedicationDetailTabs({
defaultOpen={false}
>
<div className="py-3">
<LifecycleManageBody
medicationId={id}
medicationName={medication.name}
active={medication.active}
/>
<LifecycleManageBody medication={medication} />
</div>
{/* v1.40 (#1024) — the courses: when it was taken, and a new
course once the last one has ended. */}
@@ -0,0 +1,93 @@
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
medication={{
id: "med-1",
name: "Example",
active: true,
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"');
});
});
@@ -13,7 +13,7 @@
* - Tier 1 Pausieren: PUT `{ active: false | true }`. Server derives
* `pausedAt`. Switch wraps in `<label>` so the entire row is the
* AT hit target.
* - Tier 2 Beenden: PUT `{ endsOn: <today-iso> }`. AlertDialog
* - Tier 2 Beenden: PUT `{ endsOn: <today YYYY-MM-DD> }`. AlertDialog
* confirm; Cancel autofocus stays default.
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
* `intakeCount === 0`. AlertDialog confirm.
@@ -46,9 +46,33 @@ 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";
}
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
const PAUSE_TITLE_ID = "medication-detail-pause-title";
@@ -59,23 +83,24 @@ 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,
medication,
onAfterAction,
}: {
medicationId: string;
medicationName: string;
active: boolean;
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);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [tier2Busy, setTier2Busy] = useState(false);
async function tier1Toggle(pauseNext: boolean) {
if (tier1Busy) return;
@@ -103,25 +128,6 @@ export function LifecycleManageBody({
}
}
async function tier2End() {
if (tier2Busy) return;
setTier2Busy(true);
try {
const today = new Date().toISOString();
await apiPut(`/api/medications/${medicationId}`, { endsOn: today });
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 (
<div className="space-y-4" data-slot="destructive-zone-card-a">
<label
@@ -152,6 +158,62 @@ export function LifecycleManageBody({
/>
</label>
{canEndCourse(medication.courseStatus) && (
<EndCourseControl
medicationId={medicationId}
medicationName={medication.name}
onAfterAction={onAfterAction}
/>
)}
</div>
);
}
/**
* 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 (
<>
<Separator />
<div className="flex items-center justify-between gap-3">
@@ -211,7 +273,7 @@ export function LifecycleManageBody({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}