mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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
This commit is contained in:
1 parent
90106d88c8
commit
ff49bbb5fc
5 files changed
+237
-8
No files matched your search
@@ -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[] }) => (
|
||||
<pre data-testid="bands">{JSON.stringify(props.valueBands ?? null)}</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={client}>
|
||||
<InsightsOxygenSaturationPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const match = /<pre[^>]*>([^<]*)<\/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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<HealthKitMetricPage
|
||||
measurementType="OXYGEN_SATURATION"
|
||||
@@ -26,14 +32,9 @@ export default function InsightsOxygenSaturationPage() {
|
||||
color="var(--chart-4)"
|
||||
unit="%"
|
||||
yAxisUnit="%"
|
||||
valueBands={[
|
||||
// < 90 % — clinical hypoxia floor, paint the danger band.
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
// 90 .. 95 % — watch zone.
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
// 95 .. 100 % — clinical normal.
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]}
|
||||
// The clinical shading (hypoxia floor < 90 %, watch zone to 95 %,
|
||||
// normal above), or the person's own band when they set one.
|
||||
valueBands={buildOxygenSaturationBands(ownRange)}
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="OXYGEN_SATURATION"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, { range: TrafficRange | null; isOverride: boolean }>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ThresholdsSlice>("/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;
|
||||
}
|
||||
Reference in new issue
Block a user