mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(checkups): keep a missed rolling reminder due until it is measured
A rolling reminder ("every N days") is anchored on its last reading, but
the reminder tick rolled any cycle of a week or less on after a delivered
reminder, re-anchoring it on the send. A missed every-two-days blood
pressure reading therefore moved two days ahead each evening, read "in 2
days" on the card, and never showed as today or overdue.
A rolling reminder now stays due after its reminder, whatever its
interval, until a reading lands or it is done, skipped or snoozed. While
it stays open the repeat follows its interval, capped at a week, still in
its notify hour and under the per-day claim. Calendar rules that recur
within a week (a twice-daily course) keep rolling on; appointments are
unchanged.
holdsOpenAfterReminder stays the one definition for the tick and the
edit route. The daily digest asks the narrower question underneath it,
hasLongCycle, so a short rhythm still waits for its time of day on the
start page and is not pinned under the rail cap.
Refs #1369
This commit is contained in:
1 parent
d110f0456c
commit
ea3fcce80c
15 files changed
+530
-116
No files matched your search
@@ -8,6 +8,10 @@
|
||||
* it is reminded once, keeps its due date, shows on the start page's
|
||||
* digest, is not reminded again the same day or the next, is reminded
|
||||
* once more seven days on, and moves to next year when it is marked done.
|
||||
* A missed every-two-days blood pressure reading, first due a month
|
||||
* back, does the same on its own rhythm: due and overdue after each
|
||||
* reminder, asked for again every second day, two days on from the
|
||||
* reading that lands.
|
||||
* 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:
|
||||
@@ -24,6 +28,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { runMeasurementReminderTick } from "@/lib/jobs/measurement-reminder";
|
||||
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
parseReminderAnchor,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import {
|
||||
isOverdueDue,
|
||||
relativeDueKey,
|
||||
} from "@/lib/measurement-reminders/due-day";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
|
||||
|
||||
@@ -239,6 +251,97 @@ describe("an open fortnightly questionnaire through the reminder tick", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a missed every-two-days reading through the reminder tick", () => {
|
||||
// 18:00 in Berlin in November (UTC+1).
|
||||
const SIX_PM = new Date("2026-11-08T17:00:00.000Z");
|
||||
|
||||
it("stays due and overdue, repeats every second day, and moves on from the reading", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await seedUser("bp-owner");
|
||||
// Created this morning with a first due date a month back, as the form
|
||||
// does; the due date is the one the create route computes.
|
||||
const createdAt = new Date("2026-11-08T09:00:00.000Z");
|
||||
const schedule = {
|
||||
intervalDays: 2,
|
||||
rrule: null,
|
||||
anchorDate: parseReminderAnchor("2026-10-04", TZ),
|
||||
notifyHour: 18,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt,
|
||||
};
|
||||
const firstDue = computeReminderNextDueAt(schedule, TZ, createdAt);
|
||||
expect(firstDue).toEqual(new Date("2026-10-04T16:00:00.000Z"));
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: "Blood pressure",
|
||||
measurementType: "BLOOD_PRESSURE_SYS",
|
||||
...schedule,
|
||||
origin: "VORSORGE",
|
||||
enabled: true,
|
||||
nextDueAt: firstDue,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
const dispatch = vi.fn(async () => OK);
|
||||
const tick = (at: Date) =>
|
||||
runMeasurementReminderTick(prisma, at, { dispatch });
|
||||
const read = () =>
|
||||
prisma.measurementReminder.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
});
|
||||
|
||||
// Reminded at 18:00; at 20:40 it still reads overdue, not "in 2 days".
|
||||
expect((await tick(SIX_PM)).dispatched).toBe(1);
|
||||
let row = await read();
|
||||
expect(row.nextDueAt).toEqual(firstDue);
|
||||
expect(row.lastNotifiedAt).toEqual(SIX_PM);
|
||||
const evening = new Date(SIX_PM.getTime() + 160 * 60 * 1000);
|
||||
expect(
|
||||
isOverdueDue(
|
||||
relativeDueKey(row.nextDueAt!.toISOString(), evening.getTime(), TZ),
|
||||
),
|
||||
).toBe(true);
|
||||
const fullUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
const digest = await loadDailyDigest(fullUser, evening, { locale: "en" });
|
||||
const care = digest.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.body).toContain("Blood pressure");
|
||||
|
||||
// The next evening: held. Two days on: reminded again, still due.
|
||||
expect(
|
||||
(await tick(new Date(SIX_PM.getTime() + DAY))).skippedRepeatHeld,
|
||||
).toBe(1);
|
||||
const twoDaysOn = new Date(SIX_PM.getTime() + 2 * DAY);
|
||||
await tick(twoDaysOn);
|
||||
await tick(new Date(twoDaysOn.getTime() + 15 * 60 * 1000));
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
row = await read();
|
||||
expect(row.nextDueAt).toEqual(firstDue);
|
||||
expect(row.lastNotifiedAt).toEqual(twoDaysOn);
|
||||
|
||||
// Measured the next morning: two days on from the reading.
|
||||
const measuredAt = new Date("2026-11-11T07:30:00.000Z");
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 128,
|
||||
unit: "mmHg",
|
||||
measuredAt,
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
const resolved = await tick(new Date(SIX_PM.getTime() + 3 * DAY));
|
||||
expect(resolved.autoResolved).toBe(1);
|
||||
row = await read();
|
||||
expect(row.lastSatisfiedAt).toEqual(measuredAt);
|
||||
expect(row.nextDueAt).toEqual(new Date("2026-11-13T17:00:00.000Z"));
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration 0355", () => {
|
||||
it("adds the nullable last_notified_at column", async () => {
|
||||
const rows = await getPrismaClient().$queryRawUnsafe<
|
||||
@@ -395,7 +498,7 @@ describe("migration 0355", () => {
|
||||
updatedAt: new Date("2026-09-26T07:01:00.000Z"),
|
||||
},
|
||||
{
|
||||
// A weekly check-up still rolls on under the new tick.
|
||||
// A weekly cycle is outside this migration's repair.
|
||||
id: "weekly",
|
||||
data: {
|
||||
rrule: null,
|
||||
@@ -404,7 +507,7 @@ describe("migration 0355", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
// A measurement reminder on a short cycle still rolls on.
|
||||
// A measurement reminder on a short cycle: outside this repair too.
|
||||
id: "measurement-3-day",
|
||||
data: {
|
||||
measurementType: "WEIGHT",
|
||||
|
||||
Reference in new issue
Block a user