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(
+ ]*>([^<]*)<\/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;
+}