fix(dashboard): shade blood pressure, pulse and body fat from the own target

The dashboard bands took only the weight target into account. Blood
pressure, resting pulse and body fat were resolved from age and sex
alone, so a target set on /targets left the chart zones and the tile
ranges on the standard band.

buildDashboardBands now reads the stored targets blob and routes every
override through getEffectiveRange, the same resolver the targets page
and the doctor report use. A metric without an override keeps exactly
the band it drew before. The snapshot's targetBands carry the resolved
values, so a native client reading them needs no change; the field
descriptions now say the own target wins.

Refs #1261
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 10:23:14 +02:00
1 parent fdd0f50130
commit 90106d88c8
9 files changed
+413 -138

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
+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
@@ -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,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 });
});
});