mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Say why the blood-pressure pillar scores what it does
The score popover now opens with three sentences before the reference line: which axis set the number and how far it sat from its boundary, what the scale means at that boundary, and that the pair behind it is a recency-weighted average rather than a single reading someone took. The lines read the basis the grader already publishes, so nothing here recomputes anything. A pillar that arrives without a basis contributes no lines at all instead of an empty one.
This commit is contained in:
1 parent
64a92fd392
commit
0ce40b0950
8 files changed
+328
-4
No files matched your search
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Nüchternglukose: {value} {unit}",
|
||||
"fastingReference": "{low} bis {high} {unit} nüchtern",
|
||||
"personalReference": "Dein Ziel: {value}",
|
||||
"bpBasisAbove": "Ausschlaggebend: {axis}, etwa {offset} mmHg über der Obergrenze von {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Ausschlaggebend: {axis}, etwa {offset} mmHg unter der Grenze für niedrigen Blutdruck von {boundary} mmHg.",
|
||||
"bpBasisInBand": "Beide Werte liegen auf oder unter ihren Referenz-Obergrenzen.",
|
||||
"bpScale": "An der Obergrenze liegt der Wert bei 85; 100 bedeutet rund 12 mmHg darunter.",
|
||||
"bpRepresentative": "Beruht auf einem Durchschnitt der letzten {days} Tage, der neuere Messungen stärker gewichtet, und kann deshalb von jeder einzelnen Messung abweichen.",
|
||||
"source": "Quelle: {source}, Stand {date}",
|
||||
"observedActivity": "{value} Schritte pro Tag",
|
||||
"observedSleep": "{value} Minuten pro Nacht",
|
||||
|
||||
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Fasting glucose: {value} {unit}",
|
||||
"fastingReference": "{low} to {high} {unit} fasting",
|
||||
"personalReference": "Your target: {value}",
|
||||
"bpBasisAbove": "{axis} sets this score: about {offset} mmHg over its ceiling of {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "{axis} sets this score: about {offset} mmHg under the low-pressure floor of {boundary} mmHg.",
|
||||
"bpBasisInBand": "Both values are at or below their reference ceilings.",
|
||||
"bpScale": "At the ceiling the score is 85; 100 means roughly 12 mmHg below it.",
|
||||
"bpRepresentative": "Based on a recency-weighted average of the last {days} days, so it may not match any single reading.",
|
||||
"source": "Source: {source}, as of {date}",
|
||||
"observedActivity": "{value} steps per day",
|
||||
"observedSleep": "{value} minutes per night",
|
||||
|
||||
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Glucosa en ayunas: {value} {unit}",
|
||||
"fastingReference": "{low} a {high} {unit} en ayunas",
|
||||
"personalReference": "Tu objetivo: {value}",
|
||||
"bpBasisAbove": "Determinante: {axis}, unos {offset} mmHg por encima de su límite de {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Determinante: {axis}, unos {offset} mmHg por debajo del límite de presión baja de {boundary} mmHg.",
|
||||
"bpBasisInBand": "Ambos valores están en su límite de referencia o por debajo.",
|
||||
"bpScale": "En el límite la puntuación es 85; 100 significa unos 12 mmHg por debajo.",
|
||||
"bpRepresentative": "Se basa en un promedio de los últimos {days} días que pondera más las mediciones recientes, por lo que puede no coincidir con ninguna medición concreta.",
|
||||
"source": "Fuente: {source}, a fecha de {date}",
|
||||
"observedActivity": "{value} pasos al día",
|
||||
"observedSleep": "{value} minutos por noche",
|
||||
|
||||
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Glycémie à jeun : {value} {unit}",
|
||||
"fastingReference": "{low} à {high} {unit} à jeun",
|
||||
"personalReference": "Votre objectif : {value}",
|
||||
"bpBasisAbove": "Déterminant : {axis}, environ {offset} mmHg au-dessus de son plafond de {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Déterminant : {axis}, environ {offset} mmHg sous le seuil de basse pression de {boundary} mmHg.",
|
||||
"bpBasisInBand": "Les deux valeurs sont à leur plafond de référence ou en dessous.",
|
||||
"bpScale": "Au plafond, le score est de 85 ; 100 correspond à environ 12 mmHg en dessous.",
|
||||
"bpRepresentative": "Repose sur une moyenne des {days} derniers jours qui pèse davantage les mesures récentes, et peut donc ne correspondre à aucune mesure isolée.",
|
||||
"source": "Source : {source}, au {date}",
|
||||
"observedActivity": "{value} pas par jour",
|
||||
"observedSleep": "{value} minutes par nuit",
|
||||
|
||||
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Glicemia a digiuno: {value} {unit}",
|
||||
"fastingReference": "Da {low} a {high} {unit} a digiuno",
|
||||
"personalReference": "Il tuo obiettivo: {value}",
|
||||
"bpBasisAbove": "Determinante: {axis}, circa {offset} mmHg sopra il suo limite di {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Determinante: {axis}, circa {offset} mmHg sotto la soglia di pressione bassa di {boundary} mmHg.",
|
||||
"bpBasisInBand": "Entrambi i valori sono pari o inferiori ai loro limiti di riferimento.",
|
||||
"bpScale": "Al limite il punteggio è 85; 100 significa circa 12 mmHg più in basso.",
|
||||
"bpRepresentative": "Si basa su una media degli ultimi {days} giorni che pesa di più le misurazioni recenti, quindi può non corrispondere a nessuna singola misurazione.",
|
||||
"source": "Fonte: {source}, al {date}",
|
||||
"observedActivity": "{value} passi al giorno",
|
||||
"observedSleep": "{value} minuti per notte",
|
||||
|
||||
@@ -2301,6 +2301,11 @@
|
||||
"fastingObserved": "Glukoza na czczo: {value} {unit}",
|
||||
"fastingReference": "Od {low} do {high} {unit} na czczo",
|
||||
"personalReference": "Twój cel: {value}",
|
||||
"bpBasisAbove": "Decyduje: {axis}, około {offset} mmHg powyżej swojej granicy {boundary} mmHg.",
|
||||
"bpBasisBelowFloor": "Decyduje: {axis}, około {offset} mmHg poniżej granicy niskiego ciśnienia {boundary} mmHg.",
|
||||
"bpBasisInBand": "Obie wartości są na poziomie swoich granic referencyjnych lub poniżej.",
|
||||
"bpScale": "Przy granicy wynik to 85; 100 oznacza około 12 mmHg poniżej.",
|
||||
"bpRepresentative": "Opiera się na średniej z ostatnich {days} dni, która mocniej waży nowsze pomiary, więc może nie odpowiadać żadnemu pojedynczemu pomiarowi.",
|
||||
"source": "Źródło: {source}, stan na {date}",
|
||||
"observedActivity": "{value} kroków dziennie",
|
||||
"observedSleep": "{value} minut na noc",
|
||||
|
||||
@@ -152,10 +152,59 @@ function pillarReferenceText(
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind the cell's info popover: the scored reference, the
|
||||
* user-authored target when one exists, and the source with its as-of date.
|
||||
* Ordered as they render; an absent personal target contributes no line
|
||||
* rather than an empty one.
|
||||
* Blood pressure only: how the number came about.
|
||||
*
|
||||
* The BP pillar scores the WORSE of the two axes against a reference
|
||||
* ceiling, so the score on its own cannot say which axis it came from,
|
||||
* how far over the ceiling that axis sat, or why a reading sitting
|
||||
* exactly at the ceiling reads 85 rather than 100. All three were
|
||||
* invisible, which is what made a defensible number feel arbitrary.
|
||||
*
|
||||
* The basis is resolved on the server and travels with the score, so
|
||||
* nothing here recomputes anything: this reads `scoreBasis` and writes
|
||||
* sentences. A pillar without a basis contributes no lines at all.
|
||||
*/
|
||||
function bpBasisLines(
|
||||
pillar: ScorePillarResult,
|
||||
{ t }: PillarDetailContext,
|
||||
): string[] {
|
||||
if (pillar.id !== "BLOOD_PRESSURE" || pillar.result.status !== "ok") {
|
||||
return [];
|
||||
}
|
||||
const basis = pillar.result.value.scoreBasis;
|
||||
if (!basis) return [];
|
||||
// `charts.systolic` / `charts.diastolic` ship in all six locales and are
|
||||
// already the words the BP chart uses for these axes.
|
||||
const axis =
|
||||
basis.axis === "systolic" ? t("charts.systolic") : t("charts.diastolic");
|
||||
const params = {
|
||||
axis,
|
||||
offset: basis.offsetMmHg,
|
||||
boundary: basis.boundaryMmHg,
|
||||
};
|
||||
const basisLine =
|
||||
basis.relation === "above_ceiling"
|
||||
? t("insights.healthScore.bpBasisAbove", params)
|
||||
: basis.relation === "below_floor"
|
||||
? t("insights.healthScore.bpBasisBelowFloor", params)
|
||||
: t("insights.healthScore.bpBasisInBand");
|
||||
return [
|
||||
basisLine,
|
||||
t("insights.healthScore.bpScale"),
|
||||
// The observed pair is a recency-weighted mean over the pillar's own
|
||||
// window, not a reading anyone ever took. Saying so is what keeps the
|
||||
// observed line from reading as "I never had that reading".
|
||||
t("insights.healthScore.bpRepresentative", {
|
||||
days: pillar.result.provenance.windowDays,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind the cell's info popover: for blood pressure the basis
|
||||
* of the score, then the scored reference, the user-authored target when
|
||||
* one exists, and the source with its as-of date. Ordered as they render;
|
||||
* an absent personal target contributes no line rather than an empty one.
|
||||
*/
|
||||
export function pillarDetailLines(
|
||||
pillar: ScorePillarResult,
|
||||
@@ -165,6 +214,7 @@ export function pillarDetailLines(
|
||||
const { t } = ctx;
|
||||
const value = pillar.result.value;
|
||||
const lines = [
|
||||
...bpBasisLines(pillar, ctx),
|
||||
t("insights.healthScore.reference", {
|
||||
value: pillarReferenceText(pillar, ctx),
|
||||
}),
|
||||
|
||||
@@ -230,3 +230,247 @@ describe("gradeBpScoreFromSeries", () => {
|
||||
expect(Math.abs(live!.score - rollup!.score)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.34.5 — the basis published with the score. The pillar scores the
|
||||
* WORSE of the two axes; these pin which axis that was, what it was
|
||||
* measured against, and that the three relations the popover can render
|
||||
* are the only three that exist.
|
||||
*/
|
||||
describe("gradeBpScore — the basis", () => {
|
||||
// Hypotension floors, mirrored from `bp-in-target` so the sweep below
|
||||
// can reconstruct the boundary the grader must have used.
|
||||
const SYS_FLOOR = 90;
|
||||
const DIA_FLOOR = 50;
|
||||
|
||||
it("names the systolic axis when it is the worse one", () => {
|
||||
// 137/84: sys 8 over its ceiling, dia 5 over its own — systolic worse.
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 137,
|
||||
dia: 84,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(basis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 8,
|
||||
boundaryMmHg: 129,
|
||||
});
|
||||
expect(score).toBe(57);
|
||||
});
|
||||
|
||||
it("names the diastolic axis when it is the worse one", () => {
|
||||
// 132/88: sys 3 over, dia 9 over — diastolic worse, and the basis
|
||||
// must say so rather than defaulting to the axis people read first.
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 132,
|
||||
dia: 88,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(basis).toEqual({
|
||||
axis: "diastolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 9,
|
||||
boundaryMmHg: 79,
|
||||
});
|
||||
expect(score).toBe(55);
|
||||
});
|
||||
|
||||
it("breaks a tie toward systolic", () => {
|
||||
// Both axes sit exactly 5 mmHg over their own ceiling, so both grade
|
||||
// 65 and the score cannot say which one it came from. The documented
|
||||
// rule picks systolic.
|
||||
const { basis } = gradeBpScore({ sys: 134, dia: 84, target: UNDER65 });
|
||||
expect(basis.axis).toBe("systolic");
|
||||
expect(basis.boundaryMmHg).toBe(129);
|
||||
// …and at the ceiling itself, where both axes grade 85.
|
||||
expect(gradeBpScore({ sys: 129, dia: 79, target: UNDER65 }).basis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "in_band",
|
||||
offsetMmHg: 0,
|
||||
boundaryMmHg: 129,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a reading inside the band as in_band, measured from the ceiling", () => {
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 124,
|
||||
dia: 74,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(basis.relation).toBe("in_band");
|
||||
expect(basis.boundaryMmHg).toBe(129);
|
||||
expect(basis.offsetMmHg).toBe(5); // five below the ceiling
|
||||
expect(score).toBeGreaterThanOrEqual(85);
|
||||
});
|
||||
|
||||
it("reports a hypotensive reading as below_floor, measured from the floor", () => {
|
||||
// 86/70: systolic 4 under the 90 floor; diastolic comfortably in band.
|
||||
const { basis } = gradeBpScore({ sys: 86, dia: 70, target: UNDER65 });
|
||||
expect(basis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "below_floor",
|
||||
offsetMmHg: 4,
|
||||
boundaryMmHg: SYS_FLOOR,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps below_floor even when the score is still high — the curve is gentle near the floor", () => {
|
||||
// Worth pinning because it is easy to assume "below the floor" implies
|
||||
// a poor score: two mmHg under reads 94, comfortably green. The
|
||||
// relation is decided by where the value sits, never by the score.
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 88,
|
||||
dia: 70,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(basis.relation).toBe("below_floor");
|
||||
expect(score).toBeGreaterThanOrEqual(85);
|
||||
});
|
||||
|
||||
it("keeps the relation with the score when the rounded offset reads zero", () => {
|
||||
// 129.4 is above the ceiling and scores below 85, but rounds to a
|
||||
// 0 mmHg offset. The relation follows the unrounded value, so the
|
||||
// sentence can never claim "in band" about a sub-85 number.
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 129.4,
|
||||
dia: 70,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(basis.relation).toBe("above_ceiling");
|
||||
expect(basis.offsetMmHg).toBe(0);
|
||||
expect(score).toBeLessThan(85);
|
||||
});
|
||||
|
||||
it("reproduces the worked 137.5/87 example — systolic, ~9 over, 56", () => {
|
||||
const { score, basis } = gradeBpScore({
|
||||
sys: 137.5,
|
||||
dia: 87,
|
||||
target: UNDER65,
|
||||
});
|
||||
expect(score).toBe(56);
|
||||
expect(basis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 9,
|
||||
boundaryMmHg: 129,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the targets it was handed, not a fixed band", () => {
|
||||
const over65: BpTargets = {
|
||||
sysLow: 130,
|
||||
sysHigh: 139,
|
||||
diaLow: 70,
|
||||
diaHigh: 79,
|
||||
};
|
||||
const { basis } = gradeBpScore({ sys: 137, dia: 70, target: over65 });
|
||||
expect(basis).toEqual({
|
||||
axis: "systolic",
|
||||
relation: "in_band",
|
||||
offsetMmHg: 2,
|
||||
boundaryMmHg: 139,
|
||||
});
|
||||
});
|
||||
|
||||
it("has exactly three relations, and each one reconstructs its own reading", () => {
|
||||
// The design asserts the three relations are exhaustive; this pins it
|
||||
// against the anchors instead of trusting the prose. Every integer
|
||||
// reading across the whole plausible range must land in exactly one
|
||||
// relation, and axis + relation + boundary + offset together must
|
||||
// reconstruct the value that produced the score.
|
||||
const seen = new Set<string>();
|
||||
for (let sys = 60; sys <= 220; sys += 1) {
|
||||
for (let dia = 30; dia <= 140; dia += 1) {
|
||||
const { score, basis } = gradeBpScore({ sys, dia, target: UNDER65 });
|
||||
seen.add(basis.relation);
|
||||
const value = basis.axis === "systolic" ? sys : dia;
|
||||
const floor = basis.axis === "systolic" ? SYS_FLOOR : DIA_FLOOR;
|
||||
const ceiling =
|
||||
basis.axis === "systolic" ? UNDER65.sysHigh : UNDER65.diaHigh;
|
||||
|
||||
if (basis.relation === "below_floor") {
|
||||
expect(value).toBeLessThan(floor);
|
||||
expect(basis.boundaryMmHg).toBe(floor);
|
||||
expect(floor - value).toBe(basis.offsetMmHg);
|
||||
} else {
|
||||
expect(value).toBeGreaterThanOrEqual(floor);
|
||||
expect(basis.boundaryMmHg).toBe(ceiling);
|
||||
expect(Math.abs(value - ceiling)).toBe(basis.offsetMmHg);
|
||||
if (basis.relation === "in_band") {
|
||||
expect(value).toBeLessThanOrEqual(ceiling);
|
||||
// The scale line only makes sense if in_band never reads red.
|
||||
expect(score).toBeGreaterThanOrEqual(85);
|
||||
} else {
|
||||
expect(basis.relation).toBe("above_ceiling");
|
||||
expect(value).toBeGreaterThan(ceiling);
|
||||
expect(score).toBeLessThan(85);
|
||||
}
|
||||
}
|
||||
|
||||
// The named axis alone carries the score: replace the other axis
|
||||
// with an optimal value and the number must not move.
|
||||
const optimalOther = gradeBpScore(
|
||||
basis.axis === "systolic"
|
||||
? { sys, dia: UNDER65.diaHigh - 12, target: UNDER65 }
|
||||
: { sys: UNDER65.sysHigh - 12, dia, target: UNDER65 },
|
||||
);
|
||||
expect(optimalOther.score).toBe(score);
|
||||
}
|
||||
}
|
||||
expect([...seen].sort()).toEqual([
|
||||
"above_ceiling",
|
||||
"below_floor",
|
||||
"in_band",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gradeBpScoreFromSeries — the basis travels with the score", () => {
|
||||
const NOW = new Date("2026-06-07T12:00:00.000Z");
|
||||
|
||||
it("returns the basis of the representative it graded, not of any single pair", () => {
|
||||
// Two readings, one at each end. The representative is their mean
|
||||
// (both same-day, so equal weight): 130/85. The basis must describe
|
||||
// that mean, which matches neither reading.
|
||||
const graded = gradeBpScoreFromSeries({
|
||||
pairs: [
|
||||
{ at: NOW, sys: 120, dia: 80 },
|
||||
{ at: NOW, sys: 140, dia: 90 },
|
||||
],
|
||||
target: UNDER65,
|
||||
now: NOW,
|
||||
});
|
||||
expect(graded).not.toBeNull();
|
||||
expect(graded!.basis).toEqual({
|
||||
axis: "diastolic",
|
||||
relation: "above_ceiling",
|
||||
offsetMmHg: 6,
|
||||
boundaryMmHg: 79,
|
||||
});
|
||||
expect(graded!.score).toBe(
|
||||
gradeBpScore({ sys: 130, dia: 85, target: UNDER65 }).score,
|
||||
);
|
||||
});
|
||||
|
||||
it("grades the basis against the targets it was given", () => {
|
||||
// The analytics route re-runs this helper with the CLINICAL targets
|
||||
// when the user keeps a personal one. Same series, two bands: both
|
||||
// the score and the basis must move together.
|
||||
const pairs: BpPairPoint[] = [{ at: NOW, sys: 132, dia: 88 }];
|
||||
const clinical = gradeBpScoreFromSeries({
|
||||
pairs,
|
||||
target: UNDER65,
|
||||
now: NOW,
|
||||
})!;
|
||||
const personal = gradeBpScoreFromSeries({
|
||||
pairs,
|
||||
target: { sysLow: 120, sysHigh: 135, diaLow: 70, diaHigh: 90 },
|
||||
now: NOW,
|
||||
})!;
|
||||
expect(clinical.basis.boundaryMmHg).toBe(79);
|
||||
expect(clinical.basis.relation).toBe("above_ceiling");
|
||||
expect(personal.basis.relation).toBe("in_band");
|
||||
expect(personal.score).toBeGreaterThan(clinical.score);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user