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
This commit is contained in:
Marc-André Bombeck authored and Marc-André Bombeck committed 2026-04-27 00:28:28 +02:00
1 parent 59448d473c
commit 11ed6faedd
11 files changed
+119 -64

No files matched your search

+4
View File
@@ -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)",
+4
View File
@@ -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)",
@@ -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';
+2
View File
@@ -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")
}
+2
View File
@@ -29,6 +29,8 @@ const widgetIdEnum = z.enum([
"sleep",
"steps",
"glucose",
"totalBodyWater",
"boneMass",
"bpInTarget",
]);
@@ -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({
</div>
{error && (
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
<div
role="alert"
aria-live="assertive"
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
>
{error}
</div>
)}
@@ -29,6 +29,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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 (
<div
id={id}
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
@@ -137,7 +139,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</p>
{isLoading || !layout ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
@@ -148,44 +150,44 @@ export function DashboardLayoutSection({ id }: { id: string }) {
.map((widget, index, arr) => {
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
return (
<div
key={widget.id}
className="flex items-center gap-3 rounded-md border border-border bg-background/30 p-3"
>
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => move(widget.id, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("dashboard.moveUp")}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => move(widget.id, 1)}
disabled={
index === arr.length - 1 || saveMutation.isPending
}
aria-label={t("dashboard.moveDown")}
>
<ArrowDown className="h-3 w-3" />
</Button>
<div
key={widget.id}
className="border-border bg-background/30 flex items-center gap-3 rounded-md border p-3"
>
<div className="flex flex-col gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => move(widget.id, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("dashboard.moveUp")}
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => move(widget.id, 1)}
disabled={
index === arr.length - 1 || saveMutation.isPending
}
aria-label={t("dashboard.moveDown")}
>
<ArrowDown className="h-3 w-3" />
</Button>
</div>
<span className="flex-1 text-sm">{t(labelKey)}</span>
<Switch
checked={widget.visible}
onCheckedChange={(v) => toggle(widget.id, v)}
aria-label={t(labelKey)}
disabled={saveMutation.isPending}
/>
</div>
<span className="flex-1 text-sm">{t(labelKey)}</span>
<Switch
checked={widget.visible}
onCheckedChange={(v) => toggle(widget.id, v)}
aria-label={t(labelKey)}
disabled={saveMutation.isPending}
/>
</div>
);
})}
</div>
+5 -3
View File
@@ -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<DashboardLayout>;
if (
+14 -16
View File
@@ -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<string, string> = {
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<string, string | null> = {
@@ -84,6 +87,8 @@ const TYPE_UNIT_KEYS: Record<string, string | null> = {
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
TOTAL_BODY_WATER: "kg",
BONE_MASS: "kg",
};
const MOOD_LABEL_KEYS: Record<number, string> = {
@@ -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()),
+9 -4
View File
@@ -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<string, string> = {
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<string, { min: number; max: number }> = {
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) ||
+2
View File
@@ -127,6 +127,8 @@ const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }> = {
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 {