mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
59448d473c
commit
11ed6faedd
11 files changed
+119
-64
No files matched your search
@@ -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)",
|
||||
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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) ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user