feat(vorsorge): make the mental-wellbeing screening plannable

PHQ9_SCORE / GAD7_SCORE join the Vorsorge reminder type allow-list as
their own "Seelisches Wohlbefinden" group, with a 4-week default
cadence (both instruments cover the last two weeks; 28 joins the
interval presets). The reminders ride the existing activity-aware
engine unchanged: they fire only when overdue, and a completed check-in
auto-satisfies them — the assessment route now kicks the eventful
reminder-satisfy worker, which resolves against the server-written
score row; the 15-min cron stays the idempotent safety-net. The
screening reminder's primary action opens the check-in page (a score is
never typed in), and the dispatcher gates the reminder on the opt-in
mental-health module so a disabled module never nags. OpenAPI spec
regenerated for the widened MeasurementReminderType enum.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 15:05:22 +02:00
1 parent b6aedfeb61
commit 01beabb99b
16 files changed
+256 -30

No files matched your search

+6 -3
View File
@@ -11135,9 +11135,12 @@ components:
- TOTAL_BODY_WATER
- VISCERAL_FAT
- BODY_MASS_INDEX
description: Auto-resolve target metric (vitals + body-composition family). 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
or a matching lab result.
- PHQ9_SCORE
- GAD7_SCORE
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 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 PHQ9_SCORE / GAD7_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.
MeasurementReminderUpdate:
type: object
properties:
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Knochenmasse",
"TOTAL_BODY_WATER": "Körperwasser",
"VISCERAL_FAT": "Viszeralfett",
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)"
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
"PHQ9_SCORE": "PHQ-9 · Stimmung",
"GAD7_SCORE": "GAD-7 · Sorgen"
},
"typeGroups": {
"vitals": "Vitalwerte",
"bodyComposition": "Körperzusammensetzung"
"bodyComposition": "Körperzusammensetzung",
"mentalWellbeing": "Seelisches Wohlbefinden"
},
"empty": {
"title": "Noch keine Erinnerungen",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Selbstgeplant",
"captureValue": "Wert erfassen",
"measureNow": "Jetzt messen",
"startCheckIn": "Check-in starten",
"nextDueLabel": "Nächste Fälligkeit:",
"lastDoneLabel": "Zuletzt erledigt:",
"customize": "Vorsorge anpassen",
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Bone mass",
"TOTAL_BODY_WATER": "Body water",
"VISCERAL_FAT": "Visceral fat",
"BODY_MASS_INDEX": "Body mass index (BMI)"
"BODY_MASS_INDEX": "Body mass index (BMI)",
"PHQ9_SCORE": "PHQ-9 · mood",
"GAD7_SCORE": "GAD-7 · worry"
},
"typeGroups": {
"vitals": "Vitals",
"bodyComposition": "Body composition"
"bodyComposition": "Body composition",
"mentalWellbeing": "Mental wellbeing"
},
"empty": {
"title": "No reminders yet",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Self-planned",
"captureValue": "Log value",
"measureNow": "Measure now",
"startCheckIn": "Start check-in",
"nextDueLabel": "Next due:",
"lastDoneLabel": "Last done:",
"customize": "Customize checkups",
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Masa ósea",
"TOTAL_BODY_WATER": "Agua corporal",
"VISCERAL_FAT": "Grasa visceral",
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)"
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
"PHQ9_SCORE": "PHQ-9 · ánimo",
"GAD7_SCORE": "GAD-7 · preocupación"
},
"typeGroups": {
"vitals": "Constantes vitales",
"bodyComposition": "Composición corporal"
"bodyComposition": "Composición corporal",
"mentalWellbeing": "Bienestar mental"
},
"empty": {
"title": "Aún no hay recordatorios",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Autoplanificado",
"captureValue": "Registrar valor",
"measureNow": "Medir ahora",
"startCheckIn": "Iniciar check-in",
"nextDueLabel": "Próximo vencimiento:",
"lastDoneLabel": "Última vez:",
"customize": "Personalizar revisiones",
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Masse osseuse",
"TOTAL_BODY_WATER": "Eau corporelle",
"VISCERAL_FAT": "Graisse viscérale",
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)"
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
"PHQ9_SCORE": "PHQ-9 · humeur",
"GAD7_SCORE": "GAD-7 · inquiétude"
},
"typeGroups": {
"vitals": "Constantes vitales",
"bodyComposition": "Composition corporelle"
"bodyComposition": "Composition corporelle",
"mentalWellbeing": "Bien-être mental"
},
"empty": {
"title": "Aucun rappel pour l'instant",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Auto-planifié",
"captureValue": "Enregistrer la valeur",
"measureNow": "Mesurer maintenant",
"startCheckIn": "Commencer le check-in",
"nextDueLabel": "Prochaine échéance :",
"lastDoneLabel": "Dernière fois :",
"customize": "Personnaliser les examens",
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Massa ossea",
"TOTAL_BODY_WATER": "Acqua corporea",
"VISCERAL_FAT": "Grasso viscerale",
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)"
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
"PHQ9_SCORE": "PHQ-9 · umore",
"GAD7_SCORE": "GAD-7 · preoccupazione"
},
"typeGroups": {
"vitals": "Parametri vitali",
"bodyComposition": "Composizione corporea"
"bodyComposition": "Composizione corporea",
"mentalWellbeing": "Benessere mentale"
},
"empty": {
"title": "Ancora nessun promemoria",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Autopianificato",
"captureValue": "Registra valore",
"measureNow": "Misura ora",
"startCheckIn": "Avvia il check-in",
"nextDueLabel": "Prossima scadenza:",
"lastDoneLabel": "Ultima volta:",
"customize": "Personalizza controlli",
+6 -2
View File
@@ -6987,11 +6987,14 @@
"BONE_MASS": "Masa kostna",
"TOTAL_BODY_WATER": "Woda w organizmie",
"VISCERAL_FAT": "Tłuszcz trzewny",
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)"
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
"PHQ9_SCORE": "PHQ-9 · nastrój",
"GAD7_SCORE": "GAD-7 · lęk"
},
"typeGroups": {
"vitals": "Parametry życiowe",
"bodyComposition": "Skład ciała"
"bodyComposition": "Skład ciała",
"mentalWellbeing": "Dobrostan psychiczny"
},
"empty": {
"title": "Brak przypomnień",
@@ -7025,6 +7028,7 @@
"selfPlanned": "Zaplanowane samodzielnie",
"captureValue": "Zapisz wartość",
"measureNow": "Zmierz teraz",
"startCheckIn": "Rozpocznij check-in",
"nextDueLabel": "Następny termin:",
"lastDoneLabel": "Ostatnio wykonano:",
"customize": "Dostosuj badania",
@@ -37,6 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: vi.fn(() => Buffer.from("ciphertext")),
}));
// v1.27.6 — a completed check-in kicks the eventful Vorsorge satisfy worker
// so a plannable screening reminder resolves immediately.
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +61,7 @@ vi.mock("next/headers", () => ({
}));
import { POST } from "../route";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -200,6 +207,10 @@ describe("POST /api/mental-health/assessments", () => {
expect(measArg.data.unit).toBe("score");
expect(measArg.data.source).toBe("COMPUTED");
expect(measArg.data.externalId).toBe("assessment:mha_2");
// The eventful Vorsorge satisfy worker is kicked so a plannable
// screening reminder (PHQ9_SCORE / GAD7_SCORE) auto-resolves at once.
expect(enqueueReminderSatisfy).toHaveBeenCalledWith("user-1");
});
it("dedups a repeat externalId: returns the existing administration, writes nothing new", async () => {
@@ -239,6 +250,8 @@ describe("POST /api/mental-health/assessments", () => {
);
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
expect(prisma.measurement.create).not.toHaveBeenCalled();
// No new trend point → nothing to satisfy either.
expect(enqueueReminderSatisfy).not.toHaveBeenCalled();
});
it("persists client provenance + externalId on a first write", async () => {
@@ -28,6 +28,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
@@ -327,6 +328,12 @@ async function postAssessment(request: NextRequest): Promise<Response> {
},
});
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
// a check-in resolves the reminder immediately (the ingest-route
// precedent); the 15-min cron stays the idempotent safety-net.
await enqueueReminderSatisfy(user.id);
await auditLog("mental-health.create", {
userId: user.id,
// The score band is non-sensitive; item answers are NEVER logged.
@@ -14,6 +14,12 @@ import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
* `<EmptyState>` with an add action. These tests pin both.
*/
// v1.27.6 — a screening reminder's primary action navigates to the
// check-in page via the app router, which the SSR harness doesn't mount.
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}));
const remindersMock = vi.fn();
vi.mock("@/hooks/use-measurement-reminders", () => ({
useMeasurementReminders: () => remindersMock(),
@@ -136,6 +142,32 @@ describe("<VorsorgeSection> loading + empty", () => {
expect(html).not.toContain("Log value");
});
it("renders a 'Start check-in' action for a screening reminder (v1.27.6)", () => {
// A PHQ-9 / GAD-7 reminder routes to the check-in page — the score is
// never typed in, so the numeric "Measure now" wording would mislead.
const reminder: MeasurementReminder = {
id: "screening",
label: "PHQ-9 check-in",
measurementType: "PHQ9_SCORE",
intervalDays: 28,
rrule: null,
anchorDate: null,
endsOn: null,
origin: "VORSORGE",
nextDueAt: null,
notifyHour: 9,
location: null,
lastSatisfiedAt: null,
enabled: true,
createdAt: "2030-01-01T00:00:00.000Z",
updatedAt: "2030-01-01T00:00:00.000Z",
} as MeasurementReminder;
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
const html = render(<VorsorgeSection />);
expect(html).toContain("Start check-in");
expect(html).not.toContain("Measure now");
});
it("reads a same-day due time as 'today' (calendar-day delta, not rolling 24h)", () => {
// v1.18.9 recon — a reminder whose nextDue falls on the SAME local
// calendar day must read "Due today" with the green status hue, even when
@@ -16,6 +16,7 @@
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
@@ -26,6 +27,7 @@ import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -60,6 +62,7 @@ function resolveLabel(
export function VorsorgeDashboardCard() {
const { t } = useTranslations();
const router = useRouter();
const { data: reminders, isLoading } = useMeasurementReminders();
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
@@ -74,8 +77,12 @@ export function VorsorgeDashboardCard() {
.filter((r) => r.enabled)
.slice(0, SUMMARY_LIMIT);
// v1.27.6 — a screening reminder routes to the check-in page (a score is
// never typed in); completing the test auto-satisfies the reminder.
function onPrimaryAction(reminder: MeasurementReminder) {
if (reminder.measurementType) {
if (isScreeningReminderType(reminder.measurementType)) {
router.push("/mental-wellbeing");
} else if (reminder.measurementType) {
setCapturing(reminder);
} else {
satisfy.mutate(reminder.id);
@@ -119,6 +126,9 @@ export function VorsorgeDashboardCard() {
{upcoming.map((reminder) => {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
const isScreening = isScreeningReminderType(
reminder.measurementType,
);
return (
<ListRow
key={reminder.id}
@@ -146,7 +156,11 @@ export function VorsorgeDashboardCard() {
{isLinked ? (
<>
<Plus className="h-4 w-4" />
{t("measurementReminders.captureValue")}
{t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.captureValue",
)}
</>
) : (
<>
@@ -30,6 +30,7 @@
*/
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Activity,
CalendarClock,
@@ -76,6 +77,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -113,9 +115,23 @@ const TYPE_GROUPS = [
"BODY_MASS_INDEX",
],
},
// v1.27.6 — the mental-wellbeing screenings become plannable like any
// other Vorsorge item. Auto-resolve rides the server-written PHQ9_SCORE /
// GAD7_SCORE row a completed check-in produces.
{
group: "mentalWellbeing",
types: ["PHQ9_SCORE", "GAD7_SCORE"],
},
] as const;
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
// v1.27.6 — screening types get a dedicated primary action (the check-in
// page, not the numeric MeasurementForm) and a 4-week default cadence
// (both instruments cover the last two weeks).
const MENTAL_SCREENING_DEFAULT_INTERVAL = "28";
// 28 joined in v1.27.6 as the screening default (every 4 weeks); 30 stays
// so existing monthly reminders keep resolving onto a preset.
const INTERVAL_PRESETS = [7, 14, 28, 30, 90, 180, 365] as const;
// v1.18.2 — the sentinel the cadence picker writes when the user wants a
// free interval or an RFC-5545 RRULE instead of a fixed preset.
const CADENCE_CUSTOM = "custom";
@@ -220,6 +236,7 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
const router = useRouter();
const {
data: reminders,
isLoading,
@@ -330,9 +347,14 @@ export function VorsorgeSection({
// The mark-done action. A free-text / self-planned exam satisfies
// silently; a measurement-linked reminder opens the real value-entry
// form so the user logs an actual reading.
// form so the user logs an actual reading. A screening reminder
// (v1.27.6) routes to the check-in page instead — the score is never
// typed in, it falls out of a completed test, which then auto-satisfies
// the reminder through the eventful worker.
function onPrimaryAction(reminder: MeasurementReminder) {
if (reminder.measurementType) {
if (isScreeningReminderType(reminder.measurementType)) {
router.push("/mental-wellbeing");
} else if (reminder.measurementType) {
setCapturing(reminder);
} else {
satisfy.mutate(reminder.id);
@@ -495,9 +517,19 @@ export function VorsorgeSection({
<NativeSelect
id="vorsorge-type"
value={form.measurementType}
onChange={(e) =>
setForm((f) => ({ ...f, measurementType: e.target.value }))
}
onChange={(e) => {
const nextType = e.target.value;
setForm((f) => ({
...f,
measurementType: nextType,
// v1.27.6 — a screening covers the last two weeks, so
// switching onto a screening type defaults the cadence
// to every 4 weeks (still freely changeable).
cadenceChoice: isScreeningReminderType(nextType)
? MENTAL_SCREENING_DEFAULT_INTERVAL
: f.cadenceChoice,
}));
}}
>
{TYPE_GROUPS.map(({ group, types }) => (
<optgroup
@@ -784,6 +816,9 @@ function VorsorgeCard({
: "";
const isCoach = reminder.origin === "COACH";
const isLinked = reminder.measurementType != null;
// v1.27.6 — a screening reminder's action is "start the check-in" (it
// routes to the check-in page), never the numeric value-entry wording.
const isScreening = isScreeningReminderType(reminder.measurementType);
// Due now / overdue ⇒ the action button takes the green "do it now" tone.
// The CARD stays neutral (no tint) per the project rule — only the action
// button goes green; the surface follows the medication-card grammar.
@@ -891,7 +926,11 @@ function VorsorgeCard({
{isLinked ? (
<>
<Activity className="h-4 w-4" />
{t("measurementReminders.measureNow")}
{t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)}
</>
) : (
<>
@@ -958,7 +997,11 @@ function VorsorgeCard({
disabled={busy}
>
{isLinked
? t("measurementReminders.measureNow")
? t(
isScreening
? "measurementReminders.startCheckIn"
: "measurementReminders.measureNow",
)
: t("measurementReminders.markDone")}
</Button>
{headerActions}
@@ -160,6 +160,52 @@ describe("runReminderSatisfyForUser", () => {
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
it("satisfies a screening reminder from the server-written score row (v1.27.6)", async () => {
// A completed PHQ-9 check-in writes a COMPUTED PHQ9_SCORE measurement;
// the screening reminder resolves from that row like any typed reminder.
const measuredAt = new Date("2026-07-01T10:00:00Z");
const { prisma, updates } = makePrisma({
reminders: [
reminderRow({ measurementType: "PHQ9_SCORE", intervalDays: 28 }),
],
measurement: { measuredAt },
});
const isModuleEnabled = vi.fn(async () => true);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date("2026-07-01T12:00:00Z"),
{ isModuleEnabled },
);
expect(summary.satisfied).toBe(1);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
});
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
const { prisma, updates } = makePrisma({
reminders: [
reminderRow({ measurementType: "GAD7_SCORE", intervalDays: 28 }),
],
measurement: { measuredAt: new Date("2026-07-01T10:00:00Z") },
});
const isModuleEnabled = vi.fn(async () => false);
const summary = await runReminderSatisfyForUser(
prisma as never,
"u1",
new Date(),
{ isModuleEnabled },
);
expect(summary.skippedModuleDisabled).toBe(1);
expect(summary.satisfied).toBe(0);
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
expect(updates).toHaveLength(0);
});
it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => {
const { prisma, updates } = makePrisma({
reminders: [
+8 -3
View File
@@ -51,9 +51,11 @@ import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
* pressure, pulse, body composition) or has no module of its own. A
* `null` reminder type (free-text Vorsorge entry) also yields `null`.
*
* Only the two secondary-domain types carry a gate: glucose and sleep.
* Everything else is core and dispatches regardless — disabling a module
* must never silence a core-vital reminder.
* Only the secondary-domain types carry a gate: glucose, sleep, and the
* mental-wellbeing screenings (v1.27.6 — the PHQ-9 / GAD-7 module is
* opt-in, so a screening reminder must fall silent the moment the module
* is off). Everything else is core and dispatches regardless — disabling
* a module must never silence a core-vital reminder.
*/
function moduleForMeasurementType(
type: MeasurementType | null,
@@ -63,6 +65,9 @@ function moduleForMeasurementType(
return "glucose";
case "SLEEP_DURATION":
return "sleep";
case "PHQ9_SCORE":
case "GAD7_SCORE":
return "mentalHealth";
default:
return null;
}
@@ -14,7 +14,7 @@ import {
describe("measurementReminderTypeEnum (V3 — completeness)", () => {
const allowed = measurementReminderTypeEnum.options;
it("exposes the full active set: vitals + body composition", () => {
it("exposes the full active set: vitals + body composition + screenings", () => {
expect(new Set(allowed)).toEqual(
new Set([
// Vitals
@@ -34,10 +34,22 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
// v1.27.6 — plannable mental-wellbeing screenings
"PHQ9_SCORE",
"GAD7_SCORE",
]),
);
});
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "PHQ-9 Check-in",
measurementType: "PHQ9_SCORE",
intervalDays: 28,
});
expect(parsed.success).toBe(true);
});
it("accepts a newly-added body-composition type on create", () => {
const parsed = createMeasurementReminderSchema.safeParse({
label: "Körperzusammensetzung messen",
+28 -1
View File
@@ -56,17 +56,44 @@ export const measurementReminderTypeEnum = z
"TOTAL_BODY_WATER",
"VISCERAL_FAT",
"BODY_MASS_INDEX",
// 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",
])
.meta({
id: "MeasurementReminderType",
description:
"Auto-resolve target metric (vitals + body-composition family). 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 or a matching lab result.",
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 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 PHQ9_SCORE / GAD7_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",
]);
/** 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