feat(insights): derived framing for additive HealthKit metrics

Register four new derived metrics for the additive HealthKit signals:

- WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE,
  STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing
  the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES
  allowlist. Wrist temperature is a personal-deviation band only (no illness
  or cycle inference); stair speeds are personal trend bands (no population
  cutoff, geometry-confounded).
- SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated
  distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002
  test standard), as percent-of-predicted with a green/yellow/red band;
  band null when age/height/weight/sex are incomplete.

Fall count and the breathing-disturbance index stay trend-only by design,
documented in the registry: fall count is a zero-inflated discrete safety
event, the breathing index is a regulated screening signal where a derived
band would imply a diagnosis.

Note wrist temperature as a deferred correlation outcome channel held
pending a privacy review (cycle-phase inference risk), mirroring the
medication-compliance omission note.

Add provenance + standards entries, method/caveat copy across all six
locales, and tests for the engine routing, the Enright equation, and the
percent-of-predicted band.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:29:36 +02:00
1 parent 789ec60d97
commit 059ba571e3
16 files changed
+731 -4

No files matched your search

@@ -128,4 +128,40 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
},
caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat",
},
WRIST_TEMPERATURE_BASELINE: {
methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method",
standard: {
// Robust personal-deviation band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat",
},
STAIR_ASCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat",
},
STAIR_DESCENT_SPEED_BASELINE: {
methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method",
standard: {
// Robust personal-trend band via the median ± k·MAD.
name: "Leys et al. 2013, J. Exp. Soc. Psychol.",
url: "https://doi.org/10.1016/j.jesp.2013.03.013",
},
caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat",
},
SIX_MINUTE_WALK_BAND: {
methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method",
standard: {
// Enright & Sherrill 1998 reference equations (ATS 2002 test standard).
name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.",
url: "https://doi.org/10.1164/ajrccm.158.5.9710086",
},
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
},
};
@@ -249,4 +249,12 @@ export const DISCOVERY_OUTCOMES = [
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"WEIGHT",
// Wrist temperature is a credible future OUTCOME channel (near-daily, so
// n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night
// temperature?"), but it is deliberately NOT a channel yet: it is
// privacy-sensitive and deviation-framed, and folding it into the FDR
// matrix risks surfacing a cycle-phase correlation that strays into
// reproductive inference. Held pending a deliberate privacy review —
// documented here so the omission is intentional, not a gap (same posture
// as the medication-compliance omission above).
] as const;
@@ -39,6 +39,16 @@ describe("registry", () => {
expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true);
expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true);
expect(
getDerivedMetricMeta("WRIST_TEMPERATURE_BASELINE")?.implemented,
).toBe(true);
expect(
getDerivedMetricMeta("STAIR_ASCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(
getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -59,7 +69,11 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE");
expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE");
expect(DERIVED_METRIC_IDS.length).toBe(11);
expect(DERIVED_METRIC_IDS).toContain("WRIST_TEMPERATURE_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS.length).toBe(15);
});
});
@@ -127,6 +141,43 @@ describe("computeDerivedMetric dispatch", () => {
}
});
it.each([
"WRIST_TEMPERATURE_BASELINE",
"STAIR_ASCENT_SPEED_BASELINE",
"STAIR_DESCENT_SPEED_BASELINE",
] as const)(
"routes %s through the baseline engine (no data → insufficient, not not_implemented)",
async (metric) => {
const result = await computeDerivedMetric({
metric,
userId: "u1",
profile: PROFILE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).not.toBe("not_implemented");
// The baseline engine names its fixed type as the missing input.
expect(result.provenance.inputs).toContain(
metric.replace("_BASELINE", ""),
);
}
},
);
it("routes SIX_MINUTE_WALK_BAND to its engine (no data → insufficient, not not_implemented)", async () => {
const result = await computeDerivedMetric({
metric: "SIX_MINUTE_WALK_BAND",
userId: "u1",
profile: PROFILE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
}
});
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
const result = await computeDerivedMetric({
metric: "VITALS_BASELINE",
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { lookupNormalRange, hasSharpenedNorm } from "../norms";
import {
lookupNormalRange,
hasSharpenedNorm,
predictSixMinuteWalkDistance,
} from "../norms";
describe("lookupNormalRange — the age/sex reference-range enabler", () => {
it("returns a sex-specific VO2max band for a registered metric", () => {
@@ -59,3 +63,39 @@ describe("hasSharpenedNorm", () => {
expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false);
});
});
describe("predictSixMinuteWalkDistance — Enright & Sherrill 1998", () => {
it("matches the published male equation", () => {
// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m
expect(predictSixMinuteWalkDistance(40, 180, 80, "MALE")).toBeCloseTo(
712,
6,
);
});
it("matches the published female equation", () => {
// 2.11·165 − 2.29·65 − 5.78·40 + 667 = 635.1 m
expect(predictSixMinuteWalkDistance(40, 165, 65, "FEMALE")).toBeCloseTo(
635.1,
6,
);
});
it("returns null without a usable sex (the equations differ by sex)", () => {
expect(predictSixMinuteWalkDistance(40, 180, 80, null)).toBeNull();
});
it("returns null when weight is absent (no silently-dropped term)", () => {
expect(predictSixMinuteWalkDistance(40, 180, null, "MALE")).toBeNull();
});
it("returns null without height", () => {
expect(predictSixMinuteWalkDistance(40, null, 80, "MALE")).toBeNull();
});
it("returns null for non-adult / non-finite ages", () => {
expect(predictSixMinuteWalkDistance(12, 150, 45, "MALE")).toBeNull();
expect(predictSixMinuteWalkDistance(Number.NaN, 180, 80, "MALE")).toBeNull();
expect(predictSixMinuteWalkDistance(null, 180, 80, "MALE")).toBeNull();
});
});
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const findMany = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: (...a: unknown[]) => findMany(...a),
findFirst: (...a: unknown[]) => findFirst(...a),
},
},
}));
import {
computeSixMinuteWalkBand,
placeSixMinuteWalkBand,
} from "../six-minute-walk";
const NOW = new Date("2026-06-02T07:00:00Z");
// Enright male, 40 yr, 180 cm, 80 kg →
// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m predicted.
const MALE_40 = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NO_DEMO = { ageYears: null, sex: null, heightCm: null };
const NO_WEIGHT_PROFILE = MALE_40;
beforeEach(() => vi.clearAllMocks());
describe("placeSixMinuteWalkBand", () => {
it("green at/above 80% of predicted", () => {
expect(placeSixMinuteWalkBand(80)).toBe("green");
expect(placeSixMinuteWalkBand(100)).toBe("green");
});
it("yellow between 60 and 80%", () => {
expect(placeSixMinuteWalkBand(60)).toBe("yellow");
expect(placeSixMinuteWalkBand(79)).toBe("yellow");
});
it("red below 60%", () => {
expect(placeSixMinuteWalkBand(59)).toBe("red");
});
it("null without a percent", () => {
expect(placeSixMinuteWalkBand(null)).toBeNull();
});
});
describe("computeSixMinuteWalkBand", () => {
it("insufficient when no 6MWT reading exists", async () => {
findMany.mockResolvedValueOnce([]);
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("insufficient");
if (r.status === "insufficient") {
expect(r.reason).toBe("no_readings_in_window");
}
// No reading → never reach the weight read.
expect(findFirst).not.toHaveBeenCalled();
});
it("ok with a band + percent-of-predicted when full demographics + weight exist", async () => {
// 3 readings → trend = 712 - 700 = 12; distance 712 vs predicted 712 → 100%.
findMany.mockResolvedValueOnce([
{ value: 712 },
{ value: 700 },
{ value: 690 },
]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(712);
expect(r.value.predictedM).toBe(712);
expect(r.value.percentOfPredicted).toBe(100);
expect(r.value.band).toBe("green");
expect(r.value.trendDelta).toBe(12);
}
});
it("bands red on a low percent of predicted", async () => {
// 400 / 712 = 56% → red.
findMany.mockResolvedValueOnce([{ value: 400 }]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.percentOfPredicted).toBe(56);
expect(r.value.band).toBe("red");
}
});
it("ok but band/percent null without demographics", async () => {
findMany.mockResolvedValueOnce([{ value: 500 }]);
findFirst.mockResolvedValueOnce({ value: 80 });
const r = await computeSixMinuteWalkBand("u1", NO_DEMO, { now: NOW });
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(500);
expect(r.value.predictedM).toBeNull();
expect(r.value.percentOfPredicted).toBeNull();
expect(r.value.band).toBeNull();
// Single reading → trend suppressed.
expect(r.value.trendDelta).toBeNull();
}
});
it("ok but band null when weight is absent (no fabricated placement)", async () => {
findMany.mockResolvedValueOnce([{ value: 600 }]);
findFirst.mockResolvedValueOnce(null); // no recent weight
const r = await computeSixMinuteWalkBand("u1", NO_WEIGHT_PROFILE, {
now: NOW,
});
expect(r.status).toBe("ok");
if (r.status === "ok") {
expect(r.value.distanceM).toBe(600);
expect(r.value.predictedM).toBeNull();
expect(r.value.percentOfPredicted).toBeNull();
expect(r.value.band).toBeNull();
}
});
});
+31
View File
@@ -23,6 +23,7 @@ import {
type BaselineProfile,
} from "./baseline";
import { computeFitnessAge } from "./fitness-age";
import { computeSixMinuteWalkBand } from "./six-minute-walk";
import { computeVascularAgeDelta } from "./vascular-age";
import { computeHrvBalance } from "./hrv-balance";
import { computeBmi } from "./bmi";
@@ -146,6 +147,36 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "WRIST_TEMPERATURE_BASELINE":
case "STAIR_ASCENT_SPEED_BASELINE":
case "STAIR_DESCENT_SPEED_BASELINE": {
// v1.10.3 additive HealthKit baselines — each pins ONE fixed type and
// reuses the type-generic baseline engine unchanged (median ± k·MAD).
// Wrist temperature is a personal-DEVIATION band (no illness/cycle
// inference); stair ascent/descent are personal trend bands (no
// population cutoff — stair pace is geometry-confounded). The fixed
// type is the metric's single registered input.
const baselineType: Record<
| "WRIST_TEMPERATURE_BASELINE"
| "STAIR_ASCENT_SPEED_BASELINE"
| "STAIR_DESCENT_SPEED_BASELINE",
MeasurementType
> = {
WRIST_TEMPERATURE_BASELINE: "WRIST_TEMPERATURE",
STAIR_ASCENT_SPEED_BASELINE: "STAIR_ASCENT_SPEED",
STAIR_DESCENT_SPEED_BASELINE: "STAIR_DESCENT_SPEED",
};
return computeVitalsBaseline(args.userId, args.profile, {
type: baselineType[args.metric],
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "SIX_MINUTE_WALK_BAND":
return computeSixMinuteWalkBand(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+16 -1
View File
@@ -39,9 +39,11 @@ export type { DeriveCoverageArgs } from "./coverage";
export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
BASELINE_CAPABLE_TYPES,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
isBaselineCapableType,
} from "./registry";
export type {
DerivedMetricId,
@@ -49,7 +51,11 @@ export type {
DerivedArchetype,
} from "./registry";
export { lookupNormalRange, hasSharpenedNorm } from "./norms";
export {
lookupNormalRange,
hasSharpenedNorm,
predictSixMinuteWalkDistance,
} from "./norms";
export type { NormRange, NormSex } from "./norms";
// ── server-only compute engines (do NOT value-import from a client component) ──
@@ -77,6 +83,15 @@ export {
} from "./fitness-age";
export type { FitnessAgeValue, FitnessBand } from "./fitness-age";
export {
computeSixMinuteWalkBand,
placeSixMinuteWalkBand,
} from "./six-minute-walk";
export type {
SixMinuteWalkValue,
SixMinuteWalkBand,
} from "./six-minute-walk";
export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age";
export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age";
+54
View File
@@ -24,6 +24,13 @@
* 377(9770):1011–1018, normal-range HR centiles by age).
* - Respiratory rate by age — clinical reference (WHO/PALS adult vs
* paediatric ranges).
* - Six-minute-walk distance — a published REGRESSION (not a bracket
* table): Enright & Sherrill 1998, "Reference Equations for the
* Six-Minute Walk in Healthy Adults", Am J Respir Crit Care Med
* 158(5):1384–1387; the test itself standardised by ATS 2002,
* "ATS Statement: Guidelines for the Six-Minute Walk Test",
* Am J Respir Crit Care Med 166(1):111–117. Surfaced as a
* percent-of-predicted re-frame, never a HealthLog-derived equation.
*
* Client-safe — pure data + a pure lookup, no server imports.
*/
@@ -145,6 +152,53 @@ export function lookupNormalRange(
return null;
}
/**
* Enright & Sherrill 1998 predicted six-minute-walk distance (metres) for a
* healthy adult. A published linear regression on age, height, weight, sex —
* NOT a HealthLog-derived model:
* - Men: 6MWD = 7.57·height_cm − 5.02·age − 1.76·weight_kg − 309
* - Women: 6MWD = 2.11·height_cm − 2.29·weight_kg − 5.78·age + 667
*
* Returns `null` when the inputs the equation needs are absent, so the caller
* surfaces the raw distance + trend without a fabricated placement (the
* `fitness-age.ts` `band: null` discipline). Sex is required (the two
* coefficient sets differ); height is required (the dominant term). Weight is
* required for the published full equation — when it is missing we return
* `null` rather than silently dropping the weight term, since the omission
* would inflate the predicted distance.
*
* Pure. The equation is for adults; for ages below 18 the reference does not
* apply and we return `null`.
*/
export function predictSixMinuteWalkDistance(
ageYears: number | null | undefined,
heightCm: number | null | undefined,
weightKg: number | null | undefined,
sex: NormSex,
): number | null {
if (sex !== "MALE" && sex !== "FEMALE") return null;
if (
ageYears == null ||
!Number.isFinite(ageYears) ||
ageYears < 18 ||
ageYears > 120
) {
return null;
}
if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) {
return null;
}
if (weightKg == null || !Number.isFinite(weightKg) || weightKg <= 0) {
return null;
}
const predicted =
sex === "MALE"
? 7.57 * heightCm - 5.02 * ageYears - 1.76 * weightKg - 309
: 2.11 * heightCm - 2.29 * weightKg - 5.78 * ageYears + 667;
// A non-positive prediction is physically meaningless — treat as no band.
return predicted > 0 ? predicted : null;
}
/** `true` when an age/sex-sharpened band exists for the metric+profile. */
export function hasSharpenedNorm(
metricId: MetricStatusMetricId,
+81 -1
View File
@@ -46,7 +46,27 @@ export type DerivedMetricId =
/** Persisted nightly stress composite (passthrough read of COMPUTED rows). */
| "STRESS_SCORE"
/** Persisted nightly strain composite (passthrough read of COMPUTED rows). */
| "STRAIN_SCORE";
| "STRAIN_SCORE"
/** v1.10.3: overnight wrist-temperature personal-deviation band (baseline engine). */
| "WRIST_TEMPERATURE_BASELINE"
/** v1.10.3: stair-ascent-speed personal trend band (baseline engine). */
| "STAIR_ASCENT_SPEED_BASELINE"
/** v1.10.3: stair-descent-speed personal trend band (baseline engine). */
| "STAIR_DESCENT_SPEED_BASELINE"
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
// - FALL_COUNT is a zero-inflated discrete safety EVENT (target = zero); a
// median ± k·MAD band on a near-constant-zero series is meaningless and a
// band would imply a smooth metric where there is none.
// - BREATHING_DISTURBANCES is a regulated sleep-apnea SCREENING signal (Apple
// publishes only NotElevated/Elevated, no numeric cutoff); a self-derived
// band would read as a HealthLog verdict on a screening signal and imply a
// diagnosis. The fired BREATHING_DISTURBANCE_EVENT carries the device's own
// classification in the awareness card; the continuous index stays a plain
// trend with the generic assessment.
/** Archetype of a derived metric — drives shaping + the QA inventory. */
export type DerivedArchetype =
@@ -104,6 +124,25 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [
"WEIGHT",
];
/**
* The full set of measurement types the type-generic baseline engine
* (`computeVitalsBaseline`, median ± k·MAD) can produce a personal band
* for. A superset of `VITALS_BASELINE_TYPES`: it adds the v1.10.3
* additive HealthKit signals that get an `any-user-baseline` derived
* metric of their own (wrist temperature as a personal-deviation band;
* stair ascent/descent speed as a personal trend band — NO population
* cutoff, since stair pace is geometry-confounded). The engine is
* type-generic; this allowlist is the gate, kept distinct from
* `VITALS_BASELINE_TYPES` so a stair-speed baseline is never mislabelled a
* "vital".
*/
export const BASELINE_CAPABLE_TYPES: MeasurementType[] = [
...VITALS_BASELINE_TYPES,
"WRIST_TEMPERATURE",
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
];
const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
VITALS_BASELINE: {
id: "VITALS_BASELINE",
@@ -210,6 +249,42 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
WRIST_TEMPERATURE_BASELINE: {
id: "WRIST_TEMPERATURE_BASELINE",
displayName: "Wrist-temperature baseline",
archetype: "any-user-baseline",
inputs: ["WRIST_TEMPERATURE"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
STAIR_ASCENT_SPEED_BASELINE: {
id: "STAIR_ASCENT_SPEED_BASELINE",
displayName: "Stair-ascent-speed baseline",
archetype: "any-user-baseline",
inputs: ["STAIR_ASCENT_SPEED"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
STAIR_DESCENT_SPEED_BASELINE: {
id: "STAIR_DESCENT_SPEED_BASELINE",
displayName: "Stair-descent-speed baseline",
archetype: "any-user-baseline",
inputs: ["STAIR_DESCENT_SPEED"],
minHistoryDays: 7,
minInputs: 1,
implemented: true,
},
SIX_MINUTE_WALK_BAND: {
id: "SIX_MINUTE_WALK_BAND",
displayName: "Estimated 6-minute-walk band",
archetype: "passthrough-reframe",
inputs: ["SIX_MINUTE_WALK_DISTANCE"],
minHistoryDays: 1,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -231,3 +306,8 @@ export function getDerivedMetricMeta(
export function isVitalsBaselineType(type: string): boolean {
return (VITALS_BASELINE_TYPES as string[]).includes(type);
}
/** `true` when the type-generic baseline engine can band this type. */
export function isBaselineCapableType(type: string): boolean {
return (BASELINE_CAPABLE_TYPES as string[]).includes(type);
}
+199
View File
@@ -0,0 +1,199 @@
/**
* v1.10.3 — additive HealthKit signal: estimated six-minute-walk distance,
* re-framed against a published reference equation.
*
* `computeSixMinuteWalkBand(userId, profile, opts)` takes the device's
* ESTIMATED 6-minute-walk distance (HealthKit `sixMinuteWalkTestDistance` —
* an estimate, NOT a supervised in-clinic 6MWT) and re-frames it as a
* percent-of-predicted against the Enright & Sherrill 1998 reference
* equation for healthy adults. It NEVER recomputes the distance and NEVER
* derives its own equation — this is the `passthrough-reframe` archetype,
* the same posture `fitness-age.ts` takes against VO₂max.
*
* - **percentOfPredicted** — distance ÷ Enright-predicted × 100, placed in
* a green/yellow/red band (≥ 80% green; 60–80% yellow; < 60% red),
* `null` when the demographics the equation needs are absent (sex,
* adult age, height, AND weight — the published full equation). With no
* band the metric still surfaces the raw distance + trend, never a
* fabricated placement.
* - **trend** — signed latest-vs-prior delta, surfaced only once ≥ 3
* readings exist (the catalogue's trend gate).
*
* Standard: Enright & Sherrill 1998 (reference equations) + ATS 2002 (the
* test standard) — cited in `norms.ts`. Apple's value is framed as
* "estimated", surfaced + trended, never re-derived. No diagnosis: a low
* percent is a functional-capacity awareness signal, not a verdict.
*
* Server-only — reads the latest `SIX_MINUTE_WALK_DISTANCE` + `WEIGHT` rows
* via Prisma; age/height/sex come from the caller's profile. The placement
* helper is exported pure for the unit tests.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { predictSixMinuteWalkDistance } from "./norms";
import type { BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
/** Band placement, same green/yellow/red vocabulary the tokens speak. */
export type SixMinuteWalkBand = "green" | "yellow" | "red";
const SIX_MINUTE_WALK_TYPE: MeasurementType = "SIX_MINUTE_WALK_DISTANCE";
const WEIGHT_TYPE: MeasurementType = "WEIGHT";
/** Readings needed before the latest-vs-prior trend delta is surfaced. */
const MIN_TREND_READINGS = 3;
/**
* The estimate refreshes at most every few days; widen the read window so the
* latest value + trend survive sparse cadence (matches `fitness-age.ts`).
*/
const DEFAULT_WINDOW_DAYS = 180;
/** A weight reading older than this no longer reflects current capacity. */
const WEIGHT_WINDOW_DAYS = 90;
/** Percent-of-predicted band thresholds (% of Enright-predicted). */
const GREEN_PCT = 80;
const YELLOW_PCT = 60;
/** The successful `value` payload for the 6-minute-walk band. */
export interface SixMinuteWalkValue {
/** The latest device-estimated 6-minute-walk distance (m). */
distanceM: number;
/** Enright-predicted distance for the profile (m), or `null` without demographics. */
predictedM: number | null;
/** Distance ÷ predicted × 100, rounded; `null` without a prediction. */
percentOfPredicted: number | null;
/** Green/yellow/red placement, or `null` without a prediction. */
band: SixMinuteWalkBand | null;
/** Signed latest-vs-prior delta (m); `null` until ≥ 3 readings. */
trendDelta: number | null;
/** Distinct readings in the window. */
readingCount: number;
}
/**
* Place a percent-of-predicted in a band. Pure. ≥ 80% green; 60–80% yellow;
* < 60% red; `null` when the percent is absent.
*/
export function placeSixMinuteWalkBand(
percentOfPredicted: number | null,
): SixMinuteWalkBand | null {
if (percentOfPredicted == null) return null;
if (percentOfPredicted >= GREEN_PCT) return "green";
if (percentOfPredicted >= YELLOW_PCT) return "yellow";
return "red";
}
/**
* Estimated six-minute-walk band — passthrough re-frame of
* `SIX_MINUTE_WALK_DISTANCE`. Returns `insufficient` when no reading exists;
* otherwise an `ok` value carrying the latest distance, its
* percent-of-predicted placement (when demographics allow), and a trend once
* ≥ 3 readings exist. With incomplete demographics the band is `null` and the
* value + trend still surface — never a fabricated placement.
*/
export async function computeSixMinuteWalkBand(
userId: string,
profile: BaselineProfile,
opts?: { windowDays?: number; now?: Date },
): Promise<Derived<SixMinuteWalkValue>> {
const now = opts?.now ?? new Date();
const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS;
const computedAt = nowProvenanceTimestamp(now);
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const rows = await prisma.measurement.findMany({
where: {
userId,
type: SIX_MINUTE_WALK_TYPE,
deletedAt: null,
measuredAt: { gte: since },
},
orderBy: { measuredAt: "desc" },
select: { value: true },
});
if (rows.length === 0) {
const { coverage } = deriveCoverage({
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [SIX_MINUTE_WALK_TYPE],
fullHistoryDays: windowDays,
});
return buildInsufficient<SixMinuteWalkValue>({
coverage,
provenance: {
inputs: [SIX_MINUTE_WALK_TYPE],
source: "none",
windowDays,
computedAt,
},
reason: "no_readings_in_window",
});
}
const distanceM = rows[0].value;
// The Enright equation needs a recent weight; read the latest in a tighter
// window. Absent → predicted is null and the band is suppressed.
const weightSince = new Date(
now.getTime() - WEIGHT_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const weightRow = await prisma.measurement.findFirst({
where: {
userId,
type: WEIGHT_TYPE,
deletedAt: null,
measuredAt: { gte: weightSince },
},
orderBy: { measuredAt: "desc" },
select: { value: true },
});
const predictedM = predictSixMinuteWalkDistance(
profile.ageYears,
profile.heightCm ?? null,
weightRow?.value ?? null,
profile.sex,
);
const percentOfPredicted =
predictedM != null && predictedM > 0
? Math.round((distanceM / predictedM) * 100)
: null;
const band = placeSixMinuteWalkBand(percentOfPredicted);
const trendDelta =
rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null;
const source: DerivedProvenanceSource = "live";
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: rows.length,
missing: [],
fullHistoryDays: MIN_TREND_READINGS,
});
return buildOk<SixMinuteWalkValue>({
value: {
distanceM,
predictedM: predictedM != null ? Math.round(predictedM) : null,
percentOfPredicted,
band,
trendDelta,
readingCount: rows.length,
},
coverage,
confidence,
provenance: {
inputs: [SIX_MINUTE_WALK_TYPE],
source,
windowDays,
computedAt,
},
});
}