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