mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(medications): as-needed medications log without a schedule
A medication can declare itself as-needed: no schedules, never due, never reminded, excluded from every compliance rate — but intakes log ad-hoc, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice and round- trips it on edit; card, table and detail show a calm marker where the next dose normally sits, the take-all dialog never collects one, and the supply runway stays unknowable by construction. (#316)
This commit is contained in:
1 parent
550f314a54
commit
3943dcfb72
43 files changed
+1273
-70
No files matched your search
@@ -295,3 +295,75 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
|
||||
expect(html).not.toContain("can still be taken");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) card presentation: a calm
|
||||
* "As needed" marker where next-due normally sits, no due pill, no
|
||||
* overdue escalation, and NO compliance block (neither bars nor an
|
||||
* eternal skeleton — the batched compliance read excludes PRN rows).
|
||||
* The last-intake line stays: the card is last-taken oriented.
|
||||
*/
|
||||
describe("<MedicationCard> — as-needed marker (v1.16.11, #316)", () => {
|
||||
function makeAsNeededMed(overrides: Record<string, unknown> = {}) {
|
||||
const lastTaken = new Date();
|
||||
lastTaken.setHours(lastTaken.getHours() - 3);
|
||||
return {
|
||||
id: "med-prn-1",
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
treatmentClass: undefined as string | undefined,
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: lastTaken.toISOString(),
|
||||
todayEventCount: 1,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders the calm marker, the last-intake line, and no compliance block", () => {
|
||||
const med = makeAsNeededMed();
|
||||
const client = makeClient();
|
||||
// Deliberately NOT seeding the compliance cache: the batched read
|
||||
// excludes as-needed medications, so the card must not fall back to
|
||||
// the loading skeleton.
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="medication-as-needed-marker"');
|
||||
expect(html).toContain("As needed");
|
||||
expect(html).toContain("Last intake:");
|
||||
// No due/overdue presentation, ever.
|
||||
expect(html).not.toContain("Overdue");
|
||||
expect(html).not.toContain("Take now");
|
||||
// No compliance bars and no skeleton placeholder for them.
|
||||
expect(html).not.toContain("Adherence (");
|
||||
});
|
||||
|
||||
it("keeps the quick-log action row (intakes still record)", () => {
|
||||
const med = makeAsNeededMed();
|
||||
const client = makeClient();
|
||||
const html = render(
|
||||
<MedicationCard
|
||||
medication={med}
|
||||
onEdit={() => {}}
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
// The shared intake actions render (take/skip) — an as-needed dose
|
||||
// is loggable from the card exactly like a scheduled one.
|
||||
expect(html).toContain("Taken");
|
||||
});
|
||||
});
|
||||
@@ -261,6 +261,43 @@ describe("<MedicationTable> — structure + shared payloads", () => {
|
||||
expect(html).toContain('aria-label="Take – Mounjaro"');
|
||||
expect(html).toContain("4 doses");
|
||||
});
|
||||
|
||||
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
|
||||
// payload excludes as-needed medications, and the column must show
|
||||
// the en-dash instead of an eternal skeleton.
|
||||
const lastTaken = new Date();
|
||||
lastTaken.setHours(lastTaken.getHours() - 3);
|
||||
const html = render(
|
||||
<MedicationTable
|
||||
activeMedications={[
|
||||
med({
|
||||
id: "prn1",
|
||||
name: "Ibuprofen",
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
nextDueAt: null,
|
||||
lastTakenAt: lastTaken.toISOString(),
|
||||
stockDosesRemaining: 10,
|
||||
}),
|
||||
]}
|
||||
inactiveMedications={[]}
|
||||
/>,
|
||||
client,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="medication-table-as-needed-marker"');
|
||||
expect(html).toContain("As needed");
|
||||
// No due/overdue presentation in the status or next-dose cells.
|
||||
expect(html).not.toContain("Overdue");
|
||||
// Compliance column shows the en-dash, not bars or a skeleton.
|
||||
expect(html).not.toContain("Adherence (");
|
||||
// The quick actions stay — an as-needed dose is loggable inline.
|
||||
expect(html).toContain('aria-label="Take – Ibuprofen"');
|
||||
// Stock column still renders (inventory tracking works for PRN).
|
||||
expect(html).toContain("10 doses");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationTable> — manual order + aria-sort", () => {
|
||||
|
||||
@@ -195,6 +195,26 @@ describe("deriveDueMedications — only currently-due, active, unpaused", () =>
|
||||
expect(due).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes an as-needed medication (no schedules, never due) — v1.16.11 #316", () => {
|
||||
// An as-needed (PRN) medication persists ZERO schedules and the
|
||||
// server computes no next-due, so it can NEVER enter the due set —
|
||||
// even with a recent ad-hoc intake on record.
|
||||
const due = deriveDueMedications(
|
||||
[
|
||||
makeMed({
|
||||
id: "prn",
|
||||
schedules: [],
|
||||
nextDueAt: null,
|
||||
lastTakenAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
|
||||
todayEventCount: 1,
|
||||
}),
|
||||
makeMed({ id: "scheduled" }),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(due.map((m) => m.id)).toEqual(["scheduled"]);
|
||||
});
|
||||
|
||||
it("excludes a dose already covered by an actioned intake in its band", () => {
|
||||
// Taken 30 minutes ago — inside the open 12:00 band; the in-window
|
||||
// suppression (same as the card pill) keeps it out of the due set.
|
||||
|
||||
@@ -111,6 +111,15 @@ export interface MedicationCardBodyProps {
|
||||
/** The open-cycle descriptor, or null when the display block is absent. */
|
||||
currentCycle: CurrentCycle | null;
|
||||
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. True suppresses the
|
||||
* compliance block entirely (no bars, no skeleton, no error fallback —
|
||||
* the batched compliance read excludes as-needed medications, so a
|
||||
* skeleton would spin forever) and the open-cycle slot. The next/last
|
||||
* lines carry the "Bei Bedarf" + last-taken presentation instead.
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
intakeLoading: string | null;
|
||||
/** Record the displayed dose (the card binds the slot instant). */
|
||||
@@ -137,6 +146,7 @@ export function MedicationCardBody({
|
||||
complianceError = false,
|
||||
onRetryCompliance,
|
||||
currentCycle,
|
||||
asNeeded = false,
|
||||
intakeLoading,
|
||||
onRecordIntake,
|
||||
children,
|
||||
@@ -214,6 +224,7 @@ export function MedicationCardBody({
|
||||
error fallback (no bars, one notice line + retry) instead of
|
||||
sitting on the skeleton forever. */}
|
||||
{active &&
|
||||
!asNeeded &&
|
||||
(compliance ? (
|
||||
<MedicationComplianceBars
|
||||
rate7={compliance.rate7}
|
||||
@@ -232,7 +243,7 @@ export function MedicationCardBody({
|
||||
one xs text line on every active card: the descriptor rides the
|
||||
compliance query, and a line appearing after the fetch resolved
|
||||
used to grow the card and shift the grid row (CLS). */}
|
||||
{active && (
|
||||
{active && !asNeeded && (
|
||||
<div className="min-h-4">
|
||||
{currentCycle && <MedicationCycleStatus cycle={currentCycle} />}
|
||||
</div>
|
||||
|
||||
@@ -136,6 +136,8 @@ export interface MedicationDetailSnapshot {
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
oneShot?: boolean;
|
||||
/** v1.16.11 (#316) — as-needed (PRN): no schedules, never due. */
|
||||
asNeeded?: boolean;
|
||||
atcCode?: string | null;
|
||||
rxNormCode?: string | null;
|
||||
/**
|
||||
@@ -189,6 +191,7 @@ function snapshotToWizardPayload(
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
oneShot: med.oneShot ?? false,
|
||||
asNeeded: med.asNeeded ?? false,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
@@ -226,6 +229,7 @@ export function MedicationDetailTabs({
|
||||
|
||||
const id = medication.id;
|
||||
const oneShot = medication.oneShot === true;
|
||||
const asNeeded = medication.asNeeded === true;
|
||||
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
|
||||
const isInjectable = medication.deliveryForm === "INJECTION";
|
||||
|
||||
@@ -238,9 +242,18 @@ export function MedicationDetailTabs({
|
||||
// whatever `?tab=` carries — Injektion is absent for non-injectable
|
||||
// medications, so a stale `?tab=injektion` deep-link falls back to the
|
||||
// landing tab instead of rendering an empty surface.
|
||||
// v1.16.11 — an as-needed medication has no schedule and never
|
||||
// reminds, so the Zeitplan tab (times editor + reminder card +
|
||||
// plan-history) does not apply and is dropped from the registry; a
|
||||
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
|
||||
const availableTabs = useMemo<TabSlug[]>(
|
||||
() => TAB_SLUGS.filter((slug) => slug !== "injektion" || isInjectable),
|
||||
[isInjectable],
|
||||
() =>
|
||||
TAB_SLUGS.filter(
|
||||
(slug) =>
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded),
|
||||
),
|
||||
[isInjectable, asNeeded],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
@@ -296,7 +309,9 @@ export function MedicationDetailTabs({
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: activeTab === "uebersicht",
|
||||
// v1.16.11 — no compliance read for an as-needed medication (the
|
||||
// section is hidden; there is no rate to fetch).
|
||||
enabled: activeTab === "uebersicht" && !asNeeded,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -368,6 +383,7 @@ export function MedicationDetailTabs({
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
asNeeded={asNeeded}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
|
||||
@@ -407,25 +423,35 @@ export function MedicationDetailTabs({
|
||||
value={
|
||||
!medication.active
|
||||
? t("medications.detail.uebersicht.pausedHint")
|
||||
: medication.nextDueAt
|
||||
? fmt.dateTime(medication.nextDueAt)
|
||||
: t("medications.detail.uebersicht.nextDoseNone")
|
||||
: asNeeded
|
||||
? t("medications.detail.uebersicht.nextDoseAsNeeded")
|
||||
: medication.nextDueAt
|
||||
? fmt.dateTime(medication.nextDueAt)
|
||||
: t("medications.detail.uebersicht.nextDoseNone")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
jumpLabel={
|
||||
asNeeded
|
||||
? t("medications.detail.uebersicht.jumpToVerlauf")
|
||||
: t("medications.detail.uebersicht.jumpToZeitplan")
|
||||
}
|
||||
onJump={() => onTabChange(asNeeded ? "verlauf" : "zeitplan")}
|
||||
dataSlot="uebersicht-next-dose"
|
||||
/>
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.reminderLabel")}
|
||||
value={
|
||||
medication.notificationsEnabled
|
||||
? t("medications.detail.uebersicht.reminderOn")
|
||||
: t("medications.detail.uebersicht.reminderOff")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
dataSlot="uebersicht-reminder"
|
||||
/>
|
||||
{/* v1.16.11 — as-needed never reminds; the row would only
|
||||
mislead (the cron skips schedule-less medications). */}
|
||||
{!asNeeded && (
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.reminderLabel")}
|
||||
value={
|
||||
medication.notificationsEnabled
|
||||
? t("medications.detail.uebersicht.reminderOn")
|
||||
: t("medications.detail.uebersicht.reminderOff")
|
||||
}
|
||||
jumpLabel={t("medications.detail.uebersicht.jumpToZeitplan")}
|
||||
onJump={() => onTabChange("zeitplan")}
|
||||
dataSlot="uebersicht-reminder"
|
||||
/>
|
||||
)}
|
||||
{inventoryItems.length > 0 && (
|
||||
<StatusRow
|
||||
label={t("medications.detail.uebersicht.supplyLabel")}
|
||||
@@ -471,6 +497,7 @@ export function MedicationDetailTabs({
|
||||
</ul>
|
||||
</MedicationDetailSection>
|
||||
|
||||
{!asNeeded && (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-uebersicht-compliance-heading"
|
||||
title={t("medications.detail.uebersicht.complianceTitle")}
|
||||
@@ -490,6 +517,7 @@ export function MedicationDetailTabs({
|
||||
</p>
|
||||
)}
|
||||
</MedicationDetailSection>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ZEITPLAN — inline edit of the everyday levers: dose times +
|
||||
|
||||
@@ -85,6 +85,13 @@ interface Medication {
|
||||
* still takeable" instead of the regular upcoming-intake phrasing.
|
||||
*/
|
||||
nextDueOverdue?: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication: no schedules, never
|
||||
* due, never reminded, no compliance. The card renders a calm
|
||||
* "Bei Bedarf" marker where next-due normally sits and replaces the
|
||||
* compliance bars with the last-taken oriented presentation.
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -300,6 +307,15 @@ export function MedicationCard({
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.16.11 — as-needed: a calm marker where next-due normally sits.
|
||||
// No due pill, no overdue escalation, ever (structurally there is no
|
||||
// schedule, so the window status is already null).
|
||||
const asNeededLine = medication.asNeeded ? (
|
||||
<span className="text-muted-foreground" data-slot="medication-as-needed-marker">
|
||||
{t("medications.asNeededMarker")}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// The upcoming-intake line value — a day label + window range + optional
|
||||
// dose accent. The card owns this VALUE content (a daily med reads as a
|
||||
// clock-time window); the structure / labels live in the shared body.
|
||||
@@ -396,13 +412,14 @@ export function MedicationCard({
|
||||
}
|
||||
: null
|
||||
}
|
||||
doseStatus={doseStatus}
|
||||
nextLine={nextLine}
|
||||
doseStatus={medication.asNeeded ? "upcoming" : doseStatus}
|
||||
nextLine={asNeededLine ?? nextLine}
|
||||
lastLine={
|
||||
medication.lastTakenAt
|
||||
? formatLastTakenAt(medication.lastTakenAt)
|
||||
: null
|
||||
}
|
||||
asNeeded={medication.asNeeded === true}
|
||||
compliance={
|
||||
compliance ? { rate7, rate30, streak, shortDays, longDays } : null
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface MedicationDetailSummaryProps {
|
||||
/** Wizard payload used to derive the plain-language cadence line. */
|
||||
payload: MedicationPayload;
|
||||
oneShot: boolean;
|
||||
/** v1.16.11 — as-needed (PRN): the cadence line reads "Bei Bedarf". */
|
||||
asNeeded?: boolean;
|
||||
startsOn?: string | null;
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ export function MedicationDetailSummary({
|
||||
endsOn,
|
||||
payload,
|
||||
oneShot,
|
||||
asNeeded = false,
|
||||
startsOn,
|
||||
}: MedicationDetailSummaryProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -83,7 +86,9 @@ export function MedicationDetailSummary({
|
||||
date: formatters.dateTime(startsOn),
|
||||
})
|
||||
: t("medications.detail.cadence.oneShotPending")
|
||||
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
|
||||
: asNeeded
|
||||
? t("medications.detail.cadence.asNeeded")
|
||||
: summariseCadence(hydrateWizardPayload(payload), t, formatters.date);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-slot="medication-detail-summary">
|
||||
|
||||
@@ -87,6 +87,11 @@ export interface TableMedication {
|
||||
todayEventCount?: number;
|
||||
nextDueAt?: string | null;
|
||||
nextDueOverdue?: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN): the next-dose cell shows a calm
|
||||
* "Bei Bedarf" marker, the compliance column shows "–".
|
||||
*/
|
||||
asNeeded?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
schedules: TableSchedule[];
|
||||
@@ -463,7 +468,18 @@ function MedicationTableRowItem({
|
||||
let nextCell: React.ReactNode = (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
);
|
||||
if (medication.active) {
|
||||
if (medication.asNeeded) {
|
||||
// v1.16.11 — a calm marker where next-due normally sits; no due
|
||||
// pill, no overdue escalation, ever.
|
||||
nextCell = (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
data-slot="medication-table-as-needed-marker"
|
||||
>
|
||||
{t("medications.asNeededMarker")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.active) {
|
||||
if (medication.nextDueOverdue && nextAt) {
|
||||
nextCell = (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-400">
|
||||
@@ -609,7 +625,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell>{statusCell}</TableCell>
|
||||
<TableCell className="text-sm">{nextCell}</TableCell>
|
||||
<TableCell>{medication.active ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
|
||||
<TableCell>{medication.active && !medication.asNeeded ? complianceCell : <span className="text-muted-foreground text-sm">–</span>}</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active ? (
|
||||
|
||||
@@ -1036,3 +1036,93 @@ describe("v1.15.18 per-dose windows — encode + hydrate round-trip", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// v1.16.11 (#316) — as-needed (PRN) mode
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("as-needed mode (v1.16.11, #316)", () => {
|
||||
function asNeededPayload(): WizardPayload {
|
||||
return {
|
||||
...emptyWizardPayload(),
|
||||
mode: "asNeeded",
|
||||
treatmentRow: "painRelief",
|
||||
name: "Ibuprofen",
|
||||
doseAmount: "400",
|
||||
doseUnit: "mg",
|
||||
unitsPerDose: "2",
|
||||
};
|
||||
}
|
||||
|
||||
it("progressIndices walks the one-shot-shaped path (no cadence detail, no times)", () => {
|
||||
expect(progressIndices("asNeeded", "daily")).toEqual([1, 2, 3, 4, 8]);
|
||||
});
|
||||
|
||||
it("buildCreateBody emits asNeeded:true with an EMPTY schedules array", () => {
|
||||
const body = buildCreateBody(asNeededPayload());
|
||||
expect(body.asNeeded).toBe(true);
|
||||
expect(body.oneShot).toBe(false);
|
||||
expect(body.schedules).toEqual([]);
|
||||
// Dose fields stay first-class — they feed inventory consumption.
|
||||
expect(body.dose).toBe("400 mg");
|
||||
expect(body.unitsPerDose).toBe(2);
|
||||
expect(body.category).toBe("PAIN_RELIEF");
|
||||
});
|
||||
|
||||
it("a scheduled payload emits asNeeded:false (no accidental flag bleed)", () => {
|
||||
const body = buildCreateBody(withCadence("daily"));
|
||||
expect(body.asNeeded).toBe(false);
|
||||
expect(body.schedules.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hydrateWizardPayload round-trips an as-needed snapshot back onto the mode", () => {
|
||||
const hydrated = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Ibuprofen",
|
||||
dose: "400 mg",
|
||||
category: "PAIN_RELIEF",
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
asNeeded: true,
|
||||
schedules: [],
|
||||
});
|
||||
expect(hydrated.mode).toBe("asNeeded");
|
||||
// Step 5 pre-selects "Bei Bedarf" via the mode; the placeholder
|
||||
// draft carries it too so the flat mirror stays coherent.
|
||||
expect(hydrated.schedules).toHaveLength(1);
|
||||
expect(hydrated.schedules[0].mode).toBe("asNeeded");
|
||||
|
||||
// Full round-trip: hydrate → encode emits the same wire shape.
|
||||
const body = buildCreateBody(hydrated, "edit");
|
||||
expect(body.asNeeded).toBe(true);
|
||||
expect(body.schedules).toEqual([]);
|
||||
// Edit bodies never round-trip the reminders default (invariant 16).
|
||||
expect(body.notificationsEnabled).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summariseCadence reads the asNeeded key with no times phrase", () => {
|
||||
const t = makeStubT();
|
||||
const line = summariseCadence(asNeededPayload(), t);
|
||||
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
|
||||
expect(line).not.toContain("medications.wizard.summary.times");
|
||||
});
|
||||
|
||||
it("summariseScheduleDraft reads the asNeeded key with no times phrase", () => {
|
||||
const t = makeStubT();
|
||||
const line = summariseScheduleDraft(
|
||||
{ ...emptyWizardPayload().schedules[0], mode: "asNeeded" },
|
||||
t,
|
||||
);
|
||||
expect(line).toContain("medications.wizard.summary.cadence.asNeeded");
|
||||
expect(line).not.toContain("medications.wizard.summary.times");
|
||||
});
|
||||
|
||||
it("validateStep gates: Step 5 passes once the mode is picked; review mirrors Step 4", () => {
|
||||
const p = asNeededPayload();
|
||||
expect(validateStep(p, 5)).toBe(true);
|
||||
expect(validateStep(p, 8)).toBe(true);
|
||||
expect(validateStep({ ...p, mode: null }, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,10 @@ import type { WizardPayload } from "../wizard-payload";
|
||||
import type { StepProps } from "./Step1Name";
|
||||
|
||||
/**
|
||||
* The six cadence rows surfaced in Step 5. Each maps to a (mode,
|
||||
* The seven cadence rows surfaced in Step 5. Each maps to a (mode,
|
||||
* cadenceKind) decision. "Einmalig" flips the wizard onto the
|
||||
* one-shot path; every other row stays recurring.
|
||||
* one-shot path; "Bei Bedarf" (v1.16.11, #316) onto the as-needed
|
||||
* path (no schedule at all); every other row stays recurring.
|
||||
*/
|
||||
type Step5Row =
|
||||
| "daily"
|
||||
@@ -18,7 +19,8 @@ type Step5Row =
|
||||
| "everyNWeeks"
|
||||
| "monthly"
|
||||
| "rolling"
|
||||
| "oneShot";
|
||||
| "oneShot"
|
||||
| "asNeeded";
|
||||
|
||||
const STEP5_ROWS: readonly Step5Row[] = [
|
||||
"daily",
|
||||
@@ -27,10 +29,12 @@ const STEP5_ROWS: readonly Step5Row[] = [
|
||||
"monthly",
|
||||
"rolling",
|
||||
"oneShot",
|
||||
"asNeeded",
|
||||
];
|
||||
|
||||
function rowFromPayload(payload: WizardPayload): Step5Row | null {
|
||||
if (payload.mode === "oneShot") return "oneShot";
|
||||
if (payload.mode === "asNeeded") return "asNeeded";
|
||||
if (payload.mode === "recurring") {
|
||||
switch (payload.cadence.kind) {
|
||||
case "daily":
|
||||
@@ -108,6 +112,13 @@ function applyRowPick(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row === "asNeeded") {
|
||||
// v1.16.11 — as-needed clears schedule configuration: the wizard
|
||||
// emits an empty `schedules` array on save (`buildCreateBody`), so
|
||||
// the cadence value left on the draft is inert.
|
||||
applyPartial({ mode: "asNeeded" });
|
||||
return;
|
||||
}
|
||||
const cadenceKind: CadenceKind = row;
|
||||
applyPartial({
|
||||
mode: "recurring",
|
||||
|
||||
@@ -145,7 +145,11 @@ export function Step8Summary({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{payload.mode !== "oneShot" && (
|
||||
{/* v1.16.11 — an as-needed medication carries no parallel
|
||||
schedules; the single "Bei Bedarf" card stays as the route
|
||||
back into Step 5 (mirroring the one-shot edit path), but the
|
||||
add affordance is hidden alongside one-shot. */}
|
||||
{payload.mode !== "oneShot" && payload.mode !== "asNeeded" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSchedule}
|
||||
@@ -163,6 +167,9 @@ export function Step8Summary({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.16.11 — an as-needed medication never reminds (no slots),
|
||||
so the reminders toggle is hidden alongside the schedules. */}
|
||||
{payload.mode !== "asNeeded" && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
@@ -185,8 +192,9 @@ export function Step8Summary({
|
||||
aria-label={t("medications.wizard.steps.step8.remindersLabel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoChannelHint && (
|
||||
{payload.mode !== "asNeeded" && showNoChannelHint && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="wizard-no-channel-hint"
|
||||
|
||||
@@ -176,7 +176,7 @@ export function rowFromTreatment(
|
||||
*/
|
||||
export interface ScheduleDraft {
|
||||
id?: string;
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
mode: "oneShot" | "recurring" | "asNeeded" | null;
|
||||
cadence: CadenceValue;
|
||||
subControls: CadenceSubControls;
|
||||
timesOfDay: string[];
|
||||
@@ -237,7 +237,7 @@ export interface WizardPayload {
|
||||
*/
|
||||
allowedInjectionSites: InjectionSiteKey[];
|
||||
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
mode: "oneShot" | "recurring" | "asNeeded" | null;
|
||||
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
|
||||
cadence: CadenceValue;
|
||||
/** Mirrors `schedules[activeScheduleIndex].subControls`. */
|
||||
@@ -402,6 +402,14 @@ export function removeSchedule(
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ONE_SHOT_PATH: readonly number[] = [1, 2, 3, 4, 8];
|
||||
/**
|
||||
* v1.16.11 — as-needed (PRN) walks the one-shot shape: picking
|
||||
* "Bei Bedarf" in Step 5 skips the sub-cadence + times steps entirely
|
||||
* (an as-needed medication carries no schedule), landing on the
|
||||
* summary. The dose step stays — dose text + unitsPerDose still feed
|
||||
* inventory consumption.
|
||||
*/
|
||||
const AS_NEEDED_PATH: readonly number[] = [1, 2, 3, 4, 8];
|
||||
const DAILY_PATH: readonly number[] = [1, 2, 3, 4, 5, 7, 8];
|
||||
const RECURRING_PATH: readonly number[] = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
|
||||
@@ -418,6 +426,7 @@ export function progressIndices(
|
||||
cadenceKind: CadenceKind,
|
||||
): readonly number[] {
|
||||
if (mode === "oneShot") return ONE_SHOT_PATH;
|
||||
if (mode === "asNeeded") return AS_NEEDED_PATH;
|
||||
if (mode === "recurring" && cadenceKind === "daily") return DAILY_PATH;
|
||||
return RECURRING_PATH;
|
||||
}
|
||||
@@ -552,6 +561,11 @@ export interface CreateMedicationBody {
|
||||
startsOn?: string;
|
||||
endsOn?: string;
|
||||
oneShot: boolean;
|
||||
/**
|
||||
* v1.16.11 (#316) — as-needed (PRN) medication. True emits an EMPTY
|
||||
* `schedules` array (the route 422s on any entry alongside the flag).
|
||||
*/
|
||||
asNeeded: boolean;
|
||||
schedules: Array<{
|
||||
id?: string;
|
||||
windowStart: string;
|
||||
@@ -675,10 +689,15 @@ export function buildCreateBody(
|
||||
// A one-shot medication carries exactly one schedule with no
|
||||
// recurrence. We honour the active draft (it carries the times the
|
||||
// user picked); the route enforces the one-schedule invariant.
|
||||
// v1.16.11 — an as-needed medication carries NO schedule at all; the
|
||||
// wizard clears the list client-side (the server 422s otherwise).
|
||||
const isOneShot = committed.mode === "oneShot";
|
||||
const draftsToEmit = isOneShot
|
||||
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
|
||||
: committed.schedules;
|
||||
const isAsNeeded = committed.mode === "asNeeded";
|
||||
const draftsToEmit = isAsNeeded
|
||||
? []
|
||||
: isOneShot
|
||||
? [committed.schedules[committed.activeScheduleIndex] ?? committed.schedules[0]]
|
||||
: committed.schedules;
|
||||
|
||||
const parsedDosesPerUnit = Number.parseInt(committed.dosesPerUnit, 10);
|
||||
const parsedUnitsPerDose = Number.parseInt(committed.unitsPerDose, 10);
|
||||
@@ -716,6 +735,7 @@ export function buildCreateBody(
|
||||
notificationsEnabled: committed.notificationsEnabled,
|
||||
}),
|
||||
oneShot: isOneShot,
|
||||
asNeeded: isAsNeeded,
|
||||
schedules: draftsToEmit.map((draft) => encodeScheduleDraft(draft, isOneShot)),
|
||||
};
|
||||
if (committed.startsOn) {
|
||||
@@ -739,6 +759,7 @@ function summaryKeyForDraft(
|
||||
sub: CadenceSubControls,
|
||||
): string {
|
||||
if (mode === "oneShot") return "oneShot";
|
||||
if (mode === "asNeeded") return "asNeeded";
|
||||
switch (cadence.kind) {
|
||||
case "daily":
|
||||
return "daily";
|
||||
@@ -856,7 +877,9 @@ export function summariseCadence(
|
||||
): string {
|
||||
const cadenceLine = cadenceLineFor(payload, t);
|
||||
const timesPhrase =
|
||||
payload.mode !== "oneShot" && payload.timesOfDay.length > 0
|
||||
payload.mode !== "oneShot" &&
|
||||
payload.mode !== "asNeeded" &&
|
||||
payload.timesOfDay.length > 0
|
||||
? t("medications.wizard.summary.times", {
|
||||
times: sortTimes(payload.timesOfDay).join(", "),
|
||||
})
|
||||
@@ -890,7 +913,9 @@ export function summariseScheduleDraft(
|
||||
): string {
|
||||
const cadenceLine = cadenceLineFor(draft, t);
|
||||
const timesPhrase =
|
||||
draft.mode !== "oneShot" && draft.timesOfDay.length > 0
|
||||
draft.mode !== "oneShot" &&
|
||||
draft.mode !== "asNeeded" &&
|
||||
draft.timesOfDay.length > 0
|
||||
? t("medications.wizard.summary.times", {
|
||||
times: sortTimes(draft.timesOfDay).join(", "),
|
||||
})
|
||||
@@ -928,6 +953,8 @@ export interface MedicationPayload {
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
oneShot: boolean;
|
||||
/** v1.16.11 — as-needed (PRN) flag; hydrates the "Bei Bedarf" mode. */
|
||||
asNeeded?: boolean;
|
||||
schedules: Array<{
|
||||
id?: string;
|
||||
windowStart: string;
|
||||
@@ -1012,8 +1039,13 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
const base = emptyWizardPayload();
|
||||
const parsedDose = parseDoseExpression(initial.dose ?? "");
|
||||
|
||||
const drafts: ScheduleDraft[] =
|
||||
initial.schedules.length > 0
|
||||
// v1.16.11 — an as-needed medication persists ZERO schedules; hydrate
|
||||
// one placeholder draft carrying the asNeeded mode so the flat mirror
|
||||
// and Step-5 selection round-trip (the encode path emits no schedule).
|
||||
const asNeeded = initial.asNeeded === true;
|
||||
const drafts: ScheduleDraft[] = asNeeded
|
||||
? [{ ...emptyScheduleDraft(), mode: "asNeeded" }]
|
||||
: initial.schedules.length > 0
|
||||
? initial.schedules.map((schedule) =>
|
||||
hydrateScheduleDraft(schedule, initial.oneShot),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user