diff --git a/src/lib/analytics/__tests__/health-score.test.ts b/src/lib/analytics/__tests__/health-score.test.ts new file mode 100644 index 000000000..f26721823 --- /dev/null +++ b/src/lib/analytics/__tests__/health-score.test.ts @@ -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); + }); +}); diff --git a/src/lib/analytics/health-score.ts b/src/lib/analytics/health-score.ts new file mode 100644 index 000000000..f6da86eb8 --- /dev/null +++ b/src/lib/analytics/health-score.ts @@ -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 = []; + 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; +}