mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
ef420f9e2a
commit
eae46ed535
8 files changed
+127
-6
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user