From 24cca975da765d936343ba675547647a158e0c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 21:43:07 +0100 Subject: [PATCH] fix(charts): draw the BMI of the record on screen Inside a shared or managed record the BMI chart drew nothing: it only knew the viewer's height, and dividing someone else's weights by it would have given a plausible wrong figure, so it divided by nothing. The chart now takes the record's height from /api/insights/targets, the cell the BMI page and the reference panel already read, through a small useBmiDivisor hook; in the viewer's own record it keeps using the account's height. --- .../charts/__tests__/use-bmi-divisor.test.ts | 59 +++++++++++++++++++ src/components/charts/health-chart.tsx | 12 +--- .../charts/health-chart/use-bmi-divisor.ts | 36 +++++++++++ 3 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/components/charts/__tests__/use-bmi-divisor.test.ts create mode 100644 src/components/charts/health-chart/use-bmi-divisor.ts diff --git a/src/components/charts/__tests__/use-bmi-divisor.test.ts b/src/components/charts/__tests__/use-bmi-divisor.test.ts new file mode 100644 index 000000000..1216d08eb --- /dev/null +++ b/src/components/charts/__tests__/use-bmi-divisor.test.ts @@ -0,0 +1,59 @@ +/** + * The BMI chart divides the weights on screen by the height of the record on + * screen. It used to know only the viewer's height, so inside another + * person's record it drew nothing at all. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + inSharedRecord: false, + recordHeightCm: 120 as number | null, + enabled: [] as boolean[], +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { heightCm: 180 } }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: h.inSharedRecord }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { enabled: boolean }) => { + h.enabled.push(options.enabled); + return options.enabled + ? { data: { profile: { heightCm: h.recordHeightCm } } } + : { data: undefined }; + }, +})); + +import { useBmiDivisor } from "../health-chart/use-bmi-divisor"; + +describe("useBmiDivisor", () => { + beforeEach(() => { + h.inSharedRecord = false; + h.recordHeightCm = 120; + h.enabled.length = 0; + }); + + it("uses the viewer's own height in their own record", () => { + expect(useBmiDivisor("bmi")).toBeCloseTo(3.24); + expect(h.enabled).toEqual([false]); + }); + + it("uses the record's height inside somebody else's record", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("bmi")).toBeCloseTo(1.44); + }); + + it("draws no BMI when the record has no height", () => { + h.inSharedRecord = true; + h.recordHeightCm = null; + expect(useBmiDivisor("bmi")).toBeNull(); + }); + + it("asks for nothing outside the BMI view", () => { + h.inSharedRecord = true; + expect(useBmiDivisor("raw")).toBeNull(); + expect(h.enabled).toEqual([false]); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 4761e40c6..49909cebf 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -19,7 +19,6 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { axisTickUnitSuffix } from "@/lib/charts/axis-unit"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; @@ -61,6 +60,7 @@ import { useHealthChartLabels, useHealthChartOverlays, } from "./health-chart/use-health-chart-overlays"; +import { useBmiDivisor } from "./health-chart/use-bmi-divisor"; import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; @@ -157,15 +157,7 @@ export function HealthChart({ // Viewport-aware tick density: 4 to 10 labels, whatever the point count. const viewportWidth = useViewportWidth(); - // `useAuth()` is the person at the keyboard, and under a switch that is the - // delegate: dividing the owner's weights by the delegate's height squared - // gives a plausible BMI that is somebody else's. Inside a shared record the - // chart draws no BMI rather than a wrong one. - const { inSharedRecord } = useRecordCapabilities(); - const bmiDivisor = - valueMode === "bmi" && !inSharedRecord && user?.heightCm - ? (user.heightCm / 100) ** 2 - : null; + const bmiDivisor = useBmiDivisor(valueMode); const { data, isLoading, isError, error, isPlaceholderData, refetch } = useHealthChartSeries({ diff --git a/src/components/charts/health-chart/use-bmi-divisor.ts b/src/components/charts/health-chart/use-bmi-divisor.ts new file mode 100644 index 000000000..520e7e21c --- /dev/null +++ b/src/components/charts/health-chart/use-bmi-divisor.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The BMI view's divisor: the height of the record on screen, in metres, + * squared. `useAuth()` is the person at the keyboard, and under a switch that + * is the delegate, so inside somebody else's record the height comes from + * `/api/insights/targets`, the route that knows which record is on screen + * (the BMI page and the reference panel read the same cell). No height, no + * BMI. + */ +export function useBmiDivisor( + valueMode: "raw" | "bmi" | undefined, +): number | null { + const { isAuthenticated, user } = useAuth(); + const { inSharedRecord } = useRecordCapabilities(); + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => + apiGet<{ profile?: { heightCm: number | null } }>( + "/api/insights/targets", + ), + enabled: isAuthenticated && valueMode === "bmi" && inSharedRecord, + }); + if (valueMode !== "bmi") return null; + const heightCm = inSharedRecord + ? (targets?.profile?.heightCm ?? null) + : (user?.heightCm ?? null); + return heightCm ? (heightCm / 100) ** 2 : null; +}