From e0b547a430bc26bfa1af4c156895177caa92a0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:02:11 +0200 Subject: [PATCH] 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. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 1 + messages/pl.json | 3 +- .../measurement-form-body-composition.test.ts | 112 ++++++++++++++++++ .../measurements/measurement-form.tsx | 42 ++++++- src/lib/mcp/__tests__/rich-reads.test.ts | 31 ++--- src/lib/mcp/rich-reads.ts | 16 +-- 11 files changed, 185 insertions(+), 33 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-form-body-composition.test.ts diff --git a/messages/de.json b/messages/de.json index 798c0da4a..093237aa9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Taille-Größe-Verhältnis", "unitScore": "Pkt.", "unitRatio": "Verhältnis", + "unitRating": "Stufe", "typeWho5Score": "WHO-5-Wert", "typeSciScore": "SCI-Wert" }, diff --git a/messages/en.json b/messages/en.json index 7edf5d6cc..05a471000 100644 --- a/messages/en.json +++ b/messages/en.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Waist-to-height ratio", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "rating", "typeWho5Score": "WHO-5 score", "typeSciScore": "SCI score" }, diff --git a/messages/es.json b/messages/es.json index dbeeb3ccb..f9ec4f110 100644 --- a/messages/es.json +++ b/messages/es.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "Índice cintura-altura", "unitScore": "pts", "unitRatio": "ratio", + "unitRating": "nivel", "typeWho5Score": "Puntuación WHO-5", "typeSciScore": "Puntuación SCI" }, diff --git a/messages/fr.json b/messages/fr.json index a000d2afb..5dc054e13 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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" }, diff --git a/messages/it.json b/messages/it.json index 7f56d945d..79af2a3a9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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" }, diff --git a/messages/ko.json b/messages/ko.json index 10c33002e..61b7b44db 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -760,6 +760,7 @@ "typeWaistToHeight": "허리-키 비율", "unitScore": "점", "unitRatio": "비율", + "unitRating": "단계", "typeWho5Score": "WHO-5 점수", "typeSciScore": "SCI 점수" }, diff --git a/messages/pl.json b/messages/pl.json index 5528f98ab..0d1c00460 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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" }, diff --git a/src/components/measurements/__tests__/measurement-form-body-composition.test.ts b/src/components/measurements/__tests__/measurement-form-body-composition.test.ts new file mode 100644 index 000000000..1e6fde4cf --- /dev/null +++ b/src/components/measurements/__tests__/measurement-form-body-composition.test.ts @@ -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> = { + 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( + (node, part) => + node && typeof node === "object" + ? (node as Record)[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); + }, + ); +}); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 5cc7e23e0..00edb1d9a 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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", diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 6fa043846..b23f44d6c 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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", ]); }); diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 566e5b81e..3893df9d8 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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 = LOCALISED_METRIC_INDEX.ambiguous;