diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8235440f2..c33831065 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13276,10 +13276,11 @@ paths: summary: List Vorsorge reminders (v1.17.1) description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since - v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open - check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder - at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a - measurementType, still roll on to the next slot after a delivered reminder." + v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is + more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without + a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or + completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it + stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder." responses: "200": description: The owner's measurement reminders. diff --git a/prisma/migrations/0355_checkup_stays_due/migration.sql b/prisma/migrations/0355_checkup_stays_due/migration.sql index 91974145c..5445c74b3 100644 --- a/prisma/migrations/0355_checkup_stays_due/migration.sql +++ b/prisma/migrations/0355_checkup_stays_due/migration.sql @@ -1,22 +1,23 @@ -- An open check-up stays due after its reminder. -- -- Until now the reminder tick rolled every delivered reminder on to its next --- slot. For a check-up (a reminder with no measurement type, made by the --- person or suggested by the Coach) that filed an open task under next month --- or next year the moment it was reminded, without it having been done: the --- start page and the check-up card then showed it as scheduled far ahead, --- and a one-shot check-up disappeared altogether. +-- slot. For a reminder on a cycle longer than a week (a fortnightly +-- questionnaire, a yearly check-up) that filed an open task under a slot +-- weeks or a year away the moment it was reminded, without it having been +-- done: the start page and the check-up card then showed it as scheduled far +-- ahead, and a one-shot check-up disappeared altogether. -- --- From this release a check-up keeps its due date until it is marked done, --- skipped or snoozed, and is reminded again at most once a week while it --- stays open. Three steps. +-- From this release such a reminder keeps its due date until it is satisfied +-- (a matching reading or lab result, or marked done), skipped or snoozed, and +-- is reminded again at most once a week while it stays open. A reminder on a +-- weekly or shorter cycle still rolls on. Three steps. -- 1. The cursor that keeps the repeat to once a week: when a reminder for -- this row last reached a channel. Nullable, no default; a row that was -- never reminded simply has none. ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3); --- 2. Put back the due date of check-ups the old behaviour rolled on. +-- 2. Put back the due date of reminders the old behaviour rolled on. -- -- The evidence is the reminder claim ledger: every reminder send first -- claims `measurement::` in `notification_events` @@ -24,8 +25,11 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3); -- channel delivered, but the roll-on only happened after a delivery, so a -- row counts as rolled on by a send only when ALL of these hold: -- --- - it is a live, enabled check-up: not deleted, not disabled, origin --- VORSORGE or COACH (never an appointment), no measurement type; +-- - it is live and enabled, origin VORSORGE or COACH (never an +-- appointment), and on a cycle the new behaviour holds: an interval +-- longer than seven days, a recurrence rule, or no cadence at all. +-- Measurement reminders are included; whether it names a measurement +-- type does not matter; -- - it was not done or skipped after that send, and it carries no -- snooze; -- - the send was its most recent claim, and the row's last write @@ -36,16 +40,19 @@ ALTER TABLE "measurement_reminders" ADD COLUMN "last_notified_at" TIMESTAMP(3); -- alone; -- - the due date it carries now is what the roll-on would have written -- and what the new behaviour no longer writes: more than a week past --- the send (a weekly or shorter check-up still rolls on, so it is not --- repaired), or no due date at all for a check-up with no cadence. +-- the send (a weekly or shorter cycle, including a rule that recurs +-- within the week, still rolls on, so it is not repaired), or no due +-- date at all for a reminder 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` +-- the reminder went out and at its notify hour. `last_notified_at` -- takes the same instant, so the repeat waits a week from that send -- instead of firing again at the next notify hour. A claim older than the -- ledger's 90-day retention is gone, and a row rolled on by such a send -- stays as it is: without the claim there is no evidence of which slot --- was reminded. Nothing is deleted. +-- was reminded. A reading or lab result that satisfied the reminder +-- after the send moved `last_satisfied_at` past it, so such a row is +-- excluded above. Nothing is deleted. WITH latest_claim AS ( SELECT DISTINCT ON (r."id") r."id" AS "reminder_id", @@ -65,7 +72,11 @@ 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."interval_days" > 7 + OR r."rrule" IS NOT NULL + OR (r."interval_days" IS NULL AND r."rrule" IS NULL) + ) AND r."snoozed_until" IS NULL AND r."last_notified_at" IS NULL AND (r."last_satisfied_at" IS NULL OR r."last_satisfied_at" < lc."sent_at") diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c783dd324..7d725e8d3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5483,10 +5483,11 @@ model MeasurementReminder { skipCount Int @default(0) @map("skip_count") /// v1.39.2 — when a reminder for this row last reached at least one channel. /// Written by the reminder tick after a delivered dispatch, read by the same - /// tick. A check-up (no `measurementType`, not an appointment) no longer - /// rolls `nextDueAt` forward when it is reminded: it stays due until it is - /// done, skipped or snoozed, and this cursor is what keeps the repeat to at - /// most once a week. A nudge counts as "already sent for this slot" only + /// tick. A reminder on a cycle longer than a week, or a one-shot (never an + /// appointment), no longer rolls `nextDueAt` forward when it is reminded: + /// it stays due until it is satisfied (a reading, a lab result or "done"), + /// skipped or snoozed, and this cursor keeps the repeat to at most once a + /// week. A nudge counts as "already sent for this slot" only /// while `lastNotifiedAt >= nextDueAt`, so a snooze, a skip or a satisfy /// (each of which moves `nextDueAt`) opens a fresh slot that fires on its /// own day. Operational state, deliberately not in the backup: a restored diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index b930059f1..a2a644d30 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -9,10 +9,11 @@ * logged since the last satisfy advances lastSatisfiedAt + recomputes * nextDueAt and suppresses the nudge. Free-text reminders never * auto-resolve. - * - A measurement reminder rolls on: a successful dispatch advances - * nextDueAt past now. A check-up (no measurementType, not an - * appointment) does NOT roll on: it stays due until it is done, and a - * second nudge for the same open slot waits a week. + * - A reminder on a weekly or shorter cycle rolls on: a successful + * dispatch advances nextDueAt past now. One on a longer cycle, or a + * one-shot, does NOT roll on (measurement reminder or free text): it + * stays due until it is satisfied, and a second nudge for the same open + * slot waits a week. Appointments stay one-shot. * - clientManaged suppresses the APNs leg only: the tick still * dispatches, and a reminder nothing delivered stays overdue. * @@ -633,7 +634,7 @@ describe("runMeasurementReminderTick", () => { expect("nextDueAt" in updates[0].data).toBe(false); }); - it("still rolls a measurement reminder on after its reminder", async () => { + it("still rolls a weekly measurement reminder on after its reminder", async () => { const { prisma, updates } = makePrisma({ reminders: [reminder({ origin: "COACH" })], measurementMatch: null, @@ -778,4 +779,103 @@ describe("runMeasurementReminderTick", () => { expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); }); + + // Watched red: with the rule keyed on `measurementType` (free text only), + // the first case rolls the questionnaire two weeks forward — exactly the + // reported pair of screening reminders. + it("keeps a fortnightly questionnaire due after its reminder", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + measurementType: "PHQ9_SCORE", + intervalDays: 14, + lastSatisfiedAt: new Date("2026-06-01T07:00:00Z"), + nextDueAt: new Date("2026-06-15T07:00:00Z"), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { + dispatch, + isModuleEnabled: async () => true, + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(updates[0].data).toEqual({ lastNotifiedAt: NINE_LOCAL }); + }); + + it("holds the repeat of an open measurement reminder too", async () => { + const { prisma } = makePrisma({ + reminders: [ + reminder({ + measurementType: "WEIGHT", + intervalDays: 30, + nextDueAt: new Date("2026-06-14T07:00:00Z"), + lastNotifiedAt: new Date("2026-06-14T07:00:04Z"), + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.skippedRepeatHeld).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("satisfies an open measurement reminder from the reading and schedules from it", async () => { + // Held open since yesterday; the questionnaire lands this morning. + const filledAt = new Date("2026-06-15T06:30:00Z"); + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + measurementType: "GAD7_SCORE", + intervalDays: 14, + lastSatisfiedAt: new Date("2026-05-31T07:00:00Z"), + nextDueAt: new Date("2026-06-14T07:00:00Z"), + lastNotifiedAt: new Date("2026-06-14T07:00:04Z"), + }), + ], + measurementMatch: { measuredAt: filledAt }, + }); + const dispatch = vi.fn(async () => OK); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch }, + ); + + expect(summary.autoResolved).toBe(1); + expect(dispatch).not.toHaveBeenCalled(); + expect(updates[0].data.lastSatisfiedAt).toEqual(filledAt); + // Fourteen days on from the reading, at the notify hour. + expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-29T07:00:00Z")); + }); + + it("rolls a daily course on after its reminder", async () => { + const { prisma, updates } = makePrisma({ + reminders: [ + reminder({ + origin: "COACH", + intervalDays: null, + rrule: "FREQ=DAILY;BYHOUR=9,19", + }), + ], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + // The evening slot of the same day. + expect(updates[0].data.nextDueAt).toEqual(new Date("2026-06-15T17:00:00Z")); + }); }); diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 300394502..6da948ecd 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -9,21 +9,19 @@ * * Differences from the mood reminder: * - * 1. What a delivered reminder does to the row depends on what the row - * is. A measurement reminder (it names a `measurementType`) is a - * rhythm: after a delivered dispatch `nextDueAt` rolls on to the next - * occurrence, and the next reading satisfies it. An appointment - * (`origin: ENCOUNTER`) is one-shot and closes. A check-up (no - * `measurementType`, whether the person or the Coach made it) is a - * task: it stays due until it is marked done, skipped or snoozed, so - * the start page and the check-up card keep showing it as due and - * then overdue. Rolling it on silently filed an open check-up under - * next year. While it stays open it is reminded again at most once a - * week, gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A - * check-up on a weekly or shorter cadence still rolls on, because its - * own next slot is the next nudge (see `staysDueAfterReminder`). The - * per-day claim in `notification_events` stays the guard against a - * second send on the same local day. + * 1. What a delivered reminder does to the row depends on its cycle. + * A reminder whose next slot is more than a week away (a fortnightly + * questionnaire, a yearly check-up) or that has no next slot at all (a + * one-shot) stays due until it is satisfied, skipped or snoozed, so the + * start page and the check-up card keep showing it as due and then + * overdue; rolling it on silently filed an open task under a later + * slot. While it stays open it is reminded again at most once a week, + * gated on `lastNotifiedAt` (see `isCheckupRepeatHeld`). A reminder on + * a weekly or shorter cycle rolls on to its next slot, which is the + * next nudge anyway, and an appointment (`origin: ENCOUNTER`) is + * one-shot and closes (see `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 @@ -99,54 +97,42 @@ function moduleForMeasurementType( const DUE_QUERY_SLACK_MS = 15 * 60_000; /** - * v1.39.2 — whether a reminder is a check-up: a task that is done by marking - * it done (or by a lab result landing), not by logging a reading. - * - * Decided by `measurementType`, not by `origin`. Both `VORSORGE` and `COACH` - * rows come in two shapes: a free-text check-up ("blood panel", a Coach - * "checkup.create" with a yearly preset) and a measurement rhythm ("weigh in - * weekly", the Coach's twice-daily blood-pressure course). The rhythm is - * satisfied by the next reading and has a next slot of its own that comes - * round soon; holding it on a missed slot would stall a daily course and - * paint a routine prompt as overdue. An appointment (`ENCOUNTER`) is neither - * and stays one-shot. - */ -export function isCheckupReminder(reminder: { - origin?: string | null; - measurementType: MeasurementType | string | null; -}): boolean { - return reminder.origin !== "ENCOUNTER" && reminder.measurementType === null; -} - -/** - * Calendar days an open check-up waits before it is reminded again, and the - * cadence length at or under which a check-up rolls on instead of staying - * due: a weekly or daily item's own next slot is a reminder at least as soon - * as the repeat would be. + * Calendar days an open reminder waits before it is sent again, and the cycle + * length at or under which a reminder rolls on instead of staying due: a + * weekly or daily reminder's own next slot comes round at least as soon as the + * repeat would. */ 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. + * Decided by the cycle length, not by what the reminder asks for. A reminder + * whose next slot is more than a week away stays due until it is satisfied (a + * matching reading or lab result, or "done"), skipped or snoozed: rolling it + * on would file an open task under a slot weeks or a year away. That held for + * free-text check-ups and for measurement reminders alike; the report that + * led here was a fortnightly PHQ-9 and GAD-7 pair that each rolled two weeks + * forward the morning they were reminded, without either questionnaire being + * filled. A one-shot (no interval, no rule) has no next slot and also stays + * due. A reminder on a weekly or shorter cycle rolls on as before, because its + * next slot is the next reminder anyway; holding a daily course on a missed + * slot would stall it. A cadence with no next slot left rolls to nothing as + * before. An appointment (`ENCOUNTER`) always stays one-shot. */ export function staysDueAfterReminder( reminder: { origin?: string | null; - measurementType: MeasurementType | string | null; + intervalDays: number | null; + rrule: string | null; }, rolledTo: Date | null, timezone: string, now: Date, ): boolean { - if (!isCheckupReminder(reminder)) return false; - if (rolledTo === null) return true; + if (reminder.origin === "ENCOUNTER") return false; + if (reminder.intervalDays === null && reminder.rrule === null) return true; + if (rolledTo === null) return false; return calendarDaysUntil(rolledTo, now, timezone) > CHECKUP_REPEAT_DAYS; } @@ -388,7 +374,7 @@ export async function runMeasurementReminderTick( // this it would qualify again at every notify hour. Once a week is the // repeat; the per-day claim below is not enough on its own. if ( - isCheckupReminder(reminder) && + reminder.origin !== "ENCOUNTER" && isCheckupRepeatHeld(reminder, timezone, now) ) { summary.skippedRepeatHeld += 1; diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index 3312ad0b7..a3e94af1a 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. Since v1.39.2 a check-up (a reminder with no measurementType) keeps its nextDueAt after its reminder is sent: an open check-up stays due, then overdue, until it is completed, skipped or snoozed, and the server repeats the reminder at most once a week while it stays open. A check-up on a weekly or shorter cadence, and a reminder with a measurementType, still roll on to the next slot after a delivered reminder.", + "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing. Since v1.39.2 a reminder whose cycle is longer than seven days (intervalDays > 7, an rrule whose next occurrence is more than seven days out) or that has no cadence keeps its nextDueAt after its reminder is sent, with or without a measurementType: it stays due, then overdue, until it is satisfied (a matching reading or lab result, or completed), skipped or snoozed, and the server repeats the reminder at most once every seven local days while it stays open. A reminder on a weekly or shorter cycle still rolls on to its next slot after a delivered reminder.", responses: { ...recordRefusal(), "200": { diff --git a/tests/integration/checkup-stays-due.test.ts b/tests/integration/checkup-stays-due.test.ts index e7773047b..a9ff4a80c 100644 --- a/tests/integration/checkup-stays-due.test.ts +++ b/tests/integration/checkup-stays-due.test.ts @@ -11,8 +11,9 @@ * 2. **Migration 0355's repair.** The container has every migration applied * already, so the data steps are pulled out of the `.sql` file and run * again against rows seeded the way an upgrading instance holds them: - * check-ups the old tick rolled on after a send go back to the reminded - * slot, and every row whose evidence is ambiguous is left alone. The + * reminders on a cycle longer than a week that the old tick rolled on + * after a send go back to the reminded slot, measurement reminders + * included, and every row whose evidence is ambiguous is left alone. The * steps run twice; the second run must change nothing. */ import { readFileSync } from "node:fs"; @@ -178,6 +179,65 @@ describe("an open check-up through the reminder tick", () => { }); }); +describe("an open fortnightly questionnaire through the reminder tick", () => { + const DUE = new Date("2026-09-26T07:00:00.000Z"); + + it("stays due after its reminder and moves on from the reading that fills it", async () => { + const prisma = getPrismaClient(); + const user = await seedUser("questionnaire-owner"); + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: "PHQ-9", + measurementType: "PHQ9_SCORE", + intervalDays: 14, + rrule: null, + notifyHour: 9, + origin: "VORSORGE", + enabled: true, + lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"), + nextDueAt: DUE, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + }); + const dispatch = vi.fn(async () => OK); + const tick = (at: Date) => + runMeasurementReminderTick(prisma, at, { + dispatch, + isModuleEnabled: async () => true, + }); + + await tick(DUE); + expect(dispatch).toHaveBeenCalledTimes(1); + let row = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: created.id }, + }); + expect(row.nextDueAt).toEqual(DUE); + + // The questionnaire is filled the next afternoon. + const filledAt = new Date(DUE.getTime() + DAY + 6 * 60 * 60 * 1000); + await prisma.measurement.create({ + data: { + userId: user.id, + type: "PHQ9_SCORE", + value: 4, + unit: "score", + measuredAt: filledAt, + source: "MANUAL", + }, + }); + const next = await tick(new Date(filledAt.getTime() + 15 * 60 * 1000)); + expect(next.autoResolved).toBe(1); + row = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id: created.id }, + }); + expect(row.lastSatisfiedAt).toEqual(filledAt); + // Fourteen days on from the reading, at 09:00 Berlin. + expect(row.nextDueAt).toEqual(new Date("2026-10-11T07:00:00.000Z")); + expect(dispatch).toHaveBeenCalledTimes(1); + }); +}); + describe("migration 0355", () => { it("adds the nullable last_notified_at column", async () => { const rows = await getPrismaClient().$queryRawUnsafe< @@ -253,6 +313,31 @@ describe("migration 0355", () => { id: "one-shot", data: { rrule: null, intervalDays: null, nextDueAt: null }, }, + { + // The reported shape: fortnightly questionnaires, last filled weeks + // ago, each rolled fourteen days on within a second of its send. + id: "phq9-fortnightly", + data: { + measurementType: "PHQ9_SCORE", + rrule: null, + intervalDays: 14, + lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"), + nextDueAt: new Date("2026-10-10T07:00:00.000Z"), + }, + updatedAt: new Date("2026-09-26T07:00:03.600Z"), + }, + { + id: "gad7-fortnightly", + data: { + measurementType: "GAD7_SCORE", + rrule: null, + intervalDays: 14, + lastSatisfiedAt: new Date("2026-08-15T07:00:00.000Z"), + nextDueAt: new Date("2026-10-10T07:00:00.000Z"), + }, + updatedAt: new Date("2026-09-26T07:00:03.900Z"), + }, + { id: "yearly-measurement", data: { measurementType: "WEIGHT" } }, { // An earlier cycle's send is older; the latest send decides. id: "monthly-many-sends", @@ -304,7 +389,37 @@ describe("migration 0355", () => { nextDueAt: new Date("2026-10-03T07:00:00.000Z"), }, }, - { id: "measurement", data: { measurementType: "WEIGHT" } }, + { + // A measurement reminder on a short cycle still rolls on. + id: "measurement-3-day", + data: { + measurementType: "WEIGHT", + rrule: null, + intervalDays: 3, + nextDueAt: new Date("2026-09-29T07:00:00.000Z"), + }, + }, + { + id: "measurement-7-day", + data: { + measurementType: "BLOOD_PRESSURE_SYS", + rrule: null, + intervalDays: 7, + nextDueAt: new Date("2026-10-03T07:00:00.000Z"), + }, + }, + { + // Satisfied by a reading after the send: done, not rolled on. + id: "measurement-read-after-send", + data: { + measurementType: "PHQ9_SCORE", + rrule: null, + intervalDays: 14, + lastSatisfiedAt: new Date("2026-09-26T07:03:00.000Z"), + nextDueAt: new Date("2026-10-10T07:00:00.000Z"), + }, + updatedAt: new Date("2026-09-26T07:03:01.000Z"), + }, { id: "appointment", data: { origin: "ENCOUNTER", rrule: null } }, { id: "deleted", data: { deletedAt: ROLLED } }, { id: "disabled", data: { enabled: false } },