diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index da8fbac66..f2d13f5d2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -63558,7 +63558,8 @@ components: - diaHigh additionalProperties: false - type: "null" - description: Personalised blood-pressure target numbers. Null when the profile has no date of birth. + description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the + age-banded default. Null when there is neither a target nor a date of birth." bpSysRange: anyOf: - type: object @@ -63578,7 +63579,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Systolic traffic-light range. Null when the profile has no date of birth. + description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. bpDiaRange: anyOf: - type: object @@ -63598,7 +63600,8 @@ components: - orangeMax additionalProperties: false - type: "null" - description: Diastolic traffic-light range. Null when the profile has no date of birth. + description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is + neither a target nor a date of birth. pulseDisplayRange: type: object properties: @@ -63616,7 +63619,8 @@ components: - orangeMin - orangeMax additionalProperties: false - description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth. + description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA + reference range with no date of birth). Always present." pulseBands: type: array items: @@ -63637,7 +63641,7 @@ components: - max - color additionalProperties: false - description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth. + description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present. bodyFatRange: type: object properties: @@ -63649,7 +63653,7 @@ components: - min - max additionalProperties: false - description: Gender-aware body-fat target range. Always present. + description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present." bodyFatBands: type: array items: @@ -63670,7 +63674,7 @@ components: - max - color additionalProperties: false - description: Body-fat chart bands. Always present. + description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present. weightRange: anyOf: - type: object 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/app/page-client.tsx b/src/app/page-client.tsx
index 02c00fe5d..5e7e0bb45 100644
--- a/src/app/page-client.tsx
+++ b/src/app/page-client.tsx
@@ -378,10 +378,10 @@ export default function DashboardPageClient({
analyticsThickQuery.data,
]);
- // v1.34 — the user's own weight target, for the legacy (snapshot-disabled)
- // band fallback only. In snapshot mode the server resolves the same override
- // into `targetBands` and this query never fires; the fallback would otherwise
- // keep shading the height-derived band for someone who has set a target.
+ // The user's own targets, for the legacy (snapshot-disabled) band fallback
+ // only. In snapshot mode the server resolves the same overrides into
+ // `targetBands` and this query never fires; the fallback would otherwise
+ // keep shading the default band for someone who has set a target.
const { data: thresholdsData } = useQuery({
queryKey: queryKeys.userThresholds(),
queryFn: async () => {
@@ -754,9 +754,9 @@ export default function DashboardPageClient({
// in the snapshot steady state) before this. Keyed on the `user` object
// identity so the React Compiler can preserve the memo (a narrower
// property list trips `preserve-manual-memoization`).
- // v1.34 — the weight target rides the same memo. In snapshot mode
+ // v1.34 — the user's own targets ride the same memo. In snapshot mode
// `thresholdsData` is never fetched, so the client copy falls back to the
- // height-derived band for the frames before `serverBands` lands — exactly
+ // default bands for the frames before `serverBands` lands — exactly
// as it already did for every other profile-derived number.
// v1.36.x — the profile this fallback reads is the CALLER's, and under a
// switch the caller is the delegate. `viewerBandProfile` is where that is
@@ -771,9 +771,7 @@ export default function DashboardPageClient({
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
gender: toProfileSex(user?.gender),
heightCm: user?.heightCm ?? null,
- weightTargetOverride: resolveWeightTargetOverride(
- thresholdsData?.overrides ?? null,
- ),
+ thresholdsJson: thresholdsData?.overrides ?? null,
},
inSharedRecord,
),
@@ -791,7 +789,7 @@ export default function DashboardPageClient({
const weightTrend =
(snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ??
resolveWeightTrend(
- clientBandProfile.weightTargetOverride,
+ resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
weightTrendReferenceKg(w),
);
// v1.43 — every other tile's direction comes resolved from the server
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/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts
index 85b07d2eb..3325fc8bc 100644
--- a/src/lib/dashboard/__tests__/snapshot.test.ts
+++ b/src/lib/dashboard/__tests__/snapshot.test.ts
@@ -1269,6 +1269,30 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
});
+ it("an own blood-pressure, pulse and body-fat target reach the dashboard bands", async () => {
+ const snap = await buildDashboardSnapshot(
+ fakePrisma,
+ baseUser({
+ thresholdsJson: {
+ BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
+ BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
+ PULSE: { min: 48, max: 58 },
+ BODY_FAT: { min: 12, max: 16 },
+ },
+ }),
+ { modules: () => Promise.resolve(moduleMap()) },
+ );
+ expect(snap.targetBands.bpTargets).toEqual({
+ sysLow: 115,
+ sysHigh: 125,
+ diaLow: 70,
+ diaHigh: 78,
+ });
+ expect(snap.targetBands.pulseDisplayRange.greenMin).toBe(48);
+ expect(snap.targetBands.pulseDisplayRange.greenMax).toBe(58);
+ expect(snap.targetBands.bodyFatRange).toEqual({ min: 12, max: 16 });
+ });
+
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
modules: () => Promise.resolve(moduleMap({ sleep: false })),
diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts
index 06e51dca2..a60b3eb36 100644
--- a/src/lib/dashboard/__tests__/target-bands.test.ts
+++ b/src/lib/dashboard/__tests__/target-bands.test.ts
@@ -105,28 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
- weightTargetOverride: { min: number; max: number } | null;
+ thresholdsJson: unknown;
}> = [
{
name: "male 180cm with DOB",
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
- weightTargetOverride: null,
+ thresholdsJson: null,
},
{
name: "female 165cm with DOB",
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
gender: "FEMALE",
heightCm: 165,
- weightTargetOverride: null,
+ thresholdsJson: null,
},
{
name: "no-profile user (null DOB / gender / height)",
dateOfBirth: null,
gender: null,
heightCm: null,
- weightTargetOverride: null,
+ thresholdsJson: null,
},
];
@@ -146,20 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "OTHER",
heightCm: 180,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
const unanswered = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: null,
heightCm: 180,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
expect(other).toEqual(unanswered);
const male = buildTargetBands({
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
});
@@ -169,7 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
expect(bands.bpTargets).toBeNull();
expect(bands.bpSysRange).toBeNull();
@@ -198,14 +198,14 @@ describe("buildTargetBands — user weight target", () => {
it("replaces the height-derived band with the user's own", () => {
const withTarget = buildTargetBands({
...profile,
- weightTargetOverride: { min: 74, max: 78 },
+ thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
});
expect(withTarget.weightRange?.greenMin).toBe(74);
expect(withTarget.weightRange?.greenMax).toBe(78);
const heightDerived = buildTargetBands({
...profile,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
// The chart bands follow the same range, so tile and chart agree.
@@ -221,7 +221,7 @@ describe("buildTargetBands — user weight target", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
- weightTargetOverride: { min: 60, max: 65 },
+ thresholdsJson: { WEIGHT: { min: 60, max: 65 } },
});
expect(bands.weightRange?.greenMin).toBe(60);
expect(bands.weightBands?.length).toBeGreaterThan(0);
@@ -230,14 +230,132 @@ describe("buildTargetBands — user weight target", () => {
it("leaves every non-weight band untouched", () => {
const withTarget = buildTargetBands({
...profile,
- weightTargetOverride: { min: 74, max: 78 },
+ thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
});
const without = buildTargetBands({
...profile,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
expect(withTarget.bpTargets).toEqual(without.bpTargets);
expect(withTarget.pulseBands).toEqual(without.pulseBands);
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
});
});
+
+/**
+ * The other metrics the dashboard shades take the person's own target the
+ * same way weight does. A blood-pressure, resting-pulse or body-fat target set
+ * on `/targets` used to leave the chart on the age / sex default, so the band
+ * under the line contradicted the band the person had just entered.
+ */
+describe("buildTargetBands — the user's own targets on every shaded metric", () => {
+ const profile = {
+ dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
+ gender: "MALE" as const,
+ heightCm: 180,
+ };
+ const without = buildTargetBands({ ...profile, thresholdsJson: null });
+ const greenOf = (bands: Array<{ min: number; max: number; color: string }>) =>
+ bands.find((band) => band.color === "var(--success)");
+
+ it("draws the blood-pressure zones and tile ranges from the own band", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: {
+ BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
+ BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
+ },
+ });
+ expect(bands.bpTargets).toEqual({
+ sysLow: 115,
+ sysHigh: 125,
+ diaLow: 70,
+ diaHigh: 78,
+ });
+ expect(bands.bpTargets).not.toEqual(without.bpTargets);
+ expect(bands.bpSysRange).toEqual(
+ expect.objectContaining({ greenMin: 115, greenMax: 125 }),
+ );
+ expect(bands.bpDiaRange).toEqual(
+ expect.objectContaining({ greenMin: 70, greenMax: 78 }),
+ );
+ });
+
+ it("fills a one-sided blood-pressure target with the age default", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 110, max: 128 } },
+ });
+ expect(bands.bpTargets?.sysLow).toBe(110);
+ expect(bands.bpTargets?.sysHigh).toBe(128);
+ expect(bands.bpTargets?.diaLow).toBe(without.bpTargets?.diaLow);
+ expect(bands.bpTargets?.diaHigh).toBe(without.bpTargets?.diaHigh);
+ expect(bands.bpDiaRange).toEqual(without.bpDiaRange);
+ });
+
+ it("draws a blood-pressure band from a target even without a date of birth", () => {
+ const bands = buildTargetBands({
+ dateOfBirth: null,
+ gender: null,
+ heightCm: null,
+ thresholdsJson: {
+ BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
+ BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
+ },
+ });
+ expect(bands.bpTargets).toEqual({
+ sysLow: 115,
+ sysHigh: 125,
+ diaLow: 70,
+ diaHigh: 78,
+ });
+ });
+
+ it("draws the resting-pulse bands and display range from the own band", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: { PULSE: { min: 48, max: 58 } },
+ });
+ expect(bands.pulseDisplayRange).toEqual(
+ expect.objectContaining({ greenMin: 48, greenMax: 58 }),
+ );
+ expect(greenOf(bands.pulseBands)).toEqual(
+ expect.objectContaining({ min: 48, max: 58 }),
+ );
+ expect(bands.pulseBands).not.toEqual(without.pulseBands);
+ });
+
+ it("draws the body-fat bands and range from the own band", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: { BODY_FAT: { min: 12, max: 16 } },
+ });
+ expect(bands.bodyFatRange).toEqual({ min: 12, max: 16 });
+ expect(greenOf(bands.bodyFatBands)).toEqual(
+ expect.objectContaining({ min: 12, max: 16 }),
+ );
+ expect(bands.bodyFatBands).not.toEqual(without.bodyFatBands);
+ });
+
+ it("ignores a malformed target and keeps the default band", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: {
+ BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
+ PULSE: { min: "fast", max: 60 },
+ BODY_FAT: null,
+ },
+ });
+ expect(bands).toEqual(without);
+ });
+
+ it("leaves the other metrics' bands alone", () => {
+ const bands = buildTargetBands({
+ ...profile,
+ thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
+ });
+ expect(bands.pulseBands).toEqual(without.pulseBands);
+ expect(bands.bodyFatBands).toEqual(without.bodyFatBands);
+ expect(bands.weightBands).toEqual(without.weightBands);
+ });
+});
diff --git a/src/lib/dashboard/__tests__/viewer-band-profile.test.ts b/src/lib/dashboard/__tests__/viewer-band-profile.test.ts
index 0cd9a8fd3..ac465af0a 100644
--- a/src/lib/dashboard/__tests__/viewer-band-profile.test.ts
+++ b/src/lib/dashboard/__tests__/viewer-band-profile.test.ts
@@ -30,7 +30,7 @@ const DELEGATE_PROFILE = {
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
gender: "FEMALE" as const,
heightCm: 165,
- weightTargetOverride: { min: 55, max: 62 },
+ thresholdsJson: { WEIGHT: { min: 55, max: 62 } },
};
describe("viewerBandProfile", () => {
@@ -45,7 +45,7 @@ describe("viewerBandProfile", () => {
dateOfBirth: null,
gender: null,
heightCm: null,
- weightTargetOverride: null,
+ thresholdsJson: null,
});
});
});
diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts
index c56aa49a3..1bfe1649d 100644
--- a/src/lib/dashboard/bands.ts
+++ b/src/lib/dashboard/bands.ts
@@ -15,7 +15,7 @@
* so the band/target math lives in exactly one place. `buildTargetBands`
* is kept as an alias for the snapshot-era import sites.
*/
-import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
+import type { BpTargets } from "@/lib/analytics/bp-targets";
import {
buildBandsFromTrafficRange,
buildTrafficLightBands,
@@ -30,16 +30,27 @@ import {
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
-import { getEffectiveRange } from "@/lib/analytics/effective-range";
+import {
+ getEffectiveRange,
+ resolveEffectiveBpTargets,
+ resolveThresholdOverride,
+ type ThresholdMetric,
+} from "@/lib/analytics/effective-range";
export interface DashboardTargetBands {
- /** Personalised BP target numbers (null when no DOB). */
+ /**
+ * BP target numbers: the user's own band where they set one, else the age
+ * default. Null when neither exists (no target and no DOB).
+ */
bpTargets: BpTargets | null;
- /** Systolic traffic range (null when no DOB). */
+ /** Systolic traffic range, same precedence (null without target or DOB). */
bpSysRange: TrafficRange | null;
- /** Diastolic traffic range (null when no DOB). */
+ /** Diastolic traffic range, same precedence (null without target or DOB). */
bpDiaRange: TrafficRange | null;
- /** Resting-pulse display range (always present — AHA fallback). */
+ /**
+ * Resting-pulse display range: the user's own target, else the
+ * age / sex percentile band (AHA fallback). Always present.
+ */
pulseDisplayRange: {
greenMin: number;
greenMax: number;
@@ -48,7 +59,7 @@ export interface DashboardTargetBands {
};
/** Resting-pulse chart bands (always present — AHA fallback). */
pulseBands: ValueBand[];
- /** Body-fat target range (gender-aware; always present). */
+ /** Body-fat target range: own target, else gender-aware. Always present. */
bodyFatRange: { min: number; max: number };
/** Body-fat chart bands (always present). */
bodyFatBands: ValueBand[];
@@ -66,110 +77,92 @@ export interface DashboardTargetBands {
* DB read. The snapshot builder and the page fallback both call this so
* they produce byte-identical numbers.
*
- * v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in
- * `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the
- * height-derived WHO band on both the weight range and the weight chart bands:
- * a user who has answered "this is my target" must not keep reading a shaded
- * zone that ignores the answer. `null` keeps the height-derived band. Required
- * rather than optional so every call site is named by the compiler.
+ * `thresholdsJson` is the user's own targets blob as stored in
+ * `User.thresholdsJson` (`null` when there is none). Every metric the dashboard
+ * shades and that accepts a personal target reads it: weight, blood pressure,
+ * resting pulse and body fat. A target the person set replaces the computed
+ * default on both the tile's range and the chart's bands, so a shaded zone
+ * never ignores an answer given on `/targets`. Each override goes through
+ * `getEffectiveRange`, so its orange wings match the `/targets` page and the
+ * doctor report rather than a second convention. A metric without an override
+ * keeps exactly the band it drew before. Required rather than optional so
+ * every call site is named by the compiler.
+ *
+ * v1.34 introduced this for weight alone; the other three kept the age / sex
+ * default regardless of the person's own target until v1.44.1.
*/
export function buildDashboardBands(profile: {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
- weightTargetOverride: { min: number; max: number } | null;
+ thresholdsJson: unknown;
}): DashboardTargetBands {
- const bpTargets = profile.dateOfBirth
- ? getBpTargets(profile.dateOfBirth)
- : null;
+ const rangeProfile = {
+ heightCm: profile.heightCm,
+ dateOfBirth: profile.dateOfBirth,
+ gender: profile.gender,
+ };
+ const ownTarget = (metric: ThresholdMetric) =>
+ resolveThresholdOverride(profile.thresholdsJson, metric);
+ const ownRange = (metric: ThresholdMetric): TrafficRange | null => {
+ const override = ownTarget(metric);
+ return override
+ ? getEffectiveRange(metric, rangeProfile, { [metric]: override }).range
+ : null;
+ };
+
+ const bpTargets = resolveEffectiveBpTargets(
+ rangeProfile,
+ profile.thresholdsJson,
+ );
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
// The percentile and body-fat tables carry two sexes and no third row;
// both helpers answer a null with a neutral midpoint rather than a side.
const referenceSex = binaryReferenceSex(profile.gender);
- const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex);
- const bodyFatRange = getBodyFatTargetRange(referenceSex);
- // v1.34 — an explicit target outranks the height-derived WHO band. Without
- // this the dashboard kept shading a band the user had already answered, and
- // the answer lived on `/targets` where nothing else could see it. The
- // override is routed through `getEffectiveRange` rather than widened here,
- // so the orange wings match the `/targets` page and the doctor report
- // exactly instead of inventing a second convention. No height and no target
- // → no band at all, unchanged.
- const weightRange = profile.weightTargetOverride
- ? getEffectiveRange(
- "WEIGHT",
- {
- heightCm: profile.heightCm,
- dateOfBirth: profile.dateOfBirth,
- gender: profile.gender,
- },
- { WEIGHT: profile.weightTargetOverride },
- ).range
- : profile.heightCm
- ? buildWeightRangeFromHeight(profile.heightCm)
- : null;
+ const defaultPulse = getPersonalizedPulseTarget(pulseAge, referenceSex);
+ const pulseRange: TrafficRange = ownRange("PULSE") ?? {
+ greenMin: defaultPulse.greenMin,
+ greenMax: defaultPulse.greenMax,
+ orangeMin: defaultPulse.orangeMin,
+ orangeMax: defaultPulse.orangeMax,
+ };
+ const ownBodyFat = ownRange("BODY_FAT");
+ const bodyFatRange = ownBodyFat
+ ? { min: ownBodyFat.greenMin, max: ownBodyFat.greenMax }
+ : getBodyFatTargetRange(referenceSex);
+ // No height and no target → no weight band at all, unchanged.
+ const weightRange =
+ ownRange("WEIGHT") ??
+ (profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null);
const weightBands = weightRange
? buildBandsFromTrafficRange(weightRange, {
lowerBound: 30,
upperBound: 250,
})
: null;
- const bpSysRange = bpTargets
- ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
- : null;
- const bpDiaRange = bpTargets
- ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
- : null;
- const pulseBands = [
- {
- min: 30,
- max: pulseTarget.orangeMin,
- color: "var(--destructive)",
- opacity: 0.16,
- },
- {
- min: pulseTarget.orangeMin,
- max: pulseTarget.greenMin,
- color: "var(--warning)",
- opacity: 0.18,
- },
- {
- min: pulseTarget.greenMin,
- max: pulseTarget.greenMax,
- color: "var(--success)",
- opacity: 0.2,
- },
- {
- min: pulseTarget.greenMax,
- max: pulseTarget.orangeMax,
- color: "var(--warning)",
- opacity: 0.18,
- },
- {
- min: pulseTarget.orangeMax,
- max: 220,
- color: "var(--destructive)",
- opacity: 0.16,
- },
- ].filter((band) => band.max > band.min);
- const bodyFatBands = buildTrafficLightBands(
- bodyFatRange.min,
- bodyFatRange.max,
- {
- lowerBound: 2,
- upperBound: 55,
- },
- );
+ // The tile colours a reading against the same band the chart shades: the
+ // person's own range with its effective wings, else the age default's.
+ const bpSysRange =
+ ownRange("BLOOD_PRESSURE_SYS") ??
+ (bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null);
+ const bpDiaRange =
+ ownRange("BLOOD_PRESSURE_DIA") ??
+ (bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null);
+ const pulseBands = buildBandsFromTrafficRange(pulseRange, {
+ lowerBound: 30,
+ upperBound: 220,
+ });
+ const bodyFatBands = ownBodyFat
+ ? buildBandsFromTrafficRange(ownBodyFat, { lowerBound: 2, upperBound: 55 })
+ : buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
+ lowerBound: 2,
+ upperBound: 55,
+ });
return {
bpTargets,
bpSysRange,
bpDiaRange,
- pulseDisplayRange: {
- greenMin: pulseTarget.greenMin,
- greenMax: pulseTarget.greenMax,
- orangeMin: pulseTarget.orangeMin,
- orangeMax: pulseTarget.orangeMax,
- },
+ pulseDisplayRange: pulseRange,
pulseBands,
bodyFatRange,
bodyFatBands,
@@ -187,7 +180,7 @@ export interface BandProfile {
dateOfBirth: Date | null;
gender: ProfileSex;
heightCm: number | null;
- weightTargetOverride: { min: number; max: number } | null;
+ thresholdsJson: unknown;
}
/**
@@ -220,6 +213,6 @@ export function viewerBandProfile(
dateOfBirth: null,
gender: null,
heightCm: null,
- weightTargetOverride: null,
+ thresholdsJson: null,
};
}
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 29541fa39..e7a5f1502 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -1489,10 +1489,11 @@ export async function buildDashboardSnapshot(
dateOfBirth: user.dateOfBirth,
gender,
heightCm: user.heightCm ?? null,
- // v1.34 — the user's own weight target, when they set one, replaces the
- // height-derived band on the tile + the chart. Resolved off the same
- // `thresholdsJson` blob the health score reads.
- weightTargetOverride,
+ // The user's own targets, where set, replace the computed default on
+ // every tile and chart band (weight, blood pressure, resting pulse,
+ // body fat). Resolved off the same `thresholdsJson` blob the health
+ // score and the tile directions read.
+ thresholdsJson: user.thresholdsJson,
}),
tiles: {
summaries: slim.summaries,
diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts
index 615b428cd..a088d8968 100644
--- a/src/lib/openapi/routes/insights/schemas.ts
+++ b/src/lib/openapi/routes/insights/schemas.ts
@@ -1186,8 +1186,8 @@ export const dashboardSnapshotResponse = z
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
),
// v1.18.6 — server-computed band / target math (audit finding #3),
- // resolved from the user's own profile facts (DOB / gender / height /
- // weight-target override). The dashboard reads these instead of
+ // resolved from the user's own profile facts (DOB / gender / height) and
+ // their own targets. The dashboard reads these instead of
// recomputing client-side; always present (every sub-field falls back
// to a neutral default rather than being omitted).
targetBands: z
@@ -1201,7 +1201,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
- "Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
+ "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth.",
),
bpSysRange: z
.object({
@@ -1212,7 +1212,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
- "Systolic traffic-light range. Null when the profile has no date of birth.",
+ "Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
),
bpDiaRange: z
.object({
@@ -1223,7 +1223,7 @@ export const dashboardSnapshotResponse = z
})
.nullable()
.describe(
- "Diastolic traffic-light range. Null when the profile has no date of birth.",
+ "Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
),
pulseDisplayRange: z
.object({
@@ -1233,7 +1233,7 @@ export const dashboardSnapshotResponse = z
orangeMax: z.number(),
})
.describe(
- "Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
+ "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present.",
),
pulseBands: z
.array(
@@ -1246,11 +1246,13 @@ export const dashboardSnapshotResponse = z
}),
)
.describe(
- "Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
+ "Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.",
),
bodyFatRange: z
.object({ min: z.number(), max: z.number() })
- .describe("Gender-aware body-fat target range. Always present."),
+ .describe(
+ "Body-fat target range: the user's own target where set, else the gender-aware default. Always present.",
+ ),
bodyFatBands: z
.array(
z.object({
@@ -1261,7 +1263,9 @@ export const dashboardSnapshotResponse = z
strokeOpacity: z.number().optional(),
}),
)
- .describe("Body-fat chart bands. Always present."),
+ .describe(
+ "Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.",
+ ),
weightRange: z
.object({
greenMin: z.number(),
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;
+}
diff --git a/tests/integration/dashboard-own-target-bands.test.ts b/tests/integration/dashboard-own-target-bands.test.ts
new file mode 100644
index 000000000..bc0c7994b
--- /dev/null
+++ b/tests/integration/dashboard-own-target-bands.test.ts
@@ -0,0 +1,133 @@
+/**
+ * A target the person sets on `/targets` reaches the dashboard's chart bands.
+ *
+ * The snapshot resolved the blood-pressure, resting-pulse and body-fat bands
+ * from the profile alone (age, sex) and took only the weight target into
+ * account, so the zones under the blood-pressure chart stayed on the age
+ * default after the person had entered their own band. The resolved bands
+ * are what the web dashboard draws and what a native client reads, so the
+ * proof runs through the real routes: write the target through
+ * `PUT /api/user/thresholds`, read `GET /api/dashboard/snapshot` back from
+ * the same Postgres, with the snapshot's cache cell in between.
+ */
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+async function seedUser(): Promise {
+ const prisma = getPrismaClient();
+ const user = await prisma.user.create({
+ data: {
+ username: "own-target-bands",
+ email: "own-target-bands@example.test",
+ role: "USER",
+ timezone: "Europe/Berlin",
+ dateOfBirth: new Date("1960-03-01T00:00:00.000Z"),
+ gender: "MALE",
+ heightCm: 178,
+ },
+ });
+ const session = await prisma.session.create({
+ data: { userId: user.id, expiresAt: new Date(Date.now() + 60 * 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+interface Bands {
+ bpTargets: {
+ sysLow: number;
+ sysHigh: number;
+ diaLow: number;
+ diaHigh: number;
+ } | null;
+ pulseDisplayRange: { greenMin: number; greenMax: number };
+ bodyFatRange: { min: number; max: number };
+}
+
+async function readBands(): Promise {
+ const { GET } = await import("@/app/api/dashboard/snapshot/route");
+ const res = await GET();
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { targetBands: Bands } };
+ return body.data.targetBands;
+}
+
+async function putTargets(payload: unknown): Promise {
+ const { PUT } = await import("@/app/api/user/thresholds/route");
+ const res = await PUT(
+ new NextRequest("http://localhost/api/user/thresholds", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ }),
+ );
+ expect(res.status).toBe(200);
+}
+
+describe("dashboard bands follow the person's own targets (real Postgres)", () => {
+ it("replaces the age default once a target is saved", async () => {
+ await seedUser();
+
+ const before = await readBands();
+ // The premise: an age default exists and differs from the target below.
+ expect(before.bpTargets).not.toBeNull();
+ expect(before.bpTargets?.sysHigh).not.toBe(150);
+
+ await putTargets({
+ BLOOD_PRESSURE_SYS: { min: 120, max: 150 },
+ BLOOD_PRESSURE_DIA: { min: 75, max: 95 },
+ PULSE: { min: 48, max: 58 },
+ BODY_FAT: { min: 12, max: 16 },
+ });
+
+ // Read again: the save evicts the cached snapshot, so this is a rebuild
+ // against the stored blob, not the cell from the first read.
+ const after = await readBands();
+ expect(after.bpTargets).toEqual({
+ sysLow: 120,
+ sysHigh: 150,
+ diaLow: 75,
+ diaHigh: 95,
+ });
+ expect(after.pulseDisplayRange).toEqual(
+ expect.objectContaining({ greenMin: 48, greenMax: 58 }),
+ );
+ expect(after.bodyFatRange).toEqual({ min: 12, max: 16 });
+ });
+});