diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 278f68243..45d5e3f4a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2892, - "total": 9216, + "total": 9213, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -643,7 +643,7 @@ "app/insights/walking-heart-rate/page.tsx": 1, "app/insights/walking-speed/page.tsx": 1, "app/insights/walking-steadiness/page.tsx": 1, - "app/insights/weight/page.tsx": 8, + "app/insights/weight/page.tsx": 5, "app/insights/workouts/page-client.tsx": 2, "app/insights/wrist-temperature/page.tsx": 1, "app/invite/[token]/__tests__/page.test.tsx": 1, diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx new file mode 100644 index 000000000..b0f342bc4 --- /dev/null +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -0,0 +1,80 @@ +/** + * `/insights/bmi` decides from a height whether it can show anything. It used + * the height of the person at the keyboard, so inside somebody else's record + * a viewer without a height of their own was asked to set it, and a viewer + * with one hid that the record has none. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const shellProps = vi.fn(); +vi.mock("@/components/insights/sub-page-shell", () => ({ + SubPageShell: (props: Record) => { + shellProps(props); + return null; + }, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key, locale: "en" }), +})); + +const state = { + viewerHeight: null as number | null, + inSharedRecord: true, + recordHeight: 118 as number | null, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { heightCm: state.viewerHeight, timezone: "UTC" }, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }), +})); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => ({ isEmpty: false }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ enabled }: { enabled?: boolean }) => ({ + data: + enabled === false + ? undefined + : { profile: { heightCm: state.recordHeight } }, + }), +})); + +import InsightsBmiPage from "../page"; + +/** True when the page rendered its "set your height" state. */ +function asksForHeight(): boolean { + renderToStaticMarkup(); + const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean }; + // The chart branch launches the Coach from the shell; the empty states do not. + return props.coachLaunch !== true; +} + +describe("/insights/bmi height gate", () => { + beforeEach(() => { + shellProps.mockClear(); + state.viewerHeight = null; + state.inSharedRecord = true; + state.recordHeight = 118; + }); + + it("reads the record's height inside somebody else's record", () => { + expect(asksForHeight()).toBe(false); + state.viewerHeight = 180; + state.recordHeight = null; + expect(asksForHeight()).toBe(true); + }); + + it("reads the account's own height in one's own record", () => { + state.inSharedRecord = false; + state.viewerHeight = 180; + expect(asksForHeight()).toBe(false); + state.viewerHeight = null; + expect(asksForHeight()).toBe(true); + }); +}); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 735c4d7ea..cc6db7390 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -1,9 +1,13 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { Ruler } from "lucide-react"; 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"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; @@ -45,6 +49,23 @@ export default function InsightsBmiPage() { const { isEmpty } = useInsightsAnalytics("BMI"); + // `useAuth()` is the person at the keyboard. Inside somebody else's record + // the height that decides is the record's, from the route that knows which + // record is on screen (the reference panel below reads the same cell). + const { inSharedRecord } = useRecordCapabilities(); + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => + apiGet<{ profile?: { heightCm: number | null } }>( + "/api/insights/targets", + ), + enabled: isAuthenticated && inSharedRecord, + }); + const heightCm = inSharedRecord + ? (targets?.profile?.heightCm ?? null) + : (user?.heightCm ?? null); + const heightKnown = !inSharedRecord || targets !== undefined; + // v1.4.27 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute // anything useful either; surface the empty-state CTA pointing at @@ -75,7 +96,7 @@ export default function InsightsBmiPage() { ); } - if (!user?.heightCm) { + if (heightKnown && !heightCm) { return ( ({ + SubPageShell: (props: Record) => { + shellProps(props); + return null; + }, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key, locale: "en" }), +})); + +// The viewer: 180 cm, signed in, looking at somebody else's record. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" }, + }), + useAccountOnceMounted: () => null, +})); + +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => ({ + data: { summaries: { WEIGHT: { count: 10 } } }, + isLoading: false, + isEmpty: false, + }), +})); + +// The record on screen: 118 cm, no target of its own. +const targets = { + current: { + targets: [ + { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + ], + profile: { heightCm: 118 }, + } as unknown, +}; +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({ + data: JSON.stringify(queryKey).includes("targets") + ? targets.current + : undefined, + isLoading: false, + error: null, + }), +})); + +import InsightsGewichtPage from "../page"; +import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; + +function chartBands(): Array<{ min: number; max: number }> | undefined { + renderToStaticMarkup(); + const props = shellProps.mock.calls.at(-1)![0] as { children: unknown }; + const children = Array.isArray(props.children) + ? props.children + : [props.children]; + const chart = children.find( + (el) => + el && + typeof el === "object" && + Array.isArray((el as { props?: { types?: unknown } }).props?.types), + ) as { props: { valueBands?: Array<{ min: number; max: number }> } }; + return chart.props.valueBands; +} + +describe("/insights/weight band", () => { + beforeEach(() => shellProps.mockClear()); + + it("shades the WHO band for the record's height, not the viewer's", () => { + const bands = chartBands(); + const record = buildWeightRangeFromHeight(118); + const viewer = buildWeightRangeFromHeight(180); + expect(bands).toContainEqual( + expect.objectContaining({ min: record.greenMin, max: record.greenMax }), + ); + expect(bands).not.toContainEqual( + expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }), + ); + }); + + it("shades no band when the record's targets are not readable", () => { + targets.current = undefined; + try { + expect(chartBands()).toBeUndefined(); + } finally { + targets.current = { + targets: [], + profile: { heightCm: 118 }, + }; + } + }); +}); diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts new file mode 100644 index 000000000..0a4da57a5 --- /dev/null +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -0,0 +1,46 @@ +/** + * The weight band belongs to the record on screen. It used to be built from + * the signed-in person's height, so a parent of 180 cm looking at a child's + * record saw a healthy band of about 60 to 81 kg behind the child's weights. + */ +import { describe, expect, it } from "vitest"; + +import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; +import { selectWeightBandRange } from "../weight-band"; + +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" }, + ], + profile: { heightCm: 118 }, + }); + expect(range).toEqual(buildWeightRangeFromHeight(118)); + }); + + it("paints the record's own target with the override wings", () => { + const range = selectWeightBandRange({ + targets: [ + { type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" }, + ], + profile: { heightCm: 180 }, + }); + expect(range).toEqual({ + greenMin: 70, + greenMax: 80, + orangeMin: 68.5, + orangeMax: 81.5, + }); + }); + + it("paints nothing without a target or a height, or before the read lands", () => { + expect( + selectWeightBandRange({ + targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }], + profile: { heightCm: null }, + }), + ).toBeNull(); + expect(selectWeightBandRange(null)).toBeNull(); + }); +}); diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c5ac87fff..b6685072a 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -23,11 +23,8 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { - buildBandsFromTrafficRange, - buildWeightBandsFromHeight, - type TrafficRange, -} from "@/lib/analytics/value-bands"; +import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; +import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band"; /** * v1.4.25 — `/insights/weight`. @@ -37,8 +34,8 @@ import { * (`chartKey="weight"`) lets the user toggle trend lines + comparison * overlay independently from the dashboard weight card. * - * v1.34 — the bands follow the user's own weight target when one is set, - * and fall back to the height-derived WHO band only when it is not. + * The bands follow the record's own weight target when one is set, and fall + * back to the WHO band for the record's height only when it is not. * * v1.4.28 — analytics fetch + empty-state * branch now consume `useInsightsAnalytics()` + ``. @@ -56,19 +53,13 @@ export default function InsightsGewichtPage() { } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; - // v1.34 — the user's own weight target, read through the shared - // `queryKeys.userThresholds()` cell the targets editor also subscribes to, so - // editing the target repaints this chart's band without a reload. - const { data: thresholds } = useQuery({ - queryKey: queryKeys.userThresholds(), - queryFn: async () => { - return apiGet<{ - effective: Record< - string, - { range: TrafficRange | null; isOverride: boolean } - >; - }>("/api/user/thresholds"); - }, + // The record's own target and height, from the route that knows which + // record is on screen (see `selectWeightBandRange`). Shares the + // `insightsTargets()` cell with the reference panel below; the targets + // editor invalidates it on save, so the band repaints without a reload. + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => apiGet("/api/insights/targets"), enabled: isAuthenticated, }); @@ -136,27 +127,14 @@ export default function InsightsGewichtPage() { ); } - // v1.34 — the shaded zone follows the user's OWN target when they set one. - // The page used to shade the height-derived WHO band unconditionally, so - // someone who had entered a target on `/targets` kept reading a chart that - // ignored the answer. `/api/user/thresholds` already resolves the override - // (with its orange wings) through `getEffectiveRange`, so the page reads the - // resolved band rather than re-deriving one. No target and no height → no - // bands, unchanged. - const overrideRange = thresholds?.effective?.WEIGHT?.isOverride - ? (thresholds.effective.WEIGHT.range ?? null) - : null; - const weightBands = overrideRange - ? buildBandsFromTrafficRange(overrideRange, { + // No target and no height → no bands. + const weightRange = selectWeightBandRange(targets ?? null); + const weightBands = weightRange + ? buildBandsFromTrafficRange(weightRange, { lowerBound: 30, upperBound: 250, }) - : user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { - lowerBound: 30, - upperBound: 250, - }) - : undefined; + : undefined; // The bands are kg bounds → scale into the display unit so the shaded zone // tracks the converted line (factor-only; weight carries no offset). const displayBands = diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts new file mode 100644 index 000000000..0c645f8c6 --- /dev/null +++ b/src/app/insights/weight/weight-band.ts @@ -0,0 +1,43 @@ +import { getEffectiveRange } from "@/lib/analytics/effective-range"; +import { + buildWeightRangeFromHeight, + type TrafficRange, +} from "@/lib/analytics/value-bands"; + +/** The slice of `GET /api/insights/targets` the weight band needs. */ +export interface WeightTargetsSlice { + targets: ReadonlyArray<{ + type: string; + range: { min: number; max: number } | null; + source: string; + }>; + profile?: { heightCm: number | null }; +} + +/** + * The weight band for the record on screen, as the server resolved it. + * + * The page used to read the person at the keyboard: their height from + * `useAuth()` and their own target from `/api/user/thresholds`, which + * refuses inside somebody else's record. A parent on a child's record saw a + * band built from the parent's height. `/api/insights/targets` resolves the + * 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. + */ +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) { + return getEffectiveRange( + "WEIGHT", + { heightCm: null, dateOfBirth: null, gender: null }, + { WEIGHT: weight.range }, + ).range; + } + const heightCm = data.profile?.heightCm ?? null; + return heightCm ? buildWeightRangeFromHeight(heightCm) : null; +} diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index 78ad13403..db226e1d0 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/insights/weight", })); +// The target gear asks whether this is somebody else's record; the shell +// renders here without the session it would read that from. +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); + /** * v1.4.25 — `` unit tests. * diff --git a/src/components/insights/__tests__/target-adjust-button.test.tsx b/src/components/insights/__tests__/target-adjust-button.test.tsx index 758eb1cce..8a3a3387d 100644 --- a/src/components/insights/__tests__/target-adjust-button.test.tsx +++ b/src/components/insights/__tests__/target-adjust-button.test.tsx @@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({ useTargetAdjust: () => useTargetAdjustMock(), })); +const capabilities = { inSharedRecord: false }; +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => capabilities, +})); + const { TargetAdjustButton } = await import("../target-adjust-button"); function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -53,6 +58,22 @@ describe("", () => { expect(html).toBe(""); }); + it("renders nothing inside somebody else's record", () => { + // The editor reads and writes the signed-in account's own targets, which + // the server refuses under a switch: the gear would open a dead sheet. + useTargetAdjustMock.mockReturnValue({ + canAdjust: true, + requestAdjust: vi.fn(), + register: vi.fn(), + }); + capabilities.inSharedRecord = true; + try { + expect(render()).toBe(""); + } finally { + capabilities.inSharedRecord = false; + } + }); + it("renders the gear once an editable target is registered", () => { useTargetAdjustMock.mockReturnValue({ canAdjust: true, diff --git a/src/components/insights/target-adjust-button.tsx b/src/components/insights/target-adjust-button.tsx index bcb8df982..c8bff7a46 100644 --- a/src/components/insights/target-adjust-button.tsx +++ b/src/components/insights/target-adjust-button.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; /** * Header gear that opens the per-metric target editor. @@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; * has registered for the page (e.g. a metric without a numeric * target band, where the card paints nothing), so the header never * shows a dead control; + * - renders nothing inside somebody else's record: the editor loads and + * saves the signed-in account's own targets (`/api/user/thresholds`), + * which refuses under a switch, so the gear would open a sheet that + * can do neither; * - opens the primary registered target's `` on click. * * Sized to match `` so the gear + the @@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; export function TargetAdjustButton({ className }: { className?: string }) { const { t } = useTranslations(); const adjust = useTargetAdjust(); + const { inSharedRecord } = useRecordCapabilities(); - // No provider, or no editable target registered for this page. - if (!adjust || !adjust.canAdjust) return null; + // No provider, no editable target registered for this page, or a record + // whose targets this editor cannot reach. + if (!adjust || !adjust.canAdjust || inSharedRecord) return null; // Reuse the action's existing copy ("Adjust target range" / // "Zielbereich anpassen") — the gear performs exactly what the old