fix(medications): dashboard quick-add offers only doses the server calls actionable

The dashboard and capture-picker intake form filtered the medication list
on `active && trackIntake`, so a course that had ended (still active,
nobody paused it) was offered for a dose while the cards, the table and
"take all" already followed the server's `intakeActionable`. The picker
now renders from that one answer as well.

When medications exist but none offers a dose today, the empty state no
longer claims there are no medications; it says nothing is due and
points to the medication history for a past dose.
This commit is contained in:
Marc-André Bombeck committed 2026-09-30 22:58:19 +02:00
1 parent 95d3676821
commit ca10f2e5df
9 files changed
+97 -15

No files matched your search

@@ -71,6 +71,7 @@ function makeMed(
name: `Med ${id}`,
dose: "5 mg",
active: true,
intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -316,6 +317,48 @@ describe("<MedicationIntakeQuickAdd> — SSR contract", () => {
}
});
// #1040 — the picker offers what the server calls actionable, nothing
// it re-derives. An ended course is still `active` (nobody paused it),
// which is exactly the case the old `active && trackIntake` filter let
// through.
it("does not offer an ended course, even though it is still active (#1040)", () => {
mockUseQuery.mockReturnValue({
data: [
makeMed("ended", {
name: "Alpha",
active: true,
intakeActionable: false,
}),
makeMed("current", { name: "Zulu" }),
],
isLoading: false,
});
const html = renderSSR(<MedicationIntakeQuickAdd />);
// Alphabetical fallback would land on "Alpha" if it were offered.
expect(html).toMatch(
/data-slot="select-value"[^>]*>[\s\S]*?Zulu[\s\S]*?<\/span>/,
);
expect(html).not.toContain("Alpha");
});
it("shows the empty state when no medication is actionable today (#1040)", () => {
mockUseQuery.mockReturnValue({
data: [
makeMed("ended", { name: "Alpha", intakeActionable: false }),
makeMed("upcoming", { name: "Beta", intakeActionable: false }),
],
isLoading: false,
});
const html = renderSSR(<MedicationIntakeQuickAdd />);
expect(html).toContain('data-testid="medication-intake-quick-add-empty"');
expect(html).not.toContain(
'data-testid="medication-intake-quick-add-form"',
);
// The user has medications, so "no medications yet" would be untrue.
expect(html).toContain("Nothing to take today");
expect(html).not.toContain("No medications yet");
});
it("does not leak raw i18n keys in either locale", () => {
mockUseQuery.mockReturnValue({ data: [], isLoading: false });
const en = renderSSR(<MedicationIntakeQuickAdd />, "en");
@@ -81,8 +81,13 @@ interface Schedule extends ScheduleWindowInput {
export interface MedicationOption extends DefaultMedicationOption {
dose: string;
schedules: Schedule[];
/** v1.39.1 (#1033) — false: kept as a record, not offered for a dose. */
trackIntake?: boolean;
/**
* The server's answer to "does this medication offer a dose today"
* (active, intake tracked, course includes today; see
* `src/lib/medications/intake-actionable.ts`). The list read always
* carries it, so the picker renders from it and re-derives nothing.
*/
intakeActionable: boolean;
}
interface MedicationIntakeQuickAddProps {
@@ -141,9 +146,10 @@ export function MedicationIntakeQuickAdd({
const medications = useMemo(
() =>
Array.isArray(medicationsRaw)
? // v1.39.1 (#1033) — a medication with intake tracking off is
// kept as a record; the dose capture does not offer it.
medicationsRaw.filter((m) => m.active && m.trackIntake !== false)
? // #1040 — offer exactly what the cards, the table and "take
// all" offer: a paused, record-only, not-yet-started or ended
// course is not a dose to capture now.
medicationsRaw.filter((m) => m.intakeActionable === true)
: [],
[medicationsRaw],
);
@@ -314,6 +320,10 @@ export function MedicationIntakeQuickAdd({
// anlegen</Link>` so the footer-slot promise holds across both
// branches.
if (!medicationsLoading && medications.length === 0) {
// Medications exist, none offers a dose today: say that, rather than
// "no medications yet", which would be untrue.
const noneActionable =
Array.isArray(medicationsRaw) && medicationsRaw.length > 0;
const emptyFooter = (
<div className="flex w-full items-center justify-end gap-2">
{onCancel && (
@@ -328,7 +338,9 @@ export function MedicationIntakeQuickAdd({
)}
<Button asChild className="min-h-11 sm:min-h-9">
<Link href="/medications">
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
{noneActionable
? t("dashboard.medicationIntakeQuickAdd.noneActionableCta")
: t("dashboard.medicationIntakeQuickAdd.emptyCta")}
</Link>
</Button>
</div>
@@ -344,10 +356,16 @@ export function MedicationIntakeQuickAdd({
<Pill className="text-muted-foreground h-6 w-6" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t("dashboard.medicationIntakeQuickAdd.emptyTitle")}
{noneActionable
? t("dashboard.medicationIntakeQuickAdd.noneActionableTitle")
: t("dashboard.medicationIntakeQuickAdd.emptyTitle")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
{noneActionable
? t(
"dashboard.medicationIntakeQuickAdd.noneActionableDescription",
)
: t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
</p>
</div>
</div>