mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
Merge feat/w-d-mental-wellbeing into release/v1.27.6
The Vorsorge card resolves both rules at once: the action button keeps its constant look in every state (v1.27.5), and a screening reminder's action reads 'start the check-in' instead of the numeric measure wording (this wave).
This commit is contained in:
commit
f1e5df52ab
28 files changed
+657
-886
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed PHQ-9 / GAD-7 screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -470,6 +470,24 @@ describe("measurement validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
|
||||
// v1.27.6 — the mental-wellbeing screener projects each completed
|
||||
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
|
||||
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
|
||||
// so a client can never mint a server-owned score trend point.
|
||||
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
type,
|
||||
value: 5,
|
||||
measuredAt: "2026-06-02T12:00:00Z",
|
||||
source: "COMPUTED",
|
||||
});
|
||||
expect(parsed.success, `expected COMPUTED ${type} to be rejected`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => {
|
||||
for (const source of ["MANUAL", "APPLE_HEALTH"]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
|
||||
@@ -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