mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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)
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
});
|
||||
Reference in new issue
Block a user