diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1736800d0..8235440f2 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. @@ -13346,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 @@ -18896,10 +18903,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. @@ -26945,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/messages/de.json b/messages/de.json index 988c66016..06801e37e 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", @@ -10321,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", @@ -10366,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 5fb158f6f..fa1fb5458 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", @@ -10321,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", @@ -10366,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 519e3f10b..46f903102 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", @@ -10321,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", @@ -10366,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 e037193f9..722b77a24 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é", @@ -10321,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", @@ -10366,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 60a3420d3..ae8310f2d 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", @@ -10321,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", @@ -10366,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 140a39081..674b6c9a3 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8527,6 +8527,7 @@ "inDays": "{days}일 후" }, "overdueByDays": "{days}일 지남", + "overdueSinceYesterday": "어제부터 지남", "cadence": { "everyNDays": "{days}일마다", "custom": "사용자 지정 일정", @@ -10321,7 +10322,7 @@ "preventiveCare": { "title": "건강검진 시기", "body": "{label} — 받을 때가 됐어요.", - "bodyMany": "건강검진 {count}건을 받을 때가 됐어요." + "bodyManyNamed": "받을 때가 된 건강검진: {labels}" }, "syncIssue": { "title": "동기화 확인이 필요해요", @@ -10366,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 5649bfd1d..a2c23d8b3 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", @@ -10321,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", @@ -10366,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/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/__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/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/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/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/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", 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/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" + ); +} 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.", diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 7173ee162..3312ad0b7 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": { @@ -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< diff --git a/tests/integration/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts new file mode 100644 index 000000000..e7773047b --- /dev/null +++ b/tests/integration/checkup-stays-due.test.ts @@ -0,0 +1,389 @@ +/** + * 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"), + }, + { + // 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-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. + 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(); + }); +});