mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
b6aedfeb61
commit
01beabb99b
16 files changed
+256
-30
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user