diff --git a/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json b/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json new file mode 100644 index 000000000..cd5e00bb4 --- /dev/null +++ b/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json @@ -0,0 +1,9 @@ +[ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS" +] diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json new file mode 100644 index 000000000..ef811a6a1 --- /dev/null +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -0,0 +1,218 @@ +{ + "WEIGHT": { + "loinc": "29463-7", + "display": "Body weight", + "unit": "kg", + "category": "vital-signs" + }, + "BODY_MASS_INDEX": { + "loinc": "39156-5", + "display": "Body mass index (BMI) [Ratio]", + "unit": "kg/m2", + "category": "vital-signs" + }, + "PULSE": { + "loinc": "8867-4", + "display": "Heart rate", + "unit": "/min", + "category": "vital-signs" + }, + "RESTING_HEART_RATE": { + "loinc": "40443-4", + "display": "Heart rate --resting", + "unit": "/min", + "category": "vital-signs" + }, + "RESPIRATORY_RATE": { + "loinc": "9279-1", + "display": "Respiratory rate", + "unit": "/min", + "category": "vital-signs" + }, + "BODY_TEMPERATURE": { + "loinc": "8310-5", + "display": "Body temperature", + "unit": "Cel", + "category": "vital-signs" + }, + "OXYGEN_SATURATION": { + "loinc": "59408-5", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry", + "unit": "%", + "category": "vital-signs" + }, + "BODY_FAT": { + "loinc": "41982-0", + "display": "Percentage of body fat Measured", + "unit": "%", + "category": "vital-signs" + }, + "VO2_MAX": { + "loinc": "96402-2", + "display": "Oxygen consumption maximum during exercise", + "unit": "mL/min/kg", + "category": "vital-signs" + }, + "HEART_RATE_VARIABILITY": { + "loinc": "80404-7", + "display": "R-R interval.standard deviation (Heart rate variability)", + "unit": "ms", + "category": "vital-signs" + }, + "ACTIVITY_STEPS": { + "loinc": "41950-7", + "display": "Number of steps in 24 hour Measured", + "unit": "{steps}", + "category": "activity" + }, + "SLEEP_DURATION": { + "loinc": "93832-4", + "display": "Sleep duration", + "unit": "h", + "category": "activity" + }, + "ACTIVE_ENERGY_BURNED": { + "loinc": "41981-2", + "display": "Calories burned", + "unit": "kcal", + "category": "activity" + }, + "WALKING_SPEED": { + "loinc": "41957-2", + "display": "Gait speed [Velocity] Measured", + "unit": "m/s", + "category": "vital-signs" + }, + "WALKING_ASYMMETRY": { + "loinc": "91557-1", + "display": "Walking asymmetry percentage", + "unit": "%", + "category": "vital-signs" + }, + "WALKING_STEP_LENGTH": { + "loinc": "41955-6", + "display": "Step length Measured", + "unit": "m", + "category": "vital-signs" + }, + "TOTAL_BODY_WATER": { + "loinc": "73704-9", + "display": "Body water by Bioelectrical impedance analysis", + "unit": "kg", + "category": "vital-signs" + }, + "BONE_MASS": { + "loinc": "73708-0", + "display": "Bone mineral content by DXA", + "unit": "kg", + "category": "vital-signs" + }, + "WALKING_DOUBLE_SUPPORT": { + "loinc": "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + "display": "Walking double support percentage", + "unit": "%", + "category": "vital-signs" + }, + "AUDIO_EXPOSURE_ENV": { + "loinc": "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + "display": "Environmental audio exposure", + "unit": "dB[A]", + "category": "vital-signs" + }, + "AUDIO_EXPOSURE_HEADPHONE": { + "loinc": "HKQuantityTypeIdentifierHeadphoneAudioExposure", + "display": "Headphone audio exposure", + "unit": "dB[A]", + "category": "vital-signs" + }, + "FLIGHTS_CLIMBED": { + "loinc": "HKQuantityTypeIdentifierFlightsClimbed", + "display": "Flights climbed", + "unit": "{flights}", + "category": "activity" + }, + "WALKING_RUNNING_DISTANCE": { + "loinc": "HKQuantityTypeIdentifierDistanceWalkingRunning", + "display": "Distance walking/running", + "unit": "m", + "category": "activity" + }, + "TIME_IN_DAYLIGHT": { + "loinc": "HKQuantityTypeIdentifierTimeInDaylight", + "display": "Time in daylight", + "unit": "min", + "category": "activity" + }, + "MUSCLE_MASS": { + "loinc": null, + "display": "Muscle mass", + "unit": "kg", + "category": "vital-signs" + }, + "FAT_MASS": { + "loinc": null, + "display": "Fat mass", + "unit": "kg", + "category": "vital-signs" + }, + "FAT_FREE_MASS": { + "loinc": null, + "display": "Fat-free mass", + "unit": "kg", + "category": "vital-signs" + }, + "LEAN_BODY_MASS": { + "loinc": null, + "display": "Lean body mass", + "unit": "kg", + "category": "vital-signs" + }, + "VISCERAL_FAT": { + "loinc": null, + "display": "Visceral fat", + "unit": "1", + "category": "vital-signs" + }, + "CARDIO_RECOVERY": { + "loinc": "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + "display": "Cardio recovery (1-minute heart-rate recovery)", + "unit": "/min", + "category": "vital-signs" + }, + "WRIST_TEMPERATURE": { + "loinc": "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + "display": "Sleeping wrist temperature", + "unit": "Cel", + "category": "vital-signs" + }, + "FALL_COUNT": { + "loinc": "HKQuantityTypeIdentifierNumberOfTimesFallen", + "display": "Number of times fallen", + "unit": "{falls}", + "category": "activity" + }, + "SIX_MINUTE_WALK_DISTANCE": { + "loinc": "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + "display": "Six-minute walk test distance", + "unit": "m", + "category": "activity" + }, + "STAIR_ASCENT_SPEED": { + "loinc": "HKQuantityTypeIdentifierStairAscentSpeed", + "display": "Stair ascent speed", + "unit": "m/s", + "category": "vital-signs" + }, + "STAIR_DESCENT_SPEED": { + "loinc": "HKQuantityTypeIdentifierStairDescentSpeed", + "display": "Stair descent speed", + "unit": "m/s", + "category": "vital-signs" + }, + "BREATHING_DISTURBANCES": { + "loinc": "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + "display": "Sleeping breathing disturbances", + "unit": "{count}", + "category": "vital-signs" + } +} diff --git a/src/lib/signals/__tests__/registry-invariants.test.ts b/src/lib/signals/__tests__/registry-invariants.test.ts new file mode 100644 index 000000000..d1538ba12 --- /dev/null +++ b/src/lib/signals/__tests__/registry-invariants.test.ts @@ -0,0 +1,99 @@ +/** + * Registry invariants — the drift guard that pins the SIGNAL REGISTRY to the + * values the per-surface tables carry today. Any edit that changes a derived + * table (FHIR coding, correlation eligibility, metric-status metadata) without + * intentionally updating the golden master fails here, so a registry flip stays + * a byte-for-byte no-op and accidental cross-surface drift cannot land. + * + * Golden masters under `./fixtures/*` were extracted verbatim from the + * hand-maintained tables at the v1.25 branch base; update them deliberately + * (and in lock-step with the registry) when a value genuinely changes. + */ +import { describe, expect, it } from "vitest"; + +import { + METRIC_STATUS_IDS, + getMetricStatusMeta, +} from "@/lib/insights/metric-status-registry"; +import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map"; +import { + SIGNALS, + allSignals, + getSignal, + signalForMeasurementType, +} from "@/lib/signals/registry"; +import { deriveMetricStatusRegistry } from "@/lib/signals/adapters/metric-status"; +import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir"; +import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; + +import expectedMeasurementLoinc from "./fixtures/expected-measurement-loinc.json"; +import expectedBucketedTypes from "./fixtures/expected-bucketed-types.json"; + +describe("signal registry — structural sanity", () => { + it("every entry's key matches its record key", () => { + for (const [key, signal] of Object.entries(SIGNALS)) { + expect(signal.key).toBe(key); + } + }); + + it("every signal carries a well-formed source for its kind", () => { + for (const signal of allSignals()) { + if (signal.kind === "biomarker") { + expect(signal.source.biomarkerKey.length).toBeGreaterThan(0); + } else { + expect(signal.source.measurementType.length).toBeGreaterThan(0); + } + } + }); + + it("mcp exposure follows Coach-snapshot inclusion", () => { + // The MCP get_metric_series enum is the Coach scope vocabulary, so a signal + // is MCP-readable iff it carries a Coach scope (and vice-versa). + for (const signal of allSignals()) { + const hasScope = signal.surfaces.coachSnapshot !== false; + expect(signal.surfaces.mcp).toBe(hasScope); + } + }); + + it("signalForMeasurementType round-trips every measurement/score signal", () => { + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + expect(signalForMeasurementType(signal.source.measurementType)).toBe( + signal, + ); + } + }); +}); + +describe("signal registry — metric-status projection equals the live table", () => { + it("registers every generic metric-status id", () => { + for (const id of METRIC_STATUS_IDS) { + expect(getSignal(id)).not.toBeNull(); + } + }); + + it("derives each MetricStatusMeta byte-for-byte from the registry", () => { + const derived = deriveMetricStatusRegistry(); + for (const id of METRIC_STATUS_IDS) { + expect(derived[id]).toEqual(getMetricStatusMeta(id)); + } + }); +}); + +describe("signal registry — FHIR table is byte-identical", () => { + it("derives MEASUREMENT_LOINC equal to the golden master", () => { + expect(deriveMeasurementLoinc()).toEqual(expectedMeasurementLoinc); + }); + + it("the live (flipped) MEASUREMENT_LOINC export equals the golden master", () => { + expect(MEASUREMENT_LOINC).toEqual(expectedMeasurementLoinc); + }); +}); + +describe("signal registry — correlation table is byte-identical", () => { + it("derives BUCKETED_TYPES equal to the golden master (set semantics)", () => { + expect([...deriveBucketedTypes()].sort()).toEqual( + [...(expectedBucketedTypes as string[])].sort(), + ); + }); +}); diff --git a/src/lib/signals/adapters/correlation.ts b/src/lib/signals/adapters/correlation.ts new file mode 100644 index 000000000..777399d0a --- /dev/null +++ b/src/lib/signals/adapters/correlation.ts @@ -0,0 +1,21 @@ +/** + * Adapter: derive the correlation engine's bucketed-type list from the + * registry. Replaces the hand-maintained `BUCKETED_TYPES` literal in + * `features.ts` — every signal flagged `surfaces.correlationEligible` projects + * to its DB `MeasurementType`. The list is consumed as a membership/iteration + * set (each type is read independently), so order is not semantically + * significant; the registry-invariant test compares it as a set. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { allSignals } from "@/lib/signals/registry"; + +/** The registry-derived correlation-eligible `MeasurementType` list. */ +export function deriveBucketedTypes(): MeasurementType[] { + const types: MeasurementType[] = []; + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + if (!signal.surfaces.correlationEligible) continue; + types.push(signal.source.measurementType); + } + return types; +} diff --git a/src/lib/signals/adapters/fhir.ts b/src/lib/signals/adapters/fhir.ts new file mode 100644 index 000000000..1daafb530 --- /dev/null +++ b/src/lib/signals/adapters/fhir.ts @@ -0,0 +1,29 @@ +/** + * Adapter: derive the single-value `MeasurementType → FHIR coding` table from + * the registry. Replaces the hand-maintained `MEASUREMENT_LOINC` literal in + * `loinc-map.ts` — every signal that carries a `fhir` facet projects to one + * entry keyed by its DB `MeasurementType`. Signals without a `fhir` facet are + * absent (their value is surfaced as a local text concept elsewhere, or handled + * by a dedicated builder, e.g. BP panel / glucose context). + */ +import type { LoincMapping } from "@/lib/fhir/loinc-map"; +import { allSignals } from "@/lib/signals/registry"; + +/** The registry-derived `MEASUREMENT_LOINC`, keyed by `MeasurementType` string. */ +export function deriveMeasurementLoinc(): Record { + const entries: Array<[string, LoincMapping]> = []; + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + if (!signal.fhir) continue; + entries.push([ + signal.source.measurementType, + { + loinc: signal.fhir.loinc, + display: signal.fhir.display, + unit: signal.fhir.unit, + category: signal.fhir.category, + }, + ]); + } + return Object.fromEntries(entries); +} diff --git a/src/lib/signals/adapters/metric-status.ts b/src/lib/signals/adapters/metric-status.ts new file mode 100644 index 000000000..2470a6733 --- /dev/null +++ b/src/lib/signals/adapters/metric-status.ts @@ -0,0 +1,44 @@ +/** + * Adapter: project the registry back to the `MetricStatusMeta` shape the + * generic assessment path consumes today. The hand-written `REGISTRY` in + * `metric-status-registry.ts` stays the live source for the assessment card + * (untouched in this change); this projection exists so the registry-invariant + * test can assert the two agree field-for-field and flag any future drift. + */ +import { + METRIC_STATUS_IDS, + type MetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; +import { getSignal } from "@/lib/signals/registry"; + +/** Build the `MetricStatusMeta` for one registered metric-status id. */ +function metaFor(id: MetricStatusMetricId): MetricStatusMeta { + const signal = getSignal(id); + if (!signal || signal.kind === "biomarker") { + throw new Error(`signal registry: no measurement signal for "${id}"`); + } + // Build field-by-field, omitting the optional keys when absent so the + // projection matches the hand-written object shape exactly (deepEqual). + const meta: MetricStatusMeta = { + id, + measurementType: signal.source.measurementType, + displayName: signal.displayName, + unit: signal.unit, + direction: signal.direction, + archetype: signal.archetype, + }; + if (signal.normalRange !== undefined) meta.normalRange = signal.normalRange; + if (signal.feverBandC !== undefined) meta.feverBandC = signal.feverBandC; + return meta; +} + +/** The full registry-derived metric-status table, keyed by metric-status id. */ +export function deriveMetricStatusRegistry(): Record< + MetricStatusMetricId, + MetricStatusMeta +> { + return Object.fromEntries( + METRIC_STATUS_IDS.map((id) => [id, metaFor(id)]), + ) as Record; +} diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts new file mode 100644 index 000000000..4f26dda81 --- /dev/null +++ b/src/lib/signals/registry.ts @@ -0,0 +1,1234 @@ +/** + * v1.25 — the SIGNAL REGISTRY backbone. + * + * One declarative table that describes a health signal ACROSS every surface + * it touches (DB measurement type, detail page, correlation engine, Coach + * snapshot, MCP read tool, FHIR coding). Today a single signal is described + * 8–16 times in independent tables keyed by slightly different vocabularies, + * and nothing fails the build when they disagree. This registry promotes the + * proto-registry in `metric-status-registry.ts` to the canonical definition + * and lets the per-surface tables DERIVE from it (see `./adapters/*`) instead + * of being hand-maintained beside it. + * + * Coexistence, not replacement: the DB enum stays the source of truth for + * persistence. The registry sits ABOVE it. Surfaces are flipped one at a time + * to read from the registry; the registry-invariant test pins every derived + * table byte-for-byte to the values the hand-written tables carry today, so a + * flip is a no-op diff and future drift fails CI. + * + * Adding a NEW signal (the clinical-signals wave): append ONE + * `SignalDefinition` object below, add its i18n keys, and — for a + * `measurement`/`score` — one Prisma enum member + migration. The derived + * tables (FHIR, correlation, …) light up with no further per-surface edits. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { + MetricArchetype, + MetricDirection, + MetricNormalRange, +} from "@/lib/insights/metric-status-registry"; +import type { FhirObservationCategory } from "@/lib/fhir/loinc-map"; +import { FEVER_BAND_C } from "@/lib/clinical-floors"; + +/** Discriminator: a device/manual reading, a lab biomarker, or a computed score. */ +export type SignalKind = "measurement" | "biomarker" | "score"; + +/** Reuse the assessment registry's direction vocabulary verbatim. */ +export type SignalDirection = MetricDirection; + +/** Coarse population placement band; the user's own baseline still leads. */ +export type SignalBand = MetricNormalRange; + +/** + * FHIR R4 coding facet. Field names align with `loinc-map.ts`'s `LoincMapping` + * (`unit` is the UCUM string, also used as the `code`) so the FHIR adapter is a + * straight projection with no renaming. + */ +export interface SignalFhirCoding { + /** LOINC code, or null when no stable LOINC applies (local text fallback). */ + loinc: string | null; + display: string; + /** UCUM unit string (also used as the FHIR `code`). */ + unit: string; + category: FhirObservationCategory; +} + +/** Chart-overlay facet. Typed loosely (string overlayKey) to keep the backbone free of the dashboard-layout runtime dep. */ +export interface SignalChart { + overlayKey: string; + color?: string; + yAxisUnit?: string; + /** Display rescale (e.g. m/s → km/h); applied at the chart edge only. */ + valueScale?: number; +} + +/** + * Per-surface eligibility — declarative flags, not branches scattered across + * files. `coachSnapshot.scope` is the Coach scope-source token (typed as a + * string here so the backbone does not depend on the coach module; the coach + * flip will tighten it to `CoachScopeSource`). + */ +export interface SignalSurfaces { + /** Mount the generic HealthKit/biomarker detail template. */ + detailPage: boolean; + /** Feed the FDR correlation channels (⇒ `BUCKETED_TYPES`). */ + correlationEligible: boolean; + /** Coach snapshot inclusion + its scope token, or `false` to opt out. */ + coachSnapshot: false | { scope: string }; + /** Exposed to the MCP `get_metric_series` / search inventory. */ + mcp: boolean; +} + +interface SignalCommon { + /** Stable signal key — the ONE vocabulary every surface speaks. */ + key: string; + /** i18n key prefix resolving `.title`/`.description`/`.chartTitle`/`.emptyState.*`. Optional until the clinical-signals wave fills it per signal. */ + i18nPrefix?: string; + /** Stable English display name (the model localises its own prose). */ + displayName: string; + /** Canonical storage unit (matches the DB column semantics). */ + unit: string; + direction: SignalDirection; + archetype: MetricArchetype; + normalRange?: SignalBand; + /** Single-reading fever band line (°C) for temperature signals. */ + feverBandC?: number; + /** Richer clinical bands defer to a `reference-ranges.ts` entry by key. */ + referenceMetric?: string; + /** Cross-source dedup ladder key; omit ⇒ not cross-source-deduped. */ + sourcePriorityKey?: string; + chart?: SignalChart; + surfaces: SignalSurfaces; + fhir?: SignalFhirCoding; +} + +/** How a `measurement`/`score` signal is sourced from the DB enum. */ +export interface MeasurementSignalSource { + measurementType: MeasurementType; + /** Fallback measurement type (e.g. an SDNN→RMSSD HRV proxy). */ + fallbackMeasurementType?: MeasurementType; +} + +/** How a `biomarker` signal is sourced from the labs catalog. */ +export interface BiomarkerSignalSource { + biomarkerKey: string; +} + +/** + * The canonical cross-surface signal definition, discriminated on `kind`. + * `measurement` and `score` both resolve to a `MeasurementType`; `biomarker` + * resolves to a labs-catalog key. + */ +export type SignalDefinition = + | (SignalCommon & { kind: "measurement"; source: MeasurementSignalSource }) + | (SignalCommon & { kind: "score"; source: MeasurementSignalSource }) + | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }); + +/** Convenience: build a Coach-snapshot facet from a scope token. */ +function coachScope(scope: string): { scope: string } { + return { scope }; +} + +/** + * The registry. Keyed by stable signal key. Generic HealthKit signals key by + * their `metric-status-registry` id (STEPS, RESTING_HEART_RATE, …); the six + * specialised signals (WEIGHT/BMI/PULSE/BODY_FAT/BP_SYS/BP_DIA) key by their + * `MeasurementType` and carry `detailPage:false` so the generic template never + * double-renders them — they still appear in the cross-surface indices (FHIR, + * correlation, Coach, MCP). + * + * Every value here is the value the hand-written per-surface table carries + * today; the registry-invariant test pins them byte-for-byte. + */ +export const SIGNALS: Record = { + // ── physiological-vital ────────────────────────────────────────────── + RESTING_HEART_RATE: { + key: "RESTING_HEART_RATE", + kind: "measurement", + source: { measurementType: "RESTING_HEART_RATE" }, + sourcePriorityKey: "restingHeartRate", + i18nPrefix: "insights.restingHr", + displayName: "Resting heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + normalRange: { low: 50, high: 100 }, + referenceMetric: "RESTING_HEART_RATE", + chart: { overlayKey: "restingHr", color: "#ff5555", yAxisUnit: "bpm" }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("resting_hr"), + mcp: true, + }, + fhir: { + loinc: "40443-4", + display: "Heart rate --resting", + unit: "/min", + category: "vital-signs", + }, + }, + HEART_RATE_VARIABILITY: { + key: "HEART_RATE_VARIABILITY", + kind: "measurement", + source: { measurementType: "HEART_RATE_VARIABILITY" }, + sourcePriorityKey: "hrv", + displayName: "Heart-rate variability (SDNN)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("hrv"), + mcp: true, + }, + fhir: { + loinc: "80404-7", + display: "R-R interval.standard deviation (Heart rate variability)", + unit: "ms", + category: "vital-signs", + }, + }, + OXYGEN_SATURATION: { + key: "OXYGEN_SATURATION", + kind: "measurement", + source: { measurementType: "OXYGEN_SATURATION" }, + sourcePriorityKey: "spo2", + displayName: "Blood oxygen (SpO₂)", + unit: "%", + direction: "higher-better", + archetype: "physiological-vital", + normalRange: { low: 95, high: 100 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("spo2"), + mcp: true, + }, + fhir: { + loinc: "59408-5", + display: "Oxygen saturation in Arterial blood by Pulse oximetry", + unit: "%", + category: "vital-signs", + }, + }, + RESPIRATORY_RATE: { + key: "RESPIRATORY_RATE", + kind: "measurement", + source: { measurementType: "RESPIRATORY_RATE" }, + sourcePriorityKey: "respiratoryRate", + displayName: "Respiratory rate", + unit: "breaths/min", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 12, high: 20 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("respiratory_rate"), + mcp: true, + }, + fhir: { + loinc: "9279-1", + display: "Respiratory rate", + unit: "/min", + category: "vital-signs", + }, + }, + BODY_TEMPERATURE: { + key: "BODY_TEMPERATURE", + kind: "measurement", + source: { measurementType: "BODY_TEMPERATURE" }, + sourcePriorityKey: "bodyTemperature", + displayName: "Body temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 36.1, high: 37.2 }, + feverBandC: FEVER_BAND_C, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("body_temp"), + mcp: true, + }, + fhir: { + loinc: "8310-5", + display: "Body temperature", + unit: "Cel", + category: "vital-signs", + }, + }, + SKIN_TEMPERATURE: { + key: "SKIN_TEMPERATURE", + kind: "measurement", + source: { measurementType: "SKIN_TEMPERATURE" }, + sourcePriorityKey: "skinTemperature", + displayName: "Wrist skin temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("skin_temp"), + mcp: true, + }, + }, + BLOOD_GLUCOSE: { + key: "BLOOD_GLUCOSE", + kind: "measurement", + source: { measurementType: "BLOOD_GLUCOSE" }, + displayName: "Blood glucose", + unit: "mg/dL", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 70, high: 140 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("glucose"), + mcp: true, + }, + }, + WALKING_HEART_RATE_AVERAGE: { + key: "WALKING_HEART_RATE_AVERAGE", + kind: "measurement", + source: { measurementType: "WALKING_HEART_RATE_AVERAGE" }, + displayName: "Walking heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_hr"), + mcp: true, + }, + }, + PULSE_WAVE_VELOCITY: { + key: "PULSE_WAVE_VELOCITY", + kind: "measurement", + source: { measurementType: "PULSE_WAVE_VELOCITY" }, + displayName: "Pulse-wave velocity", + unit: "m/s", + direction: "lower-better", + archetype: "physiological-vital", + normalRange: { low: 0, high: 10 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("pulse_wave_velocity"), + mcp: true, + }, + }, + VASCULAR_AGE: { + key: "VASCULAR_AGE", + kind: "measurement", + source: { measurementType: "VASCULAR_AGE" }, + displayName: "Vascular age", + unit: "years", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("vascular_age"), + mcp: true, + }, + }, + CARDIO_RECOVERY: { + key: "CARDIO_RECOVERY", + kind: "measurement", + source: { measurementType: "CARDIO_RECOVERY" }, + displayName: "Cardio recovery", + unit: "bpm", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + display: "Cardio recovery (1-minute heart-rate recovery)", + unit: "/min", + category: "vital-signs", + }, + }, + WRIST_TEMPERATURE: { + key: "WRIST_TEMPERATURE", + kind: "measurement", + source: { measurementType: "WRIST_TEMPERATURE" }, + displayName: "Wrist temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + display: "Sleeping wrist temperature", + unit: "Cel", + category: "vital-signs", + }, + }, + ANS_CHARGE: { + key: "ANS_CHARGE", + kind: "measurement", + source: { measurementType: "ANS_CHARGE" }, + displayName: "ANS charge", + unit: "score", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + AVERAGE_HEART_RATE: { + key: "AVERAGE_HEART_RATE", + kind: "measurement", + source: { measurementType: "AVERAGE_HEART_RATE" }, + displayName: "Average heart rate", + unit: "bpm", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + MAX_HEART_RATE: { + key: "MAX_HEART_RATE", + kind: "measurement", + source: { measurementType: "MAX_HEART_RATE" }, + displayName: "Max heart rate", + unit: "bpm", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── activity-fitness ───────────────────────────────────────────────── + STEPS: { + key: "STEPS", + kind: "measurement", + source: { measurementType: "ACTIVITY_STEPS" }, + sourcePriorityKey: "steps", + displayName: "Steps", + unit: "steps/day", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 8000, high: 15000 }, + surfaces: { + detailPage: true, + correlationEligible: true, + coachSnapshot: coachScope("steps"), + mcp: true, + }, + fhir: { + loinc: "41950-7", + display: "Number of steps in 24 hour Measured", + unit: "{steps}", + category: "activity", + }, + }, + ACTIVE_ENERGY: { + key: "ACTIVE_ENERGY", + kind: "measurement", + source: { measurementType: "ACTIVE_ENERGY_BURNED" }, + sourcePriorityKey: "activeEnergy", + displayName: "Active energy", + unit: "kcal/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("active_energy"), + mcp: true, + }, + fhir: { + loinc: "41981-2", + display: "Calories burned", + unit: "kcal", + category: "activity", + }, + }, + FLIGHTS_CLIMBED: { + key: "FLIGHTS_CLIMBED", + kind: "measurement", + source: { measurementType: "FLIGHTS_CLIMBED" }, + sourcePriorityKey: "flightsClimbed", + displayName: "Flights climbed", + unit: "flights/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("flights"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierFlightsClimbed", + display: "Flights climbed", + unit: "{flights}", + category: "activity", + }, + }, + WALKING_RUNNING_DISTANCE: { + key: "WALKING_RUNNING_DISTANCE", + kind: "measurement", + source: { measurementType: "WALKING_RUNNING_DISTANCE" }, + sourcePriorityKey: "walkingRunningDistance", + displayName: "Walking + running distance", + unit: "m/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("distance"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierDistanceWalkingRunning", + display: "Distance walking/running", + unit: "m", + category: "activity", + }, + }, + TIME_IN_DAYLIGHT: { + key: "TIME_IN_DAYLIGHT", + kind: "measurement", + source: { measurementType: "TIME_IN_DAYLIGHT" }, + displayName: "Time in daylight", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 30, high: 120 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("daylight"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierTimeInDaylight", + display: "Time in daylight", + unit: "min", + category: "activity", + }, + }, + VO2_MAX: { + key: "VO2_MAX", + kind: "measurement", + source: { measurementType: "VO2_MAX" }, + sourcePriorityKey: "vo2Max", + displayName: "VO₂ max (cardio fitness)", + unit: "mL/(kg·min)", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("vo2_max"), + mcp: true, + }, + fhir: { + loinc: "96402-2", + display: "Oxygen consumption maximum during exercise", + unit: "mL/min/kg", + category: "vital-signs", + }, + }, + DAY_STRAIN: { + key: "DAY_STRAIN", + kind: "measurement", + source: { measurementType: "DAY_STRAIN" }, + displayName: "Day strain", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + WORKOUT_STRAIN: { + key: "WORKOUT_STRAIN", + kind: "measurement", + source: { measurementType: "WORKOUT_STRAIN" }, + displayName: "Workout strain", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + CARDIO_LOAD: { + key: "CARDIO_LOAD", + kind: "measurement", + source: { measurementType: "CARDIO_LOAD" }, + displayName: "Cardio load", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + ENERGY_EXPENDITURE_KJ: { + key: "ENERGY_EXPENDITURE_KJ", + kind: "measurement", + source: { measurementType: "ENERGY_EXPENDITURE_KJ" }, + displayName: "Energy expenditure", + unit: "kJ", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── body-composition ───────────────────────────────────────────────── + TOTAL_BODY_WATER: { + key: "TOTAL_BODY_WATER", + kind: "measurement", + source: { measurementType: "TOTAL_BODY_WATER" }, + displayName: "Total body water", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("total_body_water"), + mcp: true, + }, + fhir: { + loinc: "73704-9", + display: "Body water by Bioelectrical impedance analysis", + unit: "kg", + category: "vital-signs", + }, + }, + BONE_MASS: { + key: "BONE_MASS", + kind: "measurement", + source: { measurementType: "BONE_MASS" }, + displayName: "Bone mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("bone_mass"), + mcp: true, + }, + fhir: { + loinc: "73708-0", + display: "Bone mineral content by DXA", + unit: "kg", + category: "vital-signs", + }, + }, + FAT_FREE_MASS: { + key: "FAT_FREE_MASS", + kind: "measurement", + source: { measurementType: "FAT_FREE_MASS" }, + displayName: "Fat-free mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("fat_free_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Fat-free mass", + unit: "kg", + category: "vital-signs", + }, + }, + FAT_MASS: { + key: "FAT_MASS", + kind: "measurement", + source: { measurementType: "FAT_MASS" }, + displayName: "Fat mass", + unit: "kg", + direction: "lower-better", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("fat_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Fat mass", + unit: "kg", + category: "vital-signs", + }, + }, + MUSCLE_MASS: { + key: "MUSCLE_MASS", + kind: "measurement", + source: { measurementType: "MUSCLE_MASS" }, + displayName: "Muscle mass", + unit: "kg", + direction: "higher-better", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("muscle_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Muscle mass", + unit: "kg", + category: "vital-signs", + }, + }, + LEAN_BODY_MASS: { + key: "LEAN_BODY_MASS", + kind: "measurement", + source: { measurementType: "LEAN_BODY_MASS" }, + displayName: "Lean body mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("lean_body_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Lean body mass", + unit: "kg", + category: "vital-signs", + }, + }, + VISCERAL_FAT: { + key: "VISCERAL_FAT", + kind: "measurement", + source: { measurementType: "VISCERAL_FAT" }, + displayName: "Visceral fat rating", + unit: "rating", + direction: "lower-better", + archetype: "body-composition", + normalRange: { low: 1, high: 12 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("visceral_fat"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Visceral fat", + unit: "1", + category: "vital-signs", + }, + }, + // ── mobility-gait ──────────────────────────────────────────────────── + WALKING_STEADINESS: { + key: "WALKING_STEADINESS", + kind: "measurement", + source: { measurementType: "WALKING_STEADINESS" }, + displayName: "Walking steadiness", + unit: "%", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 50, high: 100 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_steadiness"), + mcp: true, + }, + }, + WALKING_ASYMMETRY: { + key: "WALKING_ASYMMETRY", + kind: "measurement", + source: { measurementType: "WALKING_ASYMMETRY" }, + displayName: "Walking asymmetry", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_asymmetry"), + mcp: true, + }, + fhir: { + loinc: "91557-1", + display: "Walking asymmetry percentage", + unit: "%", + category: "vital-signs", + }, + }, + WALKING_DOUBLE_SUPPORT: { + key: "WALKING_DOUBLE_SUPPORT", + kind: "measurement", + source: { measurementType: "WALKING_DOUBLE_SUPPORT" }, + displayName: "Double-support time", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + normalRange: { low: 20, high: 40 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_double_support"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + display: "Walking double support percentage", + unit: "%", + category: "vital-signs", + }, + }, + WALKING_STEP_LENGTH: { + key: "WALKING_STEP_LENGTH", + kind: "measurement", + source: { measurementType: "WALKING_STEP_LENGTH" }, + displayName: "Step length", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_step_length"), + mcp: true, + }, + fhir: { + loinc: "41955-6", + display: "Step length Measured", + unit: "m", + category: "vital-signs", + }, + }, + WALKING_SPEED: { + key: "WALKING_SPEED", + kind: "measurement", + source: { measurementType: "WALKING_SPEED" }, + displayName: "Walking speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 1.2, high: 1.4 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_speed"), + mcp: true, + }, + fhir: { + loinc: "41957-2", + display: "Gait speed [Velocity] Measured", + unit: "m/s", + category: "vital-signs", + }, + }, + FALL_COUNT: { + key: "FALL_COUNT", + kind: "measurement", + source: { measurementType: "FALL_COUNT" }, + displayName: "Falls", + unit: "falls/day", + direction: "lower-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen", + display: "Number of times fallen", + unit: "{falls}", + category: "activity", + }, + }, + SIX_MINUTE_WALK_DISTANCE: { + key: "SIX_MINUTE_WALK_DISTANCE", + kind: "measurement", + source: { measurementType: "SIX_MINUTE_WALK_DISTANCE" }, + displayName: "Six-minute walk distance", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 400, high: 700 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + display: "Six-minute walk test distance", + unit: "m", + category: "activity", + }, + }, + STAIR_ASCENT_SPEED: { + key: "STAIR_ASCENT_SPEED", + kind: "measurement", + source: { measurementType: "STAIR_ASCENT_SPEED" }, + displayName: "Stair ascent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierStairAscentSpeed", + display: "Stair ascent speed", + unit: "m/s", + category: "vital-signs", + }, + }, + STAIR_DESCENT_SPEED: { + key: "STAIR_DESCENT_SPEED", + kind: "measurement", + source: { measurementType: "STAIR_DESCENT_SPEED" }, + displayName: "Stair descent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierStairDescentSpeed", + display: "Stair descent speed", + unit: "m/s", + category: "vital-signs", + }, + }, + // ── environmental-exposure ─────────────────────────────────────────── + AUDIO_EXPOSURE_ENV: { + key: "AUDIO_EXPOSURE_ENV", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_ENV" }, + displayName: "Environmental sound exposure", + unit: "dBA", + direction: "lower-better", + archetype: "environmental-exposure", + normalRange: { low: 0, high: 80 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_env"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + display: "Environmental audio exposure", + unit: "dB[A]", + category: "vital-signs", + }, + }, + AUDIO_EXPOSURE_HEADPHONE: { + key: "AUDIO_EXPOSURE_HEADPHONE", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_HEADPHONE" }, + displayName: "Headphone audio exposure", + unit: "dBA", + direction: "lower-better", + archetype: "environmental-exposure", + normalRange: { low: 0, high: 80 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_headphone"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierHeadphoneAudioExposure", + display: "Headphone audio exposure", + unit: "dB[A]", + category: "vital-signs", + }, + }, + AUDIO_EXPOSURE_EVENT: { + key: "AUDIO_EXPOSURE_EVENT", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_EVENT" }, + displayName: "Loud-exposure events", + unit: "events", + direction: "lower-better", + archetype: "environmental-exposure", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_event"), + mcp: true, + }, + }, + // ── sleep (dedicated template) ─────────────────────────────────────── + SLEEP_DURATION: { + key: "SLEEP_DURATION", + kind: "measurement", + source: { measurementType: "SLEEP_DURATION" }, + sourcePriorityKey: "sleep", + displayName: "Sleep duration", + unit: "min", + direction: "target-band", + archetype: "sleep", + normalRange: { low: 420, high: 540 }, + surfaces: { + detailPage: true, + correlationEligible: true, + coachSnapshot: coachScope("sleep"), + mcp: true, + }, + fhir: { + loinc: "93832-4", + display: "Sleep duration", + unit: "h", + category: "activity", + }, + }, + BREATHING_DISTURBANCES: { + key: "BREATHING_DISTURBANCES", + kind: "measurement", + source: { measurementType: "BREATHING_DISTURBANCES" }, + displayName: "Sleep breathing disturbances", + unit: "count", + direction: "lower-better", + archetype: "sleep", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + display: "Sleeping breathing disturbances", + unit: "{count}", + category: "vital-signs", + }, + }, + SLEEP_SCORE: { + key: "SLEEP_SCORE", + kind: "measurement", + source: { measurementType: "SLEEP_SCORE" }, + displayName: "Sleep score", + unit: "score", + direction: "higher-better", + archetype: "sleep", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── specialised metrics (bespoke status files; detailPage:false) ────── + // These keep their dedicated scopes + status generators; they register + // only so the cross-surface indices (FHIR, correlation, Coach, MCP) see + // them without routing them through the generic detail template. + WEIGHT: { + key: "WEIGHT", + kind: "measurement", + source: { measurementType: "WEIGHT" }, + sourcePriorityKey: "weight", + displayName: "Weight", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("weight"), + mcp: true, + }, + fhir: { + loinc: "29463-7", + display: "Body weight", + unit: "kg", + category: "vital-signs", + }, + }, + BODY_MASS_INDEX: { + key: "BODY_MASS_INDEX", + kind: "measurement", + source: { measurementType: "BODY_MASS_INDEX" }, + displayName: "Body mass index", + unit: "kg/m²", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: coachScope("bmi"), + mcp: true, + }, + fhir: { + loinc: "39156-5", + display: "Body mass index (BMI) [Ratio]", + unit: "kg/m2", + category: "vital-signs", + }, + }, + PULSE: { + key: "PULSE", + kind: "measurement", + source: { measurementType: "PULSE" }, + sourcePriorityKey: "pulse", + displayName: "Pulse", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("pulse"), + mcp: true, + }, + fhir: { + loinc: "8867-4", + display: "Heart rate", + unit: "/min", + category: "vital-signs", + }, + }, + BODY_FAT: { + key: "BODY_FAT", + kind: "measurement", + source: { measurementType: "BODY_FAT" }, + sourcePriorityKey: "bodyFat", + displayName: "Body fat", + unit: "%", + direction: "lower-better", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("body_fat"), + mcp: true, + }, + fhir: { + loinc: "41982-0", + display: "Percentage of body fat Measured", + unit: "%", + category: "vital-signs", + }, + }, + BLOOD_PRESSURE_SYS: { + key: "BLOOD_PRESSURE_SYS", + kind: "measurement", + source: { measurementType: "BLOOD_PRESSURE_SYS" }, + sourcePriorityKey: "bloodPressure", + displayName: "Blood pressure (systolic)", + unit: "mmHg", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("bp"), + mcp: true, + }, + }, + BLOOD_PRESSURE_DIA: { + key: "BLOOD_PRESSURE_DIA", + kind: "measurement", + source: { measurementType: "BLOOD_PRESSURE_DIA" }, + sourcePriorityKey: "bloodPressure", + displayName: "Blood pressure (diastolic)", + unit: "mmHg", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("bp"), + mcp: true, + }, + }, +}; + +/** Type guard: narrow an arbitrary string to a registered signal key. */ +export function isSignalKey(key: string): boolean { + return Object.prototype.hasOwnProperty.call(SIGNALS, key); +} + +/** Resolve a signal by its key, or null when unregistered. */ +export function getSignal(key: string): SignalDefinition | null { + return isSignalKey(key) ? SIGNALS[key] : null; +} + +/** Every registered signal, in declaration order. */ +export function allSignals(): SignalDefinition[] { + return Object.values(SIGNALS); +} + +/** Reverse index: DB `MeasurementType` → the signal it backs (built once). */ +const MEASUREMENT_TYPE_TO_SIGNAL = new Map( + allSignals() + .filter( + (s): s is Extract => + s.kind === "measurement" || s.kind === "score", + ) + .map((s) => [s.source.measurementType, s]), +); + +/** Resolve the signal a `MeasurementType` backs, or null when unregistered. */ +export function signalForMeasurementType( + type: MeasurementType, +): SignalDefinition | null { + return MEASUREMENT_TYPE_TO_SIGNAL.get(type) ?? null; +}