fix(reminders): decide what stays due by the cycle, not the measurement type

The first cut kept only free-text check-ups due after their reminder
and let every reminder with a measurement type roll on. A fortnightly
PHQ-9 or GAD-7 reminder is a measurement reminder, and each one still
jumped two weeks forward the morning it was sent without the
questionnaire being filled.

The rule is now the cycle length. A reminder whose next slot is more
than seven days away, or that has no cadence, stays due after its
reminder until it is satisfied (a matching reading, a lab result or
"done"), skipped or snoozed, and is sent again at most once a week. A
reminder on a weekly or shorter cycle rolls on as before. Appointments
stay one-shot. A reading that lands on an open measurement reminder
still sets lastSatisfiedAt and schedules the next cycle from itself.

The 0355 repair drops its "no measurement type" condition for the same
cycle rule, so the fortnightly questionnaires in the reported shape
are put back on the day they were reminded.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:33:13 +02:00
1 parent 4d4a527c0d
commit fb960fb72e
7 files changed
+296 -82

No files matched your search

+118 -3
View File
@@ -11,8 +11,9 @@
* 2. **Migration 0355's repair.** The container has every migration applied
* already, so the data steps are pulled out of the `.sql` file and run
* again against rows seeded the way an upgrading instance holds them:
* check-ups the old tick rolled on after a send go back to the reminded
* slot, and every row whose evidence is ambiguous is left alone. The
* reminders on a cycle longer than a week that the old tick rolled on
* after a send go back to the reminded slot, measurement reminders
* included, and every row whose evidence is ambiguous is left alone. The
* steps run twice; the second run must change nothing.
*/
import { readFileSync } from "node:fs";
@@ -178,6 +179,65 @@ describe("an open check-up through the reminder tick", () => {
});
});
describe("an open fortnightly questionnaire through the reminder tick", () => {
const DUE = new Date("2026-09-26T07:00:00.000Z");
it("stays due after its reminder and moves on from the reading that fills it", async () => {
const prisma = getPrismaClient();
const user = await seedUser("questionnaire-owner");
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: "PHQ-9",
measurementType: "PHQ9_SCORE",
intervalDays: 14,
rrule: null,
notifyHour: 9,
origin: "VORSORGE",
enabled: true,
lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"),
nextDueAt: DUE,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
});
const dispatch = vi.fn(async () => OK);
const tick = (at: Date) =>
runMeasurementReminderTick(prisma, at, {
dispatch,
isModuleEnabled: async () => true,
});
await tick(DUE);
expect(dispatch).toHaveBeenCalledTimes(1);
let row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
});
expect(row.nextDueAt).toEqual(DUE);
// The questionnaire is filled the next afternoon.
const filledAt = new Date(DUE.getTime() + DAY + 6 * 60 * 60 * 1000);
await prisma.measurement.create({
data: {
userId: user.id,
type: "PHQ9_SCORE",
value: 4,
unit: "score",
measuredAt: filledAt,
source: "MANUAL",
},
});
const next = await tick(new Date(filledAt.getTime() + 15 * 60 * 1000));
expect(next.autoResolved).toBe(1);
row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
});
expect(row.lastSatisfiedAt).toEqual(filledAt);
// Fourteen days on from the reading, at 09:00 Berlin.
expect(row.nextDueAt).toEqual(new Date("2026-10-11T07:00:00.000Z"));
expect(dispatch).toHaveBeenCalledTimes(1);
});
});
describe("migration 0355", () => {
it("adds the nullable last_notified_at column", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
@@ -253,6 +313,31 @@ describe("migration 0355", () => {
id: "one-shot",
data: { rrule: null, intervalDays: null, nextDueAt: null },
},
{
// The reported shape: fortnightly questionnaires, last filled weeks
// ago, each rolled fourteen days on within a second of its send.
id: "phq9-fortnightly",
data: {
measurementType: "PHQ9_SCORE",
rrule: null,
intervalDays: 14,
lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"),
nextDueAt: new Date("2026-10-10T07:00:00.000Z"),
},
updatedAt: new Date("2026-09-26T07:00:03.600Z"),
},
{
id: "gad7-fortnightly",
data: {
measurementType: "GAD7_SCORE",
rrule: null,
intervalDays: 14,
lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"),
nextDueAt: new Date("2026-10-10T07:00:00.000Z"),
},
updatedAt: new Date("2026-09-26T07:00:03.900Z"),
},
{ id: "yearly-measurement", data: { measurementType: "WEIGHT" } },
{
// An earlier cycle's send is older; the latest send decides.
id: "monthly-many-sends",
@@ -304,7 +389,37 @@ describe("migration 0355", () => {
nextDueAt: new Date("2026-10-03T07:00:00.000Z"),
},
},
{ id: "measurement", data: { measurementType: "WEIGHT" } },
{
// A measurement reminder on a short cycle still rolls on.
id: "measurement-3-day",
data: {
measurementType: "WEIGHT",
rrule: null,
intervalDays: 3,
nextDueAt: new Date("2026-09-29T07:00:00.000Z"),
},
},
{
id: "measurement-7-day",
data: {
measurementType: "BLOOD_PRESSURE_SYS",
rrule: null,
intervalDays: 7,
nextDueAt: new Date("2026-10-03T07:00:00.000Z"),
},
},
{
// Satisfied by a reading after the send: done, not rolled on.
id: "measurement-read-after-send",
data: {
measurementType: "PHQ9_SCORE",
rrule: null,
intervalDays: 14,
lastSatisfiedAt: new Date("2026-09-26T07:03:00.000Z"),
nextDueAt: new Date("2026-10-10T07:00:00.000Z"),
},
updatedAt: new Date("2026-09-26T07:03:01.000Z"),
},
{ id: "appointment", data: { origin: "ENCOUNTER", rrule: null } },
{ id: "deleted", data: { deletedAt: ROLLED } },
{ id: "disabled", data: { enabled: false } },