From ff49bbb5fceac506c177ff06826e8acd48accc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:23:15 +0200 Subject: [PATCH] fix(insights): shade the SpO2 chart from the own target when one is set The oxygen saturation page drew a fixed clinical band, so a person with a lower target of their own saw their normal range shaded as a warning. The page now reads the resolved range from /api/user/thresholds and draws it; without a target the clinical bands stay as they were. Refs #1261 --- .../insights/oxygen/__tests__/page.test.tsx | 92 +++++++++++++++++++ src/app/insights/oxygen/page.tsx | 17 ++-- src/lib/analytics/value-bands.ts | 58 ++++++++++++ .../__tests__/use-own-target-range.test.ts | 30 ++++++ src/lib/queries/use-own-target-range.ts | 48 ++++++++++ 5 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 src/app/insights/oxygen/__tests__/page.test.tsx create mode 100644 src/lib/queries/__tests__/use-own-target-range.test.ts create mode 100644 src/lib/queries/use-own-target-range.ts diff --git a/src/app/insights/oxygen/__tests__/page.test.tsx b/src/app/insights/oxygen/__tests__/page.test.tsx new file mode 100644 index 000000000..ca7a6b1aa --- /dev/null +++ b/src/app/insights/oxygen/__tests__/page.test.tsx @@ -0,0 +1,92 @@ +/** + * The SpO₂ chart shades the person's own target when they set one on + * `/targets`, and the clinical bands otherwise. The page used to hand the + * chart a fixed 80 / 90 / 95 / 100 band, so a target of 88 to 92 % (COPD, + * altitude) left every reading of the person's normal range shaded as a + * warning. + * + * The scaffold is replaced by a probe that prints the bands it receives; the + * thresholds read is seeded straight into the shared query cell, which is the + * cell the target editor invalidates on save. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; +import type { ValueBand } from "@/lib/analytics/value-bands"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +vi.mock("@/components/insights/healthkit-metric-page", () => ({ + HealthKitMetricPage: (props: { valueBands?: ValueBand[] }) => ( +
{JSON.stringify(props.valueBands ?? null)}
+ ), +})); + +const { default: InsightsOxygenSaturationPage } = await import("../page"); + +function bandsRendered(thresholds: unknown): ValueBand[] { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + if (thresholds !== undefined) { + client.setQueryData(queryKeys.userThresholds(), thresholds); + } + const html = renderToStaticMarkup( + + + , + ); + const match = /]*>([^<]*)<\/pre>/.exec(html); + const json = (match?.[1] ?? "null").replaceAll(""", '"'); + return JSON.parse(json) as ValueBand[]; +} + +const green = (bands: ValueBand[]) => + bands.find((band) => band.color === "var(--success)"); + +describe("/insights/oxygen value bands", () => { + it("keeps the clinical bands without a personal target", () => { + const bands = bandsRendered({ + effective: { + OXYGEN_SATURATION: { + range: { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 }, + isOverride: false, + }, + }, + }); + expect(bands).toEqual([ + { min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 }, + { min: 90, max: 95, color: "var(--warning)", opacity: 0.18 }, + { min: 95, max: 100, color: "var(--success)", opacity: 0.18 }, + ]); + }); + + it("keeps the clinical bands while the thresholds read is still loading", () => { + expect(green(bandsRendered(undefined))).toEqual( + expect.objectContaining({ min: 95, max: 100 }), + ); + }); + + it("shades the person's own band when they set one", () => { + const bands = bandsRendered({ + effective: { + OXYGEN_SATURATION: { + range: { + greenMin: 88, + greenMax: 92, + orangeMin: 87.4, + orangeMax: 92.6, + }, + isOverride: true, + }, + }, + }); + expect(green(bands)).toEqual(expect.objectContaining({ min: 88, max: 92 })); + // Nothing is shaded above 100 %. + expect(Math.max(...bands.map((band) => band.max))).toBe(100); + }); +}); diff --git a/src/app/insights/oxygen/page.tsx b/src/app/insights/oxygen/page.tsx index 02ab4d397..f45c47a81 100644 --- a/src/app/insights/oxygen/page.tsx +++ b/src/app/insights/oxygen/page.tsx @@ -3,6 +3,8 @@ import { Wind } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands"; +import { useOwnTargetRange } from "@/lib/queries/use-own-target-range"; /** * v1.4.32 — `/insights/oxygen`. @@ -13,8 +15,12 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * compressing the trend. Withings ScanWatch + Apple Watch both * report SpO₂; the v1.4.25 source-priority picker keeps the surface * deduped. + * + * The shaded zone follows the person's own SpO₂ target when they set one on + * `/targets`, and the clinical bands otherwise. */ export default function InsightsOxygenSaturationPage() { + const ownRange = useOwnTargetRange("OXYGEN_SATURATION"); return ( } emptyStateCtaType={null} captureType="OXYGEN_SATURATION" diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 7b1d24d6d..4f7cf68db 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -186,3 +186,61 @@ export function getBodyFatTargetRange(gender: string | null | undefined): { // Gender-neutral fallback: midpoint of male/female fitness+acceptable. return { min: 17, max: 27 }; } + +/** + * Chart bands for oxygen saturation (%). + * + * Without a personal target the chart keeps its clinical shading: below 90 % + * the hypoxia floor, 90 to 95 % the watch zone, 95 to 100 % normal. With one + * (a COPD or high-altitude user living at 88 to 92 %, say) the green band is + * the person's own and the wings come from the resolved range, so the zone + * agrees with the `/targets` card. Saturation cannot exceed 100 %, so nothing + * is shaded above it; the floor stays at 80 % unless the person's own wing + * reaches lower. + */ +export function buildOxygenSaturationBands( + ownRange: TrafficRange | null, +): ValueBand[] { + if (!ownRange) { + return [ + { min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 }, + { min: 90, max: 95, color: "var(--warning)", opacity: 0.18 }, + { min: 95, max: 100, color: "var(--success)", opacity: 0.18 }, + ]; + } + const floor = Math.min(80, ownRange.orangeMin); + const ceiling = 100; + const bands: ValueBand[] = [ + { + min: floor, + max: ownRange.orangeMin, + color: "var(--destructive)", + opacity: 0.18, + }, + { + min: ownRange.orangeMin, + max: ownRange.greenMin, + color: "var(--warning)", + opacity: 0.18, + }, + { + min: ownRange.greenMin, + max: Math.min(ownRange.greenMax, ceiling), + color: "var(--success)", + opacity: 0.18, + }, + { + min: Math.min(ownRange.greenMax, ceiling), + max: Math.min(ownRange.orangeMax, ceiling), + color: "var(--warning)", + opacity: 0.18, + }, + { + min: Math.min(ownRange.orangeMax, ceiling), + max: ceiling, + color: "var(--destructive)", + opacity: 0.18, + }, + ]; + return bands.filter((band) => band.max > band.min); +} diff --git a/src/lib/queries/__tests__/use-own-target-range.test.ts b/src/lib/queries/__tests__/use-own-target-range.test.ts new file mode 100644 index 000000000..d74414e56 --- /dev/null +++ b/src/lib/queries/__tests__/use-own-target-range.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { selectOwnTargetRange } from "../use-own-target-range"; + +const range = { greenMin: 88, greenMax: 92, orangeMin: 87.4, orangeMax: 92.6 }; + +describe("selectOwnTargetRange", () => { + it("returns the resolved range when the person set the target", () => { + expect( + selectOwnTargetRange( + { effective: { OXYGEN_SATURATION: { range, isOverride: true } } }, + "OXYGEN_SATURATION", + ), + ).toEqual(range); + }); + + it("returns null for a default range, so the chart keeps its own band", () => { + expect( + selectOwnTargetRange( + { effective: { OXYGEN_SATURATION: { range, isOverride: false } } }, + "OXYGEN_SATURATION", + ), + ).toBeNull(); + }); + + it("returns null before the read lands or for an absent metric", () => { + expect(selectOwnTargetRange(null, "OXYGEN_SATURATION")).toBeNull(); + expect(selectOwnTargetRange({ effective: {} }, "PULSE")).toBeNull(); + }); +}); diff --git a/src/lib/queries/use-own-target-range.ts b/src/lib/queries/use-own-target-range.ts new file mode 100644 index 000000000..53967c836 --- /dev/null +++ b/src/lib/queries/use-own-target-range.ts @@ -0,0 +1,48 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { apiGet } from "@/lib/api/api-fetch"; +import type { ThresholdMetric } from "@/lib/analytics/effective-range"; +import type { TrafficRange } from "@/lib/analytics/value-bands"; +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; + +/** The slice of `GET /api/user/thresholds` a chart band needs. */ +interface ThresholdsSlice { + effective: Partial< + Record + >; +} + +/** + * The user's own target band for one metric, as the server resolved it, or + * `null` when they have not set one (the chart then keeps its default band). + * + * `/api/user/thresholds` resolves every override through `getEffectiveRange`, + * orange wings included, so the page draws the resolved range rather than + * re-deriving one. Shares the `userThresholds()` cache entry with the target + * editor, which invalidates it on save, so editing a target repaints the + * chart without a reload. + */ +export function useOwnTargetRange( + metric: ThresholdMetric, +): TrafficRange | null { + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.userThresholds(), + queryFn: () => apiGet("/api/user/thresholds"), + enabled: isAuthenticated, + }); + return selectOwnTargetRange(data ?? null, metric); +} + +/** Pure projection, exported for the unit test. */ +export function selectOwnTargetRange( + data: ThresholdsSlice | null, + metric: ThresholdMetric, +): TrafficRange | null { + const entry = data?.effective?.[metric]; + if (!entry?.isOverride) return null; + return entry.range ?? null; +}