feat(insights): personal Health Score formula library

Composite 0-100 score with three bands (green >= 75, yellow 50..74,
red < 50). Server-deterministic so the same inputs always produce the
same output.

Sub-components:
- bpInTargetRate (30 %) — reuses the existing all-time figure
- weightTrendAlignment (20 %) — slope vs target band over 30 days
- moodStability (20 %) — `100 - CV*100`, requires >= 5 entries
- complianceRate (30 %) — mean of medication compliance30

Null components scale the remaining weights proportionally so the
score never lies about what was actually measured. Pure helpers
(linearRegressionSlope, coefficientOfVariation, weightTrendAlignment,
moodStability, complianceRate) live alongside the composite.

Tests cover strong/mixed/poor scenarios, every null-redistribution
shape (bp null, bp+mood null, all-but-compliance null, all null) and
determinism (same input -> same output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 17:31:08 +02:00
1 parent 54d3e92acc
commit 73d21881a8
2 files changed
+618

No files matched your search

@@ -0,0 +1,311 @@
import { describe, it, expect } from "vitest";
import {
coefficientOfVariation,
complianceRate,
computeHealthScore,
defaultWeightTargetFromHeight,
linearRegressionSlope,
moodStability,
weightTrendAlignment,
type HealthScoreInput,
} from "../health-score";
/**
* Build a 30-day mood series with a deterministic pattern. The helper
* keeps the variance tractable by hand so test expectations don't drift.
*/
function moodEntries(scores: number[]): Array<{ date: string; score: number }> {
return scores.map((score, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
score,
}));
}
function weightSeries(
values: number[],
): Array<{ date: string; kg: number }> {
return values.map((kg, i) => ({
date: new Date(Date.UTC(2026, 4, i + 1)).toISOString(),
kg,
}));
}
// ── Pure helpers ─────────────────────────────────────────────────────
describe("linearRegressionSlope", () => {
it("returns null for fewer than two points", () => {
expect(linearRegressionSlope([])).toBeNull();
expect(
linearRegressionSlope([
{ date: "2026-05-01T00:00:00Z", value: 80 },
]),
).toBeNull();
});
it("detects a clean upward slope (units / day)", () => {
const slope = linearRegressionSlope(weightSeries([80, 81, 82, 83, 84]).map(
(p) => ({ date: p.date, value: p.kg }),
));
expect(slope).not.toBeNull();
expect(slope!).toBeCloseTo(1, 5);
});
it("detects a clean downward slope", () => {
const slope = linearRegressionSlope(weightSeries([85, 84, 83, 82, 81]).map(
(p) => ({ date: p.date, value: p.kg }),
));
expect(slope!).toBeCloseTo(-1, 5);
});
});
describe("coefficientOfVariation", () => {
it("returns null for fewer than two values", () => {
expect(coefficientOfVariation([])).toBeNull();
expect(coefficientOfVariation([4])).toBeNull();
});
it("returns 0 for a constant series", () => {
expect(coefficientOfVariation([4, 4, 4, 4])).toBe(0);
});
it("scales with relative spread", () => {
const tight = coefficientOfVariation([4, 4, 4, 4, 5])!;
const loose = coefficientOfVariation([1, 5, 1, 5, 1])!;
expect(loose).toBeGreaterThan(tight);
});
});
describe("weightTrendAlignment", () => {
it("returns 100 when latest reading is inside the band", () => {
const series = weightSeries([90, 88, 85, 80]);
const result = weightTrendAlignment(series, { min: 78, max: 82 });
expect(result).toBe(100);
});
it("returns >50 when above the band and trending down (closing the gap)", () => {
const series = weightSeries([95, 94, 93, 92, 91]);
const result = weightTrendAlignment(series, { min: 75, max: 80 });
expect(result).not.toBeNull();
expect(result!).toBeGreaterThan(50);
});
it("returns <50 when above the band and trending up (drifting away)", () => {
const series = weightSeries([90, 91, 92, 93, 94]);
const result = weightTrendAlignment(series, { min: 75, max: 80 });
expect(result).not.toBeNull();
expect(result!).toBeLessThan(50);
});
it("returns null when the target is missing", () => {
expect(weightTrendAlignment(weightSeries([80, 81]), null)).toBeNull();
});
it("returns null with fewer than two readings", () => {
expect(weightTrendAlignment(weightSeries([80]), { min: 75, max: 80 })).toBeNull();
});
});
describe("moodStability", () => {
it("returns null for fewer than 5 entries", () => {
expect(moodStability(moodEntries([4, 4, 4, 4]))).toBeNull();
});
it("returns 100 for a constant high-mood series", () => {
expect(moodStability(moodEntries([5, 5, 5, 5, 5, 5]))).toBe(100);
});
it("scores tight variance higher than loose variance", () => {
const tight = moodStability(moodEntries([4, 4, 4, 4, 4, 5]))!;
const loose = moodStability(moodEntries([1, 5, 1, 5, 1, 5]))!;
expect(tight).toBeGreaterThan(loose);
});
});
describe("complianceRate", () => {
it("returns null for an empty list", () => {
expect(complianceRate([])).toBeNull();
});
it("returns the rounded mean of compliance percentages", () => {
expect(complianceRate([100, 80, 60])).toBe(80);
});
});
describe("defaultWeightTargetFromHeight", () => {
it("returns null when height is null", () => {
expect(defaultWeightTargetFromHeight(null)).toBeNull();
});
it("approximates BMI-22 midpoint for 178 cm", () => {
expect(defaultWeightTargetFromHeight(178)).toBeCloseTo(69.7, 1);
});
});
// ── Composite scenarios ──────────────────────────────────────────────
describe("computeHealthScore — strong positive case", () => {
it("lands in the green band (>=75)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 90,
weightSeriesLast30d: weightSeries([80, 79.5, 79, 78.5, 78]),
weightTargetKg: 78,
moodEntriesLast30d: moodEntries([4, 4, 5, 4, 5, 4, 5, 4, 4]),
medicationCompliance30: [95, 100, 90],
};
const result = computeHealthScore(input);
expect(result.score).toBeGreaterThanOrEqual(75);
expect(result.band).toBe("green");
expect(result.components.bp.value).toBe(90);
expect(result.components.compliance.value).toBe(95);
});
});
describe("computeHealthScore — mixed case", () => {
it("lands in the yellow band (50..74)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 60,
// Above the band, drifting up — alignment <50.
weightSeriesLast30d: weightSeries([85, 86, 87, 88]),
weightTargetKg: 80,
moodEntriesLast30d: moodEntries([2, 3, 5, 1, 4, 3]),
medicationCompliance30: [100, 100],
};
const result = computeHealthScore(input);
expect(result.score).toBeGreaterThanOrEqual(50);
expect(result.score).toBeLessThan(75);
expect(result.band).toBe("yellow");
});
});
describe("computeHealthScore — poor case", () => {
it("lands in the red band (<50)", () => {
const input: HealthScoreInput = {
bpInTargetRate: 20,
weightSeriesLast30d: weightSeries([95, 96, 97, 98, 99]),
weightTargetKg: 80,
moodEntriesLast30d: moodEntries([1, 5, 1, 5, 1, 5]),
medicationCompliance30: [40, 30, 20],
};
const result = computeHealthScore(input);
expect(result.score).toBeLessThan(50);
expect(result.band).toBe("red");
});
});
describe("computeHealthScore — null component redistribution", () => {
it("redistributes weights when bp is null", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80, 80]),
weightTargetKg: 80,
moodEntriesLast30d: moodEntries([5, 5, 5, 5, 5]),
medicationCompliance30: [100, 100],
};
const result = computeHealthScore(input);
// bp weight 0; remaining base weights sum 0.7. Each scales by /0.7.
expect(result.components.bp.weight).toBe(0);
expect(result.components.weight.weight).toBeCloseTo(0.2 / 0.7, 5);
expect(result.components.mood.weight).toBeCloseTo(0.2 / 0.7, 5);
expect(result.components.compliance.weight).toBeCloseTo(0.3 / 0.7, 5);
// Score should be 100 (every present component at 100).
expect(result.score).toBe(100);
});
it("handles bp + mood both null — weight + compliance carry the score", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: weightSeries([80, 80, 80]),
weightTargetKg: 80,
// <5 mood entries → moodStability returns null.
moodEntriesLast30d: moodEntries([4, 4]),
medicationCompliance30: [80],
};
const result = computeHealthScore(input);
expect(result.components.bp.value).toBeNull();
expect(result.components.mood.value).toBeNull();
// Remaining base weights sum 0.5; weight + compliance scale to 0.4 + 0.6.
expect(result.components.weight.weight).toBeCloseTo(0.4, 5);
expect(result.components.compliance.weight).toBeCloseTo(0.6, 5);
// weight 100 (in-band) * 0.4 + compliance 80 * 0.6 = 40 + 48 = 88.
expect(result.score).toBe(88);
});
it("handles all null except compliance — score equals complianceRate", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTargetKg: null,
moodEntriesLast30d: [],
medicationCompliance30: [73],
};
const result = computeHealthScore(input);
expect(result.components.compliance.weight).toBeCloseTo(1, 5);
expect(result.score).toBe(73);
expect(result.band).toBe("yellow");
});
it("returns score 0 when every component is null", () => {
const input: HealthScoreInput = {
bpInTargetRate: null,
weightSeriesLast30d: [],
weightTargetKg: null,
moodEntriesLast30d: [],
medicationCompliance30: [],
};
const result = computeHealthScore(input);
expect(result.score).toBe(0);
expect(result.band).toBe("red");
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
expect(result.components[key].value).toBeNull();
expect(result.components[key].weight).toBe(0);
}
});
});
describe("computeHealthScore — determinism", () => {
const input: HealthScoreInput = {
bpInTargetRate: 75,
weightSeriesLast30d: weightSeries([82, 81, 80, 79]),
weightTargetKg: 78,
moodEntriesLast30d: moodEntries([4, 5, 3, 4, 5, 4]),
medicationCompliance30: [85, 90, 80],
};
it("same input → same output", () => {
const a = computeHealthScore(input);
const b = computeHealthScore(input);
expect(b).toEqual(a);
});
});
describe("computeHealthScore — delta vs previous week", () => {
const baseInput: HealthScoreInput = {
bpInTargetRate: 80,
weightSeriesLast30d: weightSeries([82, 81, 80]),
weightTargetKg: 80,
moodEntriesLast30d: moodEntries([5, 4, 5, 4, 5]),
medicationCompliance30: [100, 100],
};
const previousInput: HealthScoreInput = {
...baseInput,
bpInTargetRate: 60,
medicationCompliance30: [70, 80],
};
it("returns null when no previous input is supplied", () => {
const result = computeHealthScore(baseInput);
expect(result.delta).toBeNull();
});
it("returns positive delta when current is higher than previous", () => {
const result = computeHealthScore(baseInput, previousInput);
expect(result.delta).not.toBeNull();
expect(result.delta!).toBeGreaterThan(0);
});
it("returns negative delta when current is lower than previous", () => {
const result = computeHealthScore(previousInput, baseInput);
expect(result.delta).not.toBeNull();
expect(result.delta!).toBeLessThan(0);
});
});
+307
View File
@@ -0,0 +1,307 @@
/**
* v1.4.20 phase B5 — Personal Health Score.
*
* A composite 0–100 score that blends four health pillars into one
* easy-to-read number. Server-deterministic: the same inputs always
* produce the same output, so a curious user can recompute it mentally
* (or in a spreadsheet) from the four components.
*
* Formula (when every component is present):
* score = round(
* 0.30 * bpInTargetRate // % of paired BP readings inside band
* + 0.20 * weightTrendAlignment // 0..100, see weightTrendAlignment()
* + 0.20 * moodStability // 0..100, see moodStability()
* + 0.30 * complianceRate // mean of medication compliance30
* )
*
* When a component is null (insufficient data, no medications, no
* weight target, etc.) the remaining weights are scaled proportionally
* so they still sum to 100 — the score never lies about what was
* actually measured.
*
* Bands:
* green >= 75
* yellow 50..74
* red < 50
*
* The score is **descriptive, not prescriptive** — see the disclaimer
* the UI surfaces ("Indicative — not a clinical assessment.").
*/
export type HealthScoreBand = "green" | "yellow" | "red";
export interface HealthScoreInput {
/** All-time BP-in-target rate, already 0..100 (or null). */
bpInTargetRate: number | null;
/** Weight readings over the last 30 days, ascending or unsorted (we sort). */
weightSeriesLast30d: Array<{ date: string; kg: number }>;
/** User's stored target weight in kg, if any. */
weightTargetKg: number | null;
/** Mood entries over the last 30 days. `score` 1..5 in HealthLog. */
moodEntriesLast30d: Array<{ date: string; score: number }>;
/** Per-active-medication 30-day compliance %. Empty array → null. */
medicationCompliance30: number[];
}
export interface HealthScoreComponentDetail {
/** Sub-score 0..100, or null if the component had insufficient data. */
value: number | null;
/** Effective weight after null-redistribution, 0..1. */
weight: number;
}
export interface HealthScoreResult {
/** 0..100, integer. Always present (defaults to 0 with all-null input). */
score: number;
band: HealthScoreBand;
components: {
bp: HealthScoreComponentDetail;
weight: HealthScoreComponentDetail;
mood: HealthScoreComponentDetail;
compliance: HealthScoreComponentDetail;
};
/** Difference vs. last week's score; null when no historical input. */
delta: number | null;
}
const BASE_WEIGHTS = {
bp: 0.3,
weight: 0.2,
mood: 0.2,
compliance: 0.3,
} as const;
// ── Pure helpers ─────────────────────────────────────────────────────
/**
* Linear-regression slope (units / day) over a time-series of points.
* Pure least-squares fit. Returns null when there are fewer than two
* distinct x-values (the denominator collapses to zero).
*/
export function linearRegressionSlope(
points: Array<{ date: string; value: number }>,
): number | null {
if (points.length < 2) return null;
const sorted = [...points].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const startMs = new Date(sorted[0].date).getTime();
if (Number.isNaN(startMs)) return null;
const xy = sorted.map((p) => {
const t = new Date(p.date).getTime();
return {
x: (t - startMs) / (24 * 60 * 60 * 1000),
y: p.value,
};
});
const n = xy.length;
const sumX = xy.reduce((s, p) => s + p.x, 0);
const sumY = xy.reduce((s, p) => s + p.y, 0);
const sumXY = xy.reduce((s, p) => s + p.x * p.y, 0);
const sumXX = xy.reduce((s, p) => s + p.x * p.x, 0);
const denominator = n * sumXX - sumX * sumX;
if (denominator === 0) return null;
return (n * sumXY - sumX * sumY) / denominator;
}
/**
* Coefficient of variation: stdev / |mean|. Returns null when the mean
* is zero (the metric is undefined) or when fewer than two values are
* supplied (population variance over n=1 is zero by definition but
* carries no signal).
*/
export function coefficientOfVariation(values: number[]): number | null {
if (values.length < 2) return null;
const mean = values.reduce((s, v) => s + v, 0) / values.length;
if (mean === 0) return null;
const variance =
values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
const stdev = Math.sqrt(variance);
return stdev / Math.abs(mean);
}
/**
* Score how well the recent weight trend is closing the gap toward the
* target band [targetMin..targetMax].
*
* - Already inside the band → 100 (the trend doesn't need to do anything).
* - Above the band → a downward slope is "closing the gap"; magnitude
* maps slope to 0..100 with a saturating curve so a 0.5 kg/day drop
* doesn't read more positive than a 0.05 kg/day drop.
* - Below the band → an upward slope is closing.
*
* Insufficient data (< 2 readings, no target) → null.
*/
export function weightTrendAlignment(
series: Array<{ date: string; kg: number }>,
target: { min: number; max: number } | null,
): number | null {
if (!target || series.length < 2) return null;
const points = series.map((p) => ({ date: p.date, value: p.kg }));
// Latest reading drives the "are we currently in the band" check.
const sorted = [...points].sort((a, b) =>
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
);
const latest = sorted[sorted.length - 1].value;
if (latest >= target.min && latest <= target.max) return 100;
const slope = linearRegressionSlope(points);
if (slope === null) return null;
// Saturate a 0.1 kg/day movement to ~75 % alignment so noisy data
// doesn't cliff the score. The mapping uses a tanh-style curve scaled
// around 0.05 kg/day (≈ 1.5 kg/month — a clinically reasonable rate).
const SATURATION = 0.05;
const normalised = Math.tanh(Math.abs(slope) / SATURATION);
// Above the band: slope < 0 closes the gap.
// Below the band: slope > 0 closes the gap.
const closing = latest > target.max ? slope < 0 : slope > 0;
if (slope === 0) return 50; // exactly stable counts as "neither"
return Math.round((closing ? 50 + 50 * normalised : 50 - 50 * normalised));
}
/**
* Mood stability scored as `100 - CV*100`, clamped to 0..100. Returns
* null when there are fewer than 5 entries (the variance is too noisy
* to draw a stability conclusion from).
*/
export function moodStability(
entries: Array<{ date: string; score: number }>,
): number | null {
if (entries.length < 5) return null;
const values = entries.map((e) => e.score);
const cv = coefficientOfVariation(values);
if (cv === null) return null;
const score = 100 - cv * 100;
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* Mean of medication-compliance percentages. Returns null when no
* medications are active so the score doesn't penalise people who
* haven't logged any.
*/
export function complianceRate(values: number[]): number | null {
if (values.length === 0) return null;
const sum = values.reduce((s, v) => s + v, 0);
return Math.round(sum / values.length);
}
// ── Composite ────────────────────────────────────────────────────────
function clampToHundred(v: number): number {
return Math.max(0, Math.min(100, v));
}
function bandFor(score: number): HealthScoreBand {
if (score >= 75) return "green";
if (score >= 50) return "yellow";
return "red";
}
/**
* Compute the composite score + sub-component breakdown. Pure.
*
* The optional `previous` parameter is used by the route to plug the
* "vs last week" delta — when omitted the result's `delta` is null.
*/
export function computeHealthScore(
input: HealthScoreInput,
previous?: HealthScoreInput,
): HealthScoreResult {
const bpValue =
input.bpInTargetRate === null
? null
: clampToHundred(input.bpInTargetRate);
const target = deriveWeightTarget(input.weightTargetKg);
const weightValue = weightTrendAlignment(input.weightSeriesLast30d, target);
const moodValue = moodStability(input.moodEntriesLast30d);
const complianceValue = complianceRate(input.medicationCompliance30);
const components = redistribute({
bp: bpValue,
weight: weightValue,
mood: moodValue,
compliance: complianceValue,
});
let raw = 0;
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
const c = components[key];
if (c.value !== null) raw += c.value * c.weight;
}
const score = Math.round(clampToHundred(raw));
const band = bandFor(score);
let delta: number | null = null;
if (previous) {
const prev = computeHealthScore(previous);
delta = score - prev.score;
}
return { score, band, components, delta };
}
/**
* Re-scale the four base weights to skip any component whose value is
* null. The remaining components keep the same proportional ratio
* (e.g. BP and Compliance both at 30 % of original 80 % → 37.5 %).
*/
function redistribute(values: {
bp: number | null;
weight: number | null;
mood: number | null;
compliance: number | null;
}): HealthScoreResult["components"] {
const present: Array<keyof typeof BASE_WEIGHTS> = [];
for (const key of ["bp", "weight", "mood", "compliance"] as const) {
if (values[key] !== null) present.push(key);
}
const totalBaseWeight = present.reduce(
(s, key) => s + BASE_WEIGHTS[key],
0,
);
const weightFor = (key: keyof typeof BASE_WEIGHTS): number => {
if (values[key] === null) return 0;
if (totalBaseWeight === 0) return 0;
return BASE_WEIGHTS[key] / totalBaseWeight;
};
return {
bp: { value: values.bp, weight: weightFor("bp") },
weight: { value: values.weight, weight: weightFor("weight") },
mood: { value: values.mood, weight: weightFor("mood") },
compliance: {
value: values.compliance,
weight: weightFor("compliance"),
},
};
}
/**
* The `weightTargetKg` input is a single number; we expand it to a
* narrow ±2 kg band so the alignment helper has a "target zone" to
* test inclusion against. Null target → null band → null component.
*/
function deriveWeightTarget(
targetKg: number | null,
): { min: number; max: number } | null {
if (targetKg === null || !Number.isFinite(targetKg)) return null;
return { min: targetKg - 2, max: targetKg + 2 };
}
/**
* Helper for callers that have only height + want a BMI-22 fallback
* target. Surfaces the same ±2 kg band semantics as the in-house
* derivation so the math is consistent across callers.
*/
export function defaultWeightTargetFromHeight(
heightCm: number | null,
): number | null {
if (heightCm === null || heightCm <= 0) return null;
const heightM = heightCm / 100;
return Math.round(22 * heightM * heightM * 10) / 10;
}