fix(reminders): keep an open check-up due after its reminder

The reminder tick rolled every delivered reminder on to its next slot.
For a check-up (a reminder with no measurement type, made by the person
or suggested by the Coach) that filed an open task under next month or
next year the moment it was reminded, although nobody had done it. The
start page and the check-up card then showed a yearly check-up due
today as scheduled for next year, and a check-up with no cadence
dropped off every surface.

A check-up now keeps its due date after the reminder until it is
marked done, skipped or snoozed, and is reminded again at most once a
week while it stays open. A new `last_notified_at` column records the
last delivered reminder; a repeat is held while that send was for the
current slot and fewer than seven calendar days have passed, so a
snooze, a skip or a completion opens a fresh slot that fires on its own
day. The per-day claim stays the guard against two sends in one day.
Measurement reminders keep rolling on (the next reading satisfies them
and their next slot comes round soon), as do check-ups on a weekly or
shorter cadence, whose next slot is the next reminder anyway.
Appointment reminders stay one-shot.

Migration 0355 adds the column and puts back check-ups the old tick
rolled on: a live, enabled check-up whose most recent reminder claim is
followed within ten minutes by its last write, carrying a due date more
than a week past that send (or none, for a one-shot), not done, skipped
or snoozed since. Its due date returns to the instant of that send.
Anything else is left as it is. The same migration adds `upcoming_visit`
to start-page item choices stored before visits were one of them, which
otherwise read as visits switched off.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:01:27 +02:00
1 parent f5bab3258a
commit 54b46f4679
9 files changed
+863 -39

No files matched your search

+6 -2
View File
@@ -13274,8 +13274,12 @@ paths:
tags:
- MeasurementReminders
summary: List Vorsorge reminders (v1.17.1)
description: Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending
(nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.
description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt
ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since
v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open
check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder
at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a
measurementType, still roll on to the next slot after a delivered reminder."
responses:
"200":
description: The owner's measurement reminders.
@@ -0,0 +1,104 @@
-- An open check-up stays due after its reminder.
--
-- Until now the reminder tick rolled every delivered reminder on to its next
-- slot. For a check-up (a reminder with no measurement type, made by the
-- person or suggested by the Coach) that filed an open task under next month
-- or next year the moment it was reminded, without it having been done: the
-- start page and the check-up card then showed it as scheduled far ahead,
-- and a one-shot check-up disappeared altogether.
--
-- From this release a check-up keeps its due date until it is marked done,
-- skipped or snoozed, and is reminded again at most once a week while it
-- stays open. Three steps.
-- 1. The cursor that keeps the repeat to once a week: when a reminder for
-- this row last reached a channel. Nullable, no default; a row that was
-- never reminded simply has none.
ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
-- 2. Put back the due date of check-ups the old behaviour rolled on.
--
-- The evidence is the reminder claim ledger: every reminder send first
-- claims `measurement:<reminder id>:<local date>` in `notification_events`
-- (event type MEASUREMENT_REMINDER). The claim is written whether or not a
-- channel delivered, but the roll-on only happened after a delivery, so a
-- row counts as rolled on by a send only when ALL of these hold:
--
-- - it is a live, enabled check-up: not deleted, not disabled, origin
-- VORSORGE or COACH (never an appointment), no measurement type;
-- - it was not done or skipped after that send, and it carries no
-- snooze;
-- - the send was its most recent claim, and the row's last write
-- (`updated_at`) lands within a minute before to ten minutes after
-- that claim. The roll-on is the write that follows a delivery by
-- seconds; any later write (an edit, a snooze, a skip, a Telegram
-- "later") moves `updated_at` past the window and the row is left
-- alone;
-- - the due date it carries now is what the roll-on would have written
-- and what the new behaviour no longer writes: more than a week past
-- the send (a weekly or shorter check-up still rolls on, so it is not
-- repaired), or no due date at all for a check-up with no cadence.
--
-- The due date goes back to the instant of that send, which is the day
-- the check-up was reminded and at its notify hour. `last_notified_at`
-- takes the same instant, so the repeat waits a week from that send
-- instead of firing again at the next notify hour. A claim older than the
-- ledger's 90-day retention is gone, and a row rolled on by such a send
-- stays as it is: without the claim there is no evidence of which slot
-- was reminded. Nothing is deleted.
WITH latest_claim AS (
SELECT DISTINCT ON (r."id")
r."id" AS "reminder_id",
ne."created_at" AS "sent_at"
FROM "measurement_reminders" r
JOIN "notification_events" ne
ON ne."record_user_id" = r."user_id"
AND ne."event_type" = 'MEASUREMENT_REMINDER'
AND ne."dedup_key" LIKE 'measurement:' || r."id" || ':%'
ORDER BY r."id", ne."created_at" DESC
)
UPDATE "measurement_reminders" r
SET "next_due_at" = lc."sent_at",
"last_notified_at" = lc."sent_at"
FROM latest_claim lc
WHERE r."id" = lc."reminder_id"
AND r."deleted_at" IS NULL
AND r."enabled" = TRUE
AND r."origin" IN ('VORSORGE', 'COACH')
AND r."measurement_type" IS NULL
AND r."snoozed_until" IS NULL
AND r."last_notified_at" IS NULL
AND (r."last_satisfied_at" IS NULL OR r."last_satisfied_at" < lc."sent_at")
AND (r."last_skipped_at" IS NULL OR r."last_skipped_at" < lc."sent_at")
AND (r."ends_on" IS NULL OR r."ends_on" > lc."sent_at")
AND r."updated_at" >= lc."sent_at" - INTERVAL '1 minute'
AND r."updated_at" <= lc."sent_at" + INTERVAL '10 minutes'
AND (
r."next_due_at" > lc."sent_at" + INTERVAL '7 days 12 hours'
OR (
r."next_due_at" IS NULL
AND r."interval_days" IS NULL
AND r."rrule" IS NULL
)
);
-- 3. Show booked visits on the start page for people who chose its items
-- before visits were one of them.
--
-- The start page's item choice is stored as the list of ENABLED kinds, and
-- only when it is not "all of them". A list saved before `upcoming_visit`
-- existed (v1.34.0 to v1.37.1) therefore reads as "visits switched off",
-- although nobody switched them off. Append the kind to every stored,
-- non-empty list that lacks it. An empty list is the whole rail turned off
-- and stays empty. A person who has deliberately switched visits off since
-- v1.37.2 cannot be told apart from one who never had the choice; they see
-- visits again and can switch them off once more, which now sticks.
UPDATE "users"
SET "dashboard_widgets_json" = jsonb_set(
"dashboard_widgets_json",
'{enabledHeroItemKinds}',
("dashboard_widgets_json" -> 'enabledHeroItemKinds') || '["upcoming_visit"]'::jsonb
)
WHERE jsonb_typeof("dashboard_widgets_json" -> 'enabledHeroItemKinds') = 'array'
AND jsonb_array_length("dashboard_widgets_json" -> 'enabledHeroItemKinds') > 0
AND NOT ("dashboard_widgets_json" -> 'enabledHeroItemKinds') ? 'upcoming_visit';
+11
View File
@@ -5481,6 +5481,17 @@ model MeasurementReminder {
lastSkippedAt DateTime? @map("last_skipped_at")
/// v1.37.20 — lifetime skip counter ("skipped N times").
skipCount Int @default(0) @map("skip_count")
/// v1.39.2 — when a reminder for this row last reached at least one channel.
/// Written by the reminder tick after a delivered dispatch, read by the same
/// tick. A check-up (no `measurementType`, not an appointment) no longer
/// rolls `nextDueAt` forward when it is reminded: it stays due until it is
/// done, skipped or snoozed, and this cursor is what keeps the repeat to at
/// most once a week. A nudge counts as "already sent for this slot" only
/// while `lastNotifiedAt >= nextDueAt`, so a snooze, a skip or a satisfy
/// (each of which moves `nextDueAt`) opens a fresh slot that fires on its
/// own day. Operational state, deliberately not in the backup: a restored
/// row without it re-reminds at most once, on its next notify hour.
lastNotifiedAt DateTime? @map("last_notified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
@@ -100,6 +100,7 @@ function reminderRow(
snoozedUntil: null,
lastSkippedAt: null,
skipCount: 0,
lastNotifiedAt: null,
enabled: true,
vaccinationAntigen: null,
createdAt: at,
@@ -9,8 +9,10 @@
* logged since the last satisfy advances lastSatisfiedAt + recomputes
* nextDueAt and suppresses the nudge. Free-text reminders never
* auto-resolve.
* - Ledger-free dedup: a successful dispatch advances nextDueAt past
* now so the same due cycle never re-fires.
* - A measurement reminder rolls on: a successful dispatch advances
* nextDueAt past now. A check-up (no measurementType, not an
* appointment) does NOT roll on: it stays due until it is done, and a
* second nudge for the same open slot waits a week.
* - clientManaged suppresses the APNs leg only: the tick still
* dispatches, and a reminder nothing delivered stays overdue.
*
@@ -136,6 +138,8 @@ interface FakeReminder {
location: string | null;
nextDueAt: Date | null;
lastSatisfiedAt: Date | null;
/** v1.39.2 — the last delivered nudge; absent on most fixtures. */
lastNotifiedAt?: Date | null;
enabled: boolean;
createdAt: Date;
user: {
@@ -581,4 +585,197 @@ describe("runMeasurementReminderTick", () => {
expect(summary.skippedOutsideWindow).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
});
// ── v1.39.2 — an open check-up stays due after its reminder ─────────
//
// Watched red: with the tick advancing every delivered reminder (the
// pre-v1.39.2 behaviour), the first two cases fail — the yearly check-up's
// update carries a `nextDueAt` a year out, and the dashboard and the digest
// then read the open check-up as scheduled for next year.
const checkup = (overrides: Partial<FakeReminder> = {}) =>
reminder({
measurementType: null,
intervalDays: null,
rrule: "FREQ=YEARLY;INTERVAL=1",
nextDueAt: new Date("2026-06-15T07:00:00Z"), // due today at 09:00
...overrides,
});
it("keeps a reminded check-up due and records the delivery instead", async () => {
const { prisma, updates } = makePrisma({
reminders: [checkup()],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.dispatched).toBe(1);
expect(updates).toHaveLength(1);
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
expect("nextDueAt" in updates[0].data).toBe(false);
});
it("keeps a Coach-suggested check-up due the same way", async () => {
const { prisma, updates } = makePrisma({
reminders: [checkup({ origin: "COACH" })],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates).toHaveLength(1);
expect("nextDueAt" in updates[0].data).toBe(false);
});
it("still rolls a measurement reminder on after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({ origin: "COACH" })],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates).toHaveLength(1);
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
expect(updates[0].data.lastNotifiedAt).toEqual(NINE_LOCAL);
});
it("still closes an appointment reminder after its one nudge", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminder({
origin: "ENCOUNTER",
measurementType: null,
intervalDays: null,
}),
],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates).toHaveLength(1);
expect(updates[0].data.nextDueAt).toBeNull();
});
it("does not re-remind an open check-up the day after", 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,
NINE_LOCAL,
{ dispatch },
);
expect(summary.skippedRepeatHeld).toBe(1);
expect(claimMock).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
it("holds the repeat for six days and sends it on the seventh", async () => {
const notifiedAt = new Date("2026-06-08T07:00:04Z");
const open = checkup({
nextDueAt: new Date("2026-06-08T07:00:00Z"),
lastNotifiedAt: notifiedAt,
});
const sixDays = makePrisma({ reminders: [open], labMatch: null });
const heldDispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(
sixDays.prisma as never,
new Date("2026-06-14T07:00:00Z"),
{ dispatch: heldDispatch },
);
expect(heldDispatch).not.toHaveBeenCalled();
// Seven calendar days on, at the same notify hour — a few seconds EARLIER
// on the clock than the first send, which an hour count would still read
// as "under a week".
const sevenDays = makePrisma({ reminders: [open], labMatch: null });
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
sevenDays.prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(summary.dispatched).toBe(1);
expect(sevenDays.updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("fires on the day a check-up was snoozed to, whatever the last send", async () => {
// A snooze moves `nextDueAt` past the last send: that is a new slot the
// person asked for, not a repeat of the old one.
const { prisma } = makePrisma({
reminders: [
checkup({
nextDueAt: new Date("2026-06-15T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
}),
],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("keeps a one-shot check-up due instead of dropping it", async () => {
// No cadence at all: rolling it on used to write `nextDueAt: null`, and
// the open check-up vanished from every surface.
const { prisma, updates } = makePrisma({
reminders: [checkup({ rrule: null, intervalDays: null })],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("rolls a weekly check-up on, since its next slot is the next nudge", async () => {
const { prisma, updates } = makePrisma({
reminders: [checkup({ rrule: null, intervalDays: 7 })],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
const advanced = updates[0].data.nextDueAt as Date;
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
});
it("keeps a fortnightly check-up due", async () => {
const { prisma, updates } = makePrisma({
reminders: [checkup({ rrule: null, intervalDays: 14 })],
labMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
});
+154 -34
View File
@@ -9,11 +9,21 @@
*
* Differences from the mood reminder:
*
* 1. No dedicated dispatch-ledger table. The reminder's own
* `nextDueAt` IS the dedup guard: after a successful dispatch the
* runner advances `nextDueAt` to the next occurrence strictly past
* now, so the same due cycle never re-fires. This collapses one
* table + one cleanup queue versus the mood pattern.
* 1. What a delivered reminder does to the row depends on what the row
* is. A measurement reminder (it names a `measurementType`) is a
* rhythm: after a delivered dispatch `nextDueAt` rolls on to the next
* occurrence, and the next reading satisfies it. An appointment
* (`origin: ENCOUNTER`) is one-shot and closes. A check-up (no
* `measurementType`, whether the person or the Coach made it) is a
* task: it stays due until it is marked done, skipped or snoozed, so
* the start page and the check-up card keep showing it as due and
* then overdue. Rolling it on silently filed an open check-up under
* next year. While it stays open it is reminded again at most once a
* week, gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A
* check-up on a weekly or shorter cadence still rolls on, because its
* own next slot is the next nudge (see `staysDueAfterReminder`). 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
* fire, the runner checks whether a matching reading of the
* reminder's `measurementType` has landed since the last satisfy
@@ -48,6 +58,7 @@ import {
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";
/**
* v1.18.0 — map a reminder's `measurementType` to the toggleable module
@@ -87,6 +98,82 @@ function moduleForMeasurementType(
*/
const DUE_QUERY_SLACK_MS = 15 * 60_000;
/**
* v1.39.2 — whether a reminder is a check-up: a task that is done by marking
* it done (or by a lab result landing), not by logging a reading.
*
* Decided by `measurementType`, not by `origin`. Both `VORSORGE` and `COACH`
* rows come in two shapes: a free-text check-up ("blood panel", a Coach
* "checkup.create" with a yearly preset) and a measurement rhythm ("weigh in
* weekly", the Coach's twice-daily blood-pressure course). The rhythm is
* satisfied by the next reading and has a next slot of its own that comes
* round soon; holding it on a missed slot would stall a daily course and
* paint a routine prompt as overdue. An appointment (`ENCOUNTER`) is neither
* and stays one-shot.
*/
export function isCheckupReminder(reminder: {
origin?: string | null;
measurementType: MeasurementType | string | null;
}): boolean {
return reminder.origin !== "ENCOUNTER" && reminder.measurementType === null;
}
/**
* Calendar days an open check-up waits before it is reminded again, and the
* cadence length at or under which a check-up rolls on instead of staying
* due: a weekly or daily item's own next slot is a reminder at least as soon
* as the repeat would be.
*/
export const CHECKUP_REPEAT_DAYS = 7;
/**
* v1.39.2 — whether a delivered reminder leaves the row due.
*
* A check-up stays due when its next slot is more than a week out, or when it
* has no next slot at all (a one-shot): rolling it on would file an open task
* under next month or next year, and a one-shot would drop off every surface.
* A check-up on a weekly or shorter cadence rolls on as before, because its
* next slot already brings the next nudge within the week. Measurement
* reminders and appointments always roll on.
*/
export function staysDueAfterReminder(
reminder: {
origin?: string | null;
measurementType: MeasurementType | string | null;
},
rolledTo: Date | null,
timezone: string,
now: Date,
): boolean {
if (!isCheckupReminder(reminder)) return false;
if (rolledTo === null) return true;
return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS;
}
/**
* v1.39.2 — whether an open check-up's repeat nudge is still being held.
*
* Held when the last delivered nudge was for THIS slot (`lastNotifiedAt` at
* or after `nextDueAt`) and fewer than {@link CHECKUP_REPEAT_DAYS} calendar
* days have passed in the person's timezone. Calendar days rather than a
* 7 × 24 h gap, because the tick fires within the notify hour and the next
* week's tick can land a few seconds earlier on the clock than the first
* send did.
*
* A slot that moved after the last nudge — a snooze to a named day, a
* Telegram "later", a skip, a satisfy — is a new slot and is never held.
*/
export function isCheckupRepeatHeld(
reminder: { nextDueAt: Date | null; lastNotifiedAt?: Date | null },
timezone: string,
now: Date,
): boolean {
const { nextDueAt, lastNotifiedAt } = reminder;
if (!nextDueAt || !lastNotifiedAt) return false;
if (lastNotifiedAt.getTime() < nextDueAt.getTime()) return false;
return calendarDaysUntil(now, lastNotifiedAt, timezone) < CHECKUP_REPEAT_DAYS;
}
export interface MeasurementReminderSummary {
candidatesScanned: number;
inWindow: number;
@@ -98,6 +185,8 @@ export interface MeasurementReminderSummary {
skippedNoChannel: number;
/** v1.37.19 (A6-7) — slot already claimed for this local day (racing worker or crash-recovery replay). */
skippedAlreadyClaimed: number;
/** v1.39.2 — open check-up already reminded for this slot within the week. */
skippedRepeatHeld: number;
/** v1.18.1 — expired COACH course-window reminders soft-deleted this tick. */
expiredCleaned: number;
failed: number;
@@ -205,6 +294,7 @@ export async function runMeasurementReminderTick(
skippedModuleDisabled: 0,
skippedNoChannel: 0,
skippedAlreadyClaimed: 0,
skippedRepeatHeld: 0,
expiredCleaned: 0,
failed: 0,
};
@@ -294,6 +384,17 @@ export async function runMeasurementReminderTick(
continue;
}
// v1.39.2 — an open check-up stays due after its nudge, so without
// this it would qualify again at every notify hour. Once a week is the
// repeat; the per-day claim below is not enough on its own.
if (
isCheckupReminder(reminder) &&
isCheckupRepeatHeld(reminder, timezone, now)
) {
summary.skippedRepeatHeld += 1;
continue;
}
summary.inWindow += 1;
// v1.18.0 module gate — a reminder whose measurement type belongs to
@@ -395,9 +496,16 @@ export async function runMeasurementReminderTick(
continue;
}
// Dispatch succeeded — advance `nextDueAt` past now so this due
// cycle never re-fires (the ledger-free dedup guard).
await advanceNextDue(prisma, reminder, timezone, now);
// 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)
? { lastNotifiedAt: now }
: { nextDueAt: rolledTo, lastNotifiedAt: now },
});
summary.dispatched += 1;
} catch (err: unknown) {
summary.failed += 1;
@@ -534,35 +642,34 @@ export async function runReminderSatisfyForUser(
return summary;
}
type RollableReminder = {
id: string;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
};
/**
* Advance `nextDueAt` to the next occurrence strictly after `now`. Used
* after a successful dispatch (and after a client-managed skip) as the
* ledger-free dedup guard. Does NOT touch `lastSatisfiedAt` — a fired
* reminder is not "satisfied", it just rolls to its next slot.
* The slot a reminder rolls on to after it fired at `now`: the next
* occurrence strictly after `now`, or `null` when there is none (a one-shot,
* or a course window that has ended). Does NOT touch `lastSatisfiedAt` — a
* fired reminder is not "satisfied", it just rolls to its next slot.
*/
async function advanceNextDue(
prisma: PrismaClient,
reminder: {
id: string;
intervalDays: number | null;
rrule: string | null;
anchorDate: Date | null;
notifyHour: number;
lastSatisfiedAt: Date | null;
createdAt: Date;
},
function nextSlotAfterReminder(
reminder: RollableReminder,
timezone: string,
now: Date,
): Promise<void> {
// The dedup guard must move the slot strictly forward. For an `rrule`
// the engine already walks to the next strictly-after-now occurrence,
// so passing the row as-is is correct. For a ROLLING reminder the
// engine anchors the first-due slot AT `anchorDate ?? createdAt` when
// never satisfied, which stays ≤ now and would re-fire every tick — so
// re-anchor the rolling cadence on `now` (a fire is the rhythm
// restarting from this dispatch) to roll it forward by exactly one
// interval. This does NOT advance `lastSatisfiedAt`: a fired reminder
// is not satisfied, just rescheduled past the slot it nagged on.
): Date | null {
// The slot must move strictly forward. For an `rrule` the engine already
// walks to the next strictly-after-now occurrence, so passing the row
// as-is is correct. For a ROLLING reminder the engine anchors the
// first-due slot AT `anchorDate ?? createdAt` when never satisfied, which
// stays ≤ now and would re-fire every tick — so re-anchor the rolling
// cadence on `now` (a fire is the rhythm restarting from this dispatch) to
// roll it forward by exactly one interval.
const rolling = reminder.intervalDays !== null;
const scheduleInput: ReminderScheduleInput = {
intervalDays: reminder.intervalDays,
@@ -572,7 +679,20 @@ async function advanceNextDue(
lastSatisfiedAt: rolling ? now : reminder.lastSatisfiedAt,
createdAt: reminder.createdAt,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
return computeReminderNextDueAt(scheduleInput, timezone, now);
}
/**
* Advance `nextDueAt` past the current slot. Used after a disabled-module
* skip, where nothing the reminder could deliver is left for this cycle.
*/
async function advanceNextDue(
prisma: PrismaClient,
reminder: RollableReminder,
timezone: string,
now: Date,
): Promise<void> {
const nextDueAt = nextSlotAfterReminder(reminder, timezone, now);
await prisma.measurementReminder.update({
where: { id: reminder.id },
data: { nextDueAt },
@@ -134,6 +134,7 @@ export async function handleMeasurementReminderCheck(
skipped_not_due: summary.skippedNotDue,
skipped_outside_window: summary.skippedOutsideWindow,
skipped_no_channel: summary.skippedNoChannel,
skipped_repeat_held: summary.skippedRepeatHeld,
failed: summary.failed,
},
});
@@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
tags: ["MeasurementReminders"],
summary: "List Vorsorge reminders (v1.17.1)",
description:
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.",
"Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a measurementType, still roll on to the next slot after a delivered reminder.",
responses: {
...recordRefusal(),
"200": {
+386
View File
@@ -0,0 +1,386 @@
/**
* An open check-up stays due after its reminder, against real Postgres.
*
* Two halves.
*
* 1. **The whole loop, end to end.** A yearly check-up due today goes
* through the real reminder tick (real claim ledger, stubbed channel):
* 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.
* 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
* steps run twice; the second run must change nothing.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
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 { loadDailyDigest } from "@/lib/daily/load-digest";
import type { DispatchOutcome } from "@/lib/notifications/dispatcher";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const MIGRATION = readFileSync(
path.join(
process.cwd(),
"prisma",
"migrations",
"0355_checkup_stays_due",
"migration.sql",
),
"utf8",
);
/** The migration's data statements (the repair and the hero-kind append). */
function dataSteps(): string[] {
const sql = MIGRATION.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n");
const steps = sql
.split(";")
.map((statement) => statement.trim())
.filter((statement) => /^(WITH|UPDATE)\b/i.test(statement));
// Both data steps are the point; a matcher that finds fewer is a failure.
expect(steps.length).toBe(2);
return steps;
}
async function runDataSteps(): Promise<void> {
const prisma = getPrismaClient();
for (const step of dataSteps()) await prisma.$executeRawUnsafe(step);
}
const OK: DispatchOutcome = {
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
};
const TZ = "Europe/Berlin";
const DAY = 24 * 60 * 60 * 1000;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
});
async function seedUser(
id: string,
extra: Partial<Prisma.UserCreateInput> = {},
) {
return getPrismaClient().user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: TZ,
...extra,
},
});
}
describe("an open check-up through the reminder tick", () => {
// 09:00 in Berlin.
const DUE = new Date("2026-09-26T07:00:00.000Z");
it("stays due, shows on the digest, repeats weekly, and moves on when done", async () => {
const prisma = getPrismaClient();
const user = await seedUser("checkup-owner");
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: "Skin check",
measurementType: null,
intervalDays: null,
rrule: "FREQ=YEARLY;INTERVAL=1",
notifyHour: 9,
origin: "VORSORGE",
enabled: true,
lastSatisfiedAt: new Date("2025-09-26T07:00:00.000Z"),
nextDueAt: DUE,
createdAt: new Date("2025-01-01T00:00:00.000Z"),
},
});
const dispatch = vi.fn(async () => OK);
const tick = (at: Date) =>
runMeasurementReminderTick(prisma, at, { dispatch });
// The due day's notify hour: reminded once, still due.
const first = await tick(DUE);
expect(first.dispatched).toBe(1);
expect(dispatch).toHaveBeenCalledTimes(1);
let row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
});
expect(row.nextDueAt).toEqual(DUE);
expect(row.lastNotifiedAt).toEqual(DUE);
expect(row.lastSatisfiedAt).toEqual(new Date("2025-09-26T07:00:00.000Z"));
const events = await prisma.notificationEvent.findMany({
where: { recordUserId: user.id },
});
expect(events.map((e) => e.dedupKey)).toEqual([
`measurement:${created.id}:2026-09-26`,
]);
// The start page names it, in the afternoon as much as in the morning.
const fullUser = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
});
const digest = await loadDailyDigest(
fullUser,
new Date(DUE.getTime() + 6 * 60 * 60 * 1000),
{ locale: "en" },
);
const care = digest.worthALook.find((i) => i.kind === "preventive_care");
expect(care?.body).toContain("Skin check");
// Fifteen minutes later, the same hour: not again.
await tick(new Date(DUE.getTime() + 15 * 60 * 1000));
// The next day at the notify hour: not again either.
const nextDay = await tick(new Date(DUE.getTime() + DAY));
expect(nextDay.skippedRepeatHeld).toBe(1);
// Six days on: still held.
await tick(new Date(DUE.getTime() + 6 * DAY));
expect(dispatch).toHaveBeenCalledTimes(1);
// Seven days on: reminded once more, and only once.
const week = new Date(DUE.getTime() + 7 * DAY);
await tick(week);
await tick(new Date(week.getTime() + 15 * 60 * 1000));
expect(dispatch).toHaveBeenCalledTimes(2);
row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
});
expect(row.nextDueAt).toEqual(DUE);
expect(row.lastNotifiedAt).toEqual(week);
// Done: the next cycle is next year, counted from the completion.
const doneAt = new Date(week.getTime() + 60 * 60 * 1000);
const done = await satisfyReminder(prisma, row, TZ, doneAt, "manual");
expect(done.satisfied).toBe(true);
row = await prisma.measurementReminder.findUniqueOrThrow({
where: { id: created.id },
});
expect(row.nextDueAt?.getUTCFullYear()).toBe(2027);
await tick(new Date(week.getTime() + DAY));
expect(dispatch).toHaveBeenCalledTimes(2);
});
});
describe("migration 0355", () => {
it("adds the nullable last_notified_at column", async () => {
const rows = await getPrismaClient().$queryRawUnsafe<
Array<{ is_nullable: string; data_type: string }>
>(
`SELECT is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'measurement_reminders'
AND column_name = 'last_notified_at'`,
);
expect(rows).toEqual([
{ is_nullable: "YES", data_type: "timestamp without time zone" },
]);
});
// The instant the old tick claimed and delivered the reminder, and the
// roll-on write a few seconds behind it.
const SENT = new Date("2026-09-26T07:00:03.000Z");
const ROLLED = new Date("2026-09-26T07:00:05.000Z");
const NEXT_YEAR = new Date("2027-09-26T07:00:00.000Z");
interface Case {
id: string;
data?: Partial<Prisma.MeasurementReminderUncheckedCreateInput>;
/** When the row was last written; defaults to the roll-on. */
updatedAt?: Date;
/** Claim instants for this row; defaults to the one send. */
claims?: Date[];
}
async function seedCase(userId: string, c: Case) {
const prisma = getPrismaClient();
await prisma.measurementReminder.create({
data: {
id: c.id,
userId,
label: c.id,
measurementType: null,
rrule: "FREQ=YEARLY;INTERVAL=1",
notifyHour: 9,
origin: "VORSORGE",
enabled: true,
nextDueAt: NEXT_YEAR,
createdAt: new Date("2025-01-01T00:00:00.000Z"),
...c.data,
},
});
await prisma.$executeRaw`
UPDATE "measurement_reminders"
SET "updated_at" = ${c.updatedAt ?? ROLLED}
WHERE "id" = ${c.id}`;
for (const at of c.claims ?? [SENT]) {
await prisma.notificationEvent.create({
data: {
recordUserId: userId,
eventType: "MEASUREMENT_REMINDER",
dedupKey: `measurement:${c.id}:${at.toISOString().slice(0, 10)}`,
createdAt: at,
},
});
}
}
it("puts rolled-on check-ups back on the reminded slot and leaves everything ambiguous", async () => {
const prisma = getPrismaClient();
const user = await seedUser("migration-owner");
const repaired: Case[] = [
{ id: "yearly-checkup" },
{ id: "coach-checkup", data: { origin: "COACH" } },
{
// A one-shot check-up the old tick rolled to nothing.
id: "one-shot",
data: { rrule: null, intervalDays: null, nextDueAt: null },
},
{
// An earlier cycle's send is older; the latest send decides.
id: "monthly-many-sends",
data: {
rrule: "FREQ=MONTHLY;INTERVAL=1",
nextDueAt: new Date("2026-10-26T07:00:00.000Z"),
},
claims: [new Date("2026-08-26T07:00:03.000Z"), SENT],
},
];
const untouched: Case[] = [
{
id: "done-after-send",
data: { lastSatisfiedAt: new Date("2026-09-26T10:00:00.000Z") },
updatedAt: new Date("2026-09-26T10:00:00.000Z"),
},
{
id: "skipped-after-send",
data: { lastSkippedAt: new Date("2026-09-26T07:02:00.000Z") },
updatedAt: new Date("2026-09-26T07:02:00.000Z"),
},
{
id: "snoozed",
data: {
snoozedUntil: new Date("2026-10-03T07:00:00.000Z"),
nextDueAt: new Date("2026-10-03T07:00:00.000Z"),
},
},
{
// Edited two days after the send: the last write is not the roll-on.
id: "edited-later",
updatedAt: new Date("2026-09-28T12:00:00.000Z"),
},
{
// A Telegram "later" pressed a minute after the send.
id: "telegram-later",
data: { nextDueAt: new Date("2026-09-26T10:01:00.000Z") },
updatedAt: new Date("2026-09-26T07:01:00.000Z"),
},
{
// A weekly check-up still rolls on under the new tick.
id: "weekly",
data: {
rrule: null,
intervalDays: 7,
nextDueAt: new Date("2026-10-03T07:00:00.000Z"),
},
},
{ id: "measurement", data: { measurementType: "WEIGHT" } },
{ id: "appointment", data: { origin: "ENCOUNTER", rrule: null } },
{ id: "deleted", data: { deletedAt: ROLLED } },
{ id: "disabled", data: { enabled: false } },
{ id: "never-sent", claims: [] },
{
// The last send found no channel: nothing was rolled on.
id: "no-channel",
data: { nextDueAt: new Date("2026-09-25T07:00:00.000Z") },
},
];
for (const c of [...repaired, ...untouched]) await seedCase(user.id, c);
const before = new Map(
(await prisma.measurementReminder.findMany()).map((r) => [r.id, r]),
);
await runDataSteps();
const afterFirst = await prisma.measurementReminder.findMany();
await runDataSteps();
const afterSecond = await prisma.measurementReminder.findMany();
const byId = new Map(afterFirst.map((r) => [r.id, r]));
for (const c of repaired) {
expect(byId.get(c.id)?.nextDueAt, c.id).toEqual(SENT);
expect(byId.get(c.id)?.lastNotifiedAt, c.id).toEqual(SENT);
}
for (const c of untouched) {
const was = before.get(c.id)!;
const now = byId.get(c.id)!;
expect(now.nextDueAt, c.id).toEqual(was.nextDueAt);
expect(now.lastNotifiedAt, c.id).toBeNull();
}
// Nothing deleted, and the second run is a no-op.
expect(afterFirst).toHaveLength(repaired.length + untouched.length);
const key = (rows: typeof afterFirst) =>
rows
.map(
(r) =>
`${r.id}|${r.nextDueAt?.toISOString()}|${r.lastNotifiedAt?.toISOString()}`,
)
.sort();
expect(key(afterSecond)).toEqual(key(afterFirst));
});
it("adds visits to a stored start-page choice that predates them, and only there", async () => {
const prisma = getPrismaClient();
const legacy = ["dose_window", "preventive_care", "sync_issue"];
await seedUser("hero-legacy", {
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: legacy },
});
await seedUser("hero-off", {
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: [] },
});
await seedUser("hero-has-visits", {
dashboardWidgetsJson: {
version: 1,
enabledHeroItemKinds: ["upcoming_visit"],
},
});
await seedUser("hero-default", {
dashboardWidgetsJson: { version: 1, widgets: [] },
});
await seedUser("hero-none");
await runDataSteps();
await runDataSteps();
const layout = async (id: string) =>
(await prisma.user.findUniqueOrThrow({ where: { id } }))
.dashboardWidgetsJson as Record<string, unknown> | null;
expect((await layout("hero-legacy"))?.enabledHeroItemKinds).toEqual([
...legacy,
"upcoming_visit",
]);
expect((await layout("hero-off"))?.enabledHeroItemKinds).toEqual([]);
expect((await layout("hero-has-visits"))?.enabledHeroItemKinds).toEqual([
"upcoming_visit",
]);
expect(await layout("hero-default")).toEqual({ version: 1, widgets: [] });
expect(await layout("hero-none")).toBeNull();
});
});