Let a person name their own medication categories

A medication can be filed under a category the person named, next to the
built-in ones (#1041). The labels live in medication_category_labels,
encrypted at rest, capped at 20 per account; a medication filed under one
carries its custom:<uuid> key in the category column.

- GET/POST /api/medications/categories (POST idempotent, 30/min per actor,
  admitted at MANAGE like the cycle symptom vocabulary) and owner-only
  PATCH/DELETE /api/medications/categories/{key}. DELETE moves every
  medication filed under the category to OTHER in the same transaction
  and answers movedCount.
- category is widened to a built-in value or custom:<uuid> on the wire,
  the create and update bodies refuse a key that is not the caller's
  (422 medications.category.unknown), and list, detail, the insights
  medication tiles and the MCP medications resource carry categoryLabel.
  A key with no owned label reads as OTHER and is counted on the event.
- The wizard's class step lists the person's categories under the
  built-in rows with an inline add row; the list page gains a category
  filter (session only) and a manage sheet for rename, hide and delete.
- Backup carries customMedicationCategories (decrypted labels) and the
  restore writes them before the medications; wipe plan, encrypted
  column registry, rotation script, sharing guards and OpenAPI follow.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:38:03 +02:00
1 parent 350c646463
commit 4cb8118802
55 files changed
+3201 -187

No files matched your search

@@ -135,6 +135,8 @@ export interface MedicationDetailSnapshot {
name: string;
dose: string;
category: string;
/** v1.40 — the label of a custom category; null for a built-in one. */
categoryLabel?: string | null;
treatmentClass?: string;
deliveryForm?: string;
dosesPerUnit?: number | null;
@@ -96,6 +96,8 @@ export interface Glp1Medication {
name: string;
dose: string;
category: string;
/** v1.40 — the label of a custom category; null for a built-in one. */
categoryLabel?: string | null;
treatmentClass?: string;
dosesPerUnit?: number | null;
/** v1.6.0 — route of administration (drives the injection-site prompt). */
@@ -476,7 +478,11 @@ export function Glp1MedicationCard({
/>
);
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const categoryLabel = getMedicationCategoryLabel(
medication.category,
t,
medication.categoryLabel,
);
// The upcoming-injection line value. The card owns this VALUE content —
// the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while
@@ -63,6 +63,8 @@ interface Medication {
name: string;
dose: string;
category: string;
/** v1.40 — the label of a custom category; null for a built-in one. */
categoryLabel?: string | null;
/**
* v1.4.25 W4d — Prisma treatment-class discriminator. When set to
* "GLP1" the parent should render the {@link Glp1MedicationCard}
@@ -254,7 +256,11 @@ export function MedicationCard({
// background/border tint. Defaults to "upcoming" (calm) when the display
// block is absent (older mocks).
const doseStatus = display?.currentDose.status ?? "upcoming";
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
const categoryLabel = getMedicationCategoryLabel(
medication.category,
t,
medication.categoryLabel,
);
const sortedSchedules = [...medication.schedules].sort(
(a, b) =>
a.windowStart.localeCompare(b.windowStart) ||
@@ -0,0 +1,285 @@
"use client";
/**
* v1.40 (#1041) — manage the person's own medication categories: rename,
* hide from the picker, delete, add. Opens from the category filter on the
* medications list. Rows follow the mood manager's archived-tag row anatomy;
* the delete confirmation names how many medications move to Other.
*/
import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useTranslations } from "@/lib/i18n/context";
import { AddMedicationCategoryRow } from "./medication-category-add-row";
import {
type MedicationCategoryLabelDTO,
useDeleteMedicationCategory,
useMedicationCategories,
useUpdateMedicationCategory,
} from "./use-medication-categories";
function CategoryRow({
category,
onDelete,
}: {
category: MedicationCategoryLabelDTO;
onDelete: (category: MedicationCategoryLabelDTO) => void;
}) {
const { t } = useTranslations();
const update = useUpdateMedicationCategory();
const [editing, setEditing] = useState(false);
const [label, setLabel] = useState(category.label);
async function rename() {
const trimmed = label.trim();
if (!trimmed || trimmed === category.label) {
setEditing(false);
return;
}
try {
await update.mutateAsync({ key: category.key, label: trimmed });
setEditing(false);
} catch {
toast.error(t("medications.category.custom.saveFailed"));
}
}
async function toggleHidden() {
try {
await update.mutateAsync({
key: category.key,
isActive: !category.isActive,
});
} catch {
toast.error(t("medications.category.custom.saveFailed"));
}
}
if (editing) {
return (
<div
className="border-border space-y-2 rounded-md border p-3"
data-slot="medication-category-rename"
>
<Input
value={label}
maxLength={40}
autoFocus
aria-label={t("medications.category.custom.label")}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void rename();
}
}}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => {
setLabel(category.label);
setEditing(false);
}}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={!label.trim() || update.isPending}
onClick={() => void rename()}
>
{update.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("common.save")}
</Button>
</div>
</div>
);
}
return (
<div
data-slot="medication-category-row"
data-key={category.key}
className="border-border flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<Tag
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={category.label}>
{category.label}
</span>
{!category.isActive && (
<Badge variant="secondary">
{t("medications.category.custom.hidden")}
</Badge>
)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{t("medications.category.custom.count", {
count: String(category.medicationCount),
})}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => setEditing(true)}
aria-label={`${t("medications.category.custom.rename")}: ${category.label}`}
title={t("medications.category.custom.rename")}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
disabled={update.isPending}
onClick={() => void toggleHidden()}
aria-label={`${
category.isActive
? t("medications.category.custom.hide")
: t("medications.category.custom.show")
}: ${category.label}`}
title={
category.isActive
? t("medications.category.custom.hide")
: t("medications.category.custom.show")
}
>
{category.isActive ? (
<EyeOff className="size-4" aria-hidden="true" />
) : (
<Eye className="size-4" aria-hidden="true" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-11 sm:size-9"
onClick={() => onDelete(category)}
aria-label={`${t("medications.category.custom.delete")}: ${category.label}`}
title={t("medications.category.custom.delete")}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</div>
);
}
export function MedicationCategoriesSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslations();
const categories = useMedicationCategories();
const remove = useDeleteMedicationCategory();
const [pendingDelete, setPendingDelete] =
useState<MedicationCategoryLabelDTO | null>(null);
async function confirmDelete(category: MedicationCategoryLabelDTO) {
try {
const result = await remove.mutateAsync(category.key);
toast.success(
t("medications.category.custom.movedToOther", {
count: String(result.movedCount),
}),
);
} catch {
toast.error(t("medications.category.custom.saveFailed"));
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("medications.category.custom.manage")}
description={t("medications.category.custom.manageDescription")}
>
<div className="space-y-2" data-slot="medication-categories-sheet">
{categories.isError ? (
<QueryErrorRow onRetry={() => void categories.refetch()} />
) : (
(categories.data ?? []).map((category) => (
<CategoryRow
key={category.key}
category={category}
onDelete={setPendingDelete}
/>
))
)}
<AddMedicationCategoryRow />
<p className="text-muted-foreground text-xs">
{t("medications.category.custom.bpGateNote")}
</p>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(next) => {
if (!next) setPendingDelete(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.category.custom.deleteTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.category.custom.deleteBody", {
count: String(pendingDelete?.medicationCount ?? 0),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
const category = pendingDelete;
setPendingDelete(null);
if (category) void confirmDelete(category);
}}
>
{t("medications.category.custom.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ResponsiveSheet>
);
}
@@ -0,0 +1,122 @@
"use client";
import { Loader2, Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useTranslations } from "@/lib/i18n/context";
import {
isCategoryLimitError,
useCreateMedicationCategory,
} from "./use-medication-categories";
/**
* v1.40 (#1041) — the inline "New category" row: a dashed row (the cycle
* log's add-symptom pattern) that opens into a label field in place, so
* creating a category never leaves the wizard or the manage sheet. The
* caller hears the new key as soon as the category exists.
*/
export function AddMedicationCategoryRow({
onCreated,
}: {
onCreated?: (key: string) => void;
}) {
const { t } = useTranslations();
const create = useCreateMedicationCategory();
const [open, setOpen] = useState(false);
const [label, setLabel] = useState("");
function close() {
setOpen(false);
setLabel("");
create.reset();
}
async function submit() {
const trimmed = label.trim();
if (!trimmed) return;
try {
const created = await create.mutateAsync({ label: trimmed });
onCreated?.(created.key);
close();
} catch {
// `create.error` drives the inline message below.
}
}
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md border border-dashed p-3 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
data-slot="wizard-class-add"
>
<Plus className="h-5 w-5 shrink-0" aria-hidden="true" />
{t("medications.category.custom.add")}
</button>
);
}
return (
<div
className="space-y-2 rounded-md border p-3"
data-slot="wizard-class-add-form"
>
<Input
value={label}
maxLength={40}
autoFocus
aria-label={t("medications.category.custom.label")}
placeholder={t("medications.category.custom.labelPlaceholder")}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
}
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
close();
}
}}
/>
{create.error ? (
<p className="text-destructive text-sm" role="alert">
{isCategoryLimitError(create.error)
? t("medications.category.custom.limitReached")
: t("medications.category.custom.saveFailed")}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={close}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void submit()}
disabled={!label.trim() || create.isPending}
>
{create.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("medications.category.custom.create")}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,97 @@
"use client";
/**
* v1.40 (#1041) — the record's own medication categories: read, create,
* rename / hide, delete. A rename or a delete changes what every card and the
* insights medication tiles show (a delete moves medications to Other), so
* both refetch the medication reads and the insights reads. Neither touches a
* dose, so the daily reads the intake bundle refreshes stay as they are.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ApiError,
apiDelete,
apiGet,
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
const CATEGORY_DEPENDENT_KEYS = [
queryKeys.medications(),
queryKeys.insightsRoot(),
] as const;
export interface MedicationCategoryLabelDTO {
key: string;
label: string;
sortOrder: number;
isActive: boolean;
medicationCount: number;
}
/** The `errorCode` the create POST answers when the per-account cap is hit. */
export const MEDICATION_CATEGORY_LIMIT_ERROR_CODE =
"medications.category.limitReached";
/** Whether a failed create hit the cap rather than anything transient. */
export function isCategoryLimitError(error: unknown): boolean {
return (
error instanceof ApiError &&
error.meta?.errorCode === MEDICATION_CATEGORY_LIMIT_ERROR_CODE
);
}
export function useMedicationCategories(options?: { enabled?: boolean }) {
return useQuery({
enabled: options?.enabled ?? true,
queryKey: queryKeys.medicationCategories(),
queryFn: () =>
apiGet<{ categories: MedicationCategoryLabelDTO[] }>(
"/api/medications/categories",
),
staleTime: 5 * 60_000,
select: (data) => data.categories,
});
}
export function useCreateMedicationCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: { label: string }) =>
apiPost<MedicationCategoryLabelDTO>("/api/medications/categories", input),
onSuccess: () =>
qc.invalidateQueries({ queryKey: queryKeys.medicationCategories() }),
});
}
export function useUpdateMedicationCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: {
key: string;
label?: string;
isActive?: boolean;
sortOrder?: number;
}) => {
const { key, ...body } = input;
return apiPatch<MedicationCategoryLabelDTO>(
`/api/medications/categories/${encodeURIComponent(key)}`,
body,
);
},
onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS),
});
}
export function useDeleteMedicationCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (key: string) =>
apiDelete<{ key: string; movedCount: number }>(
`/api/medications/categories/${encodeURIComponent(key)}`,
),
onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS),
});
}
@@ -1330,3 +1330,85 @@ describe("category on edit (v1.39.4)", () => {
expect(body.category).toBe("SKIN");
});
});
describe("custom categories (v1.40, #1041)", () => {
const KEY = "custom:11111111-1111-4111-8111-111111111111";
const OTHER_KEY = "custom:22222222-2222-4222-8222-222222222222";
function hydrate(category: string, treatmentClass = "GENERIC") {
return hydrateWizardPayload({
id: "m1",
name: "Tamiflu",
dose: "75 mg",
category,
treatmentClass,
notificationsEnabled: true,
startsOn: null,
endsOn: null,
oneShot: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
],
});
}
it("hydrates a custom category onto the Other row with its key", () => {
const payload = hydrate(KEY);
expect(payload.treatmentRow).toBe("other");
expect(payload.customCategoryKey).toBe(KEY);
});
it("keeps the custom category on an edit that leaves it alone", () => {
const body = buildCreateBody({ ...hydrate(KEY), name: "Renamed" }, "edit");
expect("category" in body).toBe(false);
});
it("sends the new key when the person picks another custom category", () => {
const body = buildCreateBody(
{ ...hydrate(KEY), customCategoryKey: OTHER_KEY },
"edit",
);
expect(body.category).toBe(OTHER_KEY);
expect(body.treatmentClass).toBe("GENERIC");
});
it("sends the built-in value when the person moves it back to a built-in row", () => {
const body = buildCreateBody(
{ ...hydrate(KEY), treatmentRow: "vitamin", customCategoryKey: null },
"edit",
);
expect(body.category).toBe("VITAMIN");
});
it("sends the built-in Other when the person picks the Other row itself", () => {
const body = buildCreateBody(
{ ...hydrate(KEY), customCategoryKey: null },
"edit",
);
expect(body.category).toBe("OTHER");
});
it("files a new medication under a custom category on create", () => {
const body = buildCreateBody(
{
...hydrate("VITAMIN"),
treatmentRow: "other",
customCategoryKey: KEY,
},
"create",
);
expect(body.category).toBe(KEY);
expect(body.treatmentClass).toBe("GENERIC");
});
it("keeps a GLP-1 medication on the GLP-1 row whatever its category", () => {
const payload = hydrate(KEY, "GLP1");
expect(payload.treatmentRow).toBe("glp1");
expect(payload.customCategoryKey).toBeNull();
});
});
@@ -21,8 +21,12 @@ import {
} from "lucide-react";
import type { ComponentType } from "react";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { AddMedicationCategoryRow } from "../../medication-category-add-row";
import { useMedicationCategories } from "../../use-medication-categories";
import {
type WizardTreatmentRow,
WIZARD_TREATMENT_ROWS,
@@ -48,8 +52,60 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
other: Tag as unknown as LucideIcon,
};
/** One selectable row: the same anatomy for built-in and custom categories. */
function ClassRow({
value,
label,
Icon,
selected,
onSelect,
slot,
}: {
value: string;
label: string;
Icon: ComponentType<LucideProps>;
selected: boolean;
onSelect: () => void;
slot: "wizard-class-row" | "wizard-custom-class-row";
}) {
return (
<label
className={[
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot={slot}
data-row={value}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-class"
value={value}
checked={selected}
onChange={onSelect}
className="sr-only"
aria-label={label}
/>
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-medium">{label}</span>
</label>
);
}
export function Step2Class({ payload, applyPartial }: StepProps) {
const { t } = useTranslations();
const categories = useMedicationCategories();
// Creating a category is admitted at MANAGE in a shared record.
const { canManageDomain } = useRecordCapabilities();
const customKey = payload.customCategoryKey ?? null;
// Hidden categories leave the picker, except the one this medication is
// already filed under: an edit must be able to show what it has.
const customRows = (categories.data ?? []).filter(
(c) => c.isActive || c.key === customKey,
);
return (
<div
role="radiogroup"
@@ -57,49 +113,53 @@ export function Step2Class({ payload, applyPartial }: StepProps) {
className="space-y-2"
data-slot="wizard-step2"
>
{WIZARD_TREATMENT_ROWS.map((row) => {
const Icon = ROW_ICONS[row];
const selected = payload.treatmentRow === row;
const label = t(`medications.wizard.classRow.${row}`);
return (
<label
key={row}
className={[
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
data-slot="wizard-class-row"
data-row={row}
data-selected={selected ? "true" : "false"}
>
<input
type="radio"
name="wizard-class"
value={row}
checked={selected}
onChange={() =>
applyPartial(
// The GLP-1 row is always injected; nudge the
// delivery form so the common case needs no extra
// tap. The user can still flip it on Step 3.
row === "glp1"
? { treatmentRow: row, deliveryForm: "INJECTION" }
: { treatmentRow: row },
)
}
className="sr-only"
aria-label={label}
/>
<Icon
className="text-primary h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span className="text-sm font-medium">{label}</span>
</label>
);
})}
{WIZARD_TREATMENT_ROWS.map((row) => (
<ClassRow
key={row}
value={row}
label={t(`medications.wizard.classRow.${row}`)}
Icon={ROW_ICONS[row]}
selected={payload.treatmentRow === row && customKey === null}
slot="wizard-class-row"
onSelect={() =>
applyPartial(
// The GLP-1 row is always injected; nudge the
// delivery form so the common case needs no extra
// tap. The user can still flip it on Step 3.
row === "glp1"
? {
treatmentRow: row,
customCategoryKey: null,
deliveryForm: "INJECTION",
}
: { treatmentRow: row, customCategoryKey: null },
)
}
/>
))}
{customRows.map((c) => (
<ClassRow
key={c.key}
value={c.key}
label={c.label}
Icon={Tag as unknown as LucideIcon}
selected={customKey === c.key}
slot="wizard-custom-class-row"
onSelect={() =>
applyPartial({ treatmentRow: "other", customCategoryKey: c.key })
}
/>
))}
{canManageDomain("medications") && (
<AddMedicationCategoryRow
onCreated={(key) =>
applyPartial({ treatmentRow: "other", customCategoryKey: key })
}
/>
)}
<p className="text-muted-foreground text-xs">
{t("medications.category.custom.bpGateNote")}
</p>
</div>
);
}
@@ -192,6 +192,24 @@ export function rowFromTreatment(
}
}
/**
* v1.40 (#1041) — the custom category an edit hydrates to. A GLP-1
* medication is the GLP-1 row whatever its category says, as above.
*/
function customCategoryFields(
treatmentClass: string | undefined,
category: string | undefined,
): {
customCategoryKey: string | null;
initialCustomCategoryKey: string | null;
} {
const key =
treatmentClass !== "GLP1" && category?.startsWith("custom:")
? category
: null;
return { customCategoryKey: key, initialCustomCategoryKey: key };
}
// ────────────────────────────────────────────────────────────────────
// Wizard payload + per-schedule draft
// ────────────────────────────────────────────────────────────────────
@@ -257,6 +275,15 @@ export interface WizardPayload {
* Absent on create.
*/
initialTreatmentRow?: WizardTreatmentRow | null;
/**
* v1.40 (#1041) — one of the person's own categories, picked from the
* custom rows under the built-in ones. Set together with `treatmentRow:
* "other"` (the treatment class a custom category files under); null when
* a built-in row is picked.
*/
customCategoryKey?: string | null;
/** v1.40 — the custom category the edit hydrated to, for the same reason. */
initialCustomCategoryKey?: string | null;
/**
* v1.6.0 — route of administration. Drives the injection-site
* rotation preview (shown when `INJECTION`) and, paired with
@@ -628,8 +655,12 @@ export function firstInvalidIndex(
export interface CreateMedicationBody {
name: string;
dose: string;
/** Omitted on an edit that left the category row untouched (v1.39.4). */
category?: MedicationCategoryValue;
/**
* Omitted on an edit that left the category row untouched (v1.39.4). A
* built-in value or, since v1.40, the key of one of the person's own
* categories.
*/
category?: MedicationCategoryValue | `custom:${string}`;
treatmentClass?: MedicationTreatmentClass;
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
deliveryForm: MedicationDeliveryForm;
@@ -843,16 +874,21 @@ export function buildCreateBody(
// v1.39.4 — an edit that did not touch the category row leaves the stored
// category (and treatment class) alone rather than re-deriving them from
// the row, which cannot represent every stored value.
const customCategoryKey = committed.customCategoryKey ?? null;
const keepStoredCategory =
forMode === "edit" &&
committed.initialTreatmentRow !== undefined &&
committed.treatmentRow === committed.initialTreatmentRow;
committed.treatmentRow === committed.initialTreatmentRow &&
customCategoryKey === (committed.initialCustomCategoryKey ?? null);
const body: CreateMedicationBody = {
name: committed.name.trim(),
dose,
...(!keepStoredCategory && {
category: mapping.category,
treatmentClass: mapping.treatmentClass,
// v1.40 — a custom row files under the person's own category with the
// generic treatment class, like the built-in Other row.
category: (customCategoryKey ??
mapping.category) as CreateMedicationBody["category"],
treatmentClass: customCategoryKey ? "GENERIC" : mapping.treatmentClass,
}),
deliveryForm: committed.deliveryForm,
...(Number.isFinite(parsedDosesPerUnit) &&
@@ -1092,6 +1128,8 @@ export interface MedicationPayload {
name: string;
dose: string;
category: string;
/** v1.40 — the label of a custom category; null for a built-in one. */
categoryLabel?: string | null;
treatmentClass?: string;
/** v1.6.0 — route of administration. Defaults to ORAL when absent. */
deliveryForm?: string;
@@ -1239,6 +1277,7 @@ export function hydrateWizardPayload(
initial.treatmentClass,
initial.category,
),
...customCategoryFields(initial.treatmentClass, initial.category),
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
dosesPerUnit:
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1