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).
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:33:05 +02:00
1 parent 111fa7a323
commit 988ba1196e
4 files changed
+429

No files matched your search

@@ -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>): 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)
});
});
+47
View File
@@ -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(),
};
}
+120
View File
@@ -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;
}
@@ -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.",
});