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

@@ -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}