feat(vorsorge): widen the linkable measurement set to the body-comp family

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 20:59:06 +02:00
1 parent 7b57c1d3d8
commit a0d864db8c
10 files changed
+237 -27

No files matched your search

+12 -3
View File
@@ -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:
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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ń",
@@ -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) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
{TYPE_GROUPS.map(({ group, types }) => (
<optgroup
key={group}
label={t(`measurementReminders.typeGroups.${group}`)}
>
{types.map((type) => (
<option key={type} value={type}>
{t(`measurementReminders.types.${type}`)}
</option>
))}
</optgroup>
))}
</NativeSelect>
</div>
@@ -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);
});
});
+28 -6
View File
@@ -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<