From a0d864db8ccca7668f0f66117e1c15f42a73550d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 20:59:06 +0200 Subject: [PATCH] feat(vorsorge): widen the linkable measurement set to the body-comp family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reminder type allow-list exposed 7 of the full MeasurementType enum. Widen it to the ~15 active-measurement set: vitals (weight, BP, pulse, glucose, SpO2, temperature) plus the body-composition family a scale produces (fat mass, fat-free mass, muscle, lean body mass, bone mass, body water, visceral fat, BMI). One step on the scale satisfies every linked reminder at once. Passive scores, cumulative activity, and device events stay excluded — nudging someone to 'go measure' a passive sample makes no sense. App-layer only: the column is the full enum, so no migration. Group the dropdown (Vitals / Body composition) and add the labels across all six locales. --- docs/api/openapi.yaml | 15 +++- messages/de.json | 14 +++- messages/en.json | 14 +++- messages/es.json | 14 +++- messages/fr.json | 14 +++- messages/it.json | 14 +++- messages/pl.json | 14 +++- .../vorsorge-section.tsx | 54 ++++++++++--- .../__tests__/measurement-reminders.test.ts | 77 +++++++++++++++++++ src/lib/validations/measurement-reminders.ts | 34 ++++++-- 10 files changed, 237 insertions(+), 27 deletions(-) create mode 100644 src/lib/validations/__tests__/measurement-reminders.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9485410cb..e349dc30a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -7184,10 +7184,19 @@ components: - PULSE - BLOOD_GLUCOSE - OXYGEN_SATURATION - - BODY_FAT - BODY_TEMPERATURE - description: Auto-resolve target metric. 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. + - BODY_FAT + - FAT_MASS + - FAT_FREE_MASS + - MUSCLE_MASS + - LEAN_BODY_MASS + - BONE_MASS + - 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. MeasurementReminderUpdate: type: object properties: diff --git a/messages/de.json b/messages/de.json index 35c813012..c8f36560c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Blutzucker", "OXYGEN_SATURATION": "Sauerstoffsättigung", "BODY_FAT": "Körperfett", - "BODY_TEMPERATURE": "Körpertemperatur" + "BODY_TEMPERATURE": "Körpertemperatur", + "FAT_MASS": "Fettmasse", + "FAT_FREE_MASS": "Fettfreie Masse", + "MUSCLE_MASS": "Muskelmasse", + "LEAN_BODY_MASS": "Magermasse", + "BONE_MASS": "Knochenmasse", + "TOTAL_BODY_WATER": "Körperwasser", + "VISCERAL_FAT": "Viszeralfett", + "BODY_MASS_INDEX": "Body-Mass-Index (BMI)" + }, + "typeGroups": { + "vitals": "Vitalwerte", + "bodyComposition": "Körperzusammensetzung" }, "empty": { "title": "Noch keine Erinnerungen", diff --git a/messages/en.json b/messages/en.json index ce511ebb8..7b5bbdced 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Blood glucose", "OXYGEN_SATURATION": "Oxygen saturation", "BODY_FAT": "Body fat", - "BODY_TEMPERATURE": "Body temperature" + "BODY_TEMPERATURE": "Body temperature", + "FAT_MASS": "Fat mass", + "FAT_FREE_MASS": "Fat-free mass", + "MUSCLE_MASS": "Muscle mass", + "LEAN_BODY_MASS": "Lean body mass", + "BONE_MASS": "Bone mass", + "TOTAL_BODY_WATER": "Body water", + "VISCERAL_FAT": "Visceral fat", + "BODY_MASS_INDEX": "Body mass index (BMI)" + }, + "typeGroups": { + "vitals": "Vitals", + "bodyComposition": "Body composition" }, "empty": { "title": "No reminders yet", diff --git a/messages/es.json b/messages/es.json index 083fee1bc..27d84b2a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glucosa en sangre", "OXYGEN_SATURATION": "Saturación de oxígeno", "BODY_FAT": "Grasa corporal", - "BODY_TEMPERATURE": "Temperatura corporal" + "BODY_TEMPERATURE": "Temperatura corporal", + "FAT_MASS": "Masa grasa", + "FAT_FREE_MASS": "Masa libre de grasa", + "MUSCLE_MASS": "Masa muscular", + "LEAN_BODY_MASS": "Masa magra", + "BONE_MASS": "Masa ósea", + "TOTAL_BODY_WATER": "Agua corporal", + "VISCERAL_FAT": "Grasa visceral", + "BODY_MASS_INDEX": "Índice de masa corporal (IMC)" + }, + "typeGroups": { + "vitals": "Constantes vitales", + "bodyComposition": "Composición corporal" }, "empty": { "title": "Aún no hay recordatorios", diff --git a/messages/fr.json b/messages/fr.json index 9b837a2c6..25c877947 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glycémie", "OXYGEN_SATURATION": "Saturation en oxygène", "BODY_FAT": "Masse grasse", - "BODY_TEMPERATURE": "Température corporelle" + "BODY_TEMPERATURE": "Température corporelle", + "FAT_MASS": "Masse grasse", + "FAT_FREE_MASS": "Masse maigre totale", + "MUSCLE_MASS": "Masse musculaire", + "LEAN_BODY_MASS": "Masse maigre", + "BONE_MASS": "Masse osseuse", + "TOTAL_BODY_WATER": "Eau corporelle", + "VISCERAL_FAT": "Graisse viscérale", + "BODY_MASS_INDEX": "Indice de masse corporelle (IMC)" + }, + "typeGroups": { + "vitals": "Constantes vitales", + "bodyComposition": "Composition corporelle" }, "empty": { "title": "Aucun rappel pour l'instant", diff --git a/messages/it.json b/messages/it.json index 23305e7da..2f27bd97c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glicemia", "OXYGEN_SATURATION": "Saturazione di ossigeno", "BODY_FAT": "Grasso corporeo", - "BODY_TEMPERATURE": "Temperatura corporea" + "BODY_TEMPERATURE": "Temperatura corporea", + "FAT_MASS": "Massa grassa", + "FAT_FREE_MASS": "Massa magra totale", + "MUSCLE_MASS": "Massa muscolare", + "LEAN_BODY_MASS": "Massa magra", + "BONE_MASS": "Massa ossea", + "TOTAL_BODY_WATER": "Acqua corporea", + "VISCERAL_FAT": "Grasso viscerale", + "BODY_MASS_INDEX": "Indice di massa corporea (IMC)" + }, + "typeGroups": { + "vitals": "Parametri vitali", + "bodyComposition": "Composizione corporea" }, "empty": { "title": "Ancora nessun promemoria", diff --git a/messages/pl.json b/messages/pl.json index 8e1ff3360..2a5354dbc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6478,7 +6478,19 @@ "BLOOD_GLUCOSE": "Glukoza we krwi", "OXYGEN_SATURATION": "Saturacja tlenem", "BODY_FAT": "Tkanka tłuszczowa", - "BODY_TEMPERATURE": "Temperatura ciała" + "BODY_TEMPERATURE": "Temperatura ciała", + "FAT_MASS": "Masa tłuszczowa", + "FAT_FREE_MASS": "Masa beztłuszczowa", + "MUSCLE_MASS": "Masa mięśniowa", + "LEAN_BODY_MASS": "Masa szczupła", + "BONE_MASS": "Masa kostna", + "TOTAL_BODY_WATER": "Woda w organizmie", + "VISCERAL_FAT": "Tłuszcz trzewny", + "BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)" + }, + "typeGroups": { + "vitals": "Parametry życiowe", + "bodyComposition": "Skład ciała" }, "empty": { "title": "Brak przypomnień", diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 43bd02fa6..2a9c85b7b 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -38,14 +38,37 @@ import { type MeasurementReminder, } from "@/hooks/use-measurement-reminders"; -const TYPE_OPTIONS = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "PULSE", - "BLOOD_GLUCOSE", - "OXYGEN_SATURATION", - "BODY_FAT", - "BODY_TEMPERATURE", +// v1.18.1 (V3) — the active-measurement set, grouped so the dropdown +// doesn't read as a flat 15-item list. Mirrors the Zod allow-list in +// `validations/measurement-reminders.ts`; the labels resolve through +// `measurementReminders.types.*`, the group headers through +// `measurementReminders.typeGroups.*`. +const TYPE_GROUPS = [ + { + group: "vitals", + types: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + ], + }, + { + group: "bodyComposition", + types: [ + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ], + }, ] as const; const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const; @@ -220,10 +243,17 @@ export function VorsorgeSection({ value={measurementType} onChange={(e) => setMeasurementType(e.target.value)} > - {TYPE_OPTIONS.map((type) => ( - + {TYPE_GROUPS.map(({ group, types }) => ( + + {types.map((type) => ( + + ))} + ))} diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts new file mode 100644 index 000000000..219e395ee --- /dev/null +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -0,0 +1,77 @@ +/** + * v1.18.1 (V3) — the Vorsorge measurement-type allow-list. Widened from + * the original 7 to the ~15 active-measurement set (vitals + the + * body-composition family). App-layer only — the DB column is the full + * MeasurementType enum, so this is the gate, not a migration. + */ +import { describe, expect, it } from "vitest"; + +import { + measurementReminderTypeEnum, + createMeasurementReminderSchema, +} from "../measurement-reminders"; + +describe("measurementReminderTypeEnum (V3 — completeness)", () => { + const allowed = measurementReminderTypeEnum.options; + + it("exposes the full active set: vitals + body composition", () => { + expect(new Set(allowed)).toEqual( + new Set([ + // Vitals + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "PULSE", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + // Body composition + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", + ]), + ); + }); + + it("accepts a newly-added body-composition type on create", () => { + const parsed = createMeasurementReminderSchema.safeParse({ + label: "Körperzusammensetzung messen", + measurementType: "MUSCLE_MASS", + intervalDays: 7, + }); + expect(parsed.success).toBe(true); + }); + + it("matches BP on the SYS sentinel only (DIA would double-count)", () => { + expect(allowed).toContain("BLOOD_PRESSURE_SYS"); + expect(allowed).not.toContain("BLOOD_PRESSURE_DIA"); + }); + + it("deliberately excludes passive / cumulative / event types", () => { + for (const excluded of [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "VO2_MAX", + "ACTIVITY_STEPS", + "RECOVERY_SCORE", + "SLEEP_DURATION", + "IRREGULAR_RHYTHM_NOTIFICATION", + ]) { + expect(allowed).not.toContain(excluded); + } + }); + + it("still accepts a free-text reminder (null type) resolving on satisfy / lab", () => { + const parsed = createMeasurementReminderSchema.safeParse({ + label: "Großes Blutbild", + measurementType: null, + rrule: "FREQ=YEARLY", + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index fe295c189..e8fa7c5a9 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -17,28 +17,50 @@ import { z } from "zod/v4"; /** * The auto-resolve target types. Kept as an explicit allow-list rather * than the full `MeasurementType` enum: a Vorsorge reminder only makes - * sense for the metrics a user actively measures on a cadence (BP, - * weight, glucose, pulse, SpO2, body composition, body temperature). - * Free-text reminders pass `null` and resolve only on a manual satisfy. + * sense for the metrics a user actively goes and measures on a cadence — + * vitals + the body-composition family a scale produces. + * + * v1.18.1 (V3) widens the original 7 to the full ~15 active-measurement + * set. The DB column is the full `MeasurementType` enum, so this is a + * pure application-layer change — no migration. + * + * DELIBERATELY excluded: passive wearable scores + cumulative activity + + * device events (RECOVERY_SCORE, ACTIVITY_STEPS, IRREGULAR_RHYTHM_*, …), + * and the passive nightly samples RESTING_HEART_RATE / HEART_RATE_VARIABILITY + * / VO2_MAX — nudging a user to "go measure" a passive sample makes no + * sense (they still auto-satisfy any free-text reminder via a LabResult if + * ever wanted, but they are not "remind me to measure" targets). * * BP is matched on `BLOOD_PRESSURE_SYS` as the canonical sentinel — a BP * reading is two rows (SYS + DIA) and matching either would double-count; - * SYS is the agreed "a BP was measured" anchor. + * SYS is the agreed "a BP was measured" anchor. A single step-on-the-scale + * event writes WEIGHT + several body-composition rows at once, so any one + * of them satisfies its linked reminder. */ export const measurementReminderTypeEnum = z .enum([ + // Vitals "WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE", "BLOOD_GLUCOSE", "OXYGEN_SATURATION", - "BODY_FAT", "BODY_TEMPERATURE", + // Body composition (one scale reading produces the whole family) + "BODY_FAT", + "FAT_MASS", + "FAT_FREE_MASS", + "MUSCLE_MASS", + "LEAN_BODY_MASS", + "BONE_MASS", + "TOTAL_BODY_WATER", + "VISCERAL_FAT", + "BODY_MASS_INDEX", ]) .meta({ id: "MeasurementReminderType", description: - "Auto-resolve target metric. 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.", + "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.", }); export type MeasurementReminderType = z.infer<