mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(targets): no adult weight and BMI bands on a child's record
Now that the BMI chart draws inside a managed record, a child's normal BMI sat in the red under-17 band, and the weight page painted the adult WHO band (about 26 to 35 kg for 118 cm), so a healthy 21 kg read as underweight. The release's own weight-band test pinned that band. No band here knows a child's age yet. For a record under 18, or a managed record with no date of birth, the targets read names no WHO weight range, no BMI range and no category; the record's own weight target still shows, and the values and the chart stay. The BMI chart and the weight band draw only what the server names. The weight-band test now uses an adult height and gains the child case. The targets builder and the vitals builder grow by two lines each for the flag; their size baselines are raised to match.
This commit is contained in:
1 parent
9683daf4f2
commit
ce01d455be
8 files changed
+117
-19
No files matched your search
@@ -331,8 +331,8 @@
|
||||
"lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261,
|
||||
"lib/strava/sync.ts :: runStravaSync": 219,
|
||||
"lib/support/report.ts :: collectSupportReportFacts": 332,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 330,
|
||||
"lib/targets/vitals-builder.ts :: buildVitalTargets": 324,
|
||||
"lib/targets/build-response.ts :: buildTargetsResponse": 332,
|
||||
"lib/targets/vitals-builder.ts :: buildVitalTargets": 326,
|
||||
"lib/telegram-webhook-handlers.ts :: handleCallback": 582,
|
||||
"lib/telegram-webhook-handlers.ts :: handleTextMessage": 322,
|
||||
"lib/withings/sync-activity.ts :: syncUserActivity": 377,
|
||||
|
||||
@@ -23,6 +23,7 @@ const state = {
|
||||
viewerHeight: null as number | null,
|
||||
inSharedRecord: true,
|
||||
recordHeight: 118 as number | null,
|
||||
bmiRange: { min: 18.5, max: 24.9 } as { min: number; max: number } | null,
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
@@ -41,7 +42,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
data:
|
||||
enabled === false
|
||||
? undefined
|
||||
: { profile: { heightCm: state.recordHeight } },
|
||||
: {
|
||||
targets: [{ type: "BMI", range: state.bmiRange }],
|
||||
profile: { heightCm: state.recordHeight },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -61,6 +65,7 @@ describe("/insights/bmi height gate", () => {
|
||||
state.viewerHeight = null;
|
||||
state.inSharedRecord = true;
|
||||
state.recordHeight = 118;
|
||||
state.bmiRange = { min: 18.5, max: 24.9 };
|
||||
});
|
||||
|
||||
it("reads the record's height inside somebody else's record", () => {
|
||||
@@ -77,4 +82,20 @@ describe("/insights/bmi height gate", () => {
|
||||
state.viewerHeight = null;
|
||||
expect(asksForHeight()).toBe(true);
|
||||
});
|
||||
|
||||
it("draws the adult bands only where the record's BMI range is named", () => {
|
||||
const chartBands = () => {
|
||||
renderToStaticMarkup(<InsightsBmiPage />);
|
||||
const { children } = shellProps.mock.calls.at(-1)![0] as {
|
||||
children: Array<{ props?: { valueBands?: unknown } }>;
|
||||
};
|
||||
return children.find(
|
||||
(child) => child?.props && "valueBands" in child.props,
|
||||
)?.props?.valueBands;
|
||||
};
|
||||
expect(chartBands()).toBeDefined();
|
||||
// A child's record: the server names no BMI range.
|
||||
state.bmiRange = null;
|
||||
expect(chartBands()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -56,15 +56,21 @@ export default function InsightsBmiPage() {
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: () =>
|
||||
apiGet<{ profile?: { heightCm: number | null } }>(
|
||||
"/api/insights/targets",
|
||||
),
|
||||
enabled: isAuthenticated && inSharedRecord,
|
||||
apiGet<{
|
||||
targets: ReadonlyArray<{ type: string; range: unknown }>;
|
||||
profile?: { heightCm: number | null };
|
||||
}>("/api/insights/targets"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const heightCm = inSharedRecord
|
||||
? (targets?.profile?.heightCm ?? null)
|
||||
: (user?.heightCm ?? null);
|
||||
const heightKnown = !inSharedRecord || targets !== undefined;
|
||||
// The adult bands only where the server names the BMI range: it names none
|
||||
// for a child's record, which they do not describe.
|
||||
const bmiBands = targets?.targets.find((entry) => entry.type === "BMI")?.range
|
||||
? BMI_BANDS
|
||||
: undefined;
|
||||
|
||||
// v1.4.27 — BMI is derived from WEIGHT. When no weight readings
|
||||
// exist yet, the existing "set your height" branch can never compute
|
||||
@@ -140,7 +146,7 @@ export default function InsightsBmiPage() {
|
||||
colors={["var(--chart-5)"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={BMI_BANDS}
|
||||
valueBands={bmiBands}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
showDataTable
|
||||
|
||||
@@ -12,11 +12,22 @@ describe("selectWeightBandRange", () => {
|
||||
it("builds the WHO band from the record's height", () => {
|
||||
const range = selectWeightBandRange({
|
||||
targets: [
|
||||
{ type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" },
|
||||
{ type: "WEIGHT", range: { min: 59.9, max: 81 }, source: "WHO BMI" },
|
||||
],
|
||||
profile: { heightCm: 118 },
|
||||
profile: { heightCm: 180 },
|
||||
});
|
||||
expect(range).toEqual(buildWeightRangeFromHeight(118));
|
||||
expect(range).toEqual(buildWeightRangeFromHeight(180));
|
||||
});
|
||||
|
||||
it("paints no adult band behind a child's weights", () => {
|
||||
// A 118 cm child: the server names no WHO range for a record under 18,
|
||||
// where the adult band read a normal 21 kg as underweight.
|
||||
expect(
|
||||
selectWeightBandRange({
|
||||
targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }],
|
||||
profile: { heightCm: 118 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("paints the record's own target with the override wings", () => {
|
||||
|
||||
@@ -24,14 +24,16 @@ export interface WeightTargetsSlice {
|
||||
* record's own target and height, and the reference panel under the chart
|
||||
* reads the same payload. The record's own target (`source: "Custom"`) gets
|
||||
* the wings `getEffectiveRange` gives every override; otherwise the band is
|
||||
* the height-derived WHO one, and with neither there is none.
|
||||
* the height-derived WHO one. Where the server names no range there is none:
|
||||
* no target and no height, or a child's record, which the adult WHO band does
|
||||
* not describe.
|
||||
*/
|
||||
export function selectWeightBandRange(
|
||||
data: WeightTargetsSlice | null,
|
||||
): TrafficRange | null {
|
||||
if (!data) return null;
|
||||
const weight = data.targets.find((entry) => entry.type === "WEIGHT");
|
||||
if (weight?.source === "Custom" && weight.range) {
|
||||
const weight = data?.targets.find((entry) => entry.type === "WEIGHT");
|
||||
if (!data || !weight?.range) return null;
|
||||
if (weight.source === "Custom") {
|
||||
return getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
|
||||
@@ -274,3 +274,50 @@ describe("buildVitalTargets — user weight target", () => {
|
||||
expect(weight?.source).toBe("Custom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildVitalTargets — a child's record", () => {
|
||||
// 118 cm and 21 kg: a BMI of about 15, normal for a six-year-old and red
|
||||
// under the adult cut-offs.
|
||||
const child = {
|
||||
recentMeasurements: [],
|
||||
restingPulseProxy: [],
|
||||
latestByType: { WEIGHT: 21 },
|
||||
average30ByType: { WEIGHT: 21 },
|
||||
heightCm: 118,
|
||||
age: 6,
|
||||
gender: null,
|
||||
timezone: TZ,
|
||||
now: NOW,
|
||||
weightTargetOverride: null,
|
||||
adultBands: false,
|
||||
};
|
||||
const card = (
|
||||
targets: ReturnType<typeof buildVitalTargets>["targets"],
|
||||
type: string,
|
||||
) => targets.find((t) => t.type === type);
|
||||
|
||||
it("keeps the values and names no adult band or category", () => {
|
||||
const { targets } = buildVitalTargets(child);
|
||||
expect(card(targets, "WEIGHT")).toMatchObject({
|
||||
current: 21,
|
||||
range: null,
|
||||
classification: null,
|
||||
});
|
||||
expect(card(targets, "BMI")).toMatchObject({
|
||||
current: 15.1,
|
||||
range: null,
|
||||
classification: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the record's own weight target", () => {
|
||||
const { targets } = buildVitalTargets({
|
||||
...child,
|
||||
weightTargetOverride: { min: 19, max: 24 },
|
||||
});
|
||||
expect(card(targets, "WEIGHT")).toMatchObject({
|
||||
range: { min: 19, max: 24 },
|
||||
source: "Custom",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -116,6 +116,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
managedProfileAt: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
hasDiabetes: true,
|
||||
@@ -274,6 +275,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
average30ByType,
|
||||
heightCm,
|
||||
age,
|
||||
adultBands: age != null ? age >= 18 : dbUser?.managedProfileAt == null,
|
||||
gender,
|
||||
timezone,
|
||||
now,
|
||||
|
||||
@@ -74,6 +74,13 @@ interface VitalTargetsInput {
|
||||
* names a category, not a goal. Absent or `null` keeps the age band.
|
||||
*/
|
||||
bpTargetOverride?: BloodPressureTargetRange | null;
|
||||
/**
|
||||
* Whether the adult WHO weight and BMI cut-offs apply. No band here knows a
|
||||
* child's age yet, so for a record under 18, or a managed one with no date
|
||||
* of birth, the weight card keeps only the record's own target, the BMI
|
||||
* card names no band, and neither names a category. Absent means they do.
|
||||
*/
|
||||
adultBands?: boolean;
|
||||
}
|
||||
|
||||
export interface VitalTargetsResult {
|
||||
@@ -170,6 +177,7 @@ export function buildVitalTargets({
|
||||
now,
|
||||
weightTargetOverride,
|
||||
bpTargetOverride = null,
|
||||
adultBands = true,
|
||||
}: VitalTargetsInput): VitalTargetsResult {
|
||||
const targets: TargetItem[] = [];
|
||||
const consistencyClock = { timezone, now };
|
||||
@@ -181,10 +189,11 @@ export function buildVitalTargets({
|
||||
// v1.34 — a user-set target outranks the WHO BMI band. The BMI
|
||||
// classification below stays WHO-derived either way: it names which BMI
|
||||
// category a reading falls in, which is a reference fact and not a goal.
|
||||
const whoHeightCm = adultBands ? heightCm : null;
|
||||
const weightRange =
|
||||
weightTargetOverride ?? (heightCm ? getWeightRange(heightCm) : null);
|
||||
weightTargetOverride ?? (whoHeightCm ? getWeightRange(whoHeightCm) : null);
|
||||
let weightClassification: TargetItem["classification"] = null;
|
||||
if (latestByType.WEIGHT != null && heightCm) {
|
||||
if (latestByType.WEIGHT != null && whoHeightCm) {
|
||||
const heightM = heightCm / 100;
|
||||
const classification = classifyBMI(
|
||||
latestByType.WEIGHT / (heightM * heightM),
|
||||
@@ -389,14 +398,14 @@ export function buildVitalTargets({
|
||||
? Math.round((averageWeight / heightSquared) * 10) / 10
|
||||
: null;
|
||||
let bmiClassification: TargetItem["classification"] = null;
|
||||
if (currentBmi != null) {
|
||||
if (currentBmi != null && adultBands) {
|
||||
const classification = classifyBMI(currentBmi);
|
||||
bmiClassification = {
|
||||
category: classification.category,
|
||||
color: classification.color,
|
||||
};
|
||||
}
|
||||
const bmiRange = { min: 18.5, max: 24.9 };
|
||||
const bmiRange = adultBands ? { min: 18.5, max: 24.9 } : null;
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
|
||||
Reference in new issue
Block a user