mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat: say how much of the record the score rests on
Two areas of health score now, and one does. The arithmetic is the same
at every breadth, so nothing on the web surfaces distinguished a number
resting on blood pressure alone from one resting on five areas. The
panel now states its scope.
The basis line. Below the recommended breadth the card renders "Based on
{n} of 3 areas of health" under the band sentence, in the same weight,
and stops there. It stands where the learning gate used to stand for
those accounts, which is what the gate was for before the refusal moved.
The number keeps its size and its band colour at every tier, there is no
warning glyph, no second colour, no apology: the coverage meter and the
confidence band underneath already do the quantitative caveat work, and
the composition list names the areas one tap away. At full breadth the
line says nothing, because a fraction that reads "3 of 3" (or "4 of 3" —
the block counts domains, the recommendation caps at three) is not a
label. The daily hero carries the same sentence beside its ring, in the
hero's quiet tier, because the ring shows a number and nothing else.
The gate survives for the one case that is still a refusal: not a single
pillar readable. Its copy stopped claiming the score needs three areas
and stopped counting them — above zero there is a score now, and the
meter below renders the fraction anyway.
The coverage axis. "Covers the three areas the score needs" was false
the moment three stopped being needed, and the branch that chose it was
worse: it asked whether a score exists, so a two-area account would have
been told it covers the three areas the score recommends — the old lie
one floor up. The branch is the breadth tier now. Full breadth states
the recommendation; everything narrower shows the fraction, which since
this release is a real moving number rather than a floor every scored
account cleared by definition. The key was renamed off "minimumMet"
because there is no minimum left to name.
Settings names the recommendation rather than a requirement: three areas
is what the score recommends, a selection with one pillar saves, and a
narrower score is worked out the same way.
Copy lands in all seven catalogues.
Tests: the axis guard gains a third claim, rendered rather than read out
of the source — the real panel at each tier, including the two-area arm
that was unreachable for a scored account until now. The card gains a
partial-score block comparing the headline number's markup byte for byte
against a full-breadth render, so "renders the same" is asserted where
it can actually break, plus the counter-case that a refusal still gets
the gate and nothing else. The hero strip proves the sentence reaches
the band, not only the component. The geometry reserve was re-measured
with a browser rather than widened: a three-row panel carrying the basis
line lands at 420px, inside the 400-484px range the reserve brackets, so
the declared minimum is unchanged and the narrow shape is now measured.
This commit is contained in:
1 parent
320788a984
commit
59ccf5be2a
17 files changed
+578
-60
No files matched your search
@@ -281,7 +281,20 @@ export function buildMockSnapshot(
|
||||
nextDueMedicationId: null,
|
||||
},
|
||||
healthScore: options.briefing
|
||||
? { score: 84, band: "green", delta: -2, restMode: null }
|
||||
? {
|
||||
score: 84,
|
||||
band: "green",
|
||||
delta: -2,
|
||||
restMode: null,
|
||||
// v1.38 — full breadth, so nothing extra renders. Present
|
||||
// because the server sends it on every scored snapshot.
|
||||
scoreBasis: {
|
||||
domains: 3,
|
||||
recommended: 3,
|
||||
tier: "full" as const,
|
||||
physiological: true,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
scoreRings: options.scoreRings,
|
||||
briefing: options.briefing ?? null,
|
||||
|
||||
@@ -170,6 +170,17 @@ const HEALTH_SCORE = {
|
||||
bandSetter: "SLEEP",
|
||||
composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"],
|
||||
configured: false,
|
||||
// v1.38 — four pillars across four areas of health, so this is a
|
||||
// full-breadth score and the panel adds no basis line. It rides
|
||||
// anyway, because the wire carries the block on every ok composite
|
||||
// and a fixture missing a field the server always sends stops
|
||||
// exercising the branch a person meets.
|
||||
scoreBasis: {
|
||||
domains: 4,
|
||||
recommended: 3,
|
||||
tier: "full" as const,
|
||||
physiological: true,
|
||||
},
|
||||
noiseFloor: 3,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Einige Bereiche brauchen Aufmerksamkeit",
|
||||
"red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs"
|
||||
},
|
||||
"insufficient": "{count} von {required} Gesundheitsbereichen haben genug aktuelle Daten. Der Score braucht {required}, mindestens einen davon körperlich gemessen.",
|
||||
"insufficient": "Noch hat nichts genug aktuelle Daten für einen Score. Ein Feld der Gesundheit reicht für den Anfang, und der Score nennt selbst, worauf er beruht.",
|
||||
"basis": "Beruht auf {count} von {recommended} Feldern der Gesundheit.",
|
||||
"anatomyToggle": "Wie dieser Score zustande kommt",
|
||||
"notScored": "Noch nicht bewertet ({count})",
|
||||
"method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Festlegen, was in deinen Score einfließt",
|
||||
"coverage": {
|
||||
"minimumMet": "Deckt die drei Felder ab, die der Score braucht",
|
||||
"recommendedMet": "Deckt die drei Felder ab, die der Score empfiehlt",
|
||||
"areas": "{present} von {required} Feldern der Gesundheit"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Jeder Bereich, den du aufzeichnest, zählt mit, solange du hier nichts anderes sagst."
|
||||
},
|
||||
"threeAxes": "Aufzeichnen, Anzeigen und Mitzählen sind drei verschiedene Dinge. Diese Seite ändert nur das Letzte. Ein Bereich, den du hier abschaltest, wird weiter aufgezeichnet und weiter überall in HealthLog angezeigt, er bewegt nur die Zahl nicht mehr.",
|
||||
"recommendation": "Drei Felder der Gesundheit sind die Empfehlung, keine Bedingung: Jede Auswahl mit mindestens einem Bereich lässt sich speichern. Ein schmalerer Score wird genauso berechnet und nennt auf der Karte, auf wie vielen Feldern er beruht.",
|
||||
"axis": {
|
||||
"countsFor": "{pillar} in den Score einrechnen"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Some pillars need attention",
|
||||
"red": "One or more pillars are outside the reference range"
|
||||
},
|
||||
"insufficient": "{count} of {required} areas of health have enough recent data. The score needs {required}, at least one of them a physical measurement.",
|
||||
"insufficient": "Nothing has enough recent data to score yet. One area of health is enough to start, and the score will say which areas it rests on.",
|
||||
"basis": "Based on {count} of {recommended} areas of health.",
|
||||
"anatomyToggle": "How this score comes about",
|
||||
"notScored": "Not scored yet ({count})",
|
||||
"method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Choose what counts toward your score",
|
||||
"coverage": {
|
||||
"minimumMet": "Covers the three areas the score needs",
|
||||
"recommendedMet": "Covers the three areas the score recommends",
|
||||
"areas": "{present} of {required} areas of health"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Every pillar you record counts unless you say otherwise here."
|
||||
},
|
||||
"threeAxes": "Recording, showing and counting are three separate things. This page changes only the last one. A pillar you switch off here keeps being recorded and keeps appearing everywhere else in HealthLog, it just stops moving the number.",
|
||||
"recommendation": "Three areas of health is the recommendation, not a requirement: a selection with at least one pillar saves. A narrower score is worked out the same way and says on the card how many areas it rests on.",
|
||||
"axis": {
|
||||
"countsFor": "Count {pillar} toward the score"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Algunos pilares requieren atención",
|
||||
"red": "Uno o más pilares están fuera del intervalo de referencia"
|
||||
},
|
||||
"insufficient": "{count} de {required} ámbitos de salud tienen suficientes datos recientes. La puntuación necesita {required}, y al menos uno debe ser una medición física.",
|
||||
"insufficient": "Todavía no hay datos recientes suficientes para puntuar nada. Con un solo ámbito de salud basta para empezar, y la puntuación dirá en qué ámbitos se basa.",
|
||||
"basis": "Basado en {count} de {recommended} ámbitos de salud.",
|
||||
"anatomyToggle": "Cómo se forma esta puntuación",
|
||||
"notScored": "Aún sin puntuar ({count})",
|
||||
"method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Elegir qué cuenta para tu puntuación",
|
||||
"coverage": {
|
||||
"minimumMet": "Cubre los tres ámbitos que la puntuación necesita",
|
||||
"recommendedMet": "Cubre los tres ámbitos que la puntuación recomienda",
|
||||
"areas": "{present} de {required} ámbitos de salud"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Cada pilar que registras cuenta, salvo que digas lo contrario aquí."
|
||||
},
|
||||
"threeAxes": "Registrar, mostrar y contar son tres cosas distintas. Esta página cambia solo la última. Un pilar que desactives aquí se sigue registrando y se sigue viendo en el resto de HealthLog, simplemente deja de mover el número.",
|
||||
"recommendation": "Tres ámbitos de salud son la recomendación, no un requisito: cualquier selección con al menos un pilar se guarda. Una puntuación más estrecha se calcula igual y dice en la tarjeta en cuántos ámbitos se basa.",
|
||||
"axis": {
|
||||
"countsFor": "Contar {pillar} para la puntuación"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Certains piliers demandent une attention",
|
||||
"red": "Un ou plusieurs piliers sont hors de la plage de référence"
|
||||
},
|
||||
"insufficient": "{count} domaines de santé sur {required} disposent d'assez de données récentes. Le score en demande {required}, dont au moins un issu d'une mesure physique.",
|
||||
"insufficient": "Rien ne dispose encore d'assez de données récentes pour être noté. Un seul domaine de santé suffit pour commencer, et le score indiquera sur quels domaines il repose.",
|
||||
"basis": "Basé sur {count} domaines de santé sur {recommended}.",
|
||||
"anatomyToggle": "Comment ce score est établi",
|
||||
"notScored": "Pas encore évalué ({count})",
|
||||
"method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Choisir ce qui compte dans votre score",
|
||||
"coverage": {
|
||||
"minimumMet": "Couvre les trois domaines exigés par le score",
|
||||
"recommendedMet": "Couvre les trois domaines recommandés par le score",
|
||||
"areas": "{present} domaines de santé sur {required}"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Chaque pilier que vous enregistrez compte, sauf indication contraire ici."
|
||||
},
|
||||
"threeAxes": "Enregistrer, afficher et compter sont trois choses distinctes. Cette page ne change que la dernière. Un pilier désactivé ici continue d'être enregistré et reste visible partout ailleurs dans HealthLog, il cesse simplement de faire bouger le chiffre.",
|
||||
"recommendation": "Trois domaines de santé sont une recommandation, pas une exigence : toute sélection comportant au moins un pilier est enregistrée. Un score plus étroit est calculé de la même façon et indique sur la carte sur combien de domaines il repose.",
|
||||
"axis": {
|
||||
"countsFor": "Compter {pillar} dans le score"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Alcuni pilastri richiedono attenzione",
|
||||
"red": "Uno o più pilastri sono fuori dall'intervallo di riferimento"
|
||||
},
|
||||
"insufficient": "{count} ambiti di salute su {required} hanno abbastanza dati recenti. Il punteggio ne richiede {required}, di cui almeno uno misurato fisicamente.",
|
||||
"insufficient": "Non c'è ancora niente con abbastanza dati recenti da valutare. Un solo ambito di salute basta per iniziare, e il punteggio dirà su quali ambiti si basa.",
|
||||
"basis": "Basato su {count} ambiti di salute su {recommended}.",
|
||||
"anatomyToggle": "Come nasce questo punteggio",
|
||||
"notScored": "Non ancora valutato ({count})",
|
||||
"method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Scegli cosa conta nel tuo punteggio",
|
||||
"coverage": {
|
||||
"minimumMet": "Copre i tre ambiti richiesti dal punteggio",
|
||||
"recommendedMet": "Copre i tre ambiti consigliati dal punteggio",
|
||||
"areas": "{present} ambiti di salute su {required}"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Ogni pilastro che registri conta, a meno che tu non dica diversamente qui."
|
||||
},
|
||||
"threeAxes": "Registrare, mostrare e contare sono tre cose diverse. Questa pagina cambia solo l'ultima. Un pilastro che disattivi qui continua a essere registrato e resta visibile ovunque in HealthLog, smette solo di muovere il numero.",
|
||||
"recommendation": "Tre ambiti di salute sono il consiglio, non un requisito: qualsiasi selezione con almeno un pilastro viene salvata. Un punteggio più ristretto viene calcolato allo stesso modo e indica sulla scheda su quanti ambiti si basa.",
|
||||
"axis": {
|
||||
"countsFor": "Conta {pillar} nel punteggio"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "일부 항목은 주의가 필요해요",
|
||||
"red": "참고 범위를 벗어난 항목이 하나 이상 있어요"
|
||||
},
|
||||
"insufficient": "건강 영역 {required}개 중 {count}개에 최근 데이터가 충분해요. 점수에는 {required}개가 필요하고, 그중 하나는 신체 측정이어야 해요.",
|
||||
"insufficient": "아직 점수를 매길 만큼 최근 데이터가 쌓인 곳이 없어요. 건강 영역 하나면 시작할 수 있고, 점수가 어떤 영역을 바탕으로 하는지 알려 줘요.",
|
||||
"basis": "건강 영역 {recommended}개 중 {count}개를 바탕으로 해요.",
|
||||
"anatomyToggle": "이 점수가 만들어지는 방식",
|
||||
"notScored": "아직 반영되지 않음 ({count})",
|
||||
"method": "점수는 반영 대상 항목을 같은 비중으로 평균 낸 값이고, 0에서 100까지예요. 각 항목은 공개된 참고 구간에 맞춰 채점되고, 가장 약한 항목이 전체 구간을 정할 수 있어요. 최근 데이터가 부족한 항목은 0점으로 세지 않고 빼 둬요. 참고 구간은 공개된 출처에서 가져왔고, 어떤 항목을 반영할지는 직접 고르는 값이라 합쳐진 숫자 자체가 임상 기준은 아니에요.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "점수에 반영할 항목 고르기",
|
||||
"coverage": {
|
||||
"minimumMet": "점수에 필요한 세 영역을 채웠어요",
|
||||
"recommendedMet": "점수가 권장하는 세 영역을 모두 채웠어요",
|
||||
"areas": "건강 영역 {required}개 중 {present}개"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "여기서 따로 끄지 않으면, 기록하는 모든 항목이 점수에 반영돼요."
|
||||
},
|
||||
"threeAxes": "기록하는 것, 보여주는 것, 점수에 반영하는 것은 서로 다른 세 가지예요. 이 페이지는 마지막 하나만 바꿔요. 여기서 끈 항목도 계속 기록되고 HealthLog의 다른 곳에도 그대로 나오며, 점수만 움직이지 않아요.",
|
||||
"recommendation": "건강 영역 세 개는 권장 사항이지 필수는 아니에요. 항목을 하나만 골라도 저장돼요. 더 좁은 점수도 같은 방식으로 계산되고, 카드에 몇 개 영역을 바탕으로 했는지 나와요.",
|
||||
"axis": {
|
||||
"countsFor": "{pillar} 점수에 반영"
|
||||
},
|
||||
|
||||
+4
-2
@@ -2453,7 +2453,8 @@
|
||||
"yellow": "Niektóre filary wymagają uwagi",
|
||||
"red": "Co najmniej jeden filar jest poza zakresem referencyjnym"
|
||||
},
|
||||
"insufficient": "{count} z {required} obszarów zdrowia ma wystarczająco świeżych danych. Wynik potrzebuje {required}, w tym co najmniej jednego pomiaru fizycznego.",
|
||||
"insufficient": "Nic nie ma jeszcze wystarczająco świeżych danych, żeby to ocenić. Na początek wystarczy jeden obszar zdrowia, a wynik sam powie, na których obszarach się opiera.",
|
||||
"basis": "Opiera się na {count} z {recommended} obszarów zdrowia.",
|
||||
"anatomyToggle": "Jak powstaje ten wynik",
|
||||
"notScored": "Jeszcze bez oceny ({count})",
|
||||
"method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.",
|
||||
@@ -2533,7 +2534,7 @@
|
||||
},
|
||||
"configureLink": "Wybierz, co liczy się do Twojego wyniku",
|
||||
"coverage": {
|
||||
"minimumMet": "Pokrywa trzy obszary wymagane przez wynik",
|
||||
"recommendedMet": "Pokrywa trzy obszary zalecane przez wynik",
|
||||
"areas": "{present} z {required} obszarów zdrowia"
|
||||
}
|
||||
},
|
||||
@@ -5241,6 +5242,7 @@
|
||||
"description": "Każdy filar, który zapisujesz, liczy się, dopóki nie powiesz tutaj inaczej."
|
||||
},
|
||||
"threeAxes": "Zapisywanie, pokazywanie i liczenie to trzy różne rzeczy. Ta strona zmienia tylko ostatnią. Filar wyłączony tutaj nadal jest zapisywany i nadal widoczny w całym HealthLogu, przestaje jedynie ruszać liczbą.",
|
||||
"recommendation": "Trzy obszary zdrowia to zalecenie, nie wymóg: zapisze się każdy wybór z co najmniej jednym filarem. Węższy wynik liczony jest tak samo i podaje na karcie, na ilu obszarach się opiera.",
|
||||
"axis": {
|
||||
"countsFor": "Licz {pillar} do wyniku"
|
||||
},
|
||||
|
||||
@@ -385,3 +385,62 @@ describe("<TodayHero>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38 — the hero's ring shows the number and nothing else, so a score
|
||||
* resting on one area of health would look here exactly like one resting
|
||||
* on five. The basis line is the whole difference; it is stated only
|
||||
* below the recommended breadth, in the hero's quiet tier, and it never
|
||||
* touches the ring itself.
|
||||
*/
|
||||
describe("<TodayHero> score basis", () => {
|
||||
function withBasis(
|
||||
domains: number,
|
||||
tier: "full" | "partial" | "minimal",
|
||||
): DailyDigest {
|
||||
return digest({
|
||||
score: {
|
||||
value: 82,
|
||||
band: "green",
|
||||
delta: 3,
|
||||
deltaReason: null,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
scoreBasis: { domains, recommended: 3, tier, physiological: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("says what a two-area score rests on", () => {
|
||||
const html = render(<TodayHero digest={withBasis(2, "partial")} />);
|
||||
expect(html).toContain('data-slot="today-hero-score-basis"');
|
||||
expect(visibleText(html)).toContain("Based on 2 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("leaves the ring's own face untouched", () => {
|
||||
const html = render(<TodayHero digest={withBasis(1, "minimal")} />);
|
||||
// Same band, same populated face, same delta chip as a full-breadth
|
||||
// day: the line is scope, not a downgrade.
|
||||
expect(html).toContain('data-band="green"');
|
||||
expect(html).not.toContain('data-provisional="true"');
|
||||
expect(html).toContain('data-slot="today-hero-score-delta"');
|
||||
expect(visibleText(html)).toContain("82");
|
||||
});
|
||||
|
||||
it("says nothing once the recommended breadth is met", () => {
|
||||
const html = render(<TodayHero digest={withBasis(3, "full")} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
|
||||
it("says nothing for a digest carrying no basis at all", () => {
|
||||
// An older cached digest. The hero never counts areas out of
|
||||
// `composition` — three pillars can be one area.
|
||||
const html = render(<TodayHero digest={digest()} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
|
||||
it("says nothing when there is no score to rest on anything", () => {
|
||||
const html = render(<TodayHero digest={digest({ score: null })} />);
|
||||
expect(html).not.toContain('data-slot="today-hero-score-basis"');
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,24 @@ export function TodayHero({
|
||||
// server-delivered digest, so SSR and hydration choose it identically.
|
||||
const compactAllClear = !lead && !topSignal && !hasItems;
|
||||
|
||||
// v1.38 — what the ring's number rests on, when that is less than the
|
||||
// breadth the score recommends. The hero renders the number and nothing
|
||||
// else, so without this line a one-area score is indistinguishable here
|
||||
// from a full one. Said only below the recommendation: at `full` the
|
||||
// fraction would read "3 of 3" (or worse, "4 of 3" — the block counts
|
||||
// domains, the recommendation caps at three), and a line every account
|
||||
// carries stops being a label. The areas themselves are named on the
|
||||
// insights card, one tap away through the ring; this is scope, not a
|
||||
// warning, and the ring keeps its size and band colour at every tier.
|
||||
const scoreBasis = digest.score?.scoreBasis ?? null;
|
||||
const basisLine =
|
||||
scoreBasis && scoreBasis.tier !== "full"
|
||||
? t("insights.healthScore.basis", {
|
||||
count: scoreBasis.domains,
|
||||
recommended: scoreBasis.recommended,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Calm degrade (plan §3): a genuinely empty account — no score, no rail
|
||||
// items, no cached briefing lead and no reaction line — surfaces nothing
|
||||
// here. The tile strip below carries its own "add your first reading" empty
|
||||
@@ -371,6 +389,14 @@ export function TodayHero({
|
||||
{formatDelta(digest.score.delta, t)}
|
||||
</span>
|
||||
) : null}
|
||||
{basisLine ? (
|
||||
<span
|
||||
data-slot="today-hero-score-basis"
|
||||
className="text-muted-foreground max-w-[11rem] text-center text-xs text-balance md:text-right"
|
||||
>
|
||||
{basisLine}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -181,6 +181,28 @@ function reportWithRows(rows: number): HealthScoreReport {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.38 — the same panel, plus the basis line a narrow score carries.
|
||||
*
|
||||
* It is the one block the milestone adds to the at-rest column, and it
|
||||
* lands on the shortest panels: an account with one or two areas of
|
||||
* health has few rows, so this is the shape that decides whether the
|
||||
* reserve still brackets what the report produces.
|
||||
*/
|
||||
function reportWithBasis(rows: number): HealthScoreReport {
|
||||
const report = reportWithRows(rows);
|
||||
if (report.composite.status !== "ok") {
|
||||
throw new Error("fixture is not scored");
|
||||
}
|
||||
report.composite.value.scoreBasis = {
|
||||
domains: 1,
|
||||
recommended: 3,
|
||||
tier: "minimal",
|
||||
physiological: true,
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
function band(node: React.ReactNode, locale: Locale = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
@@ -234,17 +256,28 @@ describe("hero band geometry", () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const heights: Record<string, number> = {};
|
||||
for (const rows of [3, 4, 5, 6]) {
|
||||
const shapes: { key: string; report: HealthScoreReport }[] = [
|
||||
...[3, 4, 5, 6].map((rows) => ({
|
||||
key: `rows${rows}`,
|
||||
report: reportWithRows(rows),
|
||||
})),
|
||||
// The narrow account: three rows AND the basis line. Measured
|
||||
// rather than reasoned about, because whether one extra `text-xs`
|
||||
// line pushes a three-row panel past the reserve is a fact about
|
||||
// the compiled stylesheet, not about the diff.
|
||||
{ key: "rows3basis", report: reportWithBasis(3) },
|
||||
];
|
||||
for (const shape of shapes) {
|
||||
const view = await layout(
|
||||
band(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date("2026-05-10T07:00:00Z")}
|
||||
healthScore={reportWithRows(rows)}
|
||||
healthScore={shape.report}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
heights[`rows${rows}`] = await view
|
||||
heights[shape.key] = await view
|
||||
.locator(PANEL)
|
||||
.evaluate((el) => Math.round(el.getBoundingClientRect().height));
|
||||
const width = await view
|
||||
@@ -295,9 +328,16 @@ describe("hero band geometry", () => {
|
||||
reserve,
|
||||
`reserve ${reserve}px is above a six-row panel (${heights.rows6}px): a long report leaves a collapsing gap`,
|
||||
).toBeLessThanOrEqual(heights.rows6);
|
||||
// v1.38 — the narrow account has to sit inside the same range. The
|
||||
// basis line only ever renders on a panel below the recommended
|
||||
// breadth, so this is the tallest the shortest report can get.
|
||||
expect(
|
||||
heights.rows3basis,
|
||||
`a three-row panel with the basis line is ${heights.rows3basis}px, past the tallest report the reserve brackets (${heights.rows6}px)`,
|
||||
).toBeLessThanOrEqual(heights.rows6);
|
||||
}, 60_000);
|
||||
|
||||
it("keeps every row on one line in all six locales", async () => {
|
||||
it("keeps every row on one line in every locale", async () => {
|
||||
if (skipReason) return expect(skipReason).toBeTruthy();
|
||||
|
||||
const widest: Record<string, { label: string; natural: number }> = {};
|
||||
|
||||
@@ -589,13 +589,19 @@ describe("<HealthScoreCard> composite states", () => {
|
||||
/data-slot="health-score-card"[^>]*data-status="insufficient"/,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-insufficient"');
|
||||
// `presentInputs` counts distinct DOMAINS. The sentence used to call
|
||||
// them pillars, which told an account with blood pressure, glycaemia
|
||||
// and lipids that it had one eligible pillar while it had three.
|
||||
// The gate survives exactly one case since v1.38: nothing readable at
|
||||
// all. It no longer names a breadth the score "needs" — there is none,
|
||||
// one area is enough — and it no longer counts areas, because above
|
||||
// zero there is a score instead of a gate.
|
||||
expect(html).toContain(
|
||||
"1 of 3 areas of health have enough recent data. The score needs 3, at least one of them a physical measurement.",
|
||||
"Nothing has enough recent data to score yet. One area of health is enough to start, and the score will say which areas it rests on.",
|
||||
);
|
||||
expect(html).not.toMatch(/eligible pillars/i);
|
||||
expect(html).not.toMatch(/the score needs/i);
|
||||
// The gate is the refusal's own face and nothing else wears it: a
|
||||
// narrow score gets the basis line, a different sentence in a
|
||||
// different slot.
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
// No band sentence and no fabricated number: a dash, and no bar either.
|
||||
expect(html).not.toContain('data-slot="health-score-band"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
@@ -929,3 +935,132 @@ describe("markAlgorithmNoticeDismissed", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38 — the basis line, and the promise it carries.
|
||||
*
|
||||
* Two domains score now, and one does. The panel's whole job at those
|
||||
* breadths is to say so without saying it apologetically: the number keeps
|
||||
* its size and its band colour, the bar stays, and a single sentence under
|
||||
* the band states the scope. What the panel must NOT grow is a second
|
||||
* visual language for narrow scores — a smaller number, a drained colour,
|
||||
* a warning glyph, a gate. The number's own markup is compared against the
|
||||
* full-breadth render rather than pattern-matched, because "renders the
|
||||
* same" is the actual claim and a class list is the only place it can
|
||||
* break.
|
||||
*/
|
||||
describe("<HealthScoreCard> basis line", () => {
|
||||
function withBasis(
|
||||
domains: number,
|
||||
tier: "full" | "partial" | "minimal",
|
||||
band: "green" | "yellow" | "red" = "green",
|
||||
): HealthScoreReport {
|
||||
return scoredReport({
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 86,
|
||||
band,
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
scoreBasis: { domains, recommended: 3, tier, physiological: true },
|
||||
noiseFloor: 3,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: domains,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 70, band: "medium" },
|
||||
provenance,
|
||||
},
|
||||
} as Partial<HealthScoreReport>);
|
||||
}
|
||||
|
||||
/** The opening tag of the headline number, classes and all. */
|
||||
function numberTag(html: string): string {
|
||||
const match = html.match(
|
||||
/<span data-slot="health-score-card-number"[^>]*>/,
|
||||
);
|
||||
expect(match, "the headline number is missing").not.toBeNull();
|
||||
return match![0];
|
||||
}
|
||||
|
||||
it("states the scope of a two-area score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(2, "partial")} />);
|
||||
expect(atRest(html)).toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("Based on 2 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("states the scope of a one-area score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(1, "minimal")} />);
|
||||
expect(html).toContain("Based on 1 of 3 areas of health.");
|
||||
});
|
||||
|
||||
it("renders a partial score's number exactly as a full one's", () => {
|
||||
const partial = render(
|
||||
<HealthScoreCard report={withBasis(1, "minimal")} />,
|
||||
);
|
||||
const full = render(<HealthScoreCard report={withBasis(3, "full")} />);
|
||||
|
||||
// Byte-for-byte: same size classes, same band colour, same slot.
|
||||
expect(numberTag(partial)).toBe(numberTag(full));
|
||||
expect(numberTag(partial)).toContain("text-5xl");
|
||||
expect(numberTag(partial)).toContain("sm:text-6xl");
|
||||
expect(numberTag(partial)).toContain("text-success");
|
||||
// And the number itself is there, at full value, with its bar.
|
||||
expect(partial).toContain(">86<");
|
||||
expect(partial).toContain("/ 100");
|
||||
expect(partial).toContain('data-slot="health-score-card-progress"');
|
||||
expect(partial).toMatch(
|
||||
/data-slot="health-score-card"[^>]*data-status="ok"/,
|
||||
);
|
||||
expect(partial).toMatch(
|
||||
/data-slot="health-score-card"[^>]*data-band="green"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a red partial score in its own band colour", () => {
|
||||
// The band drag is arithmetic and the tier is labelling; a narrow set
|
||||
// may not recolour the number either way.
|
||||
const html = render(
|
||||
<HealthScoreCard report={withBasis(1, "minimal", "red")} />,
|
||||
);
|
||||
expect(numberTag(html)).toContain("text-destructive");
|
||||
});
|
||||
|
||||
it("adds no gate, no alert and no glyph beside a narrow score", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(1, "minimal")} />);
|
||||
const rest = atRest(html);
|
||||
expect(rest).not.toContain('data-slot="health-score-insufficient"');
|
||||
expect(rest).not.toMatch(/enough recent data to score yet/i);
|
||||
// The only `role="alert"` this panel has ever carried is the failed
|
||||
// read; a narrow score is not a failure and must not borrow it.
|
||||
expect(rest).not.toContain('role="alert"');
|
||||
expect(rest).toContain('data-slot="health-score-band"');
|
||||
});
|
||||
|
||||
it("says nothing extra once the recommended breadth is met", () => {
|
||||
const html = render(<HealthScoreCard report={withBasis(3, "full")} />);
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
expect(html).not.toMatch(/Based on \d+ of 3 areas/);
|
||||
});
|
||||
|
||||
it("says nothing when the composite carries no basis at all", () => {
|
||||
// An older cached payload. The line is a server-resolved fact; the
|
||||
// panel never counts domains out of `composition` to invent one.
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
});
|
||||
|
||||
it("still shows the gate, and only the gate, on a refusal", () => {
|
||||
const html = render(<HealthScoreCard report={report()} />);
|
||||
expect(html).toContain('data-slot="health-score-insufficient"');
|
||||
expect(html).not.toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("—");
|
||||
expect(html).not.toContain('data-slot="health-score-card-progress"');
|
||||
});
|
||||
});
|
||||
@@ -13,29 +13,49 @@
|
||||
* fails on the value AND on the copy — passing the domain count into a
|
||||
* pillar-shaped string is caught wherever the string lives.
|
||||
*
|
||||
* **2. The composite's coverage fraction is a floor, not a proportion.**
|
||||
* `requiredInputs` is the three-domain minimum and `deriveCoverage` clamps
|
||||
* `presentInputs` to it, so every scored account rendered "3/3" forever,
|
||||
* whatever it records. The arithmetic is right and stays untouched; what
|
||||
* was wrong is presenting a met floor as full coverage of the person's own
|
||||
* data. The composite's meter therefore names its axis, and the guard
|
||||
* fails if that override is dropped.
|
||||
* **2. The composite's coverage fraction was a floor, not a proportion.**
|
||||
* `requiredInputs` is the three-domain recommendation and `deriveCoverage`
|
||||
* clamps `presentInputs` to it. Until v1.38 three domains were also the
|
||||
* price of a score, so every scored account rendered "3/3" forever,
|
||||
* whatever it recorded: a met floor presented as full coverage of the
|
||||
* person's own data. The composite's meter therefore names its axis, and
|
||||
* the guard fails if that override is dropped.
|
||||
*
|
||||
* Both are AST matchers rather than text searches, and both carry a
|
||||
* counter-test proving they stay quiet on the neighbouring call sites that
|
||||
* are legitimately about pillars.
|
||||
* **3. Since v1.38 the fraction is a real moving number, and the axis has
|
||||
* to move with it.** Two domains now score, so a scored account no longer
|
||||
* clears the recommendation by definition and the override cannot branch
|
||||
* on "is there a score" any more — that would tell a two-domain account it
|
||||
* covers the three areas the score recommends, which is claim 2 rebuilt one
|
||||
* floor higher. The branch is the breadth TIER: `full` states the
|
||||
* recommendation, everything narrower shows the fraction it can actually
|
||||
* count. The last block below renders the real panel at each tier rather
|
||||
* than reading the branch out of the source, because a branch that reads
|
||||
* correctly and renders the other arm is the failure this pair of claims
|
||||
* keeps producing.
|
||||
*
|
||||
* The first two are AST matchers rather than text searches, and both carry
|
||||
* a counter-test proving they stay quiet on the neighbouring call sites
|
||||
* that are legitimately about pillars.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { ScoreBreadthTier } from "@/lib/analytics/score/breadth";
|
||||
import type { HealthScoreReport } from "@/lib/analytics/score/types";
|
||||
import { SCORE_VERSION } from "@/lib/analytics/score/types";
|
||||
import { CoverageMeter } from "../derived/coverage-meter";
|
||||
import { HealthScoreCard } from "../health-score-card";
|
||||
import en from "../../../../messages/en.json";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { unitPreference: "metric", glucoseUnit: "mg/dL" } }),
|
||||
}));
|
||||
|
||||
const CARD_FILE = join(
|
||||
process.cwd(),
|
||||
"src/components/insights/health-score-card.tsx",
|
||||
@@ -155,9 +175,13 @@ describe("the domain count is never described as a pillar count", () => {
|
||||
|
||||
it("finds the call site at all", () => {
|
||||
// A matcher that matches nothing passes every assertion below it.
|
||||
// The named example is the axis fraction since v1.38: the refusal
|
||||
// sentence stopped counting areas when it stopped being reachable
|
||||
// above zero, and the meter a few lines below renders the same
|
||||
// fraction anyway.
|
||||
expect(sites.length).toBeGreaterThan(0);
|
||||
expect(sites.map((site) => site.key)).toContain(
|
||||
"insights.healthScore.insufficient",
|
||||
"insights.healthScore.coverage.areas",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,9 +250,12 @@ describe("the composite's coverage meter names its own axis", () => {
|
||||
});
|
||||
|
||||
it("has copy for both axis labels", () => {
|
||||
expect(message("insights.healthScore.coverage.minimumMet")).toMatch(
|
||||
/area/i,
|
||||
);
|
||||
const met = message("insights.healthScore.coverage.recommendedMet");
|
||||
expect(met).toMatch(/area/i);
|
||||
// The recommendation arm may not describe the breadth as something
|
||||
// the score needs: needing three is the rule v1.38 removed, and this
|
||||
// sentence is the last place in the panel that still spelled it.
|
||||
expect(met).not.toMatch(/\bneeds?\b|\brequire/i);
|
||||
expect(message("insights.healthScore.coverage.areas")).toContain(
|
||||
"{present}",
|
||||
);
|
||||
@@ -271,3 +298,120 @@ describe("the axis override reaches the markup", () => {
|
||||
expect(html).toContain("3 of 3 inputs");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claim 3, rendered. The three tiers the composite can carry, each put
|
||||
* through the real panel, so the axis is judged on what a person reads
|
||||
* rather than on which branch the source appears to take.
|
||||
*/
|
||||
describe("the axis states the recommendation only where it is met", () => {
|
||||
const provenance = {
|
||||
inputs: ["BLOOD_PRESSURE"],
|
||||
source: "live" as const,
|
||||
windowDays: 90,
|
||||
computedAt: "2026-07-28T12:00:00.000Z",
|
||||
};
|
||||
|
||||
/** An ok composite spanning `domains` of the three recommended. */
|
||||
function scoredReport(
|
||||
domains: number,
|
||||
tier: ScoreBreadthTier,
|
||||
): HealthScoreReport {
|
||||
return {
|
||||
composite: {
|
||||
status: "ok",
|
||||
value: {
|
||||
score: 78,
|
||||
band: "green",
|
||||
bandSetter: null,
|
||||
composition: ["BLOOD_PRESSURE"],
|
||||
configured: false,
|
||||
scoreBasis: {
|
||||
domains,
|
||||
recommended: 3,
|
||||
tier,
|
||||
physiological: true,
|
||||
},
|
||||
noiseFloor: 2,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: domains,
|
||||
historyDays: 28,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 70, band: "medium" },
|
||||
provenance,
|
||||
},
|
||||
pillars: [],
|
||||
delta: null,
|
||||
deltaReason: null,
|
||||
scoreVersion: SCORE_VERSION,
|
||||
weightGoal: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance: { ...provenance, inputs: ["WEIGHT"] },
|
||||
reason: "no_personal_goal",
|
||||
},
|
||||
algorithmNotice: null,
|
||||
} as HealthScoreReport;
|
||||
}
|
||||
|
||||
/** The refusal: not one pillar has usable data. */
|
||||
function refusedReport(): HealthScoreReport {
|
||||
return {
|
||||
...scoredReport(0, "minimal"),
|
||||
composite: {
|
||||
status: "insufficient",
|
||||
coverage: {
|
||||
requiredInputs: 3,
|
||||
presentInputs: 0,
|
||||
historyDays: 0,
|
||||
missing: [],
|
||||
},
|
||||
provenance,
|
||||
reason: "no_usable_data",
|
||||
},
|
||||
} as HealthScoreReport;
|
||||
}
|
||||
|
||||
function axisOf(report: HealthScoreReport): string {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<HealthScoreCard report={report} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The composite's meter is the panel's first; the pillar meters live
|
||||
// behind popovers and the weight-goal meter comes after it.
|
||||
const match = html.match(/data-slot="coverage-meter-label"[^>]*>([^<]*)</);
|
||||
expect(match, "the composite meter rendered no axis label").not.toBeNull();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
it("states the recommendation for a full-breadth score", () => {
|
||||
expect(axisOf(scoredReport(3, "full"))).toBe(
|
||||
"Covers the three areas the score recommends",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the moving fraction for a two-area score", () => {
|
||||
// The arm that used to be unreachable for a scored account: before
|
||||
// v1.38 two domains had no score at all, so this sentence could only
|
||||
// ever appear beside a dash.
|
||||
expect(axisOf(scoredReport(2, "partial"))).toBe("2 of 3 areas of health");
|
||||
});
|
||||
|
||||
it("shows the moving fraction for a one-area score", () => {
|
||||
expect(axisOf(scoredReport(1, "minimal"))).toBe("1 of 3 areas of health");
|
||||
});
|
||||
|
||||
it("shows the moving fraction when there is no score at all", () => {
|
||||
expect(axisOf(refusedReport())).toBe("0 of 3 areas of health");
|
||||
});
|
||||
});
|
||||
@@ -158,6 +158,21 @@ const SCORE_WITH_FAILED_READ = scoreReport([
|
||||
gated("LIPIDS", "read_failed"),
|
||||
]);
|
||||
|
||||
/** v1.38 — a score resting on one area of health, basis block and all. */
|
||||
function narrowScore(): HealthScoreReport {
|
||||
const report = scoreReport([scored("BLOOD_PRESSURE", 74)]);
|
||||
if (report.composite.status !== "ok") {
|
||||
throw new Error("fixture is not scored");
|
||||
}
|
||||
report.composite.value.scoreBasis = {
|
||||
domains: 1,
|
||||
recommended: 3,
|
||||
tier: "minimal",
|
||||
physiological: true,
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin
|
||||
const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin
|
||||
const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin
|
||||
@@ -460,6 +475,26 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"');
|
||||
});
|
||||
|
||||
it("carries a narrow score's basis line through to the band", () => {
|
||||
// v1.38 — the panel owns the sentence; the band has to actually mount
|
||||
// the panel with the report it was handed for the sentence to arrive.
|
||||
// Two ends and the pipe: the card tests prove the line exists, this
|
||||
// proves it reaches the surface a person looks at.
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={narrowScore()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-basis"');
|
||||
expect(html).toContain("Based on 1 of 3 areas of health.");
|
||||
// Still the panel's face: full number, its own band colour, its bar.
|
||||
expect(html).toContain(">74<");
|
||||
expect(html).toContain('data-slot="health-score-card-progress"');
|
||||
expect(html).not.toContain('data-slot="health-score-insufficient"');
|
||||
});
|
||||
|
||||
it("forwards the retry so a failed read can be retried from the band", () => {
|
||||
const html = render(
|
||||
<HeroStrip
|
||||
|
||||
@@ -298,6 +298,25 @@ export function HealthScoreCard({
|
||||
composite.status === "ok" ? clampScore(composite.value.score) : null;
|
||||
const compositeBand = composite.status === "ok" ? composite.value.band : null;
|
||||
|
||||
// v1.38 — what the number rests on, resolved on the server. A set that
|
||||
// spans the recommended breadth says nothing extra: three of three is
|
||||
// the shape the meter's axis already states, and a four-domain account
|
||||
// would read "4 of 3". Below the recommendation the line is the only
|
||||
// thing on the panel that tells a narrow score from a broad one, so it
|
||||
// is stated in the same weight as the band sentence it follows —
|
||||
// scope, not caveat. The number keeps its size and its band colour at
|
||||
// every tier; the coverage meter and the confidence band below carry
|
||||
// the quantitative caveat they were built for.
|
||||
const basis =
|
||||
composite.status === "ok" ? (composite.value.scoreBasis ?? null) : null;
|
||||
const basisLine =
|
||||
basis && basis.tier !== "full"
|
||||
? t("insights.healthScore.basis", {
|
||||
count: basis.domains,
|
||||
recommended: basis.recommended,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card"
|
||||
@@ -380,22 +399,22 @@ export function HealthScoreCard({
|
||||
{t(`insights.healthScore.band.${compositeBand}`)}
|
||||
</p>
|
||||
) : (
|
||||
// The gate survives for the one case that is still a refusal:
|
||||
// not a single pillar has usable data. It no longer counts
|
||||
// areas, because the count is always nothing and the meter in
|
||||
// the foot renders the same fraction honestly a few lines
|
||||
// below.
|
||||
<LearningGate
|
||||
compact
|
||||
bodySlot="health-score-insufficient"
|
||||
// `presentInputs` on the composite counts DISTINCT DOMAINS,
|
||||
// not pillars. It said "pillars" for its whole life, which
|
||||
// told someone recording blood pressure, glycaemia and lipids
|
||||
// that they had one eligible pillar when they had three. The
|
||||
// count is right; the noun was wrong. `required` rides along
|
||||
// so the sentence tracks the rule's own constant instead of
|
||||
// spelling "three" into six locales.
|
||||
message={t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
required: composite.coverage.requiredInputs,
|
||||
})}
|
||||
message={t("insights.healthScore.insufficient")}
|
||||
/>
|
||||
)}
|
||||
{basisLine ? (
|
||||
<p data-slot="health-score-basis" className="text-foreground text-xs">
|
||||
{basisLine}
|
||||
</p>
|
||||
) : null}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
@@ -521,15 +540,24 @@ export function HealthScoreCard({
|
||||
number, so a short report and a long one both end on the same line. */}
|
||||
<div className="mt-auto space-y-3 pt-1">
|
||||
{/* The composite's meter names its own axis. Its fraction counts
|
||||
distinct DOMAINS against the three the score requires, and a
|
||||
scored account has cleared that floor by definition, so the
|
||||
default "3/3" would read as full coverage of the person's data
|
||||
for everyone who has a score at all. Below the floor the
|
||||
fraction is a real, moving number and is shown as one; at or
|
||||
above it, the floor is stated instead. The dots and percentage
|
||||
are untouched either way: they track history depth, which does
|
||||
vary, and they are the part of this meter that was always
|
||||
telling the truth. */}
|
||||
distinct DOMAINS against the three the score recommends. Until
|
||||
v1.38 those three were a floor every scored account had
|
||||
cleared by definition, so the default "3/3" read as full
|
||||
coverage of the person's data for everyone who had a score at
|
||||
all, and the axis stated the floor instead.
|
||||
|
||||
The floor is gone, so the branch cannot be "does a score
|
||||
exist" any more: a two-domain account has a score AND a real
|
||||
fraction, and telling it that it covers the three areas the
|
||||
score recommends would be the old lie in a new place. The
|
||||
branch is the TIER — the recommendation is stated only where
|
||||
it is actually met, and everywhere else the moving number is
|
||||
shown as one. The dots and percentage are untouched either
|
||||
way: they track history depth, which does vary.
|
||||
|
||||
A composite carrying no basis block at all — an older cached
|
||||
payload — takes the fraction arm, which states what it can
|
||||
count rather than a recommendation it cannot check. */}
|
||||
<CoverageMeter
|
||||
coverage={composite.coverage}
|
||||
confidence={
|
||||
@@ -537,8 +565,8 @@ export function HealthScoreCard({
|
||||
}
|
||||
size="sm"
|
||||
axisLabel={
|
||||
composite.status === "ok"
|
||||
? t("insights.healthScore.coverage.minimumMet")
|
||||
basis?.tier === "full"
|
||||
? t("insights.healthScore.coverage.recommendedMet")
|
||||
: t("insights.healthScore.coverage.areas", {
|
||||
present: composite.coverage.presentInputs,
|
||||
required: composite.coverage.requiredInputs,
|
||||
|
||||
@@ -320,6 +320,19 @@ export function ScoreSection() {
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
|
||||
{/* v1.38 — the breadth sentence. Three areas of health used to
|
||||
be the price of a score, and the write refused anything
|
||||
narrower; it is a recommendation now and one pillar saves.
|
||||
The recommendation stays on the surface anyway, because a
|
||||
one-area score is honest but thin and a person choosing
|
||||
what counts should know that before they save, not after
|
||||
the card tells them what it rests on. Content, so
|
||||
foreground and in the body beside the sentence it
|
||||
qualifies — not a muted footnote under the rows. */}
|
||||
<p data-slot="score-config-recommendation" className="text-sm">
|
||||
{t("settings.sections.score.recommendation")}
|
||||
</p>
|
||||
|
||||
{/* The starting points. Both name data; neither names a
|
||||
person. A template that supplies a rationale for dropping a
|
||||
pillar is the one shape the evidence rules out, so there is
|
||||
|
||||
Reference in new issue
Block a user