One honest number
Sooner or later you want one number. Not because one number is enough — it never is — but because thirty are unreadable. This lesson is about building that number so it stays honest, and knowing exactly which lies it is capable of telling.
Pillars, and why weights come first
Do not average metrics. Group them into a handful of pillars, weight the pillars, and compute each pillar from its own metrics:
const PILLAR_WEIGHTS = {
cardio: 0.30, // blood pressure, resting HR, VO2 max
body: 0.25, // weight trend, BMI, waist
sleep: 0.20, // duration, consistency
nutrition: 0.15, // only on days you actually logged
movement: 0.10, // active minutes
};
Those numbers are an opinion. That is fine — but write down why next to them, because in six months you will want to change one and you will need to know what you were thinking. A weight without a stated reason is a number you will be unable to defend, including to yourself.
Ramps, not thresholds
The naive approach is a threshold: below 120 is good, above is bad. It produces a score that jumps a whole grade because a reading moved by one unit, and that is visibly nonsense.
Use a linear ramp between a value that scores zero and one that scores a hundred:
// interp(value, at0, at100) — note at0 may be HIGHER than at100
// when the metric is one where lower is better.
function interp(value, at0, at100) {
if (value == null || !Number.isFinite(value)) return null;
const t = (value - at0) / (at100 - at0);
return Math.max(0, Math.min(100, t * 100));
}
const sysScore = interp(systolicAvg, 160, 115); // lower is better
const vo2Score = interp(vo2max, 15, 45); // higher is better
Returning null rather than 0 for missing data is the most important line in that snippet. “I do not know” and “you scored zero” are completely different statements, and collapsing them is the most common way a health score lies. A person who never wore the watch is not a person with no cardiovascular fitness.
Redistribute the weight of what you do not know
When a pillar has no data, do not score it zero and do not silently treat the rest as the whole. Redistribute its weight across the pillars that do have data:
function combine(pillars) {
const live = pillars.filter(p => p.score != null);
if (live.length === 0) return { score: null, reason: "no data" };
const totalWeight = live.reduce((s, p) => s + p.weight, 0);
const score = live.reduce((s, p) => s + p.score * (p.weight / totalWeight), 0);
return {
score: Math.round(score),
covered: totalWeight, // report it — do not hide it
missing: pillars.filter(p => p.score == null).map(p => p.name),
};
}
And refuse to produce a number at all below a coverage floor — say, fewer than three live pillars. A score computed from one pillar is not a wellbeing index, it is that pillar wearing a hat.
covered and missing alongside the score, and show them. “72, from four of five pillars — nutrition missing” is an honest sentence. A bare “72” is not. This single habit is the difference between an instrument and a horoscope.Stale data must decay
A VO2 max from fourteen months ago is not a current fact about you, but nothing in the arithmetic knows that. Without a decay rule, the score of someone who stopped measuring drifts gently upward forever — the most flattering possible bug.
if (cardio.score != null) {
if (staleDays > 90) cardio.score = Math.min(cardio.score, 35);
else if (staleDays > 30) cardio.score = Math.min(cardio.score, 50);
}
A cap, not a subtraction: old data cannot earn you a high score, but it does not invent a low one either.
Do not let a fragment switch a pillar on
This is the subtlest trap in the whole lesson. Log one snack and the nutrition pillar activates — on a single item, of one meal, on one day. It then produces a confident, terrible number and drags the index with it.
The fix is an explicit completeness marker. A day counts for nutrition only when you have said it is complete — a nutrition_days row, set by hand or by a mark_nutrition_day tool. Partial days are excluded, and their weight redistributes like any other missing pillar.
Generalised: a pillar needs a definition of “enough data to count”, and it must not be “at least one row”. That principle is the difference between a score you trust and one you learn to ignore.
Testing a thing that has no right answer
You cannot assert the score is correct — there is no ground truth. You can assert it is not insane:
- All pillars at their
at100values → 100. Atat0→ 0. - Remove one pillar's data → the score moves a little; it does not collapse.
- Remove three → it returns
null, not a confident number. - Age every input by a year → the score goes down.
- Add one snack to an empty day → nutrition stays inactive.
That last one is the regression test for the bug above, and it is worth writing before you write the feature.
The honesty checklist
- Does missing data lower the score, or exclude itself? (It must exclude itself.)
- Can old data still earn a good score? (It must not.)
- Does the output say how much of itself is real? (It must.)
- Can a single row activate a pillar? (It must not.)
- Is the disclaimer visible next to the number, not in a footer? (It must be.)