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 {