diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3e8f38ecf..daf1431d8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 69d71c137..fc87792f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 2f1811cdf..e9e09499d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 731843472..1a913c3b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 12ffdd534..1f1b92bb3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 15225f0b5..4ac2e1f41 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 72c898efe..3568e2465 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 3181d9a5c..ec64359db 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 2a4d40aec..457d82dd1 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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 { }, }); + // 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. diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index eb608b13d..d667a56f0 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -14,6 +14,12 @@ import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; * `` 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(" 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(); + 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 diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index c27186808..2f8cb057f 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -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 ( - {t("measurementReminders.captureValue")} + {t( + isScreening + ? "measurementReminders.startCheckIn" + : "measurementReminders.captureValue", + )} ) : ( <> diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 2beba41f6..e3916dea8 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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({ - 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 }) => ( - {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")} {headerActions} diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index 7995ce86c..add9c1298 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -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: [ diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 186ad7cdf..1138cf54b 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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; } diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index 219e395ee..9ac7458cc 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -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", diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 3c17f2ea4..39794123a 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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 = 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