mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(medications): end a course now through its own action
"End course" now posts to `POST /api/medications/{id}/courses/end`, which
ends the running course at that moment: its last day becomes today on the
person's clock and the instant is stored as `endedAt`, so a dose later today
is not due, not reminded and never missed, while one taken earlier still
counts. Pending placeholders already minted for later slots are tombstoned.
A medication without dates gets the course its window describes, ended now.
End stays its own verb instead of `PUT { endsOn }`: an end date set through
the PUT or the course editor keeps meaning the whole day, and the PUT keeps
accepting it on any course, since moving an end is a legitimate edit.
The list and detail reads serve `canEndCourse`, true exactly while a course
is running, and the action refuses with 409 `medications.course.notRunning`
when it is false, so no client can reopen an ended course or invent a
one-day course from a planned one by ending it. The web controls render End
from the served flag; the contributed tests keep their cases against it.
Refs #1280
This commit is contained in:
1 parent
23a67f61e6
commit
275367f6e9
15 files changed
+990
-124
No files matched your search
@@ -193,6 +193,7 @@ export interface MedicationDetailSnapshot {
|
||||
/** v1.40 (#1024) — the server-resolved courses. */
|
||||
courses?: MedicationCourseWire[];
|
||||
canStartCourse?: boolean;
|
||||
canEndCourse?: boolean;
|
||||
recordedSchedules?: ScheduleSnapshot[];
|
||||
schedules: ScheduleSnapshot[];
|
||||
/** v1.43 — the composition per unit, as the server delivered it. */
|
||||
|
||||
@@ -3,13 +3,19 @@ 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";
|
||||
courseEndNow,
|
||||
dayKeyOfDate,
|
||||
} from "@/lib/medications/course-window";
|
||||
import { updateMedicationSchema } from "@/lib/validations/medication/create-update";
|
||||
|
||||
import { LifecycleManageBody } from "../destructive-zone-section";
|
||||
|
||||
/** The end day End writes, as the `YYYY-MM-DD` an `endsOn` field carries. */
|
||||
function endCourseBody(now: Date, timeZone: string): { endsOn: string } {
|
||||
return { endsOn: dayKeyOfDate(courseEndNow(now, timeZone).endsOn) };
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -22,8 +28,9 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
/**
|
||||
* "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.
|
||||
* toast said the medication could not be ended. End now asks the server to
|
||||
* end the course (`POST /courses/end`), which writes the person's own
|
||||
* calendar day (`courseEndNow`) as the last day.
|
||||
*/
|
||||
describe("endCourseBody", () => {
|
||||
it("is a date the update schema accepts", () => {
|
||||
@@ -58,10 +65,12 @@ describe("endCourseBody", () => {
|
||||
* 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.
|
||||
* the course runs. The server resolves that as `canEndCourse` (a medication
|
||||
* with no dates is CURRENT); the controls render from it, and without it
|
||||
* (a read that does not carry it) End stays hidden.
|
||||
*/
|
||||
describe("End is offered only for a running course", () => {
|
||||
function render(courseStatus?: "UPCOMING" | "CURRENT" | "ENDED") {
|
||||
function render(served: boolean | undefined) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
@@ -70,7 +79,7 @@ describe("End is offered only for a running course", () => {
|
||||
id: "med-1",
|
||||
name: "Example",
|
||||
active: true,
|
||||
courseStatus,
|
||||
canEndCourse: served,
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
@@ -80,12 +89,13 @@ describe("End is offered only for a running course", () => {
|
||||
|
||||
it.each([
|
||||
["CURRENT", true],
|
||||
[undefined, true],
|
||||
[undefined, false],
|
||||
["ENDED", false],
|
||||
["UPCOMING", false],
|
||||
] as const)("course %s → End shown: %s", (status, shown) => {
|
||||
expect(canEndCourse(status)).toBe(shown);
|
||||
const html = render(status);
|
||||
const served = status === undefined ? undefined : canEndCourse(status);
|
||||
expect(served ?? false).toBe(shown);
|
||||
const html = render(served);
|
||||
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,8 +13,10 @@
|
||||
* - 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 YYYY-MM-DD> }`. AlertDialog
|
||||
* confirm; Cancel autofocus stays default.
|
||||
* - Tier 2 Beenden: POST `/courses/end` (#1280), no body: the server ends
|
||||
* the running course at that moment, on the person's own day. Offered
|
||||
* while the read serves `canEndCourse`. AlertDialog confirm; Cancel
|
||||
* autofocus stays default.
|
||||
* - Tier 3a Verlauf löschen: DELETE `/intake/purge`. Disabled when
|
||||
* `intakeCount === 0`. AlertDialog confirm.
|
||||
* - Tier 3b Medikament löschen: DELETE `/api/medications/[id]`.
|
||||
@@ -46,33 +48,9 @@ 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";
|
||||
}
|
||||
import { apiDelete, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
const PAUSE_SWITCH_ID = "medication-detail-pause-switch";
|
||||
const PAUSE_TITLE_ID = "medication-detail-pause-title";
|
||||
@@ -90,8 +68,12 @@ export function LifecycleManageBody({
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
/** Where today sits in the course (server-resolved). */
|
||||
courseStatus?: CourseStatus;
|
||||
/**
|
||||
* Whether a course is running that End may end now (server-resolved).
|
||||
* Ending writes today as the course's last day, so on an ended course it
|
||||
* would reopen it and on one not yet started invent a one-day course.
|
||||
*/
|
||||
canEndCourse?: boolean;
|
||||
};
|
||||
onAfterAction?: () => void;
|
||||
}) {
|
||||
@@ -158,7 +140,7 @@ export function LifecycleManageBody({
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canEndCourse(medication.courseStatus) && (
|
||||
{medication.canEndCourse === true && (
|
||||
<EndCourseControl
|
||||
medicationId={medicationId}
|
||||
medicationName={medication.name}
|
||||
@@ -185,8 +167,6 @@ function EndCourseControl({
|
||||
}) {
|
||||
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);
|
||||
@@ -195,10 +175,7 @@ function EndCourseControl({
|
||||
if (tier2Busy) return;
|
||||
setTier2Busy(true);
|
||||
try {
|
||||
await apiPut(
|
||||
`/api/medications/${medicationId}`,
|
||||
endCourseBody(new Date(), userTz),
|
||||
);
|
||||
await apiPost(`/api/medications/${medicationId}/courses/end`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
toast.success(t("medications.detail.zone.end.toast"));
|
||||
setEndDialogOpen(false);
|
||||
@@ -207,6 +184,9 @@ function EndCourseControl({
|
||||
showErrorToast(t("medications.detail.zone.end.failed"), {
|
||||
cause: failure,
|
||||
});
|
||||
// A 409 means the course is no longer running (ended in another tab):
|
||||
// refetch, so the served `canEndCourse` takes the button away.
|
||||
void invalidateMedicationReads(queryClient);
|
||||
} finally {
|
||||
setTier2Busy(false);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user