mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
A reminder's first-due date could only be sent as a date-time, and the web form sent the browser's local midnight, so a browser set to a zone other than the profile's could move the day. The API now also takes a bare calendar date and stores it as that day's midnight in the user's zone, and the web form sends the date itself. A date-time still means the instant it names.
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
/**
|
|
* 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 goes and measures on a cadence —
|
|
* vitals + the body-composition family a scale produces.
|
|
*
|
|
* v1.18.1 (V3) widens the original 7 to the full ~15 active-measurement
|
|
* set. The DB column is the full `MeasurementType` enum, so this is a
|
|
* pure application-layer change — no migration.
|
|
*
|
|
* DELIBERATELY excluded: passive wearable scores + cumulative activity +
|
|
* device events (RECOVERY_SCORE, ACTIVITY_STEPS, IRREGULAR_RHYTHM_*, …),
|
|
* and the passive nightly samples RESTING_HEART_RATE / HEART_RATE_VARIABILITY
|
|
* / VO2_MAX — nudging a user to "go measure" a passive sample makes no
|
|
* sense (they still auto-satisfy any free-text reminder via a LabResult if
|
|
* ever wanted, but they are not "remind me to measure" targets).
|
|
*
|
|
* 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. A single step-on-the-scale
|
|
* event writes WEIGHT + several body-composition rows at once, so any one
|
|
* of them satisfies its linked reminder.
|
|
*/
|
|
export const measurementReminderTypeEnum = z
|
|
.enum([
|
|
// Vitals
|
|
"WEIGHT",
|
|
"BLOOD_PRESSURE_SYS",
|
|
"PULSE",
|
|
"BLOOD_GLUCOSE",
|
|
"OXYGEN_SATURATION",
|
|
"BODY_TEMPERATURE",
|
|
// Body composition (one scale reading produces the whole family)
|
|
"BODY_FAT",
|
|
"FAT_MASS",
|
|
"FAT_FREE_MASS",
|
|
"MUSCLE_MASS",
|
|
"LEAN_BODY_MASS",
|
|
"BONE_MASS",
|
|
"TOTAL_BODY_WATER",
|
|
"VISCERAL_FAT",
|
|
"BODY_MASS_INDEX",
|
|
"WAIST_CIRCUMFERENCE",
|
|
// v1.27.6 — the mental-wellbeing screeners become plannable Vorsorge
|
|
// items. Not a passive wearable score: a screening is an active "go and
|
|
// do it" action exactly like stepping on the scale. Auto-resolves from
|
|
// the server-owned COMPUTED PHQ9_SCORE / GAD7_SCORE row the assessment
|
|
// route writes on completion. Gated on the opt-in mentalHealth module in
|
|
// the dispatcher (see `moduleForMeasurementType`).
|
|
"PHQ9_SCORE",
|
|
"GAD7_SCORE",
|
|
// v1.27.9 — WHO-5 + SCI join the plannable screenings on the identical
|
|
// contract (auto-resolve on the server-owned COMPUTED score row, module-
|
|
// gated in the dispatcher).
|
|
"WHO5_SCORE",
|
|
"SCI_SCORE",
|
|
])
|
|
.meta({
|
|
id: "MeasurementReminderType",
|
|
description:
|
|
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 / WHO-5 / SCI screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written *_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
|
});
|
|
|
|
export type MeasurementReminderType = z.infer<
|
|
typeof measurementReminderTypeEnum
|
|
>;
|
|
|
|
/**
|
|
* v1.27.6 — the reminder types that are SCREENINGS, not numeric readings.
|
|
* Every surface with a per-reminder primary action branches on this: a
|
|
* screening routes to the check-in page (`/mental-wellbeing`) instead of
|
|
* opening the numeric MeasurementForm — a score is never typed in, it
|
|
* falls out of a completed test (which then auto-satisfies the reminder).
|
|
*/
|
|
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
|
"PHQ9_SCORE",
|
|
"GAD7_SCORE",
|
|
"WHO5_SCORE",
|
|
"SCI_SCORE",
|
|
]);
|
|
|
|
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
|
export function isScreeningReminderType(
|
|
type: string | null | undefined,
|
|
): boolean {
|
|
return type != null && SCREENING_REMINDER_TYPES.has(type);
|
|
}
|
|
|
|
/**
|
|
* 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"],
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* The first-due date: a calendar date (`YYYY-MM-DD`, read as that day in the
|
|
* user's zone) or a date-time (the instant it names).
|
|
*/
|
|
const anchorDateField = z.union([
|
|
z.iso.date(),
|
|
z.iso.datetime({ offset: true }),
|
|
]);
|
|
|
|
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: anchorDateField.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: anchorDateField.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 when the cadence changes and otherwise kept (a notifyHour edit moves only the hour on the same due day).",
|
|
});
|
|
|
|
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(),
|
|
endsOn: z.iso.datetime({ offset: true }).nullable(),
|
|
origin: z.enum(["VORSORGE", "COACH"]),
|
|
notifyHour: z.number().int(),
|
|
location: z.string().nullable(),
|
|
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
|
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
|
|
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
|
lastSkippedAt: z.iso.datetime({ offset: true }).nullable(),
|
|
skipCount: z.number().int(),
|
|
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. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended). snoozedUntil/lastSkippedAt/skipCount are resolved values: snoozed means snoozedUntil > now, a skipped cycle means lastSkippedAt > lastSatisfiedAt; clients compare against the clock and never recompute cadence. lastSatisfiedAt stays the only completed signal — skip and snooze never move it.",
|
|
});
|
|
|
|
/**
|
|
* v1.18.6 — explicit-completion result. The `complete` route is the
|
|
* user-action equivalent of the cron auto-satisfy: it stamps
|
|
* `lastSatisfiedAt` and re-anchors `nextDueAt` through the SAME shared
|
|
* primitive, so it fires no notification of its own and is idempotent.
|
|
*
|
|
* `completed` reports whether THIS call advanced the reminder (true) or was
|
|
* a forward-only no-op because a prior satisfy / matching reading already
|
|
* fulfilled the current cycle (false). Either way the response is a 200 so
|
|
* a client that completes an already-completed reminder need not special-case
|
|
* the second tap. `reminder` carries the canonical post-completion DTO.
|
|
*/
|
|
export const measurementReminderCompletionDto = z
|
|
.object({
|
|
completed: z.boolean(),
|
|
reminder: measurementReminderDto,
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderCompletion",
|
|
description:
|
|
"Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; completed=false when an earlier satisfy or a matching reading already fulfilled the current cycle (idempotent no-op). reminder is the canonical post-completion DTO.",
|
|
});
|
|
|
|
/**
|
|
* v1.37.20 (#223) — snooze body. Day precision on purpose: the client names
|
|
* a local calendar day, the server resolves it to that day's notifyHour in
|
|
* the profile timezone. No client-supplied instant ever anchors the cadence.
|
|
* The range gate (tomorrow .. +5 years) lives in the route, where the
|
|
* profile timezone is known.
|
|
*/
|
|
export const snoozeMeasurementReminderSchema = z
|
|
.object({
|
|
until: z.iso.date(),
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderSnooze",
|
|
description:
|
|
"Push a reminder's current due date back to a calendar day (YYYY-MM-DD). The server resolves the day to the reminder's notifyHour in the profile timezone. Must be at least tomorrow and at most five years out. The regular cadence is untouched: lastSatisfiedAt and the anchor stay where they are, and the reminder resumes its normal interval after the snoozed cycle.",
|
|
});
|
|
|
|
export type SnoozeMeasurementReminderInput = z.infer<
|
|
typeof snoozeMeasurementReminderSchema
|
|
>;
|
|
|
|
/**
|
|
* v1.37.20 (#223) — skip result. `skipped=false` is the forward-only no-op
|
|
* (a concurrent skip or satisfy already advanced the row).
|
|
*/
|
|
export const measurementReminderSkipDto = z
|
|
.object({
|
|
skipped: z.boolean(),
|
|
reminder: measurementReminderDto,
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderSkip",
|
|
description:
|
|
"Result of skipping the current due cycle. skipped=true when this call recorded the skip and restarted the interval from the skip instant; skipped=false when a concurrent skip or satisfy already advanced the row (idempotent no-op). A skip never touches lastSatisfiedAt — it is recorded honestly as a skip, not a completion.",
|
|
});
|
|
|
|
/**
|
|
* v1.37.20 (iOS #68) — completion-ledger query + row. History starts with
|
|
* the release that ships the ledger; the single-cursor engine holds nothing
|
|
* to backfill from.
|
|
*/
|
|
export const listReminderHistorySchema = z
|
|
.object({
|
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
|
offset: z.coerce.number().int().min(0).default(0),
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderHistoryQuery",
|
|
description: "Pagination for the reminder completion ledger.",
|
|
});
|
|
|
|
export const measurementReminderEventDto = z
|
|
.object({
|
|
id: z.string(),
|
|
kind: z.enum(["SATISFIED", "SKIPPED"]),
|
|
occurredAt: z.iso.datetime({ offset: true }),
|
|
onTime: z.boolean(),
|
|
source: z.enum([
|
|
"manual",
|
|
"auto_measurement",
|
|
"auto_lab",
|
|
"telegram",
|
|
"vaccination",
|
|
"encounter",
|
|
"skip",
|
|
]),
|
|
createdAt: z.iso.datetime({ offset: true }),
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderEvent",
|
|
description:
|
|
"One completion-ledger row: an honest fulfilment (SATISFIED) or an honest skip (SKIPPED). onTime is derived server-side at write time against the due instant that was current when the event landed — clients never re-derive it. History begins at the release that introduced the ledger.",
|
|
});
|
|
|
|
export const measurementReminderHistoryDto = z
|
|
.object({
|
|
events: z.array(measurementReminderEventDto),
|
|
meta: z.object({
|
|
total: z.number().int(),
|
|
limit: z.number().int(),
|
|
offset: z.number().int(),
|
|
}),
|
|
})
|
|
.meta({
|
|
id: "MeasurementReminderHistory",
|
|
description: "Paginated completion ledger for one reminder, newest first.",
|
|
});
|