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

@@ -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>