mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge branch 'fix/meds-ended-and-mental-health' into release/v1.39.4
This commit is contained in:
commit
206da07e8c
42 files changed
+1075
-34
No files matched your search
@@ -367,3 +367,62 @@ describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
|
||||
expect(html).toContain("Taken");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.39.4 (#1040) — a medication whose course has ended keeps its card
|
||||
* (history, adherence) but offers no taken / skip and names the state.
|
||||
*/
|
||||
describe("<MedicationCard> — ended course (#1040)", () => {
|
||||
function makeMed(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "med-course-1",
|
||||
name: "Amoxicillin",
|
||||
dose: "500 mg",
|
||||
category: "ANTIBIOTIC",
|
||||
treatmentClass: undefined as string | undefined,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
schedules: [{ id: "s1", ...pastWindow }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("drops the action row and shows the Ended badge once the course is over", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "med-course-1");
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={makeMed({ courseStatus: "ENDED", intakeActionable: false })}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="medication-intake-take"');
|
||||
expect(html).not.toContain('data-slot="medication-intake-skip"');
|
||||
expect(html).toContain('data-slot="medication-course-ended-badge"');
|
||||
});
|
||||
|
||||
it("keeps the action row on the last course day", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "med-course-1");
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={makeMed({
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
})}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
expect(html).toContain('data-slot="medication-intake-take"');
|
||||
expect(html).not.toContain('data-slot="medication-course-ended-badge"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.39.4 (#1040) — the detail header's "Ended" status follows the
|
||||
* server's `courseStatus`. It used to compare the end date's UTC midnight
|
||||
* with the clock, which read the whole last course day as ended.
|
||||
*/
|
||||
import { afterEach, beforeEach, 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 { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
|
||||
|
||||
const payload = {
|
||||
id: "m1",
|
||||
name: "Amoxicillin",
|
||||
dose: "500 mg",
|
||||
category: "ANTIBIOTIC",
|
||||
notificationsEnabled: true,
|
||||
startsOn: new Date("2026-09-20T00:00:00Z"),
|
||||
endsOn: new Date("2026-09-27T00:00:00Z"),
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function render(courseStatus: "UPCOMING" | "CURRENT" | "ENDED") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<MedicationDetailSummary
|
||||
name="Amoxicillin"
|
||||
dose="500 mg"
|
||||
active
|
||||
courseStatus={courseStatus}
|
||||
payload={payload}
|
||||
oneShot={false}
|
||||
startsOn="2026-09-20T00:00:00.000Z"
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
// The last course day, mid-morning in UTC.
|
||||
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("<MedicationDetailSummary> — course status (#1040)", () => {
|
||||
it("reads as active on the last course day", () => {
|
||||
const html = render("CURRENT");
|
||||
expect(html).toContain("bg-success");
|
||||
expect(html).not.toContain("bg-muted-foreground");
|
||||
});
|
||||
|
||||
it("reads as ended once the server says the course is over", () => {
|
||||
const html = render("ENDED");
|
||||
expect(html).toContain("bg-muted-foreground");
|
||||
expect(html).not.toContain("bg-success");
|
||||
});
|
||||
});
|
||||
@@ -411,6 +411,39 @@ describe("<MedicationTable> — structure + shared payloads", () => {
|
||||
expect(html).toContain("4 doses");
|
||||
});
|
||||
|
||||
it("offers no dose actions on an ended course and names it; the last course day keeps them (#1040)", () => {
|
||||
const client = makeClient();
|
||||
seedCompliance(client, "ended", 90, 88);
|
||||
seedCompliance(client, "last", 90, 88);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({
|
||||
id: "ended",
|
||||
name: "Amoxicillin",
|
||||
courseStatus: "ENDED",
|
||||
intakeActionable: false,
|
||||
}),
|
||||
med({
|
||||
id: "last",
|
||||
name: "Prednisolone",
|
||||
courseStatus: "CURRENT",
|
||||
intakeActionable: true,
|
||||
}),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('aria-label="Take – Amoxicillin"');
|
||||
expect(html).not.toContain('aria-label="Skip – Amoxicillin"');
|
||||
expect(html).toContain('data-slot="medication-table-course-ended-marker"');
|
||||
expect(html).toContain(">Ended<");
|
||||
expect(html).toContain('aria-label="Take – Prednisolone"');
|
||||
expect(html).toContain('aria-label="Skip – Prednisolone"');
|
||||
});
|
||||
|
||||
it("renders an as-needed row: calm marker, no compliance bars (v1.16.11 #316)", () => {
|
||||
const client = makeClient();
|
||||
// Deliberately NOT seeding compliance for the PRN row — the batched
|
||||
|
||||
@@ -174,6 +174,23 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
|
||||
expect(due.map((d) => d.id)).toEqual(["due"]);
|
||||
});
|
||||
|
||||
it("excludes a medication whose course has ended, even with an escalated dose (#1040)", () => {
|
||||
const due = deriveDueMedications(
|
||||
[
|
||||
makeMed({ id: "ended", intakeActionable: false }),
|
||||
makeMed({ id: "on" }),
|
||||
],
|
||||
{
|
||||
...opts,
|
||||
doseStatusById: new Map([
|
||||
["ended", "missed" as const],
|
||||
["on", "upcoming" as const],
|
||||
]),
|
||||
},
|
||||
);
|
||||
expect(due.map((d) => d.id)).toEqual(["on"]);
|
||||
});
|
||||
|
||||
it("excludes a medication whose dose band is not currently open", () => {
|
||||
const due = deriveDueMedications(
|
||||
[
|
||||
|
||||
@@ -141,6 +141,13 @@ export interface MedicationCardBodyProps {
|
||||
*/
|
||||
recordOnly?: boolean;
|
||||
|
||||
/**
|
||||
* v1.39.4 (#1040) — the server's `intakeActionable`: false once the course
|
||||
* has ended (or before it starts), so the card offers no take / skip and
|
||||
* raises no overdue escalation for a dose that is no longer part of it.
|
||||
*/
|
||||
intakeActionable?: boolean;
|
||||
|
||||
/**
|
||||
* v1.16.11 — projected supply runway in whole days, set ONLY while it
|
||||
* sits below the user's low-stock threshold (the variants gate it).
|
||||
@@ -191,6 +198,7 @@ export function MedicationCardBody({
|
||||
currentCycle,
|
||||
asNeeded = false,
|
||||
recordOnly = false,
|
||||
intakeActionable = true,
|
||||
lowStockRunwayDays = null,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
@@ -203,10 +211,11 @@ export function MedicationCardBody({
|
||||
// past the clinical miss cutoff) reads "Stark überfällig"; the still-takeable
|
||||
// `overdue` tail reads the calmer "Überfällig". Both use the destructive
|
||||
// tone so the urgency is unmistakable; suppressed on an inactive med.
|
||||
const canAct = active && intakeActionable;
|
||||
const overdueLabel =
|
||||
active && doseStatus === "missed"
|
||||
canAct && doseStatus === "missed"
|
||||
? t("medications.veryOverdue")
|
||||
: active && doseStatus === "overdue"
|
||||
: canAct && doseStatus === "overdue"
|
||||
? t("medications.overdue")
|
||||
: null;
|
||||
|
||||
@@ -352,7 +361,7 @@ export function MedicationCardBody({
|
||||
|
||||
{/* Quick actions — bottom-pinned so the action rows align across a
|
||||
grid row regardless of how much content sits above. */}
|
||||
{active && !recordOnly && (
|
||||
{canAct && !recordOnly && (
|
||||
<div className="mt-auto pt-0">
|
||||
<MedicationIntakeActions
|
||||
intakeLoading={intakeLoading}
|
||||
|
||||
@@ -8,6 +8,8 @@ interface MedicationStateBadgesProps {
|
||||
pausedAt: string | null;
|
||||
/** v1.39.1 (#1033) — intake tracking off: kept as a record only. */
|
||||
recordOnly?: boolean;
|
||||
/** v1.39.4 (#1040) — the course's end date has passed. */
|
||||
courseEnded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,6 +23,7 @@ export function MedicationStateBadges({
|
||||
active,
|
||||
pausedAt,
|
||||
recordOnly = false,
|
||||
courseEnded = false,
|
||||
}: MedicationStateBadgesProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -35,7 +38,16 @@ export function MedicationStateBadges({
|
||||
{t("medications.recordOnlyBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{!notificationsEnabled && !recordOnly && (
|
||||
{courseEnded && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
data-slot="medication-course-ended-badge"
|
||||
>
|
||||
{t("medications.courseEndedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{!notificationsEnabled && !recordOnly && !courseEnded && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("medications.withoutNotification")}
|
||||
</Badge>
|
||||
|
||||
@@ -170,6 +170,8 @@ export interface MedicationDetailSnapshot {
|
||||
* `recordedSchedules`; the page shows (and edits) the stored rows.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course (server-resolved). */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
recordedSchedules?: ScheduleSnapshot[];
|
||||
schedules: ScheduleSnapshot[];
|
||||
}
|
||||
@@ -501,7 +503,7 @@ export function MedicationDetailTabs({
|
||||
name={medication.name}
|
||||
dose={formatDose(medication.dose, t)}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
courseStatus={served.courseStatus}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
asNeeded={asNeeded}
|
||||
|
||||
@@ -135,6 +135,13 @@ export interface Glp1Medication {
|
||||
* compliance bars and the take / skip actions and shows a badge instead.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/**
|
||||
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
|
||||
* when true (active, tracked, and the course includes today).
|
||||
*/
|
||||
intakeActionable?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course. */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -446,6 +453,7 @@ export function Glp1MedicationCard({
|
||||
active={medication.active}
|
||||
pausedAt={medication.pausedAt}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
courseEnded={medication.courseStatus === "ENDED"}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -560,6 +568,7 @@ export function Glp1MedicationCard({
|
||||
complianceNotApplicable ? null : (display?.currentCycle ?? null)
|
||||
}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
intakeActionable={medication.intakeActionable !== false}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
|
||||
@@ -120,6 +120,13 @@ interface Medication {
|
||||
* compliance bars and the take / skip actions and shows a badge instead.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/**
|
||||
* v1.39.4 (#1040) — server-resolved: taken / skip are offered today only
|
||||
* when true (active, tracked, and the course includes today).
|
||||
*/
|
||||
intakeActionable?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course. */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -385,6 +392,7 @@ export function MedicationCard({
|
||||
active={medication.active}
|
||||
pausedAt={medication.pausedAt}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
courseEnded={medication.courseStatus === "ENDED"}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -525,6 +533,7 @@ export function MedicationCard({
|
||||
complianceNotApplicable ? null : (display?.currentCycle ?? null)
|
||||
}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
intakeActionable={medication.intakeActionable !== false}
|
||||
lowStockRunwayDays={lowStockRunwayDays}
|
||||
intakeLoading={intakeLoading}
|
||||
onRecordIntake={(skipped) => recordIntake(skipped, displayedSlot)}
|
||||
|
||||
@@ -32,7 +32,12 @@ export interface MedicationDetailSummaryProps {
|
||||
name: string;
|
||||
dose: string;
|
||||
active: boolean;
|
||||
endsOn?: string | null;
|
||||
/**
|
||||
* v1.39.4 (#1040) — the server's course position. "Ended" follows it, so
|
||||
* the last course day reads as active on the user's clock instead of
|
||||
* flipping at UTC midnight of the end date.
|
||||
*/
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** Wizard payload used to derive the plain-language cadence line. */
|
||||
payload: MedicationPayload;
|
||||
oneShot: boolean;
|
||||
@@ -45,13 +50,11 @@ export interface MedicationDetailSummaryProps {
|
||||
|
||||
type Status = "active" | "paused" | "ended";
|
||||
|
||||
function resolveStatus(active: boolean, endsOn?: string | null): Status {
|
||||
if (endsOn) {
|
||||
const end = new Date(endsOn);
|
||||
if (!Number.isNaN(end.getTime()) && end.getTime() <= Date.now()) {
|
||||
return "ended";
|
||||
}
|
||||
}
|
||||
function resolveStatus(
|
||||
active: boolean,
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED",
|
||||
): Status {
|
||||
if (courseStatus === "ENDED") return "ended";
|
||||
return active ? "active" : "paused";
|
||||
}
|
||||
|
||||
@@ -59,7 +62,7 @@ export function MedicationDetailSummary({
|
||||
name,
|
||||
dose,
|
||||
active,
|
||||
endsOn,
|
||||
courseStatus,
|
||||
payload,
|
||||
oneShot,
|
||||
asNeeded = false,
|
||||
@@ -69,7 +72,7 @@ export function MedicationDetailSummary({
|
||||
const { t, locale } = useTranslations();
|
||||
const formatters = useFormatters();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const status = resolveStatus(active, endsOn);
|
||||
const status = resolveStatus(active, courseStatus);
|
||||
|
||||
const statusLabel =
|
||||
status === "active"
|
||||
|
||||
@@ -98,6 +98,12 @@ export interface TableMedication {
|
||||
* cell names that, the compliance and action cells show "–".
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
/**
|
||||
* v1.39.4 (#1040) — server-resolved: the action cell offers taken / skip
|
||||
* only when true; an ended course shows "Ended" where next-due sits.
|
||||
*/
|
||||
intakeActionable?: boolean;
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
|
||||
@@ -478,10 +484,11 @@ function MedicationTableRowItem({
|
||||
// column with no neighbouring last-intake context. Order here:
|
||||
// last-dose context outranks the overdue escalation, which outranks
|
||||
// the window pill.
|
||||
const canAct = medication.active && medication.intakeActionable !== false;
|
||||
const overdueLabel =
|
||||
medication.active && doseStatus === "missed"
|
||||
canAct && doseStatus === "missed"
|
||||
? t("medications.veryOverdue")
|
||||
: medication.active && doseStatus === "overdue"
|
||||
: canAct && doseStatus === "overdue"
|
||||
? t("medications.overdue")
|
||||
: null;
|
||||
|
||||
@@ -528,6 +535,16 @@ function MedicationTableRowItem({
|
||||
{t("medications.recordOnlyBadge")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.courseStatus === "ENDED") {
|
||||
// v1.39.4 (#1040) — the course is over: nothing is due any more.
|
||||
nextCell = (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-slot="medication-table-course-ended-marker"
|
||||
>
|
||||
{t("medications.courseEndedBadge")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.asNeeded) {
|
||||
// v1.16.11 — a calm marker where next-due normally sits; no due
|
||||
// pill, no overdue escalation, ever.
|
||||
@@ -713,7 +730,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active && canAddIntake && !recordOnly ? (
|
||||
{canAct && canAddIntake && !recordOnly ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface DueDerivationMedication {
|
||||
asNeeded?: boolean;
|
||||
/** v1.39.1 (#1033) — false: kept as a record, never due, never in the set. */
|
||||
trackIntake?: boolean;
|
||||
/** v1.39.4 (#1040) — false: no dose actions today (e.g. the course ended). */
|
||||
intakeActionable?: boolean;
|
||||
schedules: DueDerivationSchedule[];
|
||||
}
|
||||
|
||||
@@ -155,7 +157,13 @@ export function deriveDueMedications(
|
||||
// compliance row for one must not pull it into the set.
|
||||
// A medication with intake tracking off is never due either; the list
|
||||
// already serves it with no schedules, this makes the rule explicit.
|
||||
if (!m.active || m.pausedAt || m.asNeeded || m.trackIntake === false) {
|
||||
if (
|
||||
!m.active ||
|
||||
m.pausedAt ||
|
||||
m.asNeeded ||
|
||||
m.trackIntake === false ||
|
||||
m.intakeActionable === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type WizardTreatmentRow,
|
||||
} from "@/components/medications/wizard/wizard-payload";
|
||||
import { encodeCadence } from "@/components/medications/scheduling/cadence-picker";
|
||||
import { MEDICATION_CATEGORY_VALUES } from "@/lib/validations/medication";
|
||||
import {
|
||||
type CadenceKind,
|
||||
DEFAULT_SUB_CONTROLS,
|
||||
@@ -310,6 +311,13 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps Psychische Gesundheit → (GENERIC, MENTAL_HEALTH) — new in v1.39.4", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.mentalHealth).toEqual({
|
||||
treatmentClass: "GENERIC",
|
||||
category: "MENTAL_HEALTH",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps GLP-1-Injektion → (GLP1, OTHER) — only row with GLP1", () => {
|
||||
expect(WIZARD_TREATMENT_MAPPING.glp1).toEqual({
|
||||
treatmentClass: "GLP1",
|
||||
@@ -327,6 +335,11 @@ describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => {
|
||||
"vitamin",
|
||||
"supplement",
|
||||
"antibiotic",
|
||||
"mentalHealth",
|
||||
"thyroid",
|
||||
"digestive",
|
||||
"skin",
|
||||
"sleepAid",
|
||||
"other",
|
||||
];
|
||||
for (const row of rows) {
|
||||
@@ -347,6 +360,11 @@ describe("rowFromTreatment — reverse mapping for edit-hydration", () => {
|
||||
|
||||
it("ANTIBIOTIC category maps to the antibiotic row", () => {
|
||||
expect(rowFromTreatment("GENERIC", "ANTIBIOTIC")).toBe("antibiotic");
|
||||
expect(rowFromTreatment("GENERIC", "MENTAL_HEALTH")).toBe("mentalHealth");
|
||||
expect(rowFromTreatment("GENERIC", "THYROID")).toBe("thyroid");
|
||||
expect(rowFromTreatment("GENERIC", "DIGESTIVE")).toBe("digestive");
|
||||
expect(rowFromTreatment("GENERIC", "SKIN")).toBe("skin");
|
||||
expect(rowFromTreatment("GENERIC", "SLEEP_AID")).toBe("sleepAid");
|
||||
});
|
||||
|
||||
it("unknown category falls back to 'other'", () => {
|
||||
@@ -1240,3 +1258,75 @@ describe("as-needed mode (v1.16.11, #316)", () => {
|
||||
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("category on edit (v1.39.4)", () => {
|
||||
function hydrate(category: string, treatmentClass = "GENERIC") {
|
||||
return hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Levothyroxine",
|
||||
dose: "50 µg",
|
||||
category,
|
||||
treatmentClass,
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("gives every stored category a wizard row of its own", () => {
|
||||
const covered = new Set(
|
||||
Object.values(WIZARD_TREATMENT_MAPPING).map((m) => m.category),
|
||||
);
|
||||
for (const value of MEDICATION_CATEGORY_VALUES) {
|
||||
expect(covered.has(value), value).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["THYROID", "SKIN", "SLEEP_AID", "MENTAL_HEALTH"])(
|
||||
"an edit that leaves the row alone keeps %s",
|
||||
(category) => {
|
||||
const body = buildCreateBody(
|
||||
{ ...hydrate(category), name: "Renamed" },
|
||||
"edit",
|
||||
);
|
||||
expect("category" in body).toBe(false);
|
||||
expect("treatmentClass" in body).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("never overwrites a category the wizard has no row for", () => {
|
||||
const payload = hydrate("SOMETHING_NEWER");
|
||||
expect(payload.treatmentRow).toBe("other");
|
||||
const body = buildCreateBody(payload, "edit");
|
||||
expect("category" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a GLP-1 medication's clinical category on an untouched edit", () => {
|
||||
const body = buildCreateBody(hydrate("BLOOD_PRESSURE", "GLP1"), "edit");
|
||||
expect("category" in body).toBe(false);
|
||||
expect("treatmentClass" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the new category when the row changed", () => {
|
||||
const body = buildCreateBody(
|
||||
{ ...hydrate("THYROID"), treatmentRow: "sleepAid" },
|
||||
"edit",
|
||||
);
|
||||
expect(body.category).toBe("SLEEP_AID");
|
||||
expect(body.treatmentClass).toBe("GENERIC");
|
||||
});
|
||||
|
||||
it("always sends the category on create", () => {
|
||||
const body = buildCreateBody({ ...hydrate("SKIN") }, "create");
|
||||
expect(body.category).toBe("SKIN");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,12 @@
|
||||
import {
|
||||
Activity,
|
||||
Apple,
|
||||
BedDouble,
|
||||
Brain,
|
||||
Droplet,
|
||||
Flame,
|
||||
Gauge,
|
||||
Hand,
|
||||
Leaf,
|
||||
type LucideIcon,
|
||||
type LucideProps,
|
||||
@@ -12,6 +16,7 @@ import {
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
Tag,
|
||||
Utensils,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
@@ -35,6 +40,11 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
|
||||
vitamin: Apple,
|
||||
supplement: Leaf,
|
||||
antibiotic: ShieldCheck,
|
||||
mentalHealth: Brain,
|
||||
thyroid: Gauge,
|
||||
digestive: Utensils,
|
||||
skin: Hand,
|
||||
sleepAid: BedDouble,
|
||||
other: Tag as unknown as LucideIcon,
|
||||
};
|
||||
|
||||
|
||||
@@ -86,13 +86,21 @@ export type WizardTreatmentRow =
|
||||
| "vitamin"
|
||||
| "supplement"
|
||||
| "antibiotic"
|
||||
| "mentalHealth"
|
||||
| "thyroid"
|
||||
| "digestive"
|
||||
| "skin"
|
||||
| "sleepAid"
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* The ten taxonomy rows in their visible order. maintainer-confirmed (D-1
|
||||
* §3 Step 2): Blutdruck · Diabetes · Hormone · GLP-1-Injektion ·
|
||||
* Schmerz · Allergie · Vitamine · Nahrungsergänzung · Antibiotikum ·
|
||||
* Sonstiges.
|
||||
* The taxonomy rows in their visible order (D-1 §3 Step 2): Blutdruck ·
|
||||
* Diabetes · Hormone · GLP-1-Injektion · Schmerz · Allergie · Vitamine ·
|
||||
* Nahrungsergänzung · Antibiotikum · Psychische Gesundheit · Schilddrüse ·
|
||||
* Verdauung · Haut · Schlafmittel · Sonstiges. v1.39.4 gave every stored
|
||||
* category a row; before, a thyroid, digestive, skin or sleep-aid
|
||||
* medication hydrated as
|
||||
* "Sonstiges" and an edit saved it back as OTHER.
|
||||
*/
|
||||
export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
|
||||
"bloodPressure",
|
||||
@@ -104,6 +112,11 @@ export const WIZARD_TREATMENT_ROWS: readonly WizardTreatmentRow[] = [
|
||||
"vitamin",
|
||||
"supplement",
|
||||
"antibiotic",
|
||||
"mentalHealth",
|
||||
"thyroid",
|
||||
"digestive",
|
||||
"skin",
|
||||
"sleepAid",
|
||||
"other",
|
||||
];
|
||||
|
||||
@@ -128,6 +141,11 @@ export const WIZARD_TREATMENT_MAPPING: Record<
|
||||
vitamin: { treatmentClass: "GENERIC", category: "VITAMIN" },
|
||||
supplement: { treatmentClass: "GENERIC", category: "SUPPLEMENT" },
|
||||
antibiotic: { treatmentClass: "GENERIC", category: "ANTIBIOTIC" },
|
||||
mentalHealth: { treatmentClass: "GENERIC", category: "MENTAL_HEALTH" },
|
||||
thyroid: { treatmentClass: "GENERIC", category: "THYROID" },
|
||||
digestive: { treatmentClass: "GENERIC", category: "DIGESTIVE" },
|
||||
skin: { treatmentClass: "GENERIC", category: "SKIN" },
|
||||
sleepAid: { treatmentClass: "GENERIC", category: "SLEEP_AID" },
|
||||
other: { treatmentClass: "GENERIC", category: "OTHER" },
|
||||
};
|
||||
|
||||
@@ -159,6 +177,16 @@ export function rowFromTreatment(
|
||||
return "supplement";
|
||||
case "ANTIBIOTIC":
|
||||
return "antibiotic";
|
||||
case "MENTAL_HEALTH":
|
||||
return "mentalHealth";
|
||||
case "THYROID":
|
||||
return "thyroid";
|
||||
case "DIGESTIVE":
|
||||
return "digestive";
|
||||
case "SKIN":
|
||||
return "skin";
|
||||
case "SLEEP_AID":
|
||||
return "sleepAid";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
@@ -221,6 +249,14 @@ export interface WizardPayload {
|
||||
doseAmount: string;
|
||||
doseUnit: string;
|
||||
treatmentRow: WizardTreatmentRow | null;
|
||||
/**
|
||||
* v1.39.4 — the row the edit hydrated to. An edit sends `category` and
|
||||
* `treatmentClass` only when `treatmentRow` differs from it, so a stored
|
||||
* category the wizard has no row for (a value a newer server or another
|
||||
* client wrote) is never overwritten by saving an unrelated change.
|
||||
* Absent on create.
|
||||
*/
|
||||
initialTreatmentRow?: WizardTreatmentRow | null;
|
||||
/**
|
||||
* v1.6.0 — route of administration. Drives the injection-site
|
||||
* rotation preview (shown when `INJECTION`) and, paired with
|
||||
@@ -592,8 +628,9 @@ export function firstInvalidIndex(
|
||||
export interface CreateMedicationBody {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: MedicationCategoryValue;
|
||||
treatmentClass: MedicationTreatmentClass;
|
||||
/** Omitted on an edit that left the category row untouched (v1.39.4). */
|
||||
category?: MedicationCategoryValue;
|
||||
treatmentClass?: MedicationTreatmentClass;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
|
||||
@@ -803,11 +840,20 @@ export function buildCreateBody(
|
||||
// reads 1), "1 1/2" and "1½" are 1.5, and a value the server would
|
||||
// refuse is null. Step 3 cannot be left while it is null.
|
||||
const parsedUnitsPerDose = parseUnitsPerDoseInput(committed.unitsPerDose);
|
||||
// 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 keepStoredCategory =
|
||||
forMode === "edit" &&
|
||||
committed.initialTreatmentRow !== undefined &&
|
||||
committed.treatmentRow === committed.initialTreatmentRow;
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
category: mapping.category,
|
||||
treatmentClass: mapping.treatmentClass,
|
||||
...(!keepStoredCategory && {
|
||||
category: mapping.category,
|
||||
treatmentClass: mapping.treatmentClass,
|
||||
}),
|
||||
deliveryForm: committed.deliveryForm,
|
||||
...(Number.isFinite(parsedDosesPerUnit) &&
|
||||
parsedDosesPerUnit >= 1 && {
|
||||
@@ -1189,6 +1235,10 @@ export function hydrateWizardPayload(
|
||||
doseAmount: parsedDose.amount,
|
||||
doseUnit: parsedDose.unit || base.doseUnit,
|
||||
treatmentRow: rowFromTreatment(initial.treatmentClass, initial.category),
|
||||
initialTreatmentRow: rowFromTreatment(
|
||||
initial.treatmentClass,
|
||||
initial.category,
|
||||
),
|
||||
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
|
||||
dosesPerUnit:
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
|
||||
Reference in new issue
Block a user