mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(medications): keep a record-only schedule unless the write names trackIntake
The shipped iPhone app cannot see the schedule of a medication kept as a record, because it is served with an empty schedules list. Its editor opens such a medication as daily at 08:00 and, once any schedule field is touched, sends a full replacement built from that default, plus asNeeded and oneShot, and never trackIntake. On a record-only medication the update route now drops schedules, asNeeded and oneShot from a write that does not name trackIntake and applies the rest. The web schedule editor and the wizard name the flag on every save.
This commit is contained in:
1 parent
94ec9ab932
commit
e5236d2e6a
6 files changed
+263
-7
No files matched your search
@@ -24,6 +24,10 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
medicationPauseEra: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
@@ -902,6 +906,8 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
arrange(false);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
// The web editor names the flag on a record-only schedule save.
|
||||
trackIntake: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
@@ -919,3 +925,191 @@ describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
|
||||
expect(era.payload).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1033 — the shipped iPhone app (1.0.3 / 1.0.4) cannot see the schedule of a
|
||||
* record-only medication (it is served with `schedules: []`). These are the
|
||||
* exact bodies its edit sheet sends (`EditMedicationSheet.save()` →
|
||||
* `MedicationPatch`, nil keys omitted): it never names `trackIntake`, always
|
||||
* sends `active` and `notificationsEnabled`, and sends the schedule shape
|
||||
* only when a schedule field was touched, rebuilt from the "daily at 08:00"
|
||||
* default its form shows for a medication without schedule rows.
|
||||
*/
|
||||
describe("PUT /api/medications/[id] — shipped client on a record-only medication (#1033)", () => {
|
||||
const STORED_ROW = {
|
||||
id: "s1",
|
||||
medicationId: "m1",
|
||||
windowStart: "21:00",
|
||||
windowEnd: "21:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["21:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: null,
|
||||
};
|
||||
|
||||
// `name`/`dose`/`treatmentClass`/`category`/`active`/`notificationsEnabled`/
|
||||
// `deliveryForm` ride every save; `dosesPerUnit` is nil for an empty field.
|
||||
const IOS_RENAME = {
|
||||
name: "Atorvastatin 20",
|
||||
dose: "20 mg",
|
||||
treatmentClass: "GENERIC",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
deliveryForm: "ORAL",
|
||||
};
|
||||
// The same save after the dose time was moved from the shown 08:00 default
|
||||
// to 09:00: `scheduleWrite(.daily)` + `oneShot` + `startsOn` + `asNeeded`.
|
||||
const IOS_SCHEDULE_TOUCHED = {
|
||||
...IOS_RENAME,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "09:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["09:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
oneShot: false,
|
||||
startsOn: "2026-06-01",
|
||||
asNeeded: false,
|
||||
};
|
||||
// The same save after picking the "as needed" cadence.
|
||||
const IOS_AS_NEEDED = {
|
||||
...IOS_RENAME,
|
||||
schedules: [],
|
||||
oneShot: false,
|
||||
asNeeded: true,
|
||||
};
|
||||
|
||||
function arrange(trackIntake: boolean) {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
trackIntake,
|
||||
createdAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
_count: { schedules: 1 },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.update).mockResolvedValue({
|
||||
id: "m1",
|
||||
userId: "user-1",
|
||||
trackIntake,
|
||||
unitsPerDose: 1,
|
||||
schedules: [{ ...STORED_ROW, unitsPerDose: null }],
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({});
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
|
||||
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
|
||||
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
|
||||
STORED_ROW,
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.update).mockClear();
|
||||
vi.mocked(prisma.medicationSchedule.deleteMany).mockClear();
|
||||
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
|
||||
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
|
||||
}
|
||||
|
||||
function updateData(): Record<string, unknown> {
|
||||
return vi.mocked(prisma.medication.update).mock.calls[0][0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
it("a rename applies and touches no schedule", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_RENAME), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toMatchObject({
|
||||
name: "Atorvastatin 20",
|
||||
notificationsEnabled: true,
|
||||
});
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
expect(updateData()).not.toHaveProperty("trackIntake");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a schedule-touched save keeps the recorded schedule and applies the rest", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
const data = updateData();
|
||||
// The 09:00 default the person never chose is not written.
|
||||
expect(data).not.toHaveProperty("schedules");
|
||||
expect(data).not.toHaveProperty("asNeeded");
|
||||
expect(data).not.toHaveProperty("oneShot");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
|
||||
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
|
||||
// Everything else in the save applies.
|
||||
expect(data).toMatchObject({ name: "Atorvastatin 20" });
|
||||
expect(data).toHaveProperty("startsOn");
|
||||
// And it is still a record: the stored schedule comes back on record.
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
schedules: unknown[];
|
||||
recordedSchedules?: Array<{ timesOfDay: string[] }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.schedules).toEqual([]);
|
||||
expect(body.data.recordedSchedules?.[0]?.timesOfDay).toEqual(["21:00"]);
|
||||
});
|
||||
|
||||
it("picking 'as needed' neither 422s nor deletes the recorded schedule", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(IOS_AS_NEEDED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).not.toHaveProperty("asNeeded");
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the same schedule write applies once it names trackIntake", async () => {
|
||||
arrange(false);
|
||||
const res = await PUT(
|
||||
putReq({ ...IOS_SCHEDULE_TOUCHED, trackIntake: false }),
|
||||
ROUTE_CTX,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toHaveProperty("schedules");
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("a tracked medication takes the shipped client's schedule write as before", async () => {
|
||||
arrange(true);
|
||||
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toHaveProperty("schedules");
|
||||
expect(updateData()).toMatchObject({ asNeeded: false, oneShot: false });
|
||||
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { medicationId: "m1" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pause", { active: false }],
|
||||
["reactivate", { active: true }],
|
||||
["end course", { endsOn: "2026-06-12" }],
|
||||
])("the %s write applies unchanged", async (_label, body) => {
|
||||
arrange(false);
|
||||
const res = await PUT(putReq(body), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
expect(updateData()).toMatchObject(
|
||||
"active" in body ? { active: body.active } : {},
|
||||
);
|
||||
expect(updateData()).not.toHaveProperty("schedules");
|
||||
});
|
||||
});
|
||||
@@ -299,6 +299,55 @@ export const PUT = apiHandler(
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ──
|
||||
//
|
||||
// A medication with intake tracking off is served with `schedules: []`
|
||||
// and its stored schedule in `recordedSchedules`, so a client that
|
||||
// predates the field arms no reminder from it. The price is that such a
|
||||
// client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
|
||||
// 1.0.4) opens a medication without schedule rows in its editor as
|
||||
// "daily at 08:00"; a rename or dose edit sends no `schedules` (its
|
||||
// editor diffs against that same prefill), but any touch of a schedule
|
||||
// field sends a full replacement built from the 08:00 default plus
|
||||
// `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
|
||||
// overwrite the schedule the person kept on record with one they never
|
||||
// chose (or flip it to as-needed and delete the rows).
|
||||
//
|
||||
// Rule: on a medication whose intake tracking is off, a write that does
|
||||
// not name `trackIntake` leaves the schedule shape alone. `schedules`,
|
||||
// `asNeeded` and `oneShot` are dropped from it; every other field (name,
|
||||
// dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
|
||||
// as sent. A client that knows about the switch names it on every
|
||||
// schedule save (the web editor and wizard do), and those writes apply.
|
||||
const keepRecordedSchedule =
|
||||
existing.trackIntake === false &&
|
||||
parsed.data.trackIntake === undefined &&
|
||||
(parsed.data.schedules !== undefined ||
|
||||
parsed.data.asNeeded !== undefined ||
|
||||
parsed.data.oneShot !== undefined);
|
||||
const input = keepRecordedSchedule
|
||||
? {
|
||||
...parsed.data,
|
||||
schedules: undefined,
|
||||
asNeeded: undefined,
|
||||
oneShot: undefined,
|
||||
}
|
||||
: parsed.data;
|
||||
if (keepRecordedSchedule) {
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.update.record_schedule_kept",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
dropped_schedules: parsed.data.schedules !== undefined,
|
||||
dropped_as_needed: parsed.data.asNeeded !== undefined,
|
||||
dropped_one_shot: parsed.data.oneShot !== undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
@@ -323,7 +372,7 @@ export const PUT = apiHandler(
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
} = input;
|
||||
|
||||
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
|
||||
// as a record; the live era from here on expects nothing.
|
||||
|
||||
@@ -691,6 +691,7 @@ export function MedicationDetailTabs({
|
||||
>
|
||||
<ScheduleTimesEditor
|
||||
medicationId={id}
|
||||
trackIntake={!recordOnly}
|
||||
schedules={medication.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
|
||||
@@ -82,9 +82,17 @@ function effectiveTimes(s: EditableSchedule): string[] {
|
||||
export function ScheduleTimesEditor({
|
||||
medicationId,
|
||||
schedules,
|
||||
trackIntake,
|
||||
}: {
|
||||
medicationId: string;
|
||||
schedules: EditableSchedule[];
|
||||
/**
|
||||
* v1.39.1 (#1033) — the medication's intake-tracking state, echoed on the
|
||||
* save. The server keeps the stored schedule of a record-only medication
|
||||
* unless the write names `trackIntake`, because a client that does not
|
||||
* know the field cannot have seen that schedule.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -114,6 +122,7 @@ export function ScheduleTimesEditor({
|
||||
// Replace schedules wholesale — preserve every cadence field from
|
||||
// the snapshot field-by-field, change only the times + windows.
|
||||
const body = {
|
||||
...(trackIntake !== undefined && { trackIntake }),
|
||||
schedules: schedules.map((s, i) => {
|
||||
const edit = edits[i];
|
||||
const times =
|
||||
|
||||
@@ -610,11 +610,11 @@ export interface CreateMedicationBody {
|
||||
*/
|
||||
asNeeded: boolean;
|
||||
/**
|
||||
* v1.39.1 (#1033) — intake tracking. Sent on create and edit for a
|
||||
* scheduled medication (the wizard hydrates it from the stored value);
|
||||
* omitted for an as-needed one, which is never due either way.
|
||||
* v1.39.1 (#1033) — intake tracking, sent on every create and edit (the
|
||||
* wizard hydrates it from the stored value). An as-needed medication
|
||||
* always tracks intake.
|
||||
*/
|
||||
trackIntake?: boolean;
|
||||
trackIntake: boolean;
|
||||
schedules: Array<{
|
||||
id?: string;
|
||||
windowStart: string;
|
||||
@@ -825,7 +825,10 @@ export function buildCreateBody(
|
||||
}),
|
||||
oneShot: isOneShot,
|
||||
asNeeded: isAsNeeded,
|
||||
...(!isAsNeeded && { trackIntake: committed.trackIntake }),
|
||||
// Always named, so the server applies this schedule even to a
|
||||
// medication kept as a record. Turning a medication into an as-needed
|
||||
// one means doses will be logged, so it tracks intake again.
|
||||
trackIntake: isAsNeeded ? true : committed.trackIntake,
|
||||
schedules: draftsToEmit.map((draft) =>
|
||||
encodeScheduleDraft(draft, isOneShot),
|
||||
),
|
||||
|
||||
@@ -79,7 +79,7 @@ const courseWindowFields = {
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value.",
|
||||
"v1.39.1 — intake tracking. Default true. When false the medication keeps its dose, dates and schedules as information, but nothing is ever due: no reminder on any channel, no projected slot, no entry on the today list or the doses card, and no adherence figure anywhere. Existing intake history stays stored. Switching it back on resumes from that moment; the time it was off never counts as missed. Omitted on update leaves the current value. On a medication whose tracking is off, an update that does not name `trackIntake` leaves its stored schedule alone: `schedules`, `asNeeded` and `oneShot` in such a request are ignored (a client that does not know the field cannot have seen the stored schedule, which is served in `recordedSchedules`). Name `trackIntake` to change the schedule of such a medication.",
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user