Merge branch 'fix/checkup-stays-due' into release/v1.39.2

This commit is contained in:
Marc-André Bombeck committed 2026-09-26 10:48:00 +02:00
commit 226baa8636
18 files changed
+947 -159

No files matched your search

+5 -4
View File
@@ -13276,10 +13276,11 @@ paths:
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. 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."
v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is
more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without
a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or
completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it
stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder."
responses:
"200":
description: The owner's measurement reminders.
@@ -1,22 +1,23 @@
-- 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.
-- slot. For a reminder on a cycle longer than a week (a fortnightly
-- questionnaire, a yearly check-up) that filed an open task under a slot
-- weeks or a year away 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.
-- From this release such a reminder keeps its due date until it is satisfied
-- (a matching reading or lab result, or marked done), skipped or snoozed, and
-- is reminded again at most once a week while it stays open. A reminder on a
-- weekly or shorter cycle still rolls on. 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.
-- 2. Put back the due date of reminders 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`
@@ -24,10 +25,14 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
-- 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;
-- - it is live and enabled, origin VORSORGE or COACH (never an
-- appointment), and on a cycle the new behaviour holds: an interval
-- longer than seven days, a recurrence rule, or no cadence at all.
-- Measurement reminders are included; whether it names a measurement
-- type does not matter;
-- - it was not done or skipped after that send, and no snooze reaches
-- past it (a snooze that had already run out before the send is
-- history, not a choice about this slot);
-- - 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
@@ -36,16 +41,21 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3);
-- 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 send (a weekly or shorter cycle, including a rule that recurs
-- within the week, still rolls on, so it is not repaired), or no due
-- date at all: a rule whose last occurrence was the one sent, which
-- is still open unless it was done or skipped since. A course whose
-- window ended before the send is excluded by the end-date guard.
--
-- 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`
-- the reminder went out 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.
-- was reminded. A reading or lab result that satisfied the reminder
-- after the send moved `last_satisfied_at` past it, so such a row is
-- excluded above. Nothing is deleted.
WITH latest_claim AS (
SELECT DISTINCT ON (r."id")
r."id" AS "reminder_id",
@@ -65,8 +75,12 @@ 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."interval_days" > 7
OR r."rrule" IS NOT NULL
OR (r."interval_days" IS NULL AND r."rrule" IS NULL)
)
AND (r."snoozed_until" IS NULL OR r."snoozed_until" <= lc."sent_at")
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")
@@ -75,11 +89,7 @@ WHERE r."id" = lc."reminder_id"
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
)
OR r."next_due_at" IS NULL
);
-- 3. Show booked visits on the start page for people who chose its items
@@ -92,13 +102,17 @@ WHERE r."id" = lc."reminder_id"
-- 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.
-- visits again and can switch them off once more, which now sticks. A
-- stored value that is not a list is left alone rather than read.
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';
WHERE CASE
WHEN jsonb_typeof("dashboard_widgets_json" -> 'enabledHeroItemKinds') = 'array'
THEN jsonb_array_length("dashboard_widgets_json" -> 'enabledHeroItemKinds') > 0
AND NOT ("dashboard_widgets_json" -> 'enabledHeroItemKinds') ? 'upcoming_visit'
ELSE FALSE
END;
+5 -4
View File
@@ -5483,10 +5483,11 @@ model MeasurementReminder {
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
/// tick. A reminder on a cycle longer than a week, or a one-shot (never an
/// appointment), no longer rolls `nextDueAt` forward when it is reminded:
/// it stays due until it is satisfied (a reading, a lab result or "done"),
/// skipped or snoozed, and this cursor 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
@@ -66,6 +66,8 @@ const BASE_ROW = {
location: null,
nextDueAt: new Date("2026-01-31T09:00:00Z"),
lastSatisfiedAt: null as Date | null,
lastNotifiedAt: null as Date | null,
snoozedUntil: null as Date | null,
enabled: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
@@ -270,7 +272,24 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z"));
});
it("re-enabling a disabled reminder schedules it from now", async () => {
it("re-enabling a disabled short-cycle reminder schedules it from now", async () => {
const weekly = { ...BASE_ROW, intervalDays: 7, enabled: false };
findFirstMock.mockResolvedValue(weekly);
const res = await PATCH(makeRequest({ enabled: true }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
const expected = computeReminderNextDueAt(weekly, TZ, NOW);
expect(persisted.nextDueAt).toEqual(expected);
});
it("re-enabling an overdue reminder that stays due keeps its open slot", async () => {
// Thirty-day cycle, due in January: switching it off and on again is not
// doing it.
findFirstMock.mockResolvedValue({ ...BASE_ROW, enabled: false });
const res = await PATCH(makeRequest({ enabled: true }), params);
@@ -280,7 +299,53 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n
string,
unknown
>;
const expected = computeReminderNextDueAt(BASE_ROW, TZ, NOW);
expect(persisted.nextDueAt).toEqual(expected);
expect(persisted.enabled).toBe(true);
expect("nextDueAt" in persisted).toBe(false);
});
it("reads a resent first due date on the same calendar day as unchanged", async () => {
// A booster row stores the dose instant plus N days; the web form sends
// the same day back as local midnight. Same day in the profile zone, so
// neither the anchor nor the due date moves.
findFirstMock.mockResolvedValue({
...BASE_ROW,
anchorDate: new Date("2026-01-10T13:37:00Z"),
});
const res = await PATCH(
makeRequest({
label: "New label",
anchorDate: "2026-01-09T23:00:00.000Z", // 00:00 on the 10th, Berlin
}),
params,
);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect("anchorDate" in persisted).toBe(false);
expect("nextDueAt" in persisted).toBe(false);
});
it("moves the last-sent cursor with a later notify hour on the day it was sent", async () => {
// Sent at 09:00 today, moved to 18:00: still the slot that was sent, so
// the tick must not send it again at 18:00.
findFirstMock.mockResolvedValue({
...BASE_ROW,
nextDueAt: new Date("2026-06-15T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-15T07:00:05Z"),
});
const res = await PATCH(makeRequest({ notifyHour: 18 }), params);
expect(res.status).toBe(200);
const persisted = updateMock.mock.calls[0]?.[0]?.data as Record<
string,
unknown
>;
expect(persisted.nextDueAt).toEqual(new Date("2026-06-15T16:00:00Z"));
expect(persisted.lastNotifiedAt).toEqual(new Date("2026-06-15T16:00:00Z"));
});
});
@@ -33,6 +33,8 @@ import {
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { userDayKey } from "@/lib/tz/format";
import { holdsOpenAfterReminder } from "@/lib/measurement-reminders/holds-open";
type RouteParams = { params: Promise<{ id: string }> };
@@ -191,15 +193,40 @@ export const PATCH = apiHandler(
// label, location or type edit used to recompute too, and since the
// recompute searches strictly after now, correcting the label of an
// open, overdue check-up quietly moved it to its next slot. Compared by
// value, because the edit forms resend every field on each save.
// value, because a client may resend every field on each save.
//
// The first due date is compared by CALENDAR DAY in the profile zone. The
// web form sends a date as the browser's local midnight, and a booster
// row stores the dose instant plus N days, so the same day arrives as a
// different instant. A resent same-day anchor is dropped from the write
// altogether, so the stored instant stays what it was.
const sameInstant = (a: Date | null, b: Date | null) =>
(a?.getTime() ?? null) === (b?.getTime() ?? null);
const sameDay = (a: Date | null, b: Date | null) =>
a === null || b === null
? a === b
: userDayKey(a, timezone) === userDayKey(b, timezone);
if (
Object.hasOwn(updateData, "anchorDate") &&
sameDay(merged.anchorDate, existing.anchorDate)
) {
delete updateData.anchorDate;
merged.anchorDate = existing.anchorDate;
}
const cadenceChanged =
merged.intervalDays !== existing.intervalDays ||
merged.rrule !== existing.rrule ||
!sameInstant(merged.anchorDate, existing.anchorDate) ||
(updateData.enabled === true && !existing.enabled);
if (cadenceChanged) {
!sameInstant(merged.anchorDate, existing.anchorDate);
const reEnabled = updateData.enabled === true && !existing.enabled;
// Re-enabling a reminder that stays due after it is sent keeps the slot it
// was on, overdue or not: switching it off and on again is not doing it.
// A short-cycle reminder is scheduled from now, as before.
const keepsOpenSlot =
reEnabled &&
!cadenceChanged &&
existing.nextDueAt !== null &&
holdsOpenAfterReminder(existing, timezone, existing.nextDueAt);
if (cadenceChanged || (reEnabled && !keepsOpenSlot)) {
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
@@ -230,6 +257,16 @@ export const PATCH = apiHandler(
if (sameInstant(existing.snoozedUntil, existing.nextDueAt)) {
updateData.snoozedUntil = moved;
}
// Already sent for this slot and moved to a later hour: carry the
// last-sent cursor along, or the slot would read as unsent and go out
// a second time at the new hour.
if (
existing.lastNotifiedAt !== null &&
existing.lastNotifiedAt.getTime() >= existing.nextDueAt.getTime() &&
moved.getTime() > existing.lastNotifiedAt.getTime()
) {
updateData.lastNotifiedAt = moved;
}
}
const updated = await prisma.measurementReminder.update({
@@ -51,6 +51,10 @@ import {
isOverdueDue,
relativeDueKey,
} from "@/lib/measurement-reminders/due-day";
import {
changedReminderFields,
type ReminderEditBody,
} from "@/lib/measurement-reminders/edit-body";
import { cn } from "@/lib/utils";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
@@ -259,6 +263,8 @@ export function VorsorgeSection({
setVisitSession((n) => n + 1);
};
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// The form as it opened, so an edit sends only what changed.
const [initialForm, setInitialForm] = useState<FormState>(EMPTY_FORM);
// The value-capture sheet: holds the reminder whose measurement we are
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
@@ -292,7 +298,7 @@ export function VorsorgeSection({
: interval != null
? CADENCE_CUSTOM
: "7";
setForm({
const opened: FormState = {
label: resolveReminderLabel(reminder, t),
kind: reminder.measurementType ? "type" : "freeText",
measurementType: reminder.measurementType ?? "BLOOD_PRESSURE_SYS",
@@ -303,7 +309,9 @@ export function VorsorgeSection({
anchorDate: toDateInputValue(reminder.anchorDate),
notifyHour: reminder.notifyHour,
location: reminder.location ?? "",
});
};
setForm(opened);
setInitialForm(opened);
setEditing(reminder);
}
@@ -314,36 +322,45 @@ export function VorsorgeSection({
// Resolve the picker state to the mutually-exclusive cadence pair the
// schema expects (exactly one of intervalDays / rrule).
function resolveCadence(): {
function resolveCadence(state: FormState): {
intervalDays: number | null;
rrule: string | null;
} {
if (form.cadenceChoice === CADENCE_RRULE) {
const trimmed = form.rrule.trim();
if (state.cadenceChoice === CADENCE_RRULE) {
const trimmed = state.rrule.trim();
return { intervalDays: null, rrule: trimmed || null };
}
if (form.cadenceChoice === CADENCE_CUSTOM) {
return { intervalDays: form.customIntervalDays, rrule: null };
if (state.cadenceChoice === CADENCE_CUSTOM) {
return { intervalDays: state.customIntervalDays, rrule: null };
}
return { intervalDays: Number(form.cadenceChoice), rrule: null };
return { intervalDays: Number(state.cadenceChoice), rrule: null };
}
function toEditBody(state: FormState): ReminderEditBody {
const { intervalDays, rrule } = resolveCadence(state);
return {
label: state.label.trim(),
measurementType: state.kind === "type" ? state.measurementType : null,
intervalDays,
rrule,
anchorDate: fromDateInputValue(state.anchorDate),
notifyHour: state.notifyHour,
location: state.location.trim() || null,
};
}
function submit() {
const trimmed = form.label.trim();
if (!trimmed) return;
const { intervalDays, rrule } = resolveCadence();
const body = {
label: trimmed,
measurementType: form.kind === "type" ? form.measurementType : null,
intervalDays,
rrule,
anchorDate: fromDateInputValue(form.anchorDate),
notifyHour: form.notifyHour,
location: form.location.trim() || null,
};
const body = toEditBody(form);
if (isEdit) {
// Only what changed: an untouched first due date resent as local
// midnight used to read as a new one and move an overdue check-up on.
update.mutate(
{ id: (editing as MeasurementReminder).id, body },
{
id: (editing as MeasurementReminder).id,
body: changedReminderFields(toEditBody(initialForm), body),
},
{ onSuccess: closeSheet },
);
} else {
+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
@@ -9,10 +9,11 @@
* logged since the last satisfy advances lastSatisfiedAt + recomputes
* nextDueAt and suppresses the nudge. Free-text reminders never
* auto-resolve.
* - 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.
* - A reminder on a weekly or shorter cycle rolls on: a successful
* dispatch advances nextDueAt past now. One on a longer cycle, or a
* one-shot, does NOT roll on (measurement reminder or free text): it
* stays due until it is satisfied, and a second nudge for the same open
* slot waits a week. Appointments stay one-shot.
* - clientManaged suppresses the APNs leg only: the tick still
* dispatches, and a reminder nothing delivered stays overdue.
*
@@ -633,7 +634,7 @@ describe("runMeasurementReminderTick", () => {
expect("nextDueAt" in updates[0].data).toBe(false);
});
it("still rolls a measurement reminder on after its reminder", async () => {
it("still rolls a weekly measurement reminder on after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [reminder({ origin: "COACH" })],
measurementMatch: null,
@@ -778,4 +779,148 @@ describe("runMeasurementReminderTick", () => {
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
// Watched red: with the rule keyed on `measurementType` (free text only),
// the first case rolls the questionnaire two weeks forward — exactly the
// reported pair of screening reminders.
it("keeps a fortnightly questionnaire due after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminder({
measurementType: "PHQ9_SCORE",
intervalDays: 14,
lastSatisfiedAt: new Date("2026-06-01T07:00:00Z"),
nextDueAt: new Date("2026-06-15T07:00:00Z"),
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, {
dispatch,
isModuleEnabled: async () => true,
});
expect(dispatch).toHaveBeenCalledTimes(1);
expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL });
});
it("holds the repeat of an open measurement reminder too", async () => {
const { prisma } = makePrisma({
reminders: [
reminder({
measurementType: "WEIGHT",
intervalDays: 30,
nextDueAt: new Date("2026-06-14T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.skippedRepeatHeld).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
});
it("satisfies an open measurement reminder from the reading and schedules from it", async () => {
// Held open since yesterday; the questionnaire lands this morning.
const filledAt = new Date("2026-06-15T06:30:00Z");
const { prisma, updates } = makePrisma({
reminders: [
reminder({
measurementType: "GAD7_SCORE",
intervalDays: 14,
lastSatisfiedAt: new Date("2026-05-31T07:00:00Z"),
nextDueAt: new Date("2026-06-14T07:00:00Z"),
lastNotifiedAt: new Date("2026-06-14T07:00:04Z"),
}),
],
measurementMatch: { measuredAt: filledAt },
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
const summary = await runMeasurementReminderTick(
prisma as never,
NINE_LOCAL,
{ dispatch },
);
expect(summary.autoResolved).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
expect(updates[0].data.lastSatisfiedAt).toEqual(filledAt);
// Fourteen days on from the reading, at the notify hour.
expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-29T07:00:00Z"));
});
it("rolls a daily course on after its reminder", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminder({
origin: "COACH",
intervalDays: null,
rrule: "FREQ=DAILY;BYHOUR=9,19",
}),
],
measurementMatch: null,
});
const dispatch = vi.fn<DispatchFn>(async () => OK);
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
// 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);
});
});
+39 -69
View File
@@ -9,21 +9,19 @@
*
* Differences from the mood reminder:
*
* 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.
* 1. What a delivered reminder does to the row depends on its cycle.
* A reminder whose next slot is more than a week away (a fortnightly
* questionnaire, a yearly check-up) or that has no next slot at all (a
* one-shot) stays due until it is satisfied, 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 task under a later
* slot. While it stays open it is reminded again at most once a week,
* 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 `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
* fire, the runner checks whether a matching reading of the
* reminder's `measurementType` has landed since the last satisfy
@@ -59,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
@@ -98,58 +97,9 @@ 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.
*/
/** 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.
*
* 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.
*
@@ -336,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
@@ -388,7 +356,7 @@ export async function runMeasurementReminderTick(
// 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) &&
reminder.origin !== "ENCOUNTER" &&
isCheckupRepeatHeld(reminder, timezone, now)
) {
summary.skippedRepeatHeld += 1;
@@ -499,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,45 @@
import { describe, expect, it } from "vitest";
import { changedReminderFields, type ReminderEditBody } from "../edit-body";
const BASE: ReminderEditBody = {
label: "Skin check",
measurementType: null,
intervalDays: 365,
rrule: null,
anchorDate: "2026-01-09T23:00:00.000Z",
notifyHour: 9,
location: null,
};
describe("changedReminderFields", () => {
it("sends only what the edit changed", () => {
expect(
changedReminderFields(BASE, { ...BASE, label: "Dermatology" }),
).toEqual({ label: "Dermatology" });
});
it("sends nothing for an untouched form, so a save moves no date", () => {
expect(changedReminderFields(BASE, { ...BASE })).toEqual({});
});
it("sends the cadence as a pair, since the two are mutually exclusive", () => {
expect(
changedReminderFields(BASE, {
...BASE,
intervalDays: null,
rrule: "FREQ=YEARLY",
}),
).toEqual({ intervalDays: null, rrule: "FREQ=YEARLY" });
});
it("sends a changed first due date and notify hour", () => {
expect(
changedReminderFields(BASE, {
...BASE,
anchorDate: "2026-02-01T23:00:00.000Z",
notifyHour: 18,
}),
).toEqual({ anchorDate: "2026-02-01T23:00:00.000Z", notifyHour: 18 });
});
});
@@ -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,43 @@
/**
* v1.39.2 — the body an edit of a Vorsorge reminder sends: only the fields
* the person changed.
*
* The edit sheet used to send every field on each save. The first due date
* went out as the browser's local midnight even when nobody touched it, which
* the server read as a new first due date and rescheduled from, so renaming
* an overdue check-up moved it to its next slot. The server now compares by
* calendar day as well; this keeps the client from claiming changes it did
* not make in the first place.
*/
export interface ReminderEditBody {
label: string;
measurementType: string | null;
intervalDays: number | null;
rrule: string | null;
anchorDate: string | null;
notifyHour: number;
location: string | null;
}
export function changedReminderFields(
initial: ReminderEditBody,
next: ReminderEditBody,
): Partial<ReminderEditBody> {
const body: Partial<ReminderEditBody> = {};
if (next.label !== initial.label) body.label = next.label;
if (next.measurementType !== initial.measurementType) {
body.measurementType = next.measurementType;
}
// Mutually exclusive on the server: a change to either sends both.
if (
next.intervalDays !== initial.intervalDays ||
next.rrule !== initial.rrule
) {
body.intervalDays = next.intervalDays;
body.rrule = next.rrule;
}
if (next.anchorDate !== initial.anchorDate) body.anchorDate = next.anchorDate;
if (next.notifyHour !== initial.notifyHour) body.notifyHour = next.notifyHour;
if (next.location !== initial.location) body.location = next.location;
return body;
}
@@ -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;
}
@@ -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. 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.",
"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 reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.",
responses: {
...recordRefusal(),
"200": {
+149 -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,70 @@ 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",
},
});
// Outside the notify hour the open slot is not even polled (the
// eventful satisfy worker covers that moment); the next notify hour's
// poll resolves it.
const afternoon = await tick(new Date(filledAt.getTime() + 15 * 60 * 1000));
expect(afternoon.autoResolved).toBe(0);
const next = await tick(new Date(DUE.getTime() + 2 * DAY));
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 +318,44 @@ 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" } },
{
// A snooze that ran out before the send says nothing about this slot.
id: "old-snooze",
data: { snoozedUntil: new Date("2026-09-20T07:00:00.000Z") },
},
{
// A rule whose last occurrence was the one sent: rolled to nothing.
id: "rule-exhausted",
data: {
rrule: "FREQ=MONTHLY;UNTIL=20260930T000000Z",
nextDueAt: null,
},
},
{
// An earlier cycle's send is older; the latest send decides.
id: "monthly-many-sends",
@@ -304,9 +407,48 @@ 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 } },
{
// A course whose window closed before the send stays closed.
id: "course-ended",
data: {
rrule: "FREQ=DAILY",
nextDueAt: null,
endsOn: new Date("2026-09-25T00:00:00.000Z"),
},
},
{ id: "disabled", data: { enabled: false } },
{ id: "never-sent", claims: [] },
{
@@ -368,6 +510,9 @@ describe("migration 0355", () => {
dashboardWidgetsJson: { version: 1, widgets: [] },
});
await seedUser("hero-none");
await seedUser("hero-malformed", {
dashboardWidgetsJson: { version: 1, enabledHeroItemKinds: "all" },
});
await runDataSteps();
await runDataSteps();
@@ -385,5 +530,6 @@ describe("migration 0355", () => {
]);
expect(await layout("hero-default")).toEqual({ version: 1, widgets: [] });
expect(await layout("hero-none")).toBeNull();
expect((await layout("hero-malformed"))?.enabledHeroItemKinds).toBe("all");
});
});