fix(reminders): one cycle rule for the tick and the start page

The start page read every reminder due before the end of the day, so a
daily weigh-in at 20:00 or a twice-daily blood-pressure course showed
"check-up due" from the morning on and took a pinned place on the rail.

`holdsOpenAfterReminder` is now the one definition of a reminder that
stays due after it is sent: an interval longer than seven days, a rule
whose next gap is longer than seven days, a rule with no occurrence
left, or no cadence; never an appointment. The tick uses it to decide
whether a delivered reminder rolls on, and the start page uses it to
show such a reminder for its whole due day and keep it under the cap.
A short-cycle reminder shows only once its time has come and is not
pinned.

The tick also skips a reminder already sent for its current slot
outside its notify hour before the auto-resolve read. The eventful
satisfy worker still resolves it the moment a reading or lab result
lands, and the poll still runs for it once a day, in the notify hour;
the weekly hold stays after that poll so the safety net is daily.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:36:15 +02:00
1 parent fb960fb72e
commit 31e4c95487
8 files changed
+394 -61

No files matched your search

+13 -2
View File
@@ -1354,7 +1354,10 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
it("keeps a due check-up on a full rail", () => {
const d = buildDailyDigest(
input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }),
input({
...crowd,
preventiveDue: [{ label: "Skin check", staysDue: true }],
}),
t,
);
expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK);
@@ -1363,6 +1366,14 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
expect(d.worthALook[0].kind).toBe("dose_window");
});
it("does not pin a short-cycle reminder: it rolls on anyway", () => {
const d = buildDailyDigest(
input({ ...crowd, preventiveDue: [{ label: "Weigh-in" }] }),
t,
);
expect(d.worthALook.map((i) => i.kind)).not.toContain("preventive_care");
});
it("keeps today's visit on a full rail", () => {
const d = buildDailyDigest(
input({ ...crowd, upcomingVisits: [visit()] }),
@@ -1375,7 +1386,7 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const d = buildDailyDigest(
input({
...crowd,
preventiveDue: [{ label: "Skin check" }],
preventiveDue: [{ label: "Skin check", staysDue: true }],
upcomingVisits: [visit()],
}),
t,
+70 -2
View File
@@ -302,6 +302,22 @@ describe("loadDailyDigest — AI parts", () => {
});
});
/** A reminder row as the preventive read selects it; due this morning. */
function reminderRow(over: Record<string, unknown> = {}) {
return {
label: "Skin check",
origin: "VORSORGE",
intervalDays: null,
rrule: "FREQ=YEARLY;INTERVAL=1",
anchorDate: null,
notifyHour: 9,
lastSatisfiedAt: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
nextDueAt: new Date("2026-07-17T07:00:00.000Z"),
...over,
};
}
// v1.39.2 — the two reads behind the check-up and visit rail items. NOW is
// 11:00 in Berlin on 2026-07-17, so the local day runs 2026-07-16T22:00Z to
// 2026-07-17T22:00Z.
@@ -370,8 +386,8 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
await import("@/lib/i18n/server-translator");
vi.mocked(getServerTranslator).mockReturnValueOnce({ t } as never);
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
{ label: "coach.cadence.bp7day.label", origin: "COACH" },
{ label: "coach.cadence.bp7day.label", origin: "VORSORGE" },
reminderRow({ label: "coach.cadence.bp7day.label", origin: "COACH" }),
reminderRow({ label: "coach.cadence.bp7day.label" }),
] as never);
await loadDailyDigest(USER, NOW);
@@ -380,4 +396,56 @@ describe("loadDailyDigest — due check-ups and today's visits", () => {
labels: "Blood pressure week, coach.cadence.bp7day.label",
});
});
// Watched red: with every reminder due before the end of the day on the
// rail, a daily weigh-in at 20:00 read "check-up due" from the morning on.
it("shows a reminder that stays due from the morning of its day", async () => {
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
reminderRow({
label: "PHQ-9",
measurementType: "PHQ9_SCORE",
rrule: null,
intervalDays: 14,
nextDueAt: new Date("2026-07-17T16:00:00.000Z"), // 18:00 today
}),
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
});
it("leaves a short-cycle reminder due later today off until its time", async () => {
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
reminderRow({
label: "Weigh-in",
measurementType: "WEIGHT",
rrule: null,
intervalDays: 1,
notifyHour: 20,
nextDueAt: new Date("2026-07-17T18:00:00.000Z"), // 20:00 today
}),
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.worthALook.map((i) => i.kind)).not.toContain(
"preventive_care",
);
});
it("shows a short-cycle reminder once its time has come", async () => {
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValueOnce([
reminderRow({
label: "Weigh-in",
rrule: null,
intervalDays: 1,
nextDueAt: new Date("2026-07-17T06:00:00.000Z"), // 08:00 today
}),
] as never);
const digest = await loadDailyDigest(USER, NOW);
expect(digest.worthALook.map((i) => i.kind)).toContain("preventive_care");
});
});
+13 -5
View File
@@ -142,6 +142,12 @@ export interface DailyDigestSyncIssue {
export interface DailyDigestPreventiveDue {
/** Display label, already resolved (a Coach cadence key is translated). */
label: string;
/**
* Whether this reminder stays due after it is sent (a cycle longer than a
* week, or none). Only such a reminder earns the check-up item a place
* under the rail cap; a short-cycle rhythm rolls on by itself.
*/
staysDue?: boolean;
}
/**
@@ -670,8 +676,9 @@ function buildUpcomingVisitItems(
/**
* The bounded rail: at most `max` items, in priority order, with the pinned
* items guaranteed a place. A pinned item is one a person can miss for good if
* the cap drops it today: a check-up that is due or overdue (its reminder has
* already gone out) and a visit booked for today (the day ends). The rest fill
* the cap drops it today: a reminder that stays due and is due or overdue
* (its notification has already gone out, and it will not roll on by itself)
* and a visit booked for today (the day ends). The rest fill
* the remaining slots in order, and the result keeps the priority order, so an
* overdue dose still leads.
*/
@@ -1116,9 +1123,10 @@ export function buildDailyDigest(
// Due check-ups and today's visits keep their place under the cap (see
// `capWithPinned`); they still sit below a dose in the displayed order.
const pinned = new Set<PriorityItem>(
[preventive, visits.today].filter(
(item): item is PriorityItem => item !== null,
),
[
input.preventiveDue.some((due) => due.staysDue) ? preventive : null,
visits.today,
].filter((item): item is PriorityItem => item !== null),
);
// S11 — the calm, informational tension marker sits last: it is context, not
// an action that expires, so a time-sensitive dose / sync / check-in wins the
+35 -10
View File
@@ -28,6 +28,7 @@ import {
import { userDayKey } from "@/lib/tz/format";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval";
import { isArrivalKind } from "@/lib/arrivals/types";
@@ -407,10 +408,11 @@ export async function loadDailyDigest(
// proves every one of them, and the DTO mapper refuses such a row
// outright so a site that lost its filter fails loudly.
//
// Due TODAY or overdue: anything whose due instant falls before the end
// of the person's local day, not only what has passed by now. Since
// v1.39.2 an open check-up keeps its due date after the reminder, so
// the overdue ones stay here until they are done, skipped or snoozed.
// Read up to the end of the person's local day; the filter below keeps
// what is due now, plus what is due later today when it is a reminder
// that stays due (see `holdsOpenAfterReminder`). Since v1.39.2 such a
// reminder keeps its due date after it is sent, so the overdue ones
// stay here until they are satisfied, skipped or snoozed.
where: {
userId: user.id,
enabled: true,
@@ -418,7 +420,17 @@ export async function loadDailyDigest(
origin: { not: "ENCOUNTER" },
nextDueAt: { not: null, lt: todayEndExclusive },
},
select: { label: true, origin: true },
select: {
label: true,
origin: true,
intervalDays: true,
rrule: true,
anchorDate: true,
notifyHour: true,
lastSatisfiedAt: true,
createdAt: true,
nextDueAt: true,
},
orderBy: { nextDueAt: "asc" },
take: PREVENTIVE_DUE_READ_LIMIT,
}),
@@ -637,11 +649,24 @@ export async function loadDailyDigest(
// A Coach-suggested measurement cadence stores its label as a bundle key;
// the checkups page and the dashboard card translate it, and so does this
// line. A free-text label (every other row) is the person's own words.
const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => {
if (row.origin !== "COACH") return { label: row.label };
const translated = t(row.label);
return { label: translated === row.label ? row.label : translated };
});
//
// A reminder that stays due after it is sent (a cycle longer than a week,
// a fortnightly questionnaire, a yearly check-up) is on the rail for its
// whole due day and keeps its place under the cap. A short-cycle one (a
// daily weigh-in, a twice-daily course) is a rhythm: it shows only once its
// time has come, is not pinned, and its next reading moves it on.
const preventiveDue: DailyDigestPreventiveDue[] = [];
for (const row of dueReminders) {
if (row.nextDueAt === null) continue;
const staysDue = holdsOpenAfterReminder(row, user.timezone, row.nextDueAt);
if (!staysDue && row.nextDueAt.getTime() > now.getTime()) continue;
let label = row.label;
if (row.origin === "COACH") {
const translated = t(row.label);
if (translated !== row.label) label = translated;
}
preventiveDue.push({ label, staysDue });
}
// Group the two figures for the reader. `Intl.NumberFormat` is the only
// locale-aware step the digest takes on its own; everything else it renders
@@ -878,4 +878,49 @@ describe("runMeasurementReminderTick", () => {
// The evening slot of the same day.
expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z"));
});
it("skips an already-reminded open slot outside its hour before any read", async () => {
const { prisma } = makePrisma({
reminders: [
checkup({
nextDueAt: new Date("2026-06-14T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
}),
],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
new Date("2026-06-15T12:00:00Z"), // 14:00 Berlin
{ dispatch },
);
expect(summary.skippedOutsideWindow).toBe(1);
expect(prisma.labResult.findFirst).not.toHaveBeenCalled();
});
it("still polls an already-reminded open slot for a result in its hour", async () => {
const takenAt = new Date("2026-06-14T18:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [
checkup({
nextDueAt: new Date("2026-06-14T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
}),
],
labMatch: { takenAt },
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.autoResolved).toBe(1);
expect(updates[0].data.lastSatisfiedAt).toEqual(takenAt);
});
});
+26 -42
View File
@@ -19,7 +19,7 @@
* gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on
* a weekly or shorter cycle rolls on to its next slot, which is the
* next nudge anyway, and an appointment (`origin: ENCOUNTER`) is
* one-shot and closes (see `staysDueAfterReminder`). The per-day claim
* one-shot and closes (see `holdsOpenAfterReminder`). The per-day claim
* in `notification_events` stays the guard against a second send on
* the same local day.
* 2. Auto-resolve from an incoming measurement. Before deciding to
@@ -57,6 +57,7 @@ import { findSatisfyingEvent } from "@/lib/measurement-reminders/resolve";
import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
/**
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
@@ -96,46 +97,9 @@ function moduleForMeasurementType(
*/
const DUE_QUERY_SLACK_MS = 15 * 60_000;
/**
* Calendar days an open reminder waits before it is sent again, and the cycle
* length at or under which a reminder rolls on instead of staying due: a
* weekly or daily reminder's own next slot comes round at least as soon as the
* repeat would.
*/
/** Calendar days an open reminder waits before it is sent again. */
export const CHECKUP_REPEAT_DAYS = 7;
/**
* v1.39.2 — whether a delivered reminder leaves the row due.
*
* Decided by the cycle length, not by what the reminder asks for. A reminder
* whose next slot is more than a week away stays due until it is satisfied (a
* matching reading or lab result, or "done"), skipped or snoozed: rolling it
* on would file an open task under a slot weeks or a year away. That held for
* free-text check-ups and for measurement reminders alike; the report that
* led here was a fortnightly PHQ-9 and GAD-7 pair that each rolled two weeks
* forward the morning they were reminded, without either questionnaire being
* filled. A one-shot (no interval, no rule) has no next slot and also stays
* due. A reminder on a weekly or shorter cycle rolls on as before, because its
* next slot is the next reminder anyway; holding a daily course on a missed
* slot would stall it. A cadence with no next slot left rolls to nothing as
* before. An appointment (`ENCOUNTER`) always stays one-shot.
*/
export function staysDueAfterReminder(
reminder: {
origin?: string | null;
intervalDays: number | null;
rrule: string | null;
},
rolledTo: Date | null,
timezone: string,
now: Date,
): boolean {
if (reminder.origin === "ENCOUNTER") return false;
if (reminder.intervalDays === null && reminder.rrule === null) return true;
if (rolledTo === null) return false;
return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS;
}
/**
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
*
@@ -322,6 +286,24 @@ export async function runMeasurementReminderTick(
try {
const timezone = reminder.user.timezone || "Europe/Berlin";
// v1.39.2 — a reminder already sent for its current slot stays in
// this scan every tick until it is satisfied. Outside its notify hour
// there is nothing it could do this tick, so skip it before the
// auto-resolve read: the eventful `reminder-satisfy` worker resolves it
// the moment a reading or lab result lands, and the safety-net poll
// below still runs for it once a day, in the notify hour. The weekly
// hold stays after that poll on purpose, so the safety net is daily
// rather than weekly.
if (
reminder.lastNotifiedAt != null &&
reminder.nextDueAt !== null &&
reminder.lastNotifiedAt.getTime() >= reminder.nextDueAt.getTime() &&
wallClockInTz(now, timezone).hour !== reminder.notifyHour
) {
summary.skippedOutsideWindow += 1;
continue;
}
// ── Auto-resolve from an incoming event ────────────────────────
// Cheap safety-net poll, in the cron (the eventful `reminder-satisfy`
// worker is the fast path). A typed reminder resolves from a matching
@@ -485,12 +467,14 @@ export async function runMeasurementReminderTick(
// Dispatch succeeded. Record the delivery; a check-up keeps its due
// date (it is still open, and `lastNotifiedAt` holds the repeat to a
// week), everything else rolls on past this slot.
const rolledTo = nextSlotAfterReminder(reminder, timezone, now);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: staysDueAfterReminder(reminder, rolledTo, timezone, now)
data: holdsOpenAfterReminder(reminder, timezone, now)
? { lastNotifiedAt: now }
: { nextDueAt: rolledTo, lastNotifiedAt: now },
: {
nextDueAt: nextSlotAfterReminder(reminder, timezone, now),
lastNotifiedAt: now,
},
});
summary.dispatched += 1;
} catch (err: unknown) {
@@ -0,0 +1,111 @@
/**
* v1.39.2 — which reminders stay due after they are sent. One predicate for
* the reminder tick, the daily digest and the edit route, so the three cannot
* disagree about whether a reminder is an open task or a rhythm.
*/
import { describe, expect, it } from "vitest";
import {
HOLD_OPEN_MIN_CYCLE_DAYS,
holdsOpenAfterReminder,
} from "../holds-open";
const TZ = "Europe/Berlin";
const SLOT = new Date("2026-09-26T07:00:00.000Z");
function r(over: Partial<Parameters<typeof holdsOpenAfterReminder>[0]> = {}) {
return {
origin: "VORSORGE",
intervalDays: null as number | null,
rrule: null as string | null,
anchorDate: null,
notifyHour: 9,
lastSatisfiedAt: null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
...over,
};
}
describe("holdsOpenAfterReminder", () => {
it("holds a cycle longer than a week open", () => {
expect(HOLD_OPEN_MIN_CYCLE_DAYS).toBe(7);
expect(holdsOpenAfterReminder(r({ intervalDays: 14 }), TZ, SLOT)).toBe(
true,
);
expect(holdsOpenAfterReminder(r({ intervalDays: 8 }), TZ, SLOT)).toBe(true);
});
it("lets a weekly or shorter interval roll on", () => {
expect(holdsOpenAfterReminder(r({ intervalDays: 7 }), TZ, SLOT)).toBe(
false,
);
expect(holdsOpenAfterReminder(r({ intervalDays: 1 }), TZ, SLOT)).toBe(
false,
);
});
it("reads a rule by the gap to its next occurrence", () => {
expect(
holdsOpenAfterReminder(r({ rrule: "FREQ=YEARLY;INTERVAL=1" }), TZ, SLOT),
).toBe(true);
expect(
holdsOpenAfterReminder(r({ rrule: "FREQ=MONTHLY;INTERVAL=1" }), TZ, SLOT),
).toBe(true);
expect(holdsOpenAfterReminder(r({ rrule: "FREQ=WEEKLY" }), TZ, SLOT)).toBe(
false,
);
expect(
holdsOpenAfterReminder(r({ rrule: "FREQ=DAILY;BYHOUR=9,19" }), TZ, SLOT),
).toBe(false);
});
it("holds a rule with no occurrence left open: its last slot is the task", () => {
expect(
holdsOpenAfterReminder(
r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }),
TZ,
SLOT,
),
).toBe(true);
});
it("holds a reminder with no cadence open", () => {
expect(holdsOpenAfterReminder(r(), TZ, SLOT)).toBe(true);
});
it("never holds an appointment", () => {
expect(holdsOpenAfterReminder(r({ origin: "ENCOUNTER" }), TZ, SLOT)).toBe(
false,
);
expect(
holdsOpenAfterReminder(
r({ origin: "ENCOUNTER", intervalDays: 365 }),
TZ,
SLOT,
),
).toBe(false);
});
it("holds the last occurrence open when it is sent", () => {
// The tick passes the send instant, a few seconds past the occurrence.
expect(
holdsOpenAfterReminder(
r({ rrule: "FREQ=DAILY;UNTIL=20260926T235959Z" }),
TZ,
new Date(SLOT.getTime() + 20_000),
),
).toBe(true);
});
it("measures the gap that follows the slot, not the one after it", () => {
// Twice a month, on the 3rd and on the 1st: from a slot on the 3rd the
// next occurrence is four weeks out, and it is that cycle that is open.
const rule = r({ rrule: "FREQ=MONTHLY;BYMONTHDAY=1,3" });
expect(
holdsOpenAfterReminder(rule, TZ, new Date("2026-09-03T07:00:00.000Z")),
).toBe(true);
expect(
holdsOpenAfterReminder(rule, TZ, new Date("2026-09-01T07:00:00.000Z")),
).toBe(false);
});
});
@@ -0,0 +1,81 @@
/**
* v1.39.2 — whether a reminder stays due after it is sent.
*
* Decided by the cycle length, not by what the reminder asks for. A reminder
* whose next slot is more than {@link HOLD_OPEN_MIN_CYCLE_DAYS} days after the
* current one stays due until it is satisfied (a matching reading or lab
* result, or "done"), skipped or snoozed: rolling it on would file an open
* task under a slot weeks or a year away. That holds for free-text check-ups
* and measurement reminders alike; the report that led here was a fortnightly
* PHQ-9 and GAD-7 pair. A reminder with no cadence, or a rule with no
* occurrence left, has no next slot and also stays due: its last slot is the
* task. A weekly or shorter cycle rolls on, because its next slot is the next
* reminder anyway, and holding a daily course on a missed slot would stall it.
* An appointment (`ENCOUNTER`) is always one-shot.
*
* The one definition. The reminder tick decides with it whether a delivered
* reminder rolls on; the daily digest decides with it whether a reminder due
* later today belongs on the start page already and keeps its place there;
* the edit route decides with it whether re-enabling keeps the open slot.
*
* A course window (`endsOn`) is deliberately not consulted: it ends a
* Coach-suggested measurement course, which is a short-cycle rhythm and rolls
* on regardless.
*/
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling";
/** A cycle of at most this many calendar days rolls on after its reminder. */
export const HOLD_OPEN_MIN_CYCLE_DAYS = 7;
export interface HoldOpenInput {
origin?: string | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
}
/**
* `slot` is the instant of the current slot: the send instant in the tick
* (a few seconds past the occurrence it sends), the stored `nextDueAt`
* elsewhere. For a rule the cycle is the calendar-day gap between its next
* two occurrences from `slot` on, so the answer does not depend on where
* between two occurrences `slot` sits. With one occurrence left the gap is
* measured from `slot` to it; with none left the reminder holds open.
*/
export function holdsOpenAfterReminder(
reminder: HoldOpenInput,
timezone: string,
slot: Date,
): boolean {
if (reminder.origin === "ENCOUNTER") return false;
if (reminder.intervalDays !== null) {
return reminder.intervalDays > HOLD_OPEN_MIN_CYCLE_DAYS;
}
if (reminder.rrule === null) return true;
const input = {
intervalDays: null,
rrule: reminder.rrule,
anchorDate: reminder.anchorDate,
notifyHour: reminder.notifyHour,
lastSatisfiedAt: reminder.lastSatisfiedAt,
createdAt: reminder.createdAt,
};
// At or after `slot`: a stored slot sits exactly on an occurrence.
const first = computeReminderNextDueAt(
input,
timezone,
new Date(slot.getTime() - 1),
);
if (first === null) return true;
const second = computeReminderNextDueAt(input, timezone, first);
if (second === null) {
// `slot` is the last occurrence: nothing to roll on to.
if (first.getTime() <= slot.getTime()) return true;
return calendarDaysUntil(first, slot, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS;
}
return calendarDaysUntil(second, first, timezone) > HOLD_OPEN_MIN_CYCLE_DAYS;
}