fix(medications): preselect only a dose offered today, graded by the account's thresholds

The intake pickers chose their default from every active medication, so a
course that had ended but was not archived could be the preselected dose.
The default now reads the server's intakeActionable answer; the medications
page dialog still lists ended courses for back-filling a past dose, they are
just never the default.

Both pickers also graded "due" against the fixed 120 / 240-minute defaults
while the cards beside them used the account's own late and missed
thresholds. They read the same thresholds now.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 09:48:48 +02:00
1 parent ef420f9e2a
commit eae46ed535
8 files changed
+127 -6

No files matched your search

+1
View File
@@ -665,6 +665,7 @@ export default function MedicationsPageClient() {
name: m.name,
dose: m.dose,
active: m.active,
intakeActionable: m.intakeActionable === true,
lastTakenAt: m.lastTakenAt,
todayEventCount: m.todayEventCount ?? 0,
nextDueAt: m.nextDueAt,
@@ -98,7 +98,9 @@ describe("pickDefaultMedicationId — auto-select heuristic", () => {
it("returns null when no active medications exist", () => {
expect(pickDefaultMedicationId([])).toBeNull();
expect(
pickDefaultMedicationId([makeMed("a", { active: false })]),
pickDefaultMedicationId([
makeMed("a", { active: false, intakeActionable: false }),
]),
).toBeNull();
});
@@ -32,6 +32,7 @@ import {
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
/**
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
@@ -122,6 +123,9 @@ export function MedicationIntakeQuickAdd({
// logged-out mounts behave unchanged.
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
// The account's own late / missed thresholds decide which dose counts as
// due for the preselection, the same tiers the medication cards show.
const dueThresholds = useMedicationDueThresholds();
// The record this dose lands in, or null in the caller's own.
const recordName = useActiveRecordName();
@@ -188,7 +192,7 @@ export function MedicationIntakeQuickAdd({
medications,
medicationOverride,
selectionNow,
undefined,
dueThresholds,
userTz,
) ?? "";
const selectedMedication = medications.find((m) => m.id === medicationId);
@@ -3,8 +3,15 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// The reminder-thresholds read: `undefined` = not loaded (the 120 / 240
// defaults apply), otherwise the account's own tiers.
let mockThresholds:
| { lateMinutes: number; missedMinutes: number; lowStockRunwayDays: null }
| undefined;
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQuery: () => ({ data: mockThresholds }),
}));
vi.mock("sonner", () => ({
@@ -49,6 +56,7 @@ function medication(
name: `Medication ${id}`,
dose: "5 mg",
active: true,
intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -82,6 +90,37 @@ describe("<LogIntakeDialog> medication default", () => {
afterEach(() => {
vi.useRealTimers();
mockThresholds = undefined;
});
it("grades the due dose by the account's own missed threshold", () => {
// 10:00 in Berlin, five hours after a 04:00–05:00 window closed. Under the
// 240-minute default the dose is past missed and the alphabetical
// fallback wins; an account that keeps a six-hour missed threshold still
// sees it as very late, so it is the default.
const meds = [
medication("first", { name: "Alpha" }),
medication("late", {
name: "Zulu",
nextDueAt: undefined,
schedules: [
{
windowStart: "04:00",
windowEnd: "05:00",
daysOfWeek: null,
label: null,
dose: null,
},
],
}),
];
expectSelected(renderDialog(meds), "first");
mockThresholds = {
lateMinutes: 120,
missedMinutes: 360,
lowStockRunwayDays: null,
};
expectSelected(renderDialog(meds), "late");
});
it("selects an overdue current-window medication ahead of array order", () => {
const html = renderDialog([
@@ -45,6 +45,7 @@ import {
runUndoIntake,
} from "@/components/medications/use-medication-intake";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { useMedicationDueThresholds } from "@/lib/queries/use-reminder-thresholds";
interface LogIntakeSchedule extends ScheduleWindowInput {
label: string | null;
@@ -122,6 +123,9 @@ export function LogIntakeDialog({
const queryClient = useQueryClient();
const { user } = useAuth();
const userTz = user?.timezone || DEFAULT_TIMEZONE;
// The account's own late / missed thresholds decide which dose counts as
// due for the preselection, the same tiers the medication cards show.
const dueThresholds = useMedicationDueThresholds();
const formId = useId();
const [medicationOverride, setMedicationOverride] = useState<string | null>(
@@ -133,7 +137,7 @@ export function LogIntakeDialog({
medications,
medicationOverride,
selectionNow,
undefined,
dueThresholds,
userTz,
) ?? "";
const [slot, setSlot] = useState<string>(NO_SLOT);
@@ -14,6 +14,7 @@ function medication(
id,
name: `Medication ${id}`,
active: true,
intakeActionable: true,
schedules: [],
lastTakenAt: null,
todayEventCount: 0,
@@ -62,6 +63,26 @@ describe("pickDefaultMedicationId", () => {
expect(pickDefaultMedicationId(options, now)).toBe("r");
expect(pickDefaultMedicationId([...options].reverse(), now)).toBe("r");
});
it("never preselects an ended course, even when it is the only active one", () => {
// Still active (not archived) but the course ended: the server answers
// `intakeActionable: false`, the list keeps it for back-filling, and the
// default lands on the medication that offers a dose today.
const options = [
medication("ended", {
name: "Amoxicillin",
active: true,
intakeActionable: false,
}),
medication("current", { name: "Ramipril" }),
];
expect(pickDefaultMedicationId(options)).toBe("current");
expect(
pickDefaultMedicationId([
medication("ended", { active: true, intakeActionable: false }),
]),
).toBeNull();
});
});
describe("resolveMedicationSelectionId", () => {
+13 -3
View File
@@ -8,6 +8,13 @@ export interface DefaultMedicationOption {
id: string;
name: string;
active: boolean;
/**
* The server's answer to "does this medication offer a dose today"
* (`src/lib/medications/intake-actionable.ts`): active, intake tracked,
* and the course includes today. The preselection reads it and never
* re-derives it from `active`, so an ended course is never the default.
*/
intakeActionable: boolean;
schedules: ScheduleWindowInput[];
lastTakenAt: string | null;
todayEventCount?: number | null;
@@ -27,8 +34,11 @@ const DEFAULT_THRESHOLDS: MedicationDueThresholds = {
/**
* Pick the medication that should be pre-selected when an intake form opens.
* Current, late, and overdue schedule windows win; otherwise the fallback is
* the single active medication or the first active name alphabetically.
* Only a medication that offers a dose today (`intakeActionable`) can be the
* default. Among those, current, late, and overdue schedule windows win;
* otherwise the fallback is the single candidate or the first name
* alphabetically. A list may still carry ended courses for back-filling a
* past dose; they stay pickable by hand but are never preselected.
*/
export function pickDefaultMedicationId(
options: DefaultMedicationOption[],
@@ -36,7 +46,7 @@ export function pickDefaultMedicationId(
thresholds: MedicationDueThresholds = DEFAULT_THRESHOLDS,
tz: string = DEFAULT_TIMEZONE,
): string | null {
const actives = options.filter((medication) => medication.active);
const actives = options.filter((medication) => medication.intakeActionable);
if (actives.length === 0) return null;
if (actives.length === 1) return actives[0].id;
@@ -0,0 +1,40 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import type { MedicationDueThresholds } from "@/lib/medications/default-medication";
/**
* The account's own late / missed thresholds for the intake pickers.
*
* The cards, the table and the medications page each read
* `/api/settings/reminder-thresholds` under this key with the same fetcher
* shape (failure resolves to `null`), so the pickers share their cache entry.
* `undefined` until it lands or when it failed — the default picker then
* falls back to the 120 / 240-minute defaults every reader shares.
*/
export function useMedicationDueThresholds():
MedicationDueThresholds | undefined {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.settingsReminderThresholds(),
queryFn: async () => {
try {
return await apiGet<{
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
}>("/api/settings/reminder-thresholds");
} catch {
return null;
}
},
enabled: isAuthenticated,
staleTime: 5 * 60 * 1000,
});
if (!data) return undefined;
return { lateMinutes: data.lateMinutes, missedMinutes: data.missedMinutes };
}