From 11ed6faedd9654bba65a4a7dbbeb490048f8fbe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 27 Apr 2026 00:08:28 +0200 Subject: [PATCH] feat(measurements): add total body water and bone mass types (#89) - Adds TOTAL_BODY_WATER and BONE_MASS to MeasurementType enum - Migration 0022 (purely additive ALTER TYPE ... ADD VALUE) - Both stored canonically in kilograms (matches Withings hydration/bone mass measures and Health Connect TotalBodyWaterRecord/BoneMassRecord) - Validation, formatters, i18n keys (en + de) for measurement form and doctor-report PDF - Dashboard widgets registered (default-invisible, opt-in via settings) - Withings client maps measure types 77 (hydration) and 88 (bone mass) - Doctor report PDF includes both new types in vital-signs table when data exists --- messages/de.json | 4 + messages/en.json | 4 + .../migration.sql | 15 ++++ prisma/schema.prisma | 2 + src/app/api/dashboard/widgets/route.ts | 2 + .../measurements/measurement-form.tsx | 23 +++++- .../settings/dashboard-layout-section.tsx | 80 ++++++++++--------- src/lib/dashboard-layout.ts | 8 +- src/lib/doctor-report-pdf.ts | 30 ++++--- src/lib/validations/measurement.ts | 13 ++- src/lib/withings/client.ts | 2 + 11 files changed, 119 insertions(+), 64 deletions(-) create mode 100644 prisma/migrations/0022_body_composition_metrics/migration.sql diff --git a/messages/de.json b/messages/de.json index 4791cd810..892c3ee4d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -89,6 +89,8 @@ "typeBodyFat": "Körperfettanteil", "typeSleep": "Schlafdauer", "typeSteps": "Aktivität (Schritte)", + "typeTotalBodyWater": "Gesamtkörperwasser", + "typeBoneMass": "Knochenmasse", "unitSteps": "Schritte", "bmiUnderweight": "Untergewicht", "bmiNormal": "Normalgewicht", @@ -243,6 +245,8 @@ "typeSleep": "Schlaf", "typeSteps": "Schritte", "typeBloodGlucose": "Blutzucker", + "typeTotalBodyWater": "Gesamtkörperwasser", + "typeBoneMass": "Knochenmasse", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", diff --git a/messages/en.json b/messages/en.json index 8ff2f5b95..67eb7b06b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -89,6 +89,8 @@ "typeBodyFat": "Body fat", "typeSleep": "Sleep duration", "typeSteps": "Activity (steps)", + "typeTotalBodyWater": "Total body water", + "typeBoneMass": "Bone mass", "unitSteps": "steps", "bmiUnderweight": "Underweight", "bmiNormal": "Normal weight", @@ -243,6 +245,8 @@ "typeSleep": "Sleep", "typeSteps": "Steps", "typeBloodGlucose": "Blood glucose", + "typeTotalBodyWater": "Total body water", + "typeBoneMass": "Bone mass", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", diff --git a/prisma/migrations/0022_body_composition_metrics/migration.sql b/prisma/migrations/0022_body_composition_metrics/migration.sql new file mode 100644 index 000000000..26895aba5 --- /dev/null +++ b/prisma/migrations/0022_body_composition_metrics/migration.sql @@ -0,0 +1,15 @@ +-- v1.3: Add body-composition measurement types +-- +-- Closes #89: total body water and bone mass as first-class measurement types. +-- Both are reported by Withings body-composition scales and can also be ingested +-- via the API by external pipelines (e.g. n8n + Health Connect). +-- +-- Storage units (canonical): +-- TOTAL_BODY_WATER: percent of body weight (%) +-- BONE_MASS: kilograms (kg) +-- +-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE, +-- which carries a glucose_context). The migration is purely additive. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER'; +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fbaa41bfc..8768cf22d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -150,6 +150,8 @@ enum MeasurementType { SLEEP_DURATION ACTIVITY_STEPS BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference. + TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired. + BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88). @@map("measurement_type") } diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index efd492db4..c8e764b9e 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -29,6 +29,8 @@ const widgetIdEnum = z.enum([ "sleep", "steps", "glucose", + "totalBodyWater", + "boneMass", "bpInTarget", ]); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 925bd1e4e..38e6f826d 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -67,11 +67,26 @@ const MEASUREMENT_TYPES = [ unit: "mg/dL", placeholder: "95", }, + { + value: "TOTAL_BODY_WATER", + labelKey: "measurements.typeTotalBodyWater", + unit: "kg", + placeholder: "42", + }, + { + value: "BONE_MASS", + labelKey: "measurements.typeBoneMass", + unit: "kg", + placeholder: "3.2", + }, ] as const; const GLUCOSE_CONTEXTS = [ { value: "FASTING", labelKey: "measurements.glucoseContextFasting" }, - { value: "POSTPRANDIAL", labelKey: "measurements.glucoseContextPostprandial" }, + { + value: "POSTPRANDIAL", + labelKey: "measurements.glucoseContextPostprandial", + }, { value: "RANDOM", labelKey: "measurements.glucoseContextRandom" }, { value: "BEDTIME", labelKey: "measurements.glucoseContextBedtime" }, ] as const; @@ -368,7 +383,11 @@ export function MeasurementForm({ {error && ( -
+
{error}
)} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index f277120d4..9bb28283b 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -29,6 +29,8 @@ const WIDGET_LABEL_KEYS: Record = { sleep: "measurements.typeSleep", steps: "measurements.typeSteps", glucose: "measurements.typeBloodGlucose", + totalBodyWater: "measurements.typeTotalBodyWater", + boneMass: "measurements.typeBoneMass", bpInTarget: "dashboard.bpInTarget", }; @@ -113,7 +115,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { return (
@@ -137,7 +139,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {

{isLoading || !layout ? ( -
+
{t("common.loading")}
@@ -148,44 +150,44 @@ export function DashboardLayoutSection({ id }: { id: string }) { .map((widget, index, arr) => { const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id; return ( -
-
- - +
+
+ + +
+ {t(labelKey)} + toggle(widget.id, v)} + aria-label={t(labelKey)} + disabled={saveMutation.isPending} + />
- {t(labelKey)} - toggle(widget.id, v)} - aria-label={t(labelKey)} - disabled={saveMutation.isPending} - /> -
); })}
diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 487f61bec..c203eef68 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -15,6 +15,8 @@ export type DashboardWidgetId = | "sleep" | "steps" | "glucose" + | "totalBodyWater" + | "boneMass" | "bpInTarget"; export interface DashboardWidgetConfig { @@ -48,12 +50,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { { id: "sleep", visible: false, order: 7 }, { id: "steps", visible: false, order: 8 }, { id: "glucose", visible: false, order: 9 }, + { id: "totalBodyWater", visible: false, order: 10 }, + { id: "boneMass", visible: false, order: 11 }, ], }; -export function resolveDashboardLayout( - raw: unknown, -): DashboardLayout { +export function resolveDashboardLayout(raw: unknown): DashboardLayout { if (!raw || typeof raw !== "object") return DEFAULT_DASHBOARD_LAYOUT; const candidate = raw as Partial; if ( diff --git a/src/lib/doctor-report-pdf.ts b/src/lib/doctor-report-pdf.ts index 7348352a9..af416467a 100644 --- a/src/lib/doctor-report-pdf.ts +++ b/src/lib/doctor-report-pdf.ts @@ -27,7 +27,8 @@ interface ReportData { /** Per-context glucose stats (canonical mg/dL). */ glucoseStats?: Record< "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME", - { avg: number; min: number; max: number; count: number; latest: number } | undefined + | { avg: number; min: number; max: number; count: number; latest: number } + | undefined >; /** Display-unit preference: "mg/dL" (default) or "mmol/L". */ glucoseUnit?: "mg/dL" | "mmol/L"; @@ -74,6 +75,8 @@ const TYPE_LABEL_KEYS: Record = { BODY_FAT: "doctorReport.typeBodyFat", SLEEP_DURATION: "doctorReport.typeSleep", ACTIVITY_STEPS: "doctorReport.typeSteps", + TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", + BONE_MASS: "doctorReport.typeBoneMass", }; const TYPE_UNIT_KEYS: Record = { @@ -84,6 +87,8 @@ const TYPE_UNIT_KEYS: Record = { BODY_FAT: "%", SLEEP_DURATION: "h", ACTIVITY_STEPS: null, // translated unit + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", }; const MOOD_LABEL_KEYS: Record = { @@ -169,7 +174,8 @@ export function generateDoctorReportPDF( MALE: "doctorReport.genderMale", FEMALE: "doctorReport.genderFemale", }; - const genderKey = genderKeys[data.patient.gender] ?? "doctorReport.genderOther"; + const genderKey = + genderKeys[data.patient.gender] ?? "doctorReport.genderOther"; patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`); } if (data.patient.heightCm) { @@ -203,6 +209,8 @@ export function generateDoctorReportPDF( "BLOOD_PRESSURE_DIA", "PULSE", "BODY_FAT", + "TOTAL_BODY_WATER", + "BONE_MASS", ]; for (const type of vitalTypes) { @@ -348,8 +356,7 @@ export function generateDoctorReportPDF( doc.setFont("helvetica", "normal"); for (const ctx of loggedGlucose) { const s = data.glucoseStats![ctx]!; - const range = - data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx]; + const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx]; const conv = (v: number) => convertGlucose(v, glucoseUnit); const inRange = s.avg >= range.min && s.avg <= range.max; const classKey = inRange @@ -387,8 +394,7 @@ export function generateDoctorReportPDF( y += 6; const compRows = complianceEntries.map(([name, c]) => { - const rate = - c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; + const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; return [ name, String(c.taken), @@ -503,16 +509,8 @@ export function generateDoctorReportPDF( pageDoc.setFontSize(7); pageDoc.setFont("helvetica", "italic"); pageDoc.setTextColor(140, 140, 140); - pageDoc.text( - t("doctorReport.footerDisclaimer1"), - margin, - pageHeight - 14, - ); - pageDoc.text( - t("doctorReport.footerDisclaimer2"), - margin, - pageHeight - 10, - ); + pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14); + pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10); pageDoc.text( t("doctorReport.footerSource", { timestamp: formatters.dateTime(new Date()), diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index f4ddcece0..3ccd8c7d7 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -9,6 +9,8 @@ export const measurementTypeEnum = z.enum([ "SLEEP_DURATION", "ACTIVITY_STEPS", "BLOOD_GLUCOSE", + "TOTAL_BODY_WATER", + "BONE_MASS", ]); export const glucoseContextEnum = z.enum([ @@ -29,6 +31,8 @@ const unitMap: Record = { SLEEP_DURATION: "hours", ACTIVITY_STEPS: "steps", BLOOD_GLUCOSE: "mg/dL", + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", }; export function getUnitForType(type: string): string { @@ -45,6 +49,8 @@ const VALUE_RANGES: Record = { SLEEP_DURATION: { min: 0, max: 24 }, ACTIVITY_STEPS: { min: 0, max: 200000 }, BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia + TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg + BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg) }; export function validateMeasurementRange( @@ -69,10 +75,9 @@ export const createMeasurementSchema = z // constraint in Postgres (see migration 0021). glucoseContext: glucoseContextEnum.optional(), }) - .refine( - (data) => validateMeasurementRange(data.type, data.value) === null, - { message: "Value out of plausible range" }, - ) + .refine((data) => validateMeasurementRange(data.type, data.value) === null, { + message: "Value out of plausible range", + }) .refine( (data) => (data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) || diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index f03706d90..735a9a595 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -127,6 +127,8 @@ const MEASURE_TYPE_MAP: Record = { 10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP 11: { type: "PULSE" }, // Heart rate 6: { type: "BODY_FAT" }, // Body fat % + 77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg) + 88: { type: "BONE_MASS" }, // Bone mass (kg) }; export interface WithingsMeasure {