mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(targets): hold below a default band for load measures and above the band for one-sided measures
This commit is contained in:
1 parent
c508ce61e3
commit
0cb877e0a2
3 files changed
+94
-2
No files matched your search
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user