mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
4d4a527c0d
commit
fb960fb72e
7 files changed
+296
-82
No files matched your search
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user