fix(targets): hold below a default band for load measures and above the band for one-sided measures

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:22:26 +02:00
1 parent c508ce61e3
commit 0cb877e0a2
3 files changed
+94 -2

No files matched your search

+1 -1
View File
@@ -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"])
@@ -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"],
+45 -1
View File
@@ -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<ThresholdMetric> = 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<ThresholdMetric> = new Set([
"ACTIVITY_STEPS",
"OXYGEN_SATURATION",
]);
function directionFor(
metric: ThresholdMetric,
position: TrendTargetPosition,
basis: Exclude<TrendVerdictBasis, "metric_default">,
): 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,
};