From f5bab3258a2625542e53445e2c44b95e61f45fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:01:04 +0200 Subject: [PATCH 1/6] fix(checkups): say "overdue since yesterday" instead of "overdue by 1 days" The due phrase on the check-up card and the checkups page counted a check-up one day past its date as "Overdue by 1 days", and the same counted form read wrongly in German. One day over now has its own phrase in all seven languages. Both overdue phrases keep the warning colour through a shared `isOverdueDue` helper, so a surface no longer compares against a single key. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + ...sorge-due-bucket-single-home-guard.test.ts | 1 + .../__tests__/vorsorge-section.test.tsx | 4 ++- .../vorsorge-section.tsx | 10 ++++--- .../__tests__/due-day.test.ts | 30 ++++++++++++++++--- src/lib/measurement-reminders/due-day.ts | 17 +++++++++++ 12 files changed, 60 insertions(+), 9 deletions(-) diff --git a/messages/de.json b/messages/de.json index 988c66016..3e1780f8d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8527,6 +8527,7 @@ "inDays": "In {days} Tagen" }, "overdueByDays": "Überfällig seit {days} Tagen", + "overdueSinceYesterday": "Überfällig seit gestern", "cadence": { "everyNDays": "Alle {days} Tage", "custom": "Eigener Zeitplan", diff --git a/messages/en.json b/messages/en.json index 5fb158f6f..3e5801c35 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8527,6 +8527,7 @@ "inDays": "In {days} days" }, "overdueByDays": "Overdue by {days} days", + "overdueSinceYesterday": "Overdue since yesterday", "cadence": { "everyNDays": "Every {days} days", "custom": "Custom schedule", diff --git a/messages/es.json b/messages/es.json index 519e3f10b..025be52d0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8527,6 +8527,7 @@ "inDays": "En {days} días" }, "overdueByDays": "Vencido hace {days} días", + "overdueSinceYesterday": "Vencido desde ayer", "cadence": { "everyNDays": "Cada {days} días", "custom": "Programación personalizada", diff --git a/messages/fr.json b/messages/fr.json index e037193f9..9b79d330c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8527,6 +8527,7 @@ "inDays": "Dans {days} jours" }, "overdueByDays": "En retard de {days} jours", + "overdueSinceYesterday": "En retard depuis hier", "cadence": { "everyNDays": "Tous les {days} jours", "custom": "Planning personnalisé", diff --git a/messages/it.json b/messages/it.json index 60a3420d3..a44deebae 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8527,6 +8527,7 @@ "inDays": "Tra {days} giorni" }, "overdueByDays": "In ritardo di {days} giorni", + "overdueSinceYesterday": "In ritardo da ieri", "cadence": { "everyNDays": "Ogni {days} giorni", "custom": "Pianificazione personalizzata", diff --git a/messages/ko.json b/messages/ko.json index 140a39081..789abfd44 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8527,6 +8527,7 @@ "inDays": "{days}일 후" }, "overdueByDays": "{days}일 지남", + "overdueSinceYesterday": "어제부터 지남", "cadence": { "everyNDays": "{days}일마다", "custom": "사용자 지정 일정", diff --git a/messages/pl.json b/messages/pl.json index 5649bfd1d..dcea730ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8527,6 +8527,7 @@ "inDays": "Za {days} dni" }, "overdueByDays": "Zaległe od {days} dni", + "overdueSinceYesterday": "Zaległe od wczoraj", "cadence": { "everyNDays": "Co {days} dni", "custom": "Własny harmonogram", diff --git a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts index bc960b9cf..2e94964d1 100644 --- a/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts +++ b/src/__tests__/vorsorge-due-bucket-single-home-guard.test.ts @@ -52,6 +52,7 @@ const MINTED_ONLY_BY_THE_BUCKET = [ "measurementReminders.nextDue.tomorrow", "measurementReminders.nextDue.inDays", "measurementReminders.nextDue.none", + "measurementReminders.overdueSinceYesterday", ]; /** The hook that yields the zone the surrounding dates are printed in. */ diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index 9a36baad8..7bba97edb 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -275,7 +275,9 @@ describe(" loading + empty", () => { } as MeasurementReminder; remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); const html = render(); - expect(html).toContain("Overdue by 1 days"); + // One day over reads "since yesterday": the counted form printed + // "Overdue by 1 days", and the warning hue must follow both phrases. + expect(html).toContain("Overdue since yesterday"); expect(html).toContain("text-warning"); }); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index f3fc298ea..e211df9b2 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -47,7 +47,10 @@ import { useDisplayTimezone, } from "@/lib/i18n/context"; import { relativeCalendarDate } from "@/lib/i18n/relative-time"; -import { relativeDueKey } from "@/lib/measurement-reminders/due-day"; +import { + isOverdueDue, + relativeDueKey, +} from "@/lib/measurement-reminders/due-day"; import { cn } from "@/lib/utils"; import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { EncounterSheet } from "@/components/encounters/encounter-sheet"; @@ -899,8 +902,7 @@ function VorsorgeCard({ // routes to the check-in page), never the numeric value-entry wording. const isScreening = isScreeningReminderType(reminder.measurementType); const isDue = - due.key === "measurementReminders.nextDue.today" || - due.key === "measurementReminders.overdueByDays"; + due.key === "measurementReminders.nextDue.today" || isOverdueDue(due); const progress = intervalProgress(reminder, now); // Category-style header badge: the measurement label, or "self-planned" @@ -1361,7 +1363,7 @@ function VorsorgeCard({ // normal muted-resolved date. The card surface stays neutral per the house // rule; only this inline label carries the status hue. const dueIsToday = due.key === "measurementReminders.nextDue.today"; - const dueIsOverdue = due.key === "measurementReminders.overdueByDays"; + const dueIsOverdue = isOverdueDue(due); const nextLastSlot = (
diff --git a/src/lib/measurement-reminders/__tests__/due-day.test.ts b/src/lib/measurement-reminders/__tests__/due-day.test.ts index 62fc66ade..9e09cfa67 100644 --- a/src/lib/measurement-reminders/__tests__/due-day.test.ts +++ b/src/lib/measurement-reminders/__tests__/due-day.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect } from "vitest"; -import { relativeDueKey } from "../due-day"; +import { isOverdueDue, relativeDueKey } from "../due-day"; /** * The profile zone under test. July is deliberate: no IANA zone shifts its @@ -84,11 +84,33 @@ describe("relativeDueKey", () => { ).toEqual(TOMORROW); }); - it("counts a checkup from yesterday as one day overdue, however recent", () => { - // Forty-five minutes in the past. It was still yesterday's checkup. + it("calls a checkup from yesterday overdue since yesterday, however recent", () => { + // Forty-five minutes in the past. It was still yesterday's checkup. Its + // own phrase, because "overdue by 1 days" is what the counted form reads + // as on the one day an open check-up is most likely to be looked at. expect( relativeDueKey(zoned(FRIDAY, 23, 30), at(SATURDAY, 0, 15), ZONE), - ).toEqual({ key: "measurementReminders.overdueByDays", days: 1 }); + ).toEqual({ key: "measurementReminders.overdueSinceYesterday", days: 1 }); + }); + + it("counts the days for a checkup overdue longer than that", () => { + expect(relativeDueKey(zoned(FRIDAY, 9), at(FRIDAY + 2, 10), ZONE)).toEqual({ + key: "measurementReminders.overdueByDays", + days: 2, + }); + }); + + it("reads both overdue phrases as overdue, and nothing else", () => { + const now = at(SATURDAY, 12); + expect(isOverdueDue(relativeDueKey(zoned(FRIDAY, 9), now, ZONE))).toBe( + true, + ); + expect(isOverdueDue(relativeDueKey(zoned(FRIDAY - 3, 9), now, ZONE))).toBe( + true, + ); + expect(isOverdueDue(relativeDueKey(zoned(SATURDAY, 9), now, ZONE))).toBe( + false, + ); }); it("counts whole calendar days for a checkup further out", () => { diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts index 5efa96ed1..8f8da3ee2 100644 --- a/src/lib/measurement-reminders/due-day.ts +++ b/src/lib/measurement-reminders/due-day.ts @@ -86,6 +86,11 @@ export function relativeDueKey( if (Number.isNaN(due.getTime())) return { key: "measurementReminders.nextDue.none", days: 0 }; const deltaDays = calendarDaysUntil(due, new Date(now), timeZone); + // Its own phrase rather than the counted one: "overdue by 1 days" is what + // the counted form reads as, on the one day an open check-up (which since + // v1.39.2 stays due after its reminder) is most likely to be looked at. + if (deltaDays === -1) + return { key: "measurementReminders.overdueSinceYesterday", days: 1 }; if (deltaDays < 0) return { key: "measurementReminders.overdueByDays", @@ -97,3 +102,15 @@ export function relativeDueKey( return { key: "measurementReminders.nextDue.tomorrow", days: 1 }; return { key: "measurementReminders.nextDue.inDays", days: deltaDays }; } + +/** + * Whether a phrase from {@link relativeDueKey} says "overdue". Two keys do — + * "since yesterday" and the counted one — so a surface that colours an + * overdue badge asks here rather than comparing against one of them. + */ +export function isOverdueDue(due: RelativeDue): boolean { + return ( + due.key === "measurementReminders.overdueByDays" || + due.key === "measurementReminders.overdueSinceYesterday" + ); +} From 54b46f46796c9477b58256062b36c962127ed151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:01:27 +0200 Subject: [PATCH 2/6] 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. --- docs/api/openapi.yaml | 8 +- .../0355_checkup_stays_due/migration.sql | 104 +++++ prisma/schema.prisma | 11 + .../encounter-reminder-exclusion.test.ts | 1 + .../__tests__/measurement-reminder.test.ts | 201 ++++++++- src/lib/jobs/measurement-reminder.ts | 188 +++++++-- src/lib/jobs/reminder/mood-cycle-checks.ts | 1 + .../openapi/routes/measurement-reminders.ts | 2 +- tests/integration/checkup-stays-due.test.ts | 386 ++++++++++++++++++ 9 files changed, 863 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/0355_checkup_stays_due/migration.sql create mode 100644 tests/integration/checkup-stays-due.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1736800d0..3eafcaa06 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/prisma/migrations/0355_checkup_stays_due/migration.sql b/prisma/migrations/0355_checkup_stays_due/migration.sql new file mode 100644 index 000000000..91974145c --- /dev/null +++ b/prisma/migrations/0355_checkup_stays_due/migration.sql @@ -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::` 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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d974c41d..c783dd324 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/src/__tests__/encounter-reminder-exclusion.test.ts b/src/__tests__/encounter-reminder-exclusion.test.ts index 99817264f..293f12cf6 100644 --- a/src/__tests__/encounter-reminder-exclusion.test.ts +++ b/src/__tests__/encounter-reminder-exclusion.test.ts @@ -100,6 +100,7 @@ function reminderRow( snoozedUntil: null, lastSkippedAt: null, skipCount: 0, + lastNotifiedAt: null, enabled: true, vaccinationAntigen: null, createdAt: at, diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index 34826a27c..b930059f1 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -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 = {}) => + 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(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(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(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(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(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(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(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(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(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(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(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); + }); }); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 8c9444012..300394502 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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 { - // 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 { + const nextDueAt = nextSlotAfterReminder(reminder, timezone, now); await prisma.measurementReminder.update({ where: { id: reminder.id }, data: { nextDueAt }, diff --git a/src/lib/jobs/reminder/mood-cycle-checks.ts b/src/lib/jobs/reminder/mood-cycle-checks.ts index 53b783c9d..33bb535e8 100644 --- a/src/lib/jobs/reminder/mood-cycle-checks.ts +++ b/src/lib/jobs/reminder/mood-cycle-checks.ts @@ -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, }, }); diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 7173ee162..8fdbf8742 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -41,7 +41,7 @@ export const measurementReminderPaths: NonNullable = 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": { diff --git a/tests/integration/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts new file mode 100644 index 000000000..2bd8c66e1 --- /dev/null +++ b/tests/integration/checkup-stays-due.test.ts @@ -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 { + 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 = {}, +) { + 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; + /** 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 | 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(); + }); +}); From 93881c1f93fa8204c5a6631de4958aed09917614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:01:32 +0200 Subject: [PATCH 3/6] fix(daily): keep due check-ups and today's visits on the start page The start page's short list left out things a reminder had just told the person about. A check-up counted only once its due hour had passed, a booked visit was read one at a time and dropped out at its start time, which is when its reminder fires, and both sat below doses, sync problems and the Coach check-in under a three-item cap, so a busy morning pushed them off. The check-up item now covers every check-up due today or overdue and names up to three of them. Visits are read for the whole local day, also once started, and for the next 48 hours: everything booked today shares one item, the next day with a visit gets a second, and the today / tomorrow / day-after wording follows the calendar rather than an hour count. A due check-up and today's visits always keep a place under the cap; the displayed order is unchanged. A Coach cadence label is translated here as it is on the checkups page. The digest's wire shape is unchanged: visits reach it only as `upcoming_visit` items, which may now appear twice. --- docs/api/openapi.yaml | 12 +- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- ...onfig-change-is-not-a-health-event.test.ts | 2 +- src/lib/daily/__tests__/digest.test.ts | 131 ++++++++++++- src/lib/daily/__tests__/load-digest.test.ts | 82 +++++++- src/lib/daily/digest.ts | 180 +++++++++++++----- src/lib/daily/load-digest.ts | 75 +++++--- src/lib/openapi/routes/daily.ts | 2 +- 14 files changed, 420 insertions(+), 99 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3eafcaa06..45b2bcd59 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -18900,10 +18900,14 @@ paths: - Insights summary: The unified daily digest description: "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights - cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status - and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI - gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their - capabilities, published in `ai`. Cookie or Bearer auth." + cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic + integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item + covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every + planned visit on the local today (also once it has started, until the day ends) in one item, and the next day + with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within + the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and + the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. + Cookie or Bearer auth." responses: "200": description: The daily digest. diff --git a/messages/de.json b/messages/de.json index 3e1780f8d..06801e37e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Vorsorge fällig", "body": "{label} ist fällig.", - "bodyMany": "{count} Vorsorgetermine sind fällig." + "bodyManyNamed": "Fällig: {labels}" }, "syncIssue": { "title": "Synchronisierung braucht Aufmerksamkeit", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Termin steht an", "bodyToday": "{what} — heute", - "bodyTomorrow": "{what} — morgen" + "bodyTomorrow": "{what} — morgen", + "bodyDayAfterTomorrow": "{what} — übermorgen" } }, "action": { diff --git a/messages/en.json b/messages/en.json index 3e5801c35..fa1fb5458 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Check-up due", "body": "{label} is due.", - "bodyMany": "{count} check-ups are due." + "bodyManyNamed": "Due: {labels}" }, "syncIssue": { "title": "Sync needs attention", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Appointment coming up", "bodyToday": "{what} — today", - "bodyTomorrow": "{what} — tomorrow" + "bodyTomorrow": "{what} — tomorrow", + "bodyDayAfterTomorrow": "{what} — the day after tomorrow" } }, "action": { diff --git a/messages/es.json b/messages/es.json index 025be52d0..46f903102 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Revisión pendiente", "body": "{label} está pendiente.", - "bodyMany": "Hay {count} revisiones pendientes." + "bodyManyNamed": "Pendientes: {labels}" }, "syncIssue": { "title": "La sincronización necesita atención", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Cita próxima", "bodyToday": "{what}: hoy", - "bodyTomorrow": "{what}: mañana" + "bodyTomorrow": "{what}: mañana", + "bodyDayAfterTomorrow": "{what}: pasado mañana" } }, "action": { diff --git a/messages/fr.json b/messages/fr.json index 9b79d330c..722b77a24 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Examen à faire", "body": "{label} est à faire.", - "bodyMany": "{count} examens sont à faire." + "bodyManyNamed": "À faire : {labels}" }, "syncIssue": { "title": "La synchronisation demande votre attention", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Rendez-vous à venir", "bodyToday": "{what} — aujourd'hui", - "bodyTomorrow": "{what} — demain" + "bodyTomorrow": "{what} — demain", + "bodyDayAfterTomorrow": "{what} — après-demain" } }, "action": { diff --git a/messages/it.json b/messages/it.json index a44deebae..ae8310f2d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Controllo da fare", "body": "{label} è da fare.", - "bodyMany": "Ci sono {count} controlli da fare." + "bodyManyNamed": "Da fare: {labels}" }, "syncIssue": { "title": "La sincronizzazione richiede attenzione", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Appuntamento in arrivo", "bodyToday": "{what} — oggi", - "bodyTomorrow": "{what} — domani" + "bodyTomorrow": "{what} — domani", + "bodyDayAfterTomorrow": "{what} — dopodomani" } }, "action": { diff --git a/messages/ko.json b/messages/ko.json index 789abfd44..674b6c9a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "건강검진 시기", "body": "{label} — 받을 때가 됐어요.", - "bodyMany": "건강검진 {count}건을 받을 때가 됐어요." + "bodyManyNamed": "받을 때가 된 건강검진: {labels}" }, "syncIssue": { "title": "동기화 확인이 필요해요", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "다가오는 예약", "bodyToday": "{what} — 오늘", - "bodyTomorrow": "{what} — 내일" + "bodyTomorrow": "{what} — 내일", + "bodyDayAfterTomorrow": "{what} — 모레" } }, "action": { diff --git a/messages/pl.json b/messages/pl.json index dcea730ae..a2c23d8b3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10322,7 +10322,7 @@ "preventiveCare": { "title": "Badanie do wykonania", "body": "{label} jest do wykonania.", - "bodyMany": "Do wykonania jest {count} badań." + "bodyManyNamed": "Do wykonania: {labels}" }, "syncIssue": { "title": "Synchronizacja wymaga uwagi", @@ -10367,7 +10367,8 @@ "upcomingVisit": { "title": "Zbliża się wizyta", "bodyToday": "{what} — dzisiaj", - "bodyTomorrow": "{what} — jutro" + "bodyTomorrow": "{what} — jutro", + "bodyDayAfterTomorrow": "{what} — pojutrze" } }, "action": { diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 07eddcec1..171e54fec 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -177,7 +177,7 @@ const fakeDb = vi.hoisted(() => { ecgRecording: { findFirst: async () => null }, personalRecord: { findMany: none }, arrivalReaction: { findMany: none }, - encounter: { findFirst: async () => null }, + encounter: { findMany: none }, // The AI capability loader's consent read: no receipts. consentReceipt: { findMany: none }, }; diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index acfa1e89c..5283974a8 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -639,7 +639,18 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { ); const care = d.worthALook.filter((i) => i.kind === "preventive_care"); expect(care).toHaveLength(1); - expect(care[0].body).toBe("3 check-ups are due."); + expect(care[0].body).toBe("Due: A, B, C"); + }); + + it("names the first three due check-ups and counts the rest", () => { + const d = buildDailyDigest( + input({ + preventiveDue: ["A", "B", "C", "D", "E"].map((label) => ({ label })), + }), + t, + ); + const care = d.worthALook.find((i) => i.kind === "preventive_care"); + expect(care?.body).toBe("Due: A, B, C +2"); }); it("bounds the rail at three items, never padded", () => { @@ -1317,3 +1328,121 @@ describe("buildDailyDigest — AI text follows its capability", () => { }, ); }); + +// v1.39.2 — a check-up that is due today or overdue, and a visit booked for +// today, are the two rail items a person can miss for good: the check-up +// because the reminder already went out, the visit because the day ends. +// Watched red: with the plain `slice(0, 3)` over the priority order, the +// first two cases drop the item behind an overdue dose and three sync issues. +describe("buildDailyDigest — due check-ups and today's visits hold their place", () => { + const crowd = { + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "nightscout", state: "parked" }, + { integration: "fitbit", state: "error_reauth" }, + ], + }; + const visit = (over: Record = {}) => ({ + id: "v1", + kind: "ROUTINE", + occurredAt: "2026-07-16T13:00:00.000Z", + practitionerName: "Dr. Weiss", + dayOffset: 0, + ...over, + }); + + it("keeps a due check-up on a full rail", () => { + const d = buildDailyDigest( + input({ ...crowd, preventiveDue: [{ label: "Skin check" }] }), + t, + ); + expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); + expect(d.worthALook.map((i) => i.kind)).toContain("preventive_care"); + // The overdue dose still leads; the pinned item takes a later slot. + expect(d.worthALook[0].kind).toBe("dose_window"); + }); + + it("keeps today's visit on a full rail", () => { + const d = buildDailyDigest( + input({ ...crowd, upcomingVisits: [visit()] }), + t, + ); + expect(d.worthALook.map((i) => i.kind)).toContain("upcoming_visit"); + }); + + it("keeps both a due check-up and today's visit on a full rail", () => { + const d = buildDailyDigest( + input({ + ...crowd, + preventiveDue: [{ label: "Skin check" }], + upcomingVisits: [visit()], + }), + t, + ); + expect(d.worthALook.map((i) => i.kind)).toEqual([ + "dose_window", + "preventive_care", + "upcoming_visit", + ]); + }); + + it("does not pin a visit that is not today", () => { + const d = buildDailyDigest( + input({ ...crowd, upcomingVisits: [visit({ dayOffset: 1 })] }), + t, + ); + expect(d.worthALook.map((i) => i.kind)).not.toContain("upcoming_visit"); + }); + + it("names every visit of the day in one item, including one already under way", () => { + const d = buildDailyDigest( + input({ + upcomingVisits: [ + // Started an hour before NOW: still today's, still on the rail. + visit({ occurredAt: "2026-07-16T08:00:00.000Z" }), + visit({ id: "v2", practitionerName: "Dentist" }), + ], + }), + t, + ); + const visits = d.worthALook.filter((i) => i.kind === "upcoming_visit"); + expect(visits).toHaveLength(1); + expect(visits[0].body).toBe("Dr. Weiss, Dentist — today"); + }); + + it("says tomorrow and the day after by calendar day", () => { + const tomorrow = buildDailyDigest( + input({ upcomingVisits: [visit({ dayOffset: 1 })] }), + t, + ); + expect( + tomorrow.worthALook.find((i) => i.kind === "upcoming_visit")?.body, + ).toBe("Dr. Weiss — tomorrow"); + const later = buildDailyDigest( + input({ upcomingVisits: [visit({ dayOffset: 2 })] }), + t, + ); + expect( + later.worthALook.find((i) => i.kind === "upcoming_visit")?.body, + ).toBe("Dr. Weiss — the day after tomorrow"); + }); + + it("shows today's visits and the next day's as two items", () => { + const d = buildDailyDigest( + input({ + upcomingVisits: [ + visit(), + visit({ id: "v2", practitionerName: "Dentist", dayOffset: 1 }), + visit({ id: "v3", practitionerName: "Lab", dayOffset: 2 }), + ], + }), + t, + ); + expect( + d.worthALook + .filter((i) => i.kind === "upcoming_visit") + .map((i) => i.body), + ).toEqual(["Dr. Weiss — today", "Dentist — tomorrow"]); + }); +}); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 1b3ac8349..d08d83728 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -20,7 +20,7 @@ vi.mock("@/lib/db", () => ({ dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) }, - encounter: { findFirst: vi.fn().mockResolvedValue(null) }, + encounter: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -301,3 +301,83 @@ describe("loadDailyDigest — AI parts", () => { }); }); }); + +// 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. +describe("loadDailyDigest — due check-ups and today's visits", () => { + it("reads check-ups due before the end of the local day, not only before now", async () => { + await loadDailyDigest(USER, NOW); + + const args = vi.mocked(prisma.measurementReminder.findMany).mock + .calls[0][0] as { where: Record }; + expect(args.where.nextDueAt).toEqual({ + not: null, + lt: new Date("2026-07-17T22:00:00.000Z"), + }); + // Appointments stay off the preventive read. + expect(args.where.origin).toEqual({ not: "ENCOUNTER" }); + }); + + it("reads every planned visit from the start of the local day", async () => { + await loadDailyDigest(USER, NOW); + + const args = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as { + where: { status: string; occurredAt: { gte: Date; lte: Date } }; + }; + expect(args.where.status).toBe("PLANNED"); + expect(args.where.occurredAt.gte).toEqual( + new Date("2026-07-16T22:00:00.000Z"), + ); + expect(args.where.occurredAt.lte).toEqual( + new Date("2026-07-19T09:00:00.000Z"), + ); + }); + + it("puts a visit that started this morning on the rail as today's", async () => { + vi.mocked(prisma.encounter.findMany).mockResolvedValueOnce([ + { + id: "v1", + kind: "ROUTINE", + occurredAt: new Date("2026-07-17T07:00:00.000Z"), // 09:00 Berlin + practitioner: { name: "Dr. Weiss" }, + }, + { + id: "v2", + kind: "ROUTINE", + occurredAt: new Date("2026-07-17T23:30:00.000Z"), // 01:30 tomorrow + practitioner: { name: "Lab" }, + }, + ] as never); + + const digest = await loadDailyDigest(USER, NOW); + + const visits = digest.worthALook.filter( + (item) => item.kind === "upcoming_visit", + ); + // The translator is stubbed to echo keys, so the bucket shows in the key. + expect(visits.map((item) => item.body)).toEqual([ + "daily.item.upcomingVisit.bodyToday", + "daily.item.upcomingVisit.bodyTomorrow", + ]); + }); + + it("translates a Coach cadence label and keeps a free-text one", async () => { + const t = vi.fn((key: string) => + key === "coach.cadence.bp7day.label" ? "Blood pressure week" : key, + ); + const { getServerTranslator } = + 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" }, + ] as never); + + await loadDailyDigest(USER, NOW); + + expect(t).toHaveBeenCalledWith("daily.item.preventiveCare.bodyManyNamed", { + labels: "Blood pressure week, coach.cadence.bp7day.label", + }); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 212fdc20f..9441f5344 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -133,13 +133,20 @@ export interface DailyDigestSyncIssue { state: string; } -/** A Vorsorge / measurement reminder whose next-due instant has passed. */ +/** + * A Vorsorge / measurement reminder that is due today or overdue: its + * next-due instant falls before the end of the person's local day. Since + * v1.39.2 an open check-up keeps its due date after it is reminded, so this + * list holds it until it is done, skipped or snoozed. + */ export interface DailyDigestPreventiveDue { + /** Display label, already resolved (a Coach cadence key is translated). */ label: string; } /** - * A booked visit falling today or tomorrow. + * A booked visit on the person's local today (even once it has started) or + * within the next two days. * * Read off the VISIT table, never off the reminder engine: an appointment's * one-shot reminder is excluded from every preventive-care read, and this is @@ -151,6 +158,12 @@ export interface DailyDigestUpcomingVisit { kind: string; occurredAt: string; practitionerName: string | null; + /** + * Calendar days from the person's local today, in their profile timezone: + * 0 today, 1 tomorrow, 2 the day after. Resolved by the IO seam so the + * builder never needs the timezone. + */ + dayOffset: number; } /** @@ -301,8 +314,13 @@ export interface DailyDigestInput { morningRefreshedToday: boolean; syncIssues: DailyDigestSyncIssue[]; preventiveDue: DailyDigestPreventiveDue[]; - /** The booked visit falling today or tomorrow, or null. Honest-absent. */ - upcomingVisit?: DailyDigestUpcomingVisit | null; + /** + * Every booked visit on the local today and within the next two days, + * soonest first. Optional so a consumer that predates it stays valid; a + * missing value means no visit. The digest DTO has no visit field of its + * own: visits reach the wire only as `upcoming_visit` rail items. + */ + upcomingVisits?: readonly DailyDigestUpcomingVisit[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; /** @@ -544,16 +562,32 @@ function buildSyncIssueItems( })); } -/** A single preventive-care item summarising the due Vorsorge reminders. */ +/** How many due check-ups the rail item names before it counts the rest. */ +const PREVENTIVE_NAMED_MAX = 3; + +/** + * A single preventive-care item summarising the check-ups that are due today + * or overdue. One names itself; several are named up to three, with the rest + * counted as "+N" so the line needs no plural form in any language. + */ function buildPreventiveCareItem( due: DailyDigestPreventiveDue[], t: Translate, ): PriorityItem | null { if (due.length === 0) return null; - const body = - due.length === 1 - ? t("daily.item.preventiveCare.body", { label: due[0].label }) - : t("daily.item.preventiveCare.bodyMany", { count: due.length }); + let body: string; + if (due.length === 1) { + body = t("daily.item.preventiveCare.body", { label: due[0].label }); + } else { + const named = due + .slice(0, PREVENTIVE_NAMED_MAX) + .map((d) => d.label) + .join(", "); + const rest = due.length - PREVENTIVE_NAMED_MAX; + body = t("daily.item.preventiveCare.bodyManyNamed", { + labels: rest > 0 ? `${named} +${rest}` : named, + }); + } return { kind: "preventive_care", title: t("daily.item.preventiveCare.title"), @@ -570,46 +604,90 @@ function buildPreventiveCareItem( } /** - * The booked visit falling today or tomorrow, as one rail line. + * Booked visits as rail lines: one item for everything booked today, and one + * for the next day that has a visit within the two-day horizon. + * + * Today's item stays for the whole local day, also after the visit's start + * time: the visit's reminder fires at that time, and the rail used to drop the + * visit at exactly that moment. Several visits on one day share one item and + * are named together, so a busy day cannot fill the three-item rail with + * appointments alone. * * No module gate: a visit is core, like the checkups page it lives on. No * action beyond opening the page either — there is nothing to do about an * appointment except keep it, and a "mark done" here would duplicate the * visit's own status field with a second place to set it. */ -function buildUpcomingVisitItem( - visit: DailyDigestUpcomingVisit | null, - now: Date, +function buildUpcomingVisitItems( + visits: readonly DailyDigestUpcomingVisit[], t: Translate, -): PriorityItem | null { - if (!visit) return null; - const at = new Date(visit.occurredAt); - if (Number.isNaN(at.getTime())) return null; - // Today vs tomorrow is decided on the SAME instants the read window used, so - // a visit admitted by the query can never be described as neither. - const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000); - // Through the key resolver, never `encounters.kind.${kind}`: the enum is - // `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put - // raw dot notation on a lock screen. - const what = - visit.practitionerName ?? - t(encounterKindLabelKey(visit.kind as EncounterKind)); - return { - kind: "upcoming_visit", - title: t("daily.item.upcomingVisit.title"), - body: - hoursAway <= 24 - ? t("daily.item.upcomingVisit.bodyToday", { what }) - : t("daily.item.upcomingVisit.bodyTomorrow", { what }), - status: "info", - actions: [ - { - labelKey: "daily.action.viewCheckups", - intent: "checkup.view", - href: "/checkups", - }, - ], +): { today: PriorityItem | null; next: PriorityItem | null } { + const item = (group: readonly DailyDigestUpcomingVisit[]): PriorityItem => { + // Through the key resolver, never `encounters.kind.${kind}`: the enum is + // `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put + // raw dot notation on a lock screen. + const names = group.map( + (visit) => + visit.practitionerName ?? + t(encounterKindLabelKey(visit.kind as EncounterKind)), + ); + const what = Array.from(new Set(names)).join(", "); + const offset = group[0].dayOffset; + return { + kind: "upcoming_visit", + title: t("daily.item.upcomingVisit.title"), + body: + offset <= 0 + ? t("daily.item.upcomingVisit.bodyToday", { what }) + : offset === 1 + ? t("daily.item.upcomingVisit.bodyTomorrow", { what }) + : t("daily.item.upcomingVisit.bodyDayAfterTomorrow", { what }), + status: "info", + actions: [ + { + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href: "/checkups", + }, + ], + }; }; + + const valid = visits.filter( + (visit) => !Number.isNaN(new Date(visit.occurredAt).getTime()), + ); + const today = valid.filter((visit) => visit.dayOffset <= 0); + const later = valid.filter((visit) => visit.dayOffset > 0); + const nextOffset = + later.length > 0 ? Math.min(...later.map((v) => v.dayOffset)) : null; + const next = later.filter((visit) => visit.dayOffset === nextOffset); + return { + today: today.length > 0 ? item(today) : null, + next: next.length > 0 ? item(next) : null, + }; +} + +/** + * 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 remaining slots in order, and the result keeps the priority order, so an + * overdue dose still leads. + */ +function capWithPinned( + items: readonly PriorityItem[], + pinned: ReadonlySet, + max: number, +): PriorityItem[] { + const keep = new Set(); + for (const item of items) { + if (keep.size < max && pinned.has(item)) keep.add(item); + } + for (const item of items) { + if (keep.size < max) keep.add(item); + } + return items.filter((item) => keep.has(item)); } /** @@ -993,7 +1071,9 @@ export function buildDailyDigest( // Priority order: a due or overdue dose is the most time-sensitive daily // action, a broken sync next, then the calm coach check-in, a preventive // check-up least urgent. Bounded to 3 — a check-in the cap crowds out - // resurfaces on a following day (within its window), never lost. + // resurfaces on a following day (within its window), never lost. A due + // check-up and today's visits are not left to the order: the cap keeps + // them (see `capWithPinned`). const worthALook: PriorityItem[] = []; worthALook.push( ...buildDoseWindowItems( @@ -1030,12 +1110,16 @@ export function buildDailyDigest( // rail because a person reads "what am I meant to arrange" and "where am I // meant to be" in one glance, and they stay separate items because one is // overdue and the other is simply scheduled. - const visit = buildUpcomingVisitItem( - input.upcomingVisit ?? null, - input.now, - t, + const visits = buildUpcomingVisitItems(input.upcomingVisits ?? [], t); + if (visits.today) worthALook.push(visits.today); + if (visits.next) worthALook.push(visits.next); + // 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( + [preventive, visits.today].filter( + (item): item is PriorityItem => item !== null, + ), ); - if (visit) worthALook.push(visit); // 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 // bounded rail ahead of it. @@ -1086,7 +1170,7 @@ export function buildDailyDigest( topSignal, briefingLead, line: composeLine(briefingLead, topSignal, input.score, t), - worthALook: visible.slice(0, MAX_WORTH_A_LOOK), + worthALook: capWithPinned(visible, pinned, MAX_WORTH_A_LOOK), justIn, reactionLine, ai: input.ai, diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index c2de4da55..97c164a58 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -27,6 +27,7 @@ import { } from "@/lib/ai/capabilities/gate"; import { userDayKey } from "@/lib/tz/format"; import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; 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"; @@ -71,7 +72,8 @@ import { const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; /** - * How far ahead the rail mentions a booked appointment: today or tomorrow. + * How far ahead the rail mentions a booked appointment: the next 48 hours, on + * top of everything booked for the local today. * * The rail is a "what about today" surface, so anything further out is not a * thing to act on this morning. Two days rather than one because an evening @@ -80,6 +82,9 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; */ const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; +/** Defensive cap on how many planned visits the rail read returns. */ +const UPCOMING_VISIT_READ_LIMIT = 10; + /** Defensive cap on how many overdue reminders we read for the rail summary. */ const PREVENTIVE_DUE_READ_LIMIT = 20; @@ -372,9 +377,8 @@ export async function loadDailyDigest( // First instant of the user's NEXT local day (profile tz) — bounds the // dose-window rail to today's business. `getUserTodayBounds` returns the // inclusive last millisecond of today; +1 ms is exactly tomorrow's start. - const todayEndExclusive = new Date( - getUserTodayBounds(now, user.timezone).end.getTime() + 1, - ); + const todayBounds = getUserTodayBounds(now, user.timezone); + const todayEndExclusive = new Date(todayBounds.end.getTime() + 1); const [ { body: snapshot, locale }, @@ -384,7 +388,7 @@ export async function loadDailyDigest( planRows, ecgRow, arrivalRows, - nextVisitRow, + visitRows, coachAi, reactionLinesAi, ] = await Promise.all([ @@ -402,14 +406,19 @@ export async function loadDailyDigest( // sites carry this exclusion; `encounter-reminder-exclusion.test.ts` // 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. where: { userId: user.id, enabled: true, deletedAt: null, origin: { not: "ENCOUNTER" }, - nextDueAt: { not: null, lte: now }, + nextDueAt: { not: null, lt: todayEndExclusive }, }, - select: { label: true }, + select: { label: true, origin: true }, orderBy: { nextDueAt: "asc" }, take: PREVENTIVE_DUE_READ_LIMIT, }), @@ -460,20 +469,22 @@ export async function loadDailyDigest( generatedAt: true, }, }), - // The next PLANNED visit inside the two-day horizon. Read straight off the - // visit table, never off the reminder engine — see the mapping below for - // why the exclusion above must stay untouched. - prisma.encounter.findFirst({ + // Every PLANNED visit from the start of the local today to the end of the + // two-day horizon. Read straight off the visit table, never off the + // reminder engine — see the mapping below for why the exclusion above + // must stay untouched. + prisma.encounter.findMany({ where: { userId: user.id, deletedAt: null, status: "PLANNED", occurredAt: { - gt: now, + gte: todayBounds.start, lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS), }, }, orderBy: { occurredAt: "asc" }, + take: UPCOMING_VISIT_READ_LIMIT, select: { id: true, kind: true, @@ -531,12 +542,9 @@ export async function loadDailyDigest( state: row.state, })); - const preventiveDue: DailyDigestPreventiveDue[] = dueReminders.map((row) => ({ - label: row.label, - })); - /** - * The appointment falling today or tomorrow, read from the VISIT table. + * The booked visits of the local today and the next 48 hours, read from the + * VISIT table. * * Not from the reminder table. An ENCOUNTER-origin reminder is excluded from * every preventive-care read, which is right — an appointment is not a @@ -545,18 +553,18 @@ export async function loadDailyDigest( * in, and the exclusion above stays exactly as it is. A diff here that * touches `origin` is the wrong fix. * - * `occurredAt > now` deliberately, not `>= todayStart`: an appointment that - * has already happened today is not something to be reminded about, and the - * person is more likely to be at it than looking at this. + * `occurredAt >= todayStart`, not `> now`: a visit's reminder fires at its + * start time, and a rail that dropped the visit at that very moment showed a + * notification about something the start page no longer mentioned. A visit + * that is still PLANNED stays on the rail until its day ends. */ - const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow - ? { - id: nextVisitRow.id, - kind: nextVisitRow.kind, - occurredAt: nextVisitRow.occurredAt.toISOString(), - practitionerName: nextVisitRow.practitioner?.name ?? null, - } - : null; + const upcomingVisits: DailyDigestUpcomingVisit[] = visitRows.map((row) => ({ + id: row.id, + kind: row.kind, + occurredAt: row.occurredAt.toISOString(), + practitionerName: row.practitioner?.name ?? null, + dayOffset: calendarDaysUntil(row.occurredAt, now, user.timezone), + })); // S10 — the freshest ECG recording (device verdict + recordedAt only). The // builder decides "new" and gates on the `insights` module. An ECG row only @@ -626,6 +634,15 @@ export async function loadDailyDigest( const resolvedLocale: Locale = locale; const { t } = getServerTranslator(resolvedLocale); + // 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 }; + }); + // 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 // comes through the translator. @@ -655,7 +672,7 @@ export async function loadDailyDigest( morningRefreshedToday, syncIssues, preventiveDue, - upcomingVisit, + upcomingVisits, coachPlans, milestone, tensionWindow, diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index ad3657d37..64a777261 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -177,7 +177,7 @@ export const dailyPaths: NonNullable = { tags: ["Insights"], summary: "The unified daily digest", description: - "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status and Vorsorge reads for the 'worth a look' rail. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.", + "Assembles the day's read from already-cached data: the nightly briefing lifted read-only from the insights cache, the dashboard-snapshot health score / meds-today / sleep freshness, plus deterministic integration-status, Vorsorge and visit reads for the 'worth a look' rail. The rail's `preventive_care` item covers every check-up due today or overdue and names up to three of them; `upcoming_visit` items cover every planned visit on the local today (also once it has started, until the day ends) in one item, and the next day with a visit inside the next 48 hours in a second. A due check-up and today's visits always keep a place within the three-item cap. No provider call, no warm-on-mount. No module gate and no AI gate: the digest is data, and the `insights` module is the AI analysis opt-out. Its AI parts follow their capabilities, published in `ai`. Cookie or Bearer auth.", responses: { "200": { description: "The daily digest.", From cd79cfaae9778cc934737d6b46534ee732842f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:05:54 +0200 Subject: [PATCH 4/6] fix(reminders): keep a check-up's due date when only its details are edited Every edit of a reminder recomputed its due date from now, so fixing the label or the location of an open, overdue check-up moved it to its next slot, the same "gone until next year" the reminder tick caused. The due date is now recomputed only when the edit changes when the reminder recurs: the interval, the rule, the first due date (compared by value, since the edit forms resend every field), or switching a disabled reminder back on. A notify-hour edit keeps the due day and moves the hour on it, together with a snooze pinned to that slot. --- docs/api/openapi.yaml | 9 ++- .../[id]/__tests__/route.test.ts | 49 +++++++++++++++ .../api/measurement-reminders/[id]/route.ts | 59 +++++++++++++++---- .../openapi/routes/measurement-reminders.ts | 2 +- src/lib/validations/measurement-reminders.ts | 2 +- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45b2bcd59..8235440f2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13350,8 +13350,11 @@ paths: tags: - MeasurementReminders summary: Edit a Vorsorge reminder (v1.17.1) - description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence - merge. Owner-scoped. + description: Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit + changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back + on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any + other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. + Owner-scoped. parameters: - in: path name: id @@ -26953,7 +26956,7 @@ components: enabled: type: boolean description: Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side - after the patch applies. + when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day). MeasurementReminderSnooze: type: object properties: diff --git a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts index 97c9fb415..8ca59d072 100644 --- a/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/__tests__/route.test.ts @@ -231,6 +231,55 @@ describe("PATCH /api/measurement-reminders/[id] — nextDueAt honours explicit-n expect("rrule" in persisted).toBe(false); expect("anchorDate" in persisted).toBe(false); + // v1.39.2 — and nextDueAt is not written either. It used to be + // recomputed on every PATCH, so correcting the label of an open, + // overdue check-up (BASE_ROW is due 2026-01-31, NOW is June) quietly + // moved it to its next slot, the same "reminded and gone" defect the + // reminder tick had. Only a change to when it recurs reschedules it. + expect("nextDueAt" in persisted).toBe(false); + expect("snoozedUntil" in persisted).toBe(false); + }); + + it("an edit that resends the unchanged cadence keeps an overdue slot", async () => { + findFirstMock.mockResolvedValue(BASE_ROW); + + const res = await PATCH( + makeRequest({ label: "New label", intervalDays: 30, rrule: null }), + params, + ); + expect(res.status).toBe(200); + + const persisted = updateMock.mock.calls[0]?.[0]?.data as Record< + string, + unknown + >; + expect("nextDueAt" in persisted).toBe(false); + }); + + it("a notify-hour edit keeps the due day and moves only the hour", async () => { + findFirstMock.mockResolvedValue(BASE_ROW); + + 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 + >; + // 2026-01-31 18:00 in Berlin (UTC+1 in winter). + expect(persisted.nextDueAt).toEqual(new Date("2026-01-31T17:00:00Z")); + }); + + it("re-enabling a disabled reminder schedules it from now", async () => { + findFirstMock.mockResolvedValue({ ...BASE_ROW, enabled: false }); + + 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(BASE_ROW, TZ, NOW); expect(persisted.nextDueAt).toEqual(expected); }); diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index f51d742ca..082d4cf50 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -1,8 +1,10 @@ /** * v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete. * - * PATCH re-derives the server-authoritative `nextDueAt` after applying - * the (mutually-exclusive cadence) edit. DELETE removes the row; the + * PATCH re-derives the server-authoritative `nextDueAt` when the edit + * changes when the reminder recurs (interval, rule, anchor, or re-enabling + * it); a notify-hour edit moves the hour on the same due day, and any other + * edit leaves the due date alone. DELETE removes the row; the * confirmation dialog says the reminder is permanently deleted, and nothing * here needs a tombstone to keep that promise honest. */ @@ -30,6 +32,7 @@ import { type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; type RouteParams = { params: Promise<{ id: string }> }; @@ -184,14 +187,50 @@ export const PATCH = apiHandler( // (Coach-suggested) cadence keeps self-expiring. endsOn: existing.endsOn, }; - const after = - existing.lastSatisfiedAt && existing.lastSatisfiedAt > now - ? existing.lastSatisfiedAt - : now; - updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after); - // v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears - // the snooze cursor: the cycle the snooze pushed back no longer exists. - updateData.snoozedUntil = null; + // v1.39.2 — only an edit to WHEN the reminder recurs reschedules it. A + // 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. + const sameInstant = (a: Date | null, b: Date | null) => + (a?.getTime() ?? null) === (b?.getTime() ?? null); + const cadenceChanged = + merged.intervalDays !== existing.intervalDays || + merged.rrule !== existing.rrule || + !sameInstant(merged.anchorDate, existing.anchorDate) || + (updateData.enabled === true && !existing.enabled); + if (cadenceChanged) { + const after = + existing.lastSatisfiedAt && existing.lastSatisfiedAt > now + ? existing.lastSatisfiedAt + : now; + updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after); + // v1.37.20 (#223) — a cadence edit recomputes `nextDueAt`, so it clears + // the snooze cursor: the cycle the snooze pushed back no longer exists. + updateData.snoozedUntil = null; + } else if ( + merged.notifyHour !== existing.notifyHour && + existing.nextDueAt !== null + ) { + // A new notify hour keeps the due DAY and moves the hour on it, so an + // open slot stays open. A snooze pinned to that same slot moves with it. + const day = wallClockInTz(existing.nextDueAt, timezone); + const moved = zonedWallClockToUtc( + { + year: day.year, + month: day.month, + day: day.day, + hour: merged.notifyHour, + minute: 0, + second: 0, + }, + timezone, + ); + updateData.nextDueAt = moved; + if (sameInstant(existing.snoozedUntil, existing.nextDueAt)) { + updateData.snoozedUntil = moved; + } + } const updated = await prisma.measurementReminder.update({ where: { id }, diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 8fdbf8742..3312ad0b7 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -115,7 +115,7 @@ export const measurementReminderPaths: NonNullable = tags: ["MeasurementReminders"], summary: "Edit a Vorsorge reminder (v1.17.1)", description: - "Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side after the cadence merge. Owner-scoped.", + "Partial edit; omitted fields are left untouched. nextDueAt is recomputed server-side only when the edit changes when the reminder recurs (intervalDays, rrule, anchorDate compared by value, or enabled switched back on); that recompute also clears a snooze. A notifyHour-only edit keeps the due day and moves the hour on it. Any other edit (label, location, measurementType) leaves nextDueAt as it is, so an open, overdue check-up stays due. Owner-scoped.", requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 31e9d2f6a..724e2b2d4 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -185,7 +185,7 @@ export const updateMeasurementReminderSchema = z .meta({ id: "MeasurementReminderUpdate", description: - "Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.", + "Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).", }); export type UpdateMeasurementReminderInput = z.infer< From 8b78e5486d7457facbef4ac964a1fb8a6f1f6d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:05:55 +0200 Subject: [PATCH 5/6] docs(daily): note that a new rail kind needs its stored lists extended The start-page item choice stores the enabled kinds, so a list saved before a kind existed reads as that kind switched off. Say so next to the kind list, where the next kind will be added. --- src/lib/daily/priority-item.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/daily/priority-item.ts b/src/lib/daily/priority-item.ts index 6fc3b6c76..fda13e6f5 100644 --- a/src/lib/daily/priority-item.ts +++ b/src/lib/daily/priority-item.ts @@ -25,6 +25,13 @@ import type { ModuleKey } from "@/lib/modules/registry"; * dashboard-layout label. Typed consumers derive from this tuple so stored * visibility settings and API validation cannot accept vocabulary the current * build does not understand. + * + * A new kind also needs a data migration that appends it to every stored, + * non-empty `enabledHeroItemKinds` list. The layout stores the ENABLED kinds + * whenever they are not all of them, so a list saved before the kind existed + * reads as "switched off" and the new kind never shows for that person. + * `upcoming_visit` shipped without one and stayed hidden that way until + * migration 0355 appended it. */ export const PRIORITY_ITEM_KINDS = [ "coach_checkin", From 4d4a527c0d95629bb2f1e0f86fde1fa86f98b775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 10:21:22 +0200 Subject: [PATCH 6/6] test(reminders): pin the snooze guard of the 0355 repair on its own The snoozed fixture sat inside a week of the send, so the one-week clause already excluded it and dropping the snooze guard from the migration left the suite green. Snooze it further out, a minute after the send, so only the snooze cursor tells it apart from a roll-on. --- tests/integration/checkup-stays-due.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/integration/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts index 2bd8c66e1..e7773047b 100644 --- a/tests/integration/checkup-stays-due.test.ts +++ b/tests/integration/checkup-stays-due.test.ts @@ -275,11 +275,14 @@ describe("migration 0355", () => { updatedAt: new Date("2026-09-26T07:02:00.000Z"), }, { + // Snoozed a minute after the send to a day well past a week out, so + // only the snooze cursor tells it apart from a roll-on. id: "snoozed", data: { - snoozedUntil: new Date("2026-10-03T07:00:00.000Z"), - nextDueAt: new Date("2026-10-03T07:00:00.000Z"), + snoozedUntil: new Date("2026-11-02T08:00:00.000Z"), + nextDueAt: new Date("2026-11-02T08:00:00.000Z"), }, + updatedAt: new Date("2026-09-26T07:01:00.000Z"), }, { // Edited two days after the send: the last write is not the roll-on.