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;