mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user