fix(measurements): offer every body-composition reading in the manual form (#1013)

The manual form keeps a hand-written type list, and the server's grew
past it: visceral fat, fat mass, fat-free mass, lean body mass and
muscle mass all had units and plausibility ranges on the server and
arrived from scales, but had no row to type a reading into. They have
rows now, with the scale's kg and lb units and a "rating" unit for
visceral fat in every language.

A new test derives the body-composition family from the server's own
categories and fails when a type in it is neither offered by the form
nor named with the reason it is left out (BMI, which is computed from
weight and height). It also checks each row's unit and placeholder
against the server.

Listing the four side by side exposed labels that named two different
readings with one word in French, Italian and Polish (body fat and fat
mass, fat-free and lean mass). Each now has its own name, which also
makes them resolvable by name for the MCP reads that used to refuse the
ambiguous words.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 08:02:11 +02:00
1 parent cb12882aff
commit e0b547a430
11 files changed
+185 -33

No files matched your search

+1
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Taille-Größe-Verhältnis",
"unitScore": "Pkt.",
"unitRatio": "Verhältnis",
"unitRating": "Stufe",
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert"
},
+1
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Waist-to-height ratio",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "rating",
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score"
},
+1
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Índice cintura-altura",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "nivel",
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI"
},
+3 -2
View File
@@ -619,7 +619,7 @@
"typeBpSys": "Sys",
"typeBpDia": "Dia",
"typePulse": "Pouls",
"typeBodyFat": "Masse grasse",
"typeBodyFat": "Graisse corporelle",
"typeSleep": "Sommeil",
"typeSteps": "Pas",
"typeBloodGlucose": "Glycémie",
@@ -633,7 +633,7 @@
"typeWalkingRunningDistance": "Distance à pied et en course",
"typeVo2Max": "VO₂ max",
"typeBodyTemperature": "Température corporelle",
"typeFatFreeMass": "Masse maigre",
"typeFatFreeMass": "Masse non grasse",
"typeFatMass": "Masse grasse",
"typeMuscleMass": "Masse musculaire",
"typeSkinTemperature": "Température cutanée",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Rapport taille-taille",
"unitScore": "pts",
"unitRatio": "ratio",
"unitRating": "niveau",
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI"
},
+3 -2
View File
@@ -619,7 +619,7 @@
"typeBpSys": "Sis",
"typeBpDia": "Dia",
"typePulse": "Polso",
"typeBodyFat": "Massa grassa",
"typeBodyFat": "Grasso corporeo",
"typeSleep": "Sonno",
"typeSteps": "Passi",
"typeBloodGlucose": "Glicemia",
@@ -633,7 +633,7 @@
"typeWalkingRunningDistance": "Distanza a piedi e di corsa",
"typeVo2Max": "VO₂ max",
"typeBodyTemperature": "Temperatura corporea",
"typeFatFreeMass": "Massa magra",
"typeFatFreeMass": "Massa libera da grasso",
"typeFatMass": "Massa grassa",
"typeMuscleMass": "Massa muscolare",
"typeSkinTemperature": "Temperatura cutanea",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Rapporto vita-altezza",
"unitScore": "pti",
"unitRatio": "rapporto",
"unitRating": "livello",
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI"
},
+1
View File
@@ -760,6 +760,7 @@
"typeWaistToHeight": "허리-키 비율",
"unitScore": "점",
"unitRatio": "비율",
"unitRating": "단계",
"typeWho5Score": "WHO-5 점수",
"typeSciScore": "SCI 점수"
},
+2 -1
View File
@@ -647,7 +647,7 @@
"typeAudioExposureEvent": "Zdarzenie głośnego słuchania",
"typeRespiratoryRate": "Częstość oddechów",
"typeBodyMassIndex": "Wskaźnik masy ciała",
"typeLeanBodyMass": "Masa beztłuszczowa",
"typeLeanBodyMass": "Szczupła masa ciała",
"typeWalkingHeartRateAverage": "Tętno przy chodzeniu (śr.)",
"typeWalkingAsymmetry": "Asymetria chodu",
"typeWalkingDoubleSupport": "Podwójna podpora chodu",
@@ -760,6 +760,7 @@
"typeWaistToHeight": "Stosunek talii do wzrostu",
"unitScore": "pkt",
"unitRatio": "stosunek",
"unitRating": "poziom",
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI"
},
@@ -0,0 +1,112 @@
/**
* The manual-entry form offers every body-composition reading the server
* accepts from a person (#1013).
*
* The form keeps a hand-written type list, and the server's list of
* measurement types grew past it: visceral fat, fat mass, fat-free mass and
* lean body mass all arrived from scales and Apple Health, with units and
* plausibility ranges, while a person reading the same number off a scale's
* display had no row to type it into. Nothing noticed, because nothing
* compared the two.
*
* The comparison is derived from the server's own grouping
* (`MEASUREMENT_CATEGORIES`, the "body" family), so a type added to that
* family later fails here until it is either given a form row or named below
* with the reason a person would not type it.
*/
import { describe, it, expect } from "vitest";
import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
import { VALUE_RANGES, getUnitForType } from "@/lib/validations/measurement";
import { hasDisplayTransform } from "@/lib/measurements/display-transform";
import { parseDecimalEntry } from "@/lib/measurements/entry-units";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MEASUREMENT_TYPES,
} from "@/components/measurements/measurement-form";
import en from "../../../../messages/en.json";
/**
* Body-composition types deliberately absent from the manual form, each with
* the reason. Keep this list short and honest: "a scale reports it" is a
* reason to be in the form, not out of it.
*/
const NOT_HAND_ENTERED: Readonly<Record<string, string>> = {
BODY_MASS_INDEX:
"Derived from weight and height. A typed copy would be a second number that can disagree with the weight it came from.",
};
const BODY_COMPOSITION = [...MEASUREMENT_CATEGORIES.entries()]
.filter(([, category]) => category === "body")
.map(([type]) => type as string);
function translate(key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
en,
);
}
describe("manual measurement form — body composition", () => {
it("reads a non-empty body family off the server's categories", () => {
// A guard over an empty set passes for the wrong reason.
expect(BODY_COMPOSITION.length).toBeGreaterThan(5);
expect(BODY_COMPOSITION).toContain("VISCERAL_FAT");
});
it.each(BODY_COMPOSITION)(
"%s is offered in the form or excluded with a reason",
(type) => {
const inForm = MEASUREMENT_FORM_TYPE_VALUES.includes(type);
const excluded = type in NOT_HAND_ENTERED;
expect(
inForm !== excluded,
`${type}: add a form row, or name it in NOT_HAND_ENTERED with a reason (and never both)`,
).toBe(true);
},
);
it("excludes only real body-composition types, each for a stated reason", () => {
for (const [type, reason] of Object.entries(NOT_HAND_ENTERED)) {
expect(BODY_COMPOSITION, `${type} is not a body type`).toContain(type);
expect(reason.trim().length).toBeGreaterThan(20);
}
});
const formBodyRows = MEASUREMENT_TYPES.filter((row) =>
BODY_COMPOSITION.includes(row.value),
);
it.each(formBodyRows.map((row) => [row.value, row] as const))(
"%s asks in the unit the server stores, with a label in every place it needs one",
(type, row) => {
const canonical = getUnitForType(type);
expect(canonical).not.toBe("unknown");
if ("unit" in row && !hasDisplayTransform(type)) {
expect(row.unit).toBe(canonical);
}
if ("unitKey" in row) {
expect(typeof translate(row.unitKey)).toBe("string");
}
expect(typeof translate(row.labelKey)).toBe("string");
},
);
it.each(formBodyRows.map((row) => [row.value, row] as const))(
"%s suggests a placeholder the server would accept",
(type, row) => {
const range = VALUE_RANGES[type];
expect(range, `${type} has no plausibility range`).toBeDefined();
const placeholder =
"placeholder" in row ? parseDecimalEntry(row.placeholder) : null;
expect(placeholder).not.toBeNull();
expect(placeholder!).toBeGreaterThanOrEqual(range.min);
expect(placeholder!).toBeLessThanOrEqual(range.max);
},
);
});
@@ -47,7 +47,7 @@ import {
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
const MEASUREMENT_TYPES = [
export const MEASUREMENT_TYPES = [
{
value: "BLOOD_PRESSURE",
labelKey: "measurements.typeBloodPressure",
@@ -104,6 +104,46 @@ const MEASUREMENT_TYPES = [
placeholder: "3.2",
placeholderImperial: "7",
},
// The rest of what a body-composition scale reports. The server has
// accepted all of these since the Withings and Apple Health work; only this
// list lagged, so a reading typed off the scale's display had no row. The
// guard in `measurement-form-body-composition.test.ts` keeps the two lists
// from drifting again.
{
value: "FAT_MASS",
labelKey: "measurements.typeFatMass",
unit: "kg",
placeholder: "18",
placeholderImperial: "40",
},
{
value: "FAT_FREE_MASS",
labelKey: "measurements.typeFatFreeMass",
unit: "kg",
placeholder: "58",
placeholderImperial: "128",
},
{
value: "LEAN_BODY_MASS",
labelKey: "measurements.typeLeanBodyMass",
unit: "kg",
placeholder: "55",
placeholderImperial: "121",
},
{
value: "MUSCLE_MASS",
labelKey: "measurements.typeMuscleMass",
unit: "kg",
placeholder: "52",
placeholderImperial: "115",
},
{
// A scale's own rating (Withings prints 1-12), not a percent.
value: "VISCERAL_FAT",
labelKey: "measurements.typeVisceralFat",
unitKey: "measurements.unitRating",
placeholder: "7",
},
{
value: "OXYGEN_SATURATION",
labelKey: "measurements.typeOxygenSaturation",
+11 -20
View File
@@ -278,21 +278,16 @@ describe("resolveRichMetric — localised metric names", () => {
});
// Body fat, fat mass, fat-free mass and lean body mass are four distinct
// measurements that the Romance and Polish bundles collapse into two words:
// "masse grasse" / "massa grassa" cover the first pair, "masse maigre" /
// "massa magra" / "masa beztłuszczowa" the second. The index drops a word
// two metrics claim rather than picking one — answering kilograms to
// someone who asked for a percentage is worse than saying the word was not
// recognised. Pinned, so a NEW collision fails here instead of silently
// resolving to whichever entry happened to be built first.
// measurements, and the French, Italian and Polish bundles used to collapse
// them into two words each. The index drops a word two metrics claim rather
// than picking one — answering kilograms to someone who asked for a
// percentage is worse than saying the word was not recognised. The labels
// now name four different things in every language (the manual entry form
// lists all four side by side, where two identical names were unusable), so
// the set is empty and pinned empty: a NEW collision fails here instead of
// silently resolving to whichever entry happened to be built first.
it("refuses a word two different metrics claim, and only those words", () => {
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([
"masa_beztluszczowa",
"massa_grassa",
"massa_magra",
"masse_grasse",
"masse_maigre",
]);
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
});
// The property, not a sample: over every metric and every shipped locale,
@@ -331,15 +326,11 @@ describe("resolveRichMetric — localised metric names", () => {
partial.push(`${type}: ${resolved.join(",")}`);
}
}
// The body-composition types whose bundles are genuinely ambiguous (see
// the collision guard above), plus the same three English-substring
// artefacts.
// Only the three English-substring artefacts: with no collisions left,
// every body-composition type is nameable in every language.
expect(partial.sort()).toEqual([
"BODY_TEMPERATURE_DEVIATION: en",
"BREATHING_DISTURBANCE_EVENT: en",
"FAT_FREE_MASS: de,en,es,ko",
"FAT_MASS: de,en,es,pl,ko",
"LEAN_BODY_MASS: de,en,es,ko",
"WALKING_STEADINESS_EVENT: en",
]);
});
+9 -7
View File
@@ -538,10 +538,12 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
//
// Every OTHER measurement type is still entered, value-less, because
// ambiguity has to be judged over the whole label space rather than over the
// exposed slice of it. French and Italian call body fat and fat mass by one
// phrase ("masse grasse" / "massa grassa"); only one of the two is exposed,
// so an index that knew about the exposed one alone would have seen no
// collision and answered kilograms to someone who asked for a percentage.
// exposed slice of it. French and Italian once called body fat and fat mass
// by one phrase ("masse grasse" / "massa grassa") while only one of the two
// was exposed, so an index that knew about the exposed one alone would have
// seen no collision and answered kilograms to someone who asked for a
// percentage. Those labels are distinct now; the whole-space check stays so a
// future bundle cannot reintroduce the trap unseen.
//
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
// `vo2max` are protocol slugs, not translations of anything.
@@ -568,9 +570,9 @@ const LOCALISED_METRIC_INDEX = (() => {
/**
* Words two different measurement types claim in some bundle, and which
* therefore resolve to neither. Exported so the guard can pin the set: the
* known members are a genuine translation collision, a NEW one is a
* regression to look at rather than something to resolve by coin flip.
* therefore resolve to neither. Exported so the guard can pin the set, which
* is empty: a NEW member is a regression to look at rather than something to
* resolve by coin flip.
*/
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
LOCALISED_METRIC_INDEX.ambiguous;