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<