From 0cb877e0a26cdd0cd69d17929700acaaacf9e49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 05:22:26 +0200 Subject: [PATCH] fix(targets): hold below a default band for load measures and above the band for one-sided measures --- src/lib/openapi/routes/trend-verdict.ts | 2 +- .../targets/__tests__/trend-verdict.test.ts | 48 +++++++++++++++++++ src/lib/targets/trend-verdict.ts | 46 +++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/lib/openapi/routes/trend-verdict.ts b/src/lib/openapi/routes/trend-verdict.ts index c8772683e..fcf5862ee 100644 --- a/src/lib/openapi/routes/trend-verdict.ts +++ b/src/lib/openapi/routes/trend-verdict.ts @@ -13,7 +13,7 @@ export const trendVerdict = z direction: z .enum(["up-good", "up-bad", "neutral", "hold"]) .describe( - "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside its band so staying is progress, `neutral` no judgement.", + "How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` staying is progress and a move either way is no setback, `neutral` no judgement. `hold` covers a value inside its band, a blood pressure, resting pulse or body fat below a profile-default band (a default's lower edge is no goal; below the person's own target a rise stays `up-good`), and steps or oxygen saturation above their band (more is never worse).", ), targetPosition: z .enum(["below", "inside", "above"]) diff --git a/src/lib/targets/__tests__/trend-verdict.test.ts b/src/lib/targets/__tests__/trend-verdict.test.ts index 2051265c4..23e88bca6 100644 --- a/src/lib/targets/__tests__/trend-verdict.test.ts +++ b/src/lib/targets/__tests__/trend-verdict.test.ts @@ -36,6 +36,9 @@ describe("resolveTrendVerdict against the person's own target", () => { expect(trendSentimentOf(4, v)).toBe("negative"); }); + // A target the person set themselves is a goal in both directions: below + // it, climbing back up is what they asked for. This stays `up-good` even + // for the load measures, unlike the profile-default case further down. it("calls the same rise below the target progress", () => { const v = verdict("BLOOD_PRESSURE_SYS", 108, OWN_BP); expect(v).toEqual({ @@ -112,6 +115,28 @@ describe("resolveTrendVerdict against a profile default", () => { expect(verdict("BODY_FAT", 30).basis).toBe("metric_default"); }); + // A default band's lower edge is a population convention, not a goal. A + // low blood pressure, resting pulse or body fat against a band nobody chose + // holds: its fall is never painted as a setback. + it("holds below a default band for the load measures", () => { + expect(verdict("BLOOD_PRESSURE_SYS", 108, null, MAN_41)).toEqual({ + direction: "hold", + targetPosition: "below", + basis: "profile_default", + }); + expect(verdict("BLOOD_PRESSURE_DIA", 62, null, MAN_41).direction).toBe( + "hold", + ); + for (const key of ["PULSE", "RESTING_HEART_RATE"]) { + const v = verdict(key, 45, null, MAN_41); + expect(v).toMatchObject({ direction: "hold", targetPosition: "below" }); + expect(trendSentimentOf(-3, v)).toBe("neutral"); + } + expect( + verdict("BODY_FAT", 5, null, { ...MAN_41, gender: "MALE" }), + ).toMatchObject({ direction: "hold", targetPosition: "below" }); + }); + it("prefers the person's own target over the profile default", () => { expect(verdict("BLOOD_PRESSURE_SYS", 125, OWN_BP, MAN_41).basis).toBe( "personal_target", @@ -119,6 +144,29 @@ describe("resolveTrendVerdict against a profile default", () => { }); }); +describe("one-sided measures", () => { + it("never calls steps above the own target a setback", () => { + const own = { ACTIVITY_STEPS: { min: 8000, max: 12_000 } }; + const v = verdict("ACTIVITY_STEPS", 15_000, own); + expect(v).toEqual({ + direction: "hold", + targetPosition: "above", + basis: "personal_target", + }); + expect(trendSentimentOf(2000, v)).not.toBe("negative"); + expect(trendSentimentOf(-2000, v)).not.toBe("negative"); + expect(verdict("ACTIVITY_STEPS", 5000, own).direction).toBe("up-good"); + }); + + it("never calls oxygen saturation above the own target a setback", () => { + const own = { OXYGEN_SATURATION: { min: 92, max: 96 } }; + expect(verdict("OXYGEN_SATURATION", 98, own)).toMatchObject({ + direction: "hold", + targetPosition: "above", + }); + }); +}); + describe("resolveTrendVerdict metric defaults", () => { it.each([ ["ACTIVITY_STEPS", "up-good"], diff --git a/src/lib/targets/trend-verdict.ts b/src/lib/targets/trend-verdict.ts index 22ca94065..a0a312799 100644 --- a/src/lib/targets/trend-verdict.ts +++ b/src/lib/targets/trend-verdict.ts @@ -17,6 +17,17 @@ * below the band → a rise is progress (`up-good`) * above the band → a fall is progress (`up-bad`) * inside the band → holding is progress (`hold`) + * - two exceptions to "below / above": + * a low reading of a load measure (blood pressure, resting pulse, body + * fat) against a band nobody chose is `hold`, not `up-good`. A + * default band's lower edge is a population convention, not a goal: + * a well-trained resting pulse of 48 or a systolic of 108 is not + * something to climb out of, and painting its fall orange would tell + * the person to raise it. Only a target the person set themselves + * turns "below" into "a rise is progress". + * a one-sided measure (steps, oxygen saturation) above its band is + * `hold`, never `up-bad`: more steps than the target, or a saturation + * above it, is never a setback. * - no band, or no value to place: the metric's own default direction. * * Weight keeps its own rule (`weight-trend.ts`): only a target the person set @@ -200,6 +211,39 @@ export function placeAgainstBand( return "inside"; } +/** + * Load measures whose default band's lower edge is no goal (see the module + * comment): below a profile-default band they hold rather than climb. + */ +const LOW_IS_NOT_A_SETBACK: ReadonlySet = new Set([ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", +]); + +/** Measures where more is never worse: above the band they hold. */ +const ONE_SIDED_UPWARD: ReadonlySet = new Set([ + "ACTIVITY_STEPS", + "OXYGEN_SATURATION", +]); + +function directionFor( + metric: ThresholdMetric, + position: TrendTargetPosition, + basis: Exclude, +): TrendDirectionSentiment { + if ( + position === "below" && + basis === "profile_default" && + LOW_IS_NOT_A_SETBACK.has(metric) + ) { + return "hold"; + } + if (position === "above" && ONE_SIDED_UPWARD.has(metric)) return "hold"; + return DIRECTION_BY_POSITION[position]; +} + const DIRECTION_BY_POSITION: Record< TrendTargetPosition, TrendDirectionSentiment @@ -246,7 +290,7 @@ export function resolveTrendVerdict( toUnit ? toUnit(reference) : reference, ); return { - direction: DIRECTION_BY_POSITION[position], + direction: directionFor(metric, position, personal.basis), targetPosition: position, basis: personal.basis, };