Merge branch 'fix/1261-own-targets' into release/v1.44.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-22 12:04:29 +02:00
commit 803d90c6c4
14 files changed
+650 -146

No files matched your search

+11 -7
View File
@@ -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
@@ -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("&quot;", '"');
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);
});
});
+9 -8
View File
@@ -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"
+8 -10
View File
@@ -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
+58
View File
@@ -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);
}
@@ -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 })),
+131 -13
View File
@@ -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);
});
});
@@ -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,
});
});
});
+86 -93
View File
@@ -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,
};
}
+5 -4
View File
@@ -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,
+13 -9
View File
@@ -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(),
@@ -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();
});
});
+48
View File
@@ -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;
}
@@ -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<void> {
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<Bands> {
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<void> {
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 });
});
});