feat(measurements): add a COMPUTED source and first-class score types

Add a server-owned COMPUTED measurement source for rows a server engine
derives rather than a device or a person reports, plus three first-class
0-100 score types — RECOVERY_SCORE, STRESS_SCORE, STRAIN_SCORE (unit
"score", plausibility range 0..100).

COMPUTED is read-only from a client's perspective: it joins the read /
decode enum so the native client can decode the rows it queries and
charts, but every client-facing write surface rejects it exactly like
WITHINGS and IMPORT — the batch ingest allowlist stays {APPLE_HEALTH,
MANUAL} and the single-entry POST 422s a COMPUTED source.

All three score types thread through every type-exhaustiveness site: the
unit + plausibility-range maps, the PR-direction resolver (scores carry
no record direction, like the device events), the category overlay (a
new "scores" cluster), the list-view label / icon / colour maps with
locale strings across all six bundles, and the insight chart-token
allowlist + orphan-enum stripper. They are not clinical vitals, so they
stay out of the doctor-report PDF.

Only the recovery score is computed today; the stress and strain types
are defined now so the engines that compute them later need no schema
change. Migration 0106 is purely additive (ADD VALUE IF NOT EXISTS).
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:31:21 +02:00
1 parent 51fc48f93c
commit 33b75361f3
18 files changed
+374 -9

No files matched your search

@@ -96,6 +96,10 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
LOW_HEART_RATE_EVENT: "measurements.typeLowHeartRateEvent",
WALKING_STEADINESS_EVENT: "measurements.typeWalkingSteadinessEvent",
BREATHING_DISTURBANCE_EVENT: "measurements.typeBreathingDisturbanceEvent",
// ── v1.10.0 — computed scores (WX-C) ──
RECOVERY_SCORE: "measurements.typeRecoveryScore",
STRESS_SCORE: "measurements.typeStressScore",
STRAIN_SCORE: "measurements.typeStrainScore",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -182,6 +186,11 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
LOW_HEART_RATE_EVENT: HeartPulse,
WALKING_STEADINESS_EVENT: Footprints,
BREATHING_DISTURBANCE_EVENT: Wind,
// ── v1.10.0 — computed scores (WX-C) ──
// Gauge reads as a composite "index / score" dial for all three.
RECOVERY_SCORE: Gauge,
STRESS_SCORE: Gauge,
STRAIN_SCORE: Gauge,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -259,4 +268,10 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
LOW_HEART_RATE_EVENT: "bg-chart-3/20 text-chart-3",
WALKING_STEADINESS_EVENT: "bg-chart-2/20 text-chart-2",
BREATHING_DISTURBANCE_EVENT: "bg-chart-5/20 text-chart-5",
// ── v1.10.0 — computed scores (WX-C) ──
// chart-1 (Dracula purple) marks the server-derived composites as their
// own visual group, distinct from the raw-signal families above.
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
};
@@ -73,10 +73,14 @@ const EXPECTED_TYPES = [
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// ── v1.10.0 — computed scores (WX-C) ──
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 50 canonical measurement types", () => {
it("exposes the 53 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -168,6 +172,14 @@ describe("measurementTypeEnum coverage", () => {
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain) are 0–100 composites recomputed nightly,
// not measured clinical vitals. They surface on their own Insights
// cluster with a "descriptive, not clinical" disclaimer and never belong
// in the clinical vitals PDF. See doctor-report-pdf-core.ts.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+7
View File
@@ -181,6 +181,13 @@ export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
* table for clinical readability. Glucose ships separately via
* per-context `glucoseStats`. Sleep + activity are intentionally
* excluded from a clinical-focused report.
*
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
* they are 0–100 composites recomputed nightly from the underlying signals
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
* `measurement-type-enum-coverage.test.ts`).
*/
export const DOCTOR_REPORT_VITAL_TYPES = [
"WEIGHT",
+13
View File
@@ -89,6 +89,14 @@ export const ALLOWED_CHART_TOKENS = [
"metric:STAIR_ASCENT_SPEED",
"metric:STAIR_DESCENT_SPEED",
"metric:BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C). Server-derived 0–100 wellness scores.
// Unlike the categorical EVENT classes they ARE a continuous daily series,
// so they carry a `metric:<TYPE>` token and render through the generic
// chart renderer. Only RECOVERY_SCORE is computed in v1.10.0; the other
// two tokens are reserved for the later engines.
"metric:RECOVERY_SCORE",
"metric:STRESS_SCORE",
"metric:STRAIN_SCORE",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -184,6 +192,11 @@ const ORPHAN_ENUMS = [
"STAIR_ASCENT_SPEED",
"STAIR_DESCENT_SPEED",
"BREATHING_DISTURBANCES",
// v1.10.0 — computed scores (WX-C), same shape. Strip the bare enum name
// if the model drops it into prose (mirrors the MOOD_SCORE precedent).
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -32,6 +32,13 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"PULSE_WAVE_VELOCITY",
"VASCULAR_AGE",
"VISCERAL_FAT",
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain) are minted by a nightly engine from the
// user's already-stored signals; HealthKit ships no identifier for them
// and they are never ingested, so they have no HK mapping by design.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+13 -1
View File
@@ -42,7 +42,11 @@ export type MeasurementCategory =
| "hearing"
| "environment"
| "cardiovascular"
| "metabolic";
| "metabolic"
// v1.10.0 — computed scores (WX-C). Server-derived wellness scores
// (Recovery / Stress / Strain). Their own presentation cluster — they are
// composite indices, not a raw signal in any of the above buckets.
| "scores";
/**
* Per-MeasurementType → category mapping. The map is exhaustive over
@@ -153,6 +157,14 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["LOW_HEART_RATE_EVENT", "cardiovascular"],
["WALKING_STEADINESS_EVENT", "activity"],
["BREATHING_DISTURBANCE_EVENT", "sleep"],
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived wellness scores share their own presentation cluster.
// The category is only for list grouping — scores never participate in
// trend/rollup analytics (they ARE a nightly-computed composite).
["RECOVERY_SCORE", "scores"],
["STRESS_SCORE", "scores"],
["STRAIN_SCORE", "scores"],
]);
/**
+8
View File
@@ -144,6 +144,14 @@ export function getPRDirection(
case "LOW_HEART_RATE_EVENT":
case "WALKING_STEADINESS_EVENT":
case "BREATHING_DISTURBANCE_EVENT":
// v1.10.0 — computed scores (WX-C). The server-derived wellness scores
// are themselves derived composites recomputed nightly from the
// underlying signals; a "personal record" on a derived score would
// double-count the PRs of its own inputs and read as noise. They
// explicitly have no PR direction, like the categorical events above.
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
return null;
}
}
@@ -271,4 +271,50 @@ describe("measurement validation", () => {
expect(parsed.success).toBe(true);
});
});
// v1.10.0 — computed scores (WX-C).
describe("computed scores", () => {
it("canonical unit is 'score' for each computed-score type", () => {
expect(getUnitForType("RECOVERY_SCORE")).toBe("score");
expect(getUnitForType("STRESS_SCORE")).toBe("score");
expect(getUnitForType("STRAIN_SCORE")).toBe("score");
});
it("plausibility range is 0..100 for a computed score", () => {
expect(validateMeasurementRange("RECOVERY_SCORE", 0)).toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", 100)).toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", -1)).not.toBeNull();
expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull();
});
it("rejects the server-owned COMPUTED source on a client write", () => {
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
measuredAt: "2026-06-02T12:00:00Z",
source: "COMPUTED",
});
expect(parsed.success).toBe(false);
if (!parsed.success) {
const issue = parsed.error.issues.find((i) =>
i.path.includes("source"),
);
expect(issue, "expected a source-path rejection").toBeDefined();
expect(issue!.message).toMatch(/server-owned/i);
}
});
it("still accepts a client-writable source for a score-typed manual row", () => {
// The COMPUTED source is the only one this refine blocks; a MANUAL
// write of any type stays valid (the source guard is orthogonal to
// whether a UI ever offers manual score entry).
const parsed = createMeasurementSchema.safeParse({
type: "RECOVERY_SCORE",
value: 72,
measuredAt: "2026-06-02T12:00:00Z",
source: "MANUAL",
});
expect(parsed.success).toBe(true);
});
});
});
+68 -1
View File
@@ -66,6 +66,29 @@ export const measurementTypeEnum = z.enum([
"LOW_HEART_RATE_EVENT",
"WALKING_STEADINESS_EVENT",
"BREATHING_DISTURBANCE_EVENT",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived wellness scores (0–100, unit `score`). Minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source row — never ingested from a client (the batch +
// single-POST write surfaces reject the COMPUTED source). Only
// RECOVERY_SCORE is computed in v1.10.0; the other two are defined now so
// the later engines that compute them need no schema change.
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
/**
* v1.10.0 — computed scores (WX-C). The closed set of server-derived
* `*_SCORE` MeasurementTypes. Each is a 0–100 wellness score persisted with
* `source = COMPUTED` by a nightly engine — never ingested from a client.
* Kept as a `Set` so read/write paths can branch on "is this a computed
* score?" the same way `EVENT_MEASUREMENT_TYPES` gates the event rows.
*/
export const SCORE_MEASUREMENT_TYPES: ReadonlySet<string> = new Set<string>([
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
]);
/**
@@ -104,8 +127,31 @@ export const measurementSourceEnum = z.enum([
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
// v1.10.0 — computed scores (WX-C). Server-owned / read-only: a `COMPUTED`
// row is minted by a nightly engine, never written by a client. It is part
// of this enum so the read/response shapes (and the iOS decoder) can decode
// the rows it surfaces; the client-facing write surfaces reject it — see
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
"COMPUTED",
]);
/**
* v1.10.0 — computed scores (WX-C). The subset of `MeasurementSource` a
* client may attribute on a write. `COMPUTED` is server-owned (a nightly
* engine mints it) and `WITHINGS` / `IMPORT` are owned by the Withings
* webhook + CSV importer respectively, so all three are excluded — letting a
* client forge a row attributed to them would pollute the per-source
* canonical picker with rows the server never produced. The single-entry
* POST validates `source` against this set; the batch route mirrors the same
* exclusion with its own narrower `{APPLE_HEALTH, MANUAL}` allowlist.
*/
export const WRITABLE_MEASUREMENT_SOURCES = [
"MANUAL",
"WITHINGS",
"IMPORT",
"APPLE_HEALTH",
] as const;
const unitMap: Record<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
@@ -200,6 +246,12 @@ const unitMap: Record<string, string> = {
LOW_HEART_RATE_EVENT: "event",
WALKING_STEADINESS_EVENT: "event",
BREATHING_DISTURBANCE_EVENT: "event",
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 wellness scores. The canonical unit is the bare
// "score" so the value reads sensibly anywhere it surfaces.
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
};
export function getUnitForType(type: string): string {
@@ -343,6 +395,12 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
LOW_HEART_RATE_EVENT: { min: 1, max: 1 },
WALKING_STEADINESS_EVENT: { min: 1, max: 1 },
BREATHING_DISTURBANCE_EVENT: { min: 1, max: 1 },
// ── v1.10.0 — computed scores (WX-C) ──
// Server-derived 0–100 scores. The plausibility band pins them to the
// score range so a malformed store (a stray negative or > 100) is rejected.
RECOVERY_SCORE: { min: 0, max: 100 },
STRESS_SCORE: { min: 0, max: 100 },
STRAIN_SCORE: { min: 0, max: 100 },
};
export function validateMeasurementRange(
@@ -395,7 +453,16 @@ export const createMeasurementSchema = z
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
path: ["glucoseContext"],
},
);
)
// v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned:
// a nightly engine mints those rows, a client never does. Reject it here
// so the single-entry POST cannot forge a server-derived row (the batch
// route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}`
// allowlist).
.refine((data) => data.source !== "COMPUTED", {
message: "The COMPUTED source is server-owned and cannot be set by a client.",
path: ["source"],
});
export const updateMeasurementSchema = z.object({
value: z.number().min(0).max(500000).optional(),