From eae46ed535d44388ba6ac33abccf20559687493e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:48:48 +0200 Subject: [PATCH] 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. --- src/app/medications/page-client.tsx | 1 + .../medication-intake-quick-add.test.tsx | 4 +- .../dashboard/medication-intake-quick-add.tsx | 6 ++- .../__tests__/log-intake-dialog.test.tsx | 39 ++++++++++++++++++ .../medications/log-intake-dialog.tsx | 6 ++- .../__tests__/default-medication.test.ts | 21 ++++++++++ src/lib/medications/default-medication.ts | 16 ++++++-- src/lib/queries/use-reminder-thresholds.ts | 40 +++++++++++++++++++ 8 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/lib/queries/use-reminder-thresholds.ts diff --git a/src/app/medications/page-client.tsx b/src/app/medications/page-client.tsx index 73bd791ae..23df5de21 100644 --- a/src/app/medications/page-client.tsx +++ b/src/app/medications/page-client.tsx @@ -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, diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 343bedae8..9b0ca7e92 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -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(); }); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 29ae37927..91676c106 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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); diff --git a/src/components/medications/__tests__/log-intake-dialog.test.tsx b/src/components/medications/__tests__/log-intake-dialog.test.tsx index 2388cc65d..538e47e25 100644 --- a/src/components/medications/__tests__/log-intake-dialog.test.tsx +++ b/src/components/medications/__tests__/log-intake-dialog.test.tsx @@ -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(" 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([ diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 411ef022f..82df0513e 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -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( @@ -133,7 +137,7 @@ export function LogIntakeDialog({ medications, medicationOverride, selectionNow, - undefined, + dueThresholds, userTz, ) ?? ""; const [slot, setSlot] = useState(NO_SLOT); diff --git a/src/lib/medications/__tests__/default-medication.test.ts b/src/lib/medications/__tests__/default-medication.test.ts index 8390c4b69..adcd529e4 100644 --- a/src/lib/medications/__tests__/default-medication.test.ts +++ b/src/lib/medications/__tests__/default-medication.test.ts @@ -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", () => { diff --git a/src/lib/medications/default-medication.ts b/src/lib/medications/default-medication.ts index dee3be9b9..c91e526ef 100644 --- a/src/lib/medications/default-medication.ts +++ b/src/lib/medications/default-medication.ts @@ -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; diff --git a/src/lib/queries/use-reminder-thresholds.ts b/src/lib/queries/use-reminder-thresholds.ts new file mode 100644 index 000000000..dab89e19b --- /dev/null +++ b/src/lib/queries/use-reminder-thresholds.ts @@ -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 }; +}