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.

Read this before the rest of the lesson. What you are building is a personal instrument panel, not a medical device. It does not diagnose, screen, or advise. No number produced by it should change what you do about your health without a clinician. The reason this warning sits at the top rather than the bottom is that composite scores are unusually good at borrowing authority they have not earned — including from the person who built them.

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.

Always return 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:

That last one is the regression test for the bug above, and it is worth writing before you write the feature.

The honesty checklist

Where next