From 988ba1196e439961a47137840bf2287923df4071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:33:05 +0200 Subject: [PATCH] feat(reminders): server-authoritative next-due engine for preventive-care reminders Add the Vorsorge (measurement) reminder cadence layer: a thin adapter that maps a reminder onto the canonical recurrence engine so a rolling "every N days" or an RFC-5545 rrule cadence is driven by the same code the medication next-due line uses. nextDueAt is computed and stored on the server, so web and iPhone read identical numbers. Covers the DTO serializer and the create/update validation schemas (exactly one of intervalDays or rrule; BP auto-resolves on the SYS sentinel). --- .../__tests__/scheduling.test.ts | 100 +++++++++++ src/lib/measurement-reminders/dto.ts | 47 +++++ src/lib/measurement-reminders/scheduling.ts | 120 +++++++++++++ src/lib/validations/measurement-reminders.ts | 162 ++++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 src/lib/measurement-reminders/__tests__/scheduling.test.ts create mode 100644 src/lib/measurement-reminders/dto.ts create mode 100644 src/lib/measurement-reminders/scheduling.ts create mode 100644 src/lib/validations/measurement-reminders.ts diff --git a/src/lib/measurement-reminders/__tests__/scheduling.test.ts b/src/lib/measurement-reminders/__tests__/scheduling.test.ts new file mode 100644 index 000000000..e49caa45b --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/scheduling.test.ts @@ -0,0 +1,100 @@ +/** + * v1.17.1 — server-authoritative next-due computation for Vorsorge + * reminders. Pins the rolling + rrule cadence contract that web ↔ iOS + * both read off `nextDueAt`. + */ +import { describe, it, expect } from "vitest"; + +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "../scheduling"; + +const TZ = "Europe/Berlin"; + +function base(overrides: Partial): ReminderScheduleInput { + return { + intervalDays: null, + rrule: null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-06-01T08:00:00Z"), + ...overrides, + }; +} + +describe("computeReminderNextDueAt", () => { + it("returns null when neither intervalDays nor rrule is set", () => { + const now = new Date("2026-06-10T07:00:00Z"); + expect(computeReminderNextDueAt(base({}), TZ, now)).toBeNull(); + }); + + it("rolling, never satisfied: first due AT the anchor day notify-hour", () => { + // Anchor 2026-06-10; notifyHour 9 local (Berlin = UTC+2 in June → 07:00Z). + const reminder = base({ + intervalDays: 7, + anchorDate: new Date("2026-06-10T00:00:00Z"), + notifyHour: 9, + createdAt: new Date("2026-06-09T00:00:00Z"), + }); + // "after" before the anchor → the first due is the anchor's slot. + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-09T12:00:00Z"), + ); + expect(due).not.toBeNull(); + // 09:00 Berlin on 2026-06-10 == 07:00Z (DST). + expect(due!.toISOString()).toBe("2026-06-10T07:00:00.000Z"); + }); + + it("rolling, satisfied: next due is lastSatisfiedAt + N days at notify-hour", () => { + const reminder = base({ + intervalDays: 7, + lastSatisfiedAt: new Date("2026-06-10T15:00:00Z"), + notifyHour: 9, + }); + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-10T16:00:00Z"), + ); + expect(due).not.toBeNull(); + // 7 days after the satisfy → 2026-06-17, slot at 09:00 Berlin = 07:00Z. + expect(due!.toISOString()).toBe("2026-06-17T07:00:00.000Z"); + }); + + it("rolling honours the interval length (30 days)", () => { + const reminder = base({ + intervalDays: 30, + lastSatisfiedAt: new Date("2026-06-01T15:00:00Z"), + notifyHour: 9, + }); + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-02T00:00:00Z"), + ); + expect(due!.toISOString()).toBe("2026-07-01T07:00:00.000Z"); + }); + + it("rrule annual: walks to the next yearly occurrence after now", () => { + // FREQ=YEARLY anchored on the createdAt day (2026-06-01). + const reminder = base({ + rrule: "FREQ=YEARLY", + anchorDate: new Date("2026-06-01T00:00:00Z"), + createdAt: new Date("2026-06-01T00:00:00Z"), + notifyHour: 9, + }); + // After the 2026 occurrence → expect the 2027 one. + const due = computeReminderNextDueAt( + reminder, + TZ, + new Date("2026-06-02T00:00:00Z"), + ); + expect(due).not.toBeNull(); + expect(due!.getUTCFullYear()).toBe(2027); + expect(due!.getUTCMonth()).toBe(5); // June (0-indexed) + }); +}); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts new file mode 100644 index 000000000..14ddd2239 --- /dev/null +++ b/src/lib/measurement-reminders/dto.ts @@ -0,0 +1,47 @@ +/** + * v1.17.1 — DTO serializer for Vorsorge (measurement) reminders. + * + * Maps the Prisma `MeasurementReminder` row onto the canonical + * `MeasurementReminderDTO` the web list, dashboard tile, and iOS client + * all mirror. Dates serialise to ISO-8601 with offset. + */ +import type { MeasurementReminder } from "@/generated/prisma/client"; +import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders"; + +export interface MeasurementReminderDtoShape { + id: string; + label: string; + measurementType: MeasurementReminderType | null; + intervalDays: number | null; + rrule: string | null; + anchorDate: string | null; + notifyHour: number; + location: string | null; + nextDueAt: string | null; + lastSatisfiedAt: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export function toMeasurementReminderDto( + row: MeasurementReminder, +): MeasurementReminderDtoShape { + return { + id: row.id, + label: row.label, + measurementType: row.measurementType as MeasurementReminderType | null, + intervalDays: row.intervalDays, + rrule: row.rrule, + anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null, + notifyHour: row.notifyHour, + location: row.location, + nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null, + lastSatisfiedAt: row.lastSatisfiedAt + ? row.lastSatisfiedAt.toISOString() + : null, + enabled: row.enabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts new file mode 100644 index 000000000..856448636 --- /dev/null +++ b/src/lib/measurement-reminders/scheduling.ts @@ -0,0 +1,120 @@ +/** + * v1.17.1 — server-authoritative next-due computation for Vorsorge + * (measurement) reminders. + * + * Reuses the canonical medication recurrence engine + * (`src/lib/medications/scheduling/recurrence.ts`) so a Vorsorge cadence + * is driven by exactly the same code that powers the medication + * "nextDueAt" line — web ↔ iOS read identical numbers (server-authoritative + * per project memory: iOS consumes the resolved DTO, never recomputes). + * + * A `MeasurementReminder` maps onto the engine's `CanonicalSchedule` + + * `RecurrenceContext` as follows: + * + * - rolling `intervalDays` → `rollingIntervalDays`, anchored on + * `lastSatisfiedAt ?? anchorDate ?? createdAt`. With no satisfy yet + * the first due is AT the anchor (not anchor + N); once satisfied the + * `+ N` cadence begins, exactly like a rolling medication's + * last-intake anchor. + * - `rrule` → passed straight through (RFC-5545). + * - the single `notifyHour` → the schedule's one `timesOfDay` entry, so + * the slot fires at the user's chosen local hour (DST-safe — the + * engine applies the time in the user's IANA timezone). + * + * Pure: no DB access. The caller fetches the reminder row + the user's + * timezone and threads them in. + */ +import { + type CanonicalSchedule, + type RecurrenceContext, + nextOccurrenceAfter, +} from "@/lib/medications/scheduling/recurrence"; + +/** + * The reminder fields this module reads. A subset of the Prisma row so + * tests can construct it without the full model. + */ +export interface ReminderScheduleInput { + intervalDays: number | null; + rrule: string | null; + anchorDate: Date | null; + notifyHour: number; + lastSatisfiedAt: Date | null; + createdAt: Date; +} + +function hourToHhmm(hour: number): string { + const safe = Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : 9; + return `${safe.toString().padStart(2, "0")}:00`; +} + +/** + * Build the `(CanonicalSchedule, RecurrenceContext)` pair the engine + * consumes for one Vorsorge reminder. + * + * The rolling anchor is threaded through the engine's `lastIntakeAt` + * (after a satisfy) and `medication.startsOn` (the first-due anchor when + * never satisfied) so the rolling path's "first dose AT the anchor, then + * + N after the first satisfy" semantics apply verbatim. + */ +export function buildReminderRecurrence( + reminder: ReminderScheduleInput, + timeZone: string, +): { schedule: CanonicalSchedule; ctx: RecurrenceContext } { + const hhmm = hourToHhmm(reminder.notifyHour); + + const schedule: CanonicalSchedule = { + id: "measurement-reminder", + rrule: reminder.rrule, + rollingIntervalDays: reminder.intervalDays, + timesOfDay: [hhmm], + daysOfWeek: null, + windowStart: hhmm, + windowEnd: hhmm, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; + + // First-due anchor when never satisfied: anchorDate ?? createdAt. After + // a satisfy the rolling path re-anchors on `lastIntakeAt + N`, so we + // feed `lastSatisfiedAt` through `lastIntakeAt`. + const startsOn = reminder.anchorDate ?? reminder.createdAt; + + const ctx: RecurrenceContext = { + medication: { + id: "measurement-reminder", + startsOn, + endsOn: null, + oneShot: false, + createdAt: reminder.createdAt, + }, + timeZone: timeZone || "Europe/Berlin", + lastIntakeAt: reminder.lastSatisfiedAt, + }; + + return { schedule, ctx }; +} + +/** + * Compute the canonical next-due instant for a reminder, strictly after + * `after`. Returns `null` when the cadence is uncomputable (no interval + + * no rrule) or the engine finds no future occurrence. + * + * `after` defaults to `now` but the caller can floor it (e.g. to the last + * satisfy instant) so a freshly-satisfied reminder advances past the + * current due cycle. + */ +export function computeReminderNextDueAt( + reminder: ReminderScheduleInput, + timeZone: string, + after: Date, +): Date | null { + if (reminder.intervalDays === null && reminder.rrule === null) { + return null; + } + const { schedule, ctx } = buildReminderRecurrence(reminder, timeZone); + const occurrence = nextOccurrenceAfter(schedule, after, ctx); + return occurrence?.at ?? null; +} diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts new file mode 100644 index 000000000..fe295c189 --- /dev/null +++ b/src/lib/validations/measurement-reminders.ts @@ -0,0 +1,162 @@ +/** + * v1.17.1 — Measurement / Vorsorge (preventive-care) reminders. + * + * A reminder class distinct from the medication loop: "measure BP every + * 7 days", "annual blood panel", "log weight weekly". Tied to a + * measurement TYPE (auto-resolving when a matching reading lands) or to a + * free-text checklist label (resolving only on a manual "Erledigt"). + * + * The cadence vocabulary reuses the medication recurrence engine: a + * `intervalDays` rolling cadence OR an RFC-5545 `rrule`. The server + * computes the canonical `nextDueAt` so web ↔ iOS show identical numbers + * (server-authoritative; the client consumes the resolved DTO, never + * recomputes). + */ +import { z } from "zod/v4"; + +/** + * The auto-resolve target types. Kept as an explicit allow-list rather + * than the full `MeasurementType` enum: a Vorsorge reminder only makes + * sense for the metrics a user actively measures on a cadence (BP, + * weight, glucose, pulse, SpO2, body composition, body temperature). + * Free-text reminders pass `null` and resolve only on a manual satisfy. + * + * BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP + * reading is two rows (SYS + DIA) and matching either would double-count; + * SYS is the agreed "a BP was measured" anchor. + */ +export const measurementReminderTypeEnum = z + .enum([ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_FAT", + "BODY_TEMPERATURE", + ]) + .meta({ + id: "MeasurementReminderType", + description: + "Auto-resolve target metric. BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy.", + }); + +export type MeasurementReminderType = z.infer< + typeof measurementReminderTypeEnum +>; + +/** + * RFC-5545 RRULE guard. The recurrence engine parses it; here we only + * bound the length and require the `FREQ=` token so a typo doesn't reach + * the engine. Full validation is the engine's job (it tolerates a + * malformed rule by emitting no slots rather than throwing). + */ +const rruleField = z + .string() + .min(1) + .max(512) + .refine((v) => /FREQ=/i.test(v), { + message: "rrule must contain a FREQ= component", + }); + +/** + * Shared cadence refinement: exactly one of `intervalDays` / `rrule` is + * required on create. Both NULL would leave `nextDueAt` uncomputable. + */ +const cadenceRefinement = ( + data: { intervalDays?: number | null; rrule?: string | null }, + ctx: z.RefinementCtx, +): void => { + const hasInterval = + data.intervalDays !== undefined && data.intervalDays !== null; + const hasRrule = data.rrule !== undefined && data.rrule !== null; + if (!hasInterval && !hasRrule) { + ctx.addIssue({ + code: "custom", + message: "Provide either intervalDays or rrule", + path: ["intervalDays"], + }); + } + if (hasInterval && hasRrule) { + ctx.addIssue({ + code: "custom", + message: "Provide only one of intervalDays or rrule", + path: ["rrule"], + }); + } +}; + +export const createMeasurementReminderSchema = z + .object({ + label: z.string().trim().min(1).max(120), + measurementType: measurementReminderTypeEnum.nullish(), + intervalDays: z.number().int().min(1).max(3650).nullish(), + rrule: rruleField.nullish(), + anchorDate: z.iso.datetime({ offset: true }).nullish(), + notifyHour: z.number().int().min(0).max(23).default(9), + location: z.string().trim().max(200).nullish(), + enabled: z.boolean().default(true), + }) + .superRefine(cadenceRefinement) + .meta({ + id: "MeasurementReminderCreate", + description: + "Create a Vorsorge reminder. Exactly one of intervalDays (rolling, every N days) or rrule (RFC-5545, e.g. FREQ=YEARLY) is required. measurementType enables auto-resolve from an incoming reading; omit it for a free-text checklist reminder.", + }); + +export type CreateMeasurementReminderInput = z.infer< + typeof createMeasurementReminderSchema +>; + +/** + * Update is fully partial. Cadence fields stay mutually-exclusive but + * are not required (an edit that only changes the label leaves the + * cadence alone). The route re-derives `nextDueAt` after applying the + * patch. + */ +export const updateMeasurementReminderSchema = z + .object({ + label: z.string().trim().min(1).max(120).optional(), + measurementType: measurementReminderTypeEnum.nullish(), + intervalDays: z.number().int().min(1).max(3650).nullish(), + rrule: rruleField.nullish(), + anchorDate: z.iso.datetime({ offset: true }).nullish(), + notifyHour: z.number().int().min(0).max(23).optional(), + location: z.string().trim().max(200).nullish(), + enabled: z.boolean().optional(), + }) + .meta({ + id: "MeasurementReminderUpdate", + description: + "Partial edit of a Vorsorge reminder. Omitted fields are left untouched; nextDueAt is recomputed server-side after the patch applies.", + }); + +export type UpdateMeasurementReminderInput = z.infer< + typeof updateMeasurementReminderSchema +>; + +/** + * The canonical DTO every surface (web list, dashboard tile, iOS) mirrors. + * `nextDueAt` is the server-computed next-due instant. + */ +export const measurementReminderDto = z + .object({ + id: z.string(), + label: z.string(), + measurementType: measurementReminderTypeEnum.nullable(), + intervalDays: z.number().int().nullable(), + rrule: z.string().nullable(), + anchorDate: z.iso.datetime({ offset: true }).nullable(), + notifyHour: z.number().int(), + location: z.string().nullable(), + nextDueAt: z.iso.datetime({ offset: true }).nullable(), + lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(), + enabled: z.boolean(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MeasurementReminderDTO", + description: + "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.", + });