feat(signals): register the v1.25 clinical signals through the registry

Add the clinical-signals wave's new measurement types and wire them through
the signal registry so FHIR, correlation eligibility, ingestion, and the
measurement-list surfaces derive from one definition each:

- MeasurementType: PHQ9_SCORE, GAD7_SCORE, GRIP_STRENGTH, PAIN_NRS,
  WAIST_CIRCUMFERENCE, WAIST_TO_HEIGHT (+ AssessmentInstrument enum and the
  MentalHealthAssessment model that backs the screener scores).
- Registry: the six measurement/score signals plus the longevity lab panel as
  kind:"biomarker" entries; the FHIR golden fixture is regenerated in lockstep.
  Grip-strength and waist-to-height carry a null LOINC (NEEDS-VERIFY) and emit a
  local text concept until a stable code is confirmed.
- FHIR: add the survey observation category for the screener / pain signals.
- Labs: extend the biomarker catalogue with ApoB, Lp(a), fasting insulin, and
  the omega-3 index; add LOINC codes for those plus GGT and hs-CRP; add a
  computed HOMA-IR helper.
- Exhaustive maps updated to keep the enum coverage, category, list-meta, and
  personal-record drift guards green.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 15:47:25 +02:00
1 parent 3a84a07423
commit e4875db93e
14 files changed
+706 -2

No files matched your search

@@ -0,0 +1,10 @@
-- v1.25 — clinical-signals wave: physical / clinical measurement types.
--
-- Grip strength (EWGSOP2 sarcopenia limb), pain Numeric Rating Scale (0–10),
-- waist circumference (WHO STEPS), and the waist-to-height ratio (WHtR). Each
-- is a first-class numeric Measurement type; bands/units are applied at the
-- display edge from the signal registry. ADD VALUE IF NOT EXISTS is idempotent.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GRIP_STRENGTH';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PAIN_NRS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_CIRCUMFERENCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_TO_HEIGHT';
+81
View File
@@ -608,6 +608,7 @@ model User {
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
@@ -1107,10 +1108,90 @@ enum MeasurementType {
// The 5-level → ordinal mapping is the single source of truth in
// `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`.
RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5).
// ── v1.25 — clinical-signals wave (manual / instrument-derived) ──
// PHQ-9 / GAD-7 total scores. Server-derived from a completed
// `MentalHealthAssessment` (one measurement row per administration, value =
// total). Unit `score`. The raw item answers live encrypted on the
// assessment row, never here — these rows carry only the numeric trend so
// the existing chart / rollup infra reads them with no item exposure.
PHQ9_SCORE // score (0–27) — PHQ-9 depression screen total. Opt-in. See MentalHealthAssessment.
GAD7_SCORE // score (0–21) — GAD-7 anxiety screen total. Opt-in. See MentalHealthAssessment.
// Maximal isometric handgrip force (best of all trials, either hand). The
// EWGSOP2 sarcopenia floor is sex-specific (men < 27 kg, women < 16 kg); the
// band is applied at display time, the column stores the raw kg.
GRIP_STRENGTH // kg — hand-dynamometer maximal grip (EWGSOP2 sarcopenia limb).
// Patient-reported pain intensity on the 0–10 Numeric Rating Scale
// (0 = no pain, 10 = worst imaginable). LOINC 72514-3.
PAIN_NRS // score (0–10) — pain Numeric Rating Scale.
// Waist circumference in cm (WHO STEPS protocol) + the ancestry-robust
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
@@map("measurement_type")
}
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
/// (depression) + GAD-7 (anxiety). The discriminator on
/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future
/// siblings) without a schema change.
enum AssessmentInstrument {
PHQ9
GAD7
@@map("assessment_instrument")
}
/// v1.25 — one completed administration of a validated mental-health screener
/// (PHQ-9 / GAD-7). Opt-in, beside the existing `MoodEntry` (never replaces it).
/// The per-item answers are the most sensitive payload, so they live in a
/// single AES-256-GCM `Bytes` blob (`responsesEncrypted`) and are never indexed
/// nor logged. The derived total / band / item-9 flag are denormalised
/// (server-authoritative) for cheap history reads without decrypting. A
/// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per
/// administration (linked by `externalId = assessment:<id>`) so the score rides
/// the existing chart / rollup infra — the trend, never the item content.
model MentalHealthAssessment {
id String @id @default(cuid())
userId String @map("user_id")
instrument AssessmentInstrument
/// The validated translation actually presented (officially-validated wording
/// per locale, English fallback otherwise — recorded for the audit trail).
locale String
/// Instrument variant identifier (e.g. "standard"); future-proofs revisions.
version String @default("standard")
/// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }.
/// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS +
/// the rotation script. The item-9 raw value lives ONLY here.
responsesEncrypted Bytes @map("responses_encrypted")
/// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads.
totalScore Int @map("total_score")
/// Derived severity band label (server-authoritative; descriptive, never a diagnosis).
severityBand String @map("severity_band")
/// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so
/// the history view can re-surface crisis resources without decrypting.
item9Flagged Boolean @default(false) @map("item9_flagged")
/// Audit stamp: the supportive crisis-resource signpost was shown to the user.
/// Never triggers any third-party alert — private, on-device-only signposting.
crisisShownAt DateTime? @map("crisis_shown_at")
/// Recall-window anchor (when the screener was taken).
takenAt DateTime @map("taken_at")
tz String?
source String @default("WEB")
externalId String? @map("external_id")
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, source, externalId])
@@index([userId, instrument, takenAt])
@@index([userId, updatedAt, id])
@@map("mental_health_assessments")
}
/// v1.10.0 — categorical events (WX-B)
/// The device's own classification RESULT / severity for an EVENT row.
/// Non-NULL only for the categorical EVENT `MeasurementType`s
@@ -122,6 +122,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
// ── v1.19.0 — Oura resilience ──
RESILIENCE: "measurements.typeResilience",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "measurements.typePhq9Score",
GAD7_SCORE: "measurements.typeGad7Score",
GRIP_STRENGTH: "measurements.typeGripStrength",
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -245,6 +252,15 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// Gauge reads as the composite "index / level" dial, matching the other
// recovery / score composites (RECOVERY_SCORE, DAY_STRAIN).
RESILIENCE: Gauge,
// ── v1.25 — clinical-signals wave ──
// Gauge reads as the screener-score dial; Dumbbell carries grip strength;
// Activity the pain trace; Scale the anthropometric waist pair.
PHQ9_SCORE: Gauge,
GAD7_SCORE: Gauge,
GRIP_STRENGTH: Dumbbell,
PAIN_NRS: Activity,
WAIST_CIRCUMFERENCE: Scale,
WAIST_TO_HEIGHT: Scale,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -359,4 +375,13 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// chart-1 (Dracula purple) marks the derived recovery / score composites as
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
RESILIENCE: "bg-chart-1/20 text-chart-1",
// ── v1.25 — clinical-signals wave ──
// chart-1 (composite/score family) for the screener totals + grip strength;
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
PAIN_NRS: "bg-chart-3/20 text-chart-3",
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
WAIST_TO_HEIGHT: "bg-chart-4/20 text-chart-4",
};
@@ -98,10 +98,17 @@ const EXPECTED_TYPES = [
"BODY_TEMPERATURE_DEVIATION",
// ── v1.19.0 — Oura resilience (ordinal-encoded level) ──
"RESILIENCE",
// ── v1.25 — clinical-signals wave ──
"PHQ9_SCORE",
"GAD7_SCORE",
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 67 canonical measurement types", () => {
it("exposes the 73 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -244,6 +251,19 @@ describe("measurementTypeEnum coverage", () => {
// Insights / measurement-list surfaces and never belongs in the clinical
// vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
"RESILIENCE",
// v1.25 — clinical-signals wave. The mental-health screener totals are
// opt-in, derived, and deliberately excluded from any export/share/AI
// surface by default (item answers are encrypted). The physical clinical
// signals (grip strength, pain NRS, waist circumference + WHtR) surface on
// their own measurement/insights surfaces; they join the doctor PDF once a
// clinical layout + reference ranges land alongside the other v1.5+
// additions. See doctor-report-pdf-core.ts for the matching rationale.
"PHQ9_SCORE",
"GAD7_SCORE",
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+44
View File
@@ -139,6 +139,36 @@ const LAB_LOINC_BY_KEY: Record<string, LabLoincMapping> = {
display: "Hemoglobin [Mass/volume] in Blood",
ucum: "g/dL",
},
// ── v1.25 — longevity lab panel additions (loinc.org confirmed this pass) ──
apob: {
loinc: "1884-6",
display: "Apolipoprotein B [Mass/volume] in Serum or Plasma",
ucum: "mg/dL",
},
lpa: {
loinc: "43583-4",
display: "Lipoprotein a [Moles/volume] in Serum or Plasma",
ucum: "nmol/L",
},
hscrp: {
loinc: "30522-7",
display:
"C reactive protein [Mass/volume] in Serum or Plasma by High sensitivity method",
ucum: "mg/L",
},
ggt: {
loinc: "2324-2",
display:
"Gamma glutamyl transferase [Enzymatic activity/volume] in Serum or Plasma",
ucum: "U/L",
},
fastinginsulin: {
loinc: "27873-9",
display: "Insulin [Units/volume] in Serum or Plasma --fasting",
ucum: "u[IU]/mL",
},
// NOTE(NEEDS-VERIFY): the RBC omega-3 index has no confirmed LOINC this pass
// — it intentionally falls back to a local text-only concept on export.
};
/**
@@ -206,6 +236,20 @@ const LAB_ALIASES: Record<string, string> = {
hb: "hemoglobin",
hgb: "hemoglobin",
hamoglobin: "hemoglobin",
// ── v1.25 — longevity lab panel aliases ──
apolipoproteinb: "apob",
apolipoprotein: "apob",
lipoproteina: "lpa",
lpkleina: "lpa",
hscrp: "hscrp",
highsensitivitycrp: "hscrp",
hochsensitivescrp: "hscrp",
gammagt: "ggt",
gammaglutamyltransferase: "ggt",
ggtp: "ggt",
fastinginsulin: "fastinginsulin",
insulinfasting: "fastinginsulin",
nuchterninsulin: "fastinginsulin",
};
/**
+8 -1
View File
@@ -23,7 +23,14 @@ export const UCUM_SYSTEM = "http://unitsofmeasure.org";
export const HEALTHKIT_CODESYSTEM =
"https://healthlog.dev/fhir/CodeSystem/healthkit";
export type FhirObservationCategory = "vital-signs" | "laboratory" | "activity";
export type FhirObservationCategory =
| "vital-signs"
| "laboratory"
| "activity"
// v1.25 — patient-reported screener / questionnaire observations (PHQ-9 /
// GAD-7 totals, pain NRS). The builder already emits a `survey` category for
// the descriptive wellness composites; `categoryConcept` takes any string.
| "survey";
export interface LoincMapping {
/** LOINC code, or null when no stable LOINC applies (local text fallback). */
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { computeHomaIr, classifyHomaIr } from "@/lib/labs/homa-ir";
describe("computeHomaIr", () => {
it("computes the conventional-unit formula (glucose × insulin ÷ 405)", () => {
// 90 mg/dL × 5 µIU/mL ÷ 405 = 1.111…
expect(computeHomaIr(90, 5)).toBeCloseTo(1.1111, 3);
});
it("matches the SI form within rounding (mmol/L × insulin ÷ 22.5)", () => {
// 100 mg/dL = 5.556 mmol/L; SI: 5.556 × 8 ÷ 22.5 ≈ 1.975; conv: 100×8÷405 ≈ 1.975
const conv = computeHomaIr(100, 8)!;
const si = (5.5556 * 8) / 22.5;
expect(conv).toBeCloseTo(si, 2);
});
it("returns null for missing or non-positive inputs", () => {
expect(computeHomaIr(null, 5)).toBeNull();
expect(computeHomaIr(90, undefined)).toBeNull();
expect(computeHomaIr(0, 5)).toBeNull();
expect(computeHomaIr(90, -1)).toBeNull();
expect(computeHomaIr(Number.NaN, 5)).toBeNull();
});
});
describe("classifyHomaIr", () => {
it("places values in the documented bands", () => {
expect(classifyHomaIr(0.8)).toBe("optimal");
expect(classifyHomaIr(1.5)).toBe("intermediate");
expect(classifyHomaIr(2.5)).toBe("elevated");
expect(classifyHomaIr(3.5)).toBe("high");
});
});
+37
View File
@@ -61,6 +61,33 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
lowerBound: null,
upperBound: 150,
},
// v1.25 — longevity panel: atherogenic-particle burden + genetic + RBC EPA/DHA.
// ApoB is the primary atherogenic-burden number (ESC/EAS 2019); the upper
// bound is the population-desirable line, lower goals are care-context-specific.
{
slug: "apob",
panel: "lipids",
unit: "mg/dL",
lowerBound: null,
upperBound: 90,
},
// Lp(a) is largely genetically fixed (measure once, flag elevated); molar
// (nmol/L) reporting preferred — never interconvert mass↔molar by a factor.
{
slug: "lp-a",
panel: "lipids",
unit: "nmol/L",
lowerBound: null,
upperBound: 125,
},
// Omega-3 index (RBC EPA+DHA %): cardioprotective/optimal 8–12%.
{
slug: "omega-3-index",
panel: "lipids",
unit: "%",
lowerBound: 8,
upperBound: null,
},
// Glucose metabolism
{
slug: "fasting-glucose",
@@ -76,6 +103,16 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
lowerBound: null,
upperBound: 5.7,
},
// v1.25 — longevity panel: fasting insulin (metabolically-optimal often
// cited < 8 µIU/mL; typical reference ~2.5–13). HOMA-IR is derived from
// fasting glucose × insulin (see `homa-ir.ts`), not a stored catalog marker.
{
slug: "fasting-insulin",
panel: "glucose",
unit: "µIU/mL",
lowerBound: 2.5,
upperBound: 13,
},
// Thyroid
{
slug: "tsh",
+47
View File
@@ -0,0 +1,47 @@
/**
* v1.25 — HOMA-IR (Homeostatic Model Assessment of Insulin Resistance).
*
* A computed longevity marker derived from fasting glucose + fasting insulin —
* NOT a stored biomarker catalog entry (BMI's derived-value precedent). Server-
* authoritative per the parity rule: compute it here, never recompute on the
* client. Pure + side-effect-free so it is trivially testable.
*
* Conventional-unit formula (fasting glucose mg/dL × fasting insulin µIU/mL ÷
* 405) and the SI form (mmol/L × µIU/mL ÷ 22.5) are equivalent
* (mg/dL ÷ 18 = mmol/L; 405 = 22.5 × 18). Population-dependent interpretive
* bands (optimal < 1.0, early IR > 1.9, significant IR > 2.9) are applied at the
* display edge, never asserted here. [HOMA-IR, PMC5033570].
*/
/** Interpretive band for a HOMA-IR index value (population-dependent guidance). */
export type HomaIrBand = "optimal" | "intermediate" | "elevated" | "high";
/**
* Compute HOMA-IR from fasting glucose (mg/dL) and fasting insulin (µIU/mL).
* Returns `null` when either input is missing or non-positive (the index is
* undefined for a zero/negative reading).
*/
export function computeHomaIr(
fastingGlucoseMgDl: number | null | undefined,
fastingInsulinUiuMl: number | null | undefined,
): number | null {
if (
fastingGlucoseMgDl == null ||
fastingInsulinUiuMl == null ||
!Number.isFinite(fastingGlucoseMgDl) ||
!Number.isFinite(fastingInsulinUiuMl) ||
fastingGlucoseMgDl <= 0 ||
fastingInsulinUiuMl <= 0
) {
return null;
}
return (fastingGlucoseMgDl * fastingInsulinUiuMl) / 405;
}
/** Place a HOMA-IR value in its descriptive band (general guidance, not a diagnosis). */
export function classifyHomaIr(value: number): HomaIrBand {
if (value < 1.0) return "optimal";
if (value <= 1.9) return "intermediate";
if (value <= 2.9) return "elevated";
return "high";
}
+12
View File
@@ -204,6 +204,18 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// The resilience level is a derived recovery composite (ordinal-encoded
// band) → scores cluster alongside RECOVERY_SCORE / ANS_CHARGE.
["RESILIENCE", "scores"],
// ── v1.25 — clinical-signals wave ──
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
["PHQ9_SCORE", "scores"],
["GAD7_SCORE", "scores"],
// Grip strength is a strength/fitness reading → activity cluster.
["GRIP_STRENGTH", "activity"],
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
["PAIN_NRS", "vitals"],
// Waist circumference + waist-to-height are anthropometric → body cluster.
["WAIST_CIRCUMFERENCE", "body"],
["WAIST_TO_HEIGHT", "body"],
]);
/**
+14
View File
@@ -77,6 +77,9 @@ export function getPRDirection(
case "SIX_MINUTE_WALK_DISTANCE":
case "STAIR_ASCENT_SPEED":
case "STAIR_DESCENT_SPEED":
// v1.25 — grip strength is a clean strength-achievement axis (higher is the
// record), the strength limb of the EWGSOP2 sarcopenia algorithm.
case "GRIP_STRENGTH":
return PersonalRecordDirection.MAX;
// MIN direction — lower value is the record.
@@ -194,6 +197,17 @@ export function getPRDirection(
// inputs — a derived composite with no clean goal axis, so it stays null
// like the other device-derived scores.
case "RESILIENCE":
// v1.25 — clinical-signals wave. The mental-health screener totals are
// deliberately NOT gamified (no streaks / "best score" framing around a
// depression / anxiety screen — research safety rule). Pain NRS is a
// symptom, not an achievement axis. Waist circumference + waist-to-height
// are goal-dependent like WEIGHT (a lower waist is a goal for some, not
// all). All defer to a null PR direction.
case "PHQ9_SCORE":
case "GAD7_SCORE":
case "PAIN_NRS":
case "WAIST_CIRCUMFERENCE":
case "WAIST_TO_HEIGHT":
return null;
}
}
@@ -214,5 +214,41 @@
"display": "Sleeping breathing disturbances",
"unit": "{count}",
"category": "vital-signs"
},
"PHQ9_SCORE": {
"loinc": "44261-6",
"display": "Patient Health Questionnaire 9 item (PHQ-9) total score [Reported]",
"unit": "{score}",
"category": "survey"
},
"GAD7_SCORE": {
"loinc": "70274-6",
"display": "Generalized anxiety disorder 7 item (GAD-7) total score [Reported.PHQ]",
"unit": "{score}",
"category": "survey"
},
"GRIP_STRENGTH": {
"loinc": null,
"display": "Hand grip strength",
"unit": "kg",
"category": "vital-signs"
},
"PAIN_NRS": {
"loinc": "72514-3",
"display": "Pain severity - 0-10 verbal numeric rating [Score] - Reported",
"unit": "{score}",
"category": "survey"
},
"WAIST_CIRCUMFERENCE": {
"loinc": "8280-0",
"display": "Waist circumference at umbilicus by Tape measure",
"unit": "cm",
"category": "vital-signs"
},
"WAIST_TO_HEIGHT": {
"loinc": null,
"display": "Waist to height ratio",
"unit": "1",
"category": "vital-signs"
}
}
+304
View File
@@ -1199,6 +1199,310 @@ export const SIGNALS: Record<string, SignalDefinition> = {
mcp: true,
},
},
// ── v1.25 clinical-signals wave — validated mental-health screeners ──────
// PHQ-9 / GAD-7 totals. Opt-in, beside mood tracking. Deliberately kept OFF
// the Coach snapshot + MCP surfaces (`coachSnapshot:false` ⇒ `mcp:false`):
// mental-health item content is excluded from AI by construction, and the
// score trend stays a quiet local signal. FHIR rides the `survey` category
// with the instrument total-score LOINC.
PHQ9_SCORE: {
key: "PHQ9_SCORE",
kind: "score",
source: { measurementType: "PHQ9_SCORE" },
displayName: "PHQ-9 depression screen (total)",
unit: "score",
direction: "lower-better",
archetype: "physiological-vital",
normalRange: { low: 0, high: 4 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
loinc: "44261-6",
display: "Patient Health Questionnaire 9 item (PHQ-9) total score [Reported]",
unit: "{score}",
category: "survey",
},
},
GAD7_SCORE: {
key: "GAD7_SCORE",
kind: "score",
source: { measurementType: "GAD7_SCORE" },
displayName: "GAD-7 anxiety screen (total)",
unit: "score",
direction: "lower-better",
archetype: "physiological-vital",
normalRange: { low: 0, high: 4 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
loinc: "70274-6",
display:
"Generalized anxiety disorder 7 item (GAD-7) total score [Reported.PHQ]",
unit: "{score}",
category: "survey",
},
},
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
GRIP_STRENGTH: {
key: "GRIP_STRENGTH",
kind: "measurement",
source: { measurementType: "GRIP_STRENGTH" },
displayName: "Grip strength",
unit: "kg",
direction: "higher-better",
archetype: "activity-fitness",
// Coarse population floor; the EWGSOP2 cut-off is sex-specific (men < 27,
// women < 16 kg) and applied at the display edge.
normalRange: { low: 16, high: 60 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
// TODO(NEEDS-VERIFY): no hand-grip-strength LOINC confirmed this pass —
// emits a local text concept until a stable LOINC is checked on loinc.org.
loinc: null,
display: "Hand grip strength",
unit: "kg",
category: "vital-signs",
},
},
PAIN_NRS: {
key: "PAIN_NRS",
kind: "measurement",
source: { measurementType: "PAIN_NRS" },
displayName: "Pain (0–10 NRS)",
unit: "score",
direction: "lower-better",
archetype: "physiological-vital",
normalRange: { low: 0, high: 3 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
loinc: "72514-3",
display: "Pain severity - 0-10 verbal numeric rating [Score] - Reported",
unit: "{score}",
category: "survey",
},
},
WAIST_CIRCUMFERENCE: {
key: "WAIST_CIRCUMFERENCE",
kind: "measurement",
source: { measurementType: "WAIST_CIRCUMFERENCE" },
sourcePriorityKey: "waist",
displayName: "Waist circumference",
unit: "cm",
direction: "lower-better",
archetype: "body-composition",
// WHO European-origin increased-risk threshold (men > 94); ethnicity-aware
// bands are applied at the display edge.
normalRange: { low: 0, high: 94 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
loinc: "8280-0",
display: "Waist circumference at umbilicus by Tape measure",
unit: "cm",
category: "vital-signs",
},
},
WAIST_TO_HEIGHT: {
key: "WAIST_TO_HEIGHT",
kind: "measurement",
source: { measurementType: "WAIST_TO_HEIGHT" },
displayName: "Waist-to-height ratio",
unit: "ratio",
direction: "lower-better",
archetype: "body-composition",
// NICE: keep waist under half your height — WHtR ≥ 0.5 flags increased risk.
normalRange: { low: 0, high: 0.5 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
// TODO(NEEDS-VERIFY): WHtR LOINC not confirmed this pass — local text concept.
loinc: null,
display: "Waist to height ratio",
unit: "1",
category: "vital-signs",
},
},
// ── v1.25 clinical-signals wave — longevity lab panel (kind:"biomarker") ─
// Registered through the registry to prove the labs path under one definition
// shape. The lab FHIR/UCUM coding lives in `fhir/lab-loinc.ts`; the
// biomarker-detail rail renders these via the labs catalog. Off Coach/MCP
// here (the labs surface has its own `get_labs` MCP tool).
APOB: {
key: "APOB",
kind: "biomarker",
source: { biomarkerKey: "apob" },
displayName: "Apolipoprotein B",
unit: "mg/dL",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
LPA: {
key: "LPA",
kind: "biomarker",
source: { biomarkerKey: "lp-a" },
displayName: "Lipoprotein(a)",
unit: "nmol/L",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
HS_CRP: {
key: "HS_CRP",
kind: "biomarker",
source: { biomarkerKey: "hs-crp" },
displayName: "hs-CRP",
unit: "mg/L",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
HBA1C: {
key: "HBA1C",
kind: "biomarker",
source: { biomarkerKey: "hba1c" },
displayName: "HbA1c",
unit: "%",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
FASTING_GLUCOSE: {
key: "FASTING_GLUCOSE",
kind: "biomarker",
source: { biomarkerKey: "fasting-glucose" },
displayName: "Fasting glucose",
unit: "mg/dL",
direction: "target-band",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
FASTING_INSULIN: {
key: "FASTING_INSULIN",
kind: "biomarker",
source: { biomarkerKey: "fasting-insulin" },
displayName: "Fasting insulin",
unit: "µIU/mL",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
EGFR: {
key: "EGFR",
kind: "biomarker",
source: { biomarkerKey: "egfr" },
displayName: "eGFR (CKD-EPI 2021)",
unit: "mL/min/1.73m²",
direction: "higher-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
GGT: {
key: "GGT",
kind: "biomarker",
source: { biomarkerKey: "ggt" },
displayName: "GGT",
unit: "U/L",
direction: "lower-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
FERRITIN: {
key: "FERRITIN",
kind: "biomarker",
source: { biomarkerKey: "ferritin" },
displayName: "Ferritin",
unit: "ng/mL",
direction: "target-band",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
OMEGA3_INDEX: {
key: "OMEGA3_INDEX",
kind: "biomarker",
source: { biomarkerKey: "omega-3-index" },
displayName: "Omega-3 index",
unit: "%",
direction: "higher-better",
archetype: "physiological-vital",
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
},
};
/** Type guard: narrow an arbitrary string to a registered signal key. */
+34
View File
@@ -122,6 +122,19 @@ export const measurementTypeEnum = z.enum([
// into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests
// server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`.
"RESILIENCE",
// ── v1.25 — clinical-signals wave ──
// PHQ-9 / GAD-7 derived total scores (one row per completed administration;
// value = total). The raw item answers never ride a Measurement row — they
// stay in the encrypted MentalHealthAssessment blob.
"PHQ9_SCORE",
"GAD7_SCORE",
// Grip strength (kg), pain Numeric Rating Scale (0–10), waist circumference
// (cm) + waist-to-height ratio. First-class numeric clinical signals; bands
// applied at the display edge from the signal registry.
"GRIP_STRENGTH",
"PAIN_NRS",
"WAIST_CIRCUMFERENCE",
"WAIST_TO_HEIGHT",
]);
/**
@@ -357,6 +370,13 @@ const unitMap: Record<string, string> = {
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
RESILIENCE: "level",
// ── v1.25 — clinical-signals wave ──
PHQ9_SCORE: "score",
GAD7_SCORE: "score",
GRIP_STRENGTH: "kg",
PAIN_NRS: "score",
WAIST_CIRCUMFERENCE: "cm",
WAIST_TO_HEIGHT: "ratio",
};
export function getUnitForType(type: string): string {
@@ -551,6 +571,20 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding
// never falls outside this band.
RESILIENCE: { min: 1, max: 5 },
// ── v1.25 — clinical-signals wave ──
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
PHQ9_SCORE: { min: 0, max: 27 },
GAD7_SCORE: { min: 0, max: 21 },
// Grip strength kg — a child can read in the single digits, an elite athlete
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
GRIP_STRENGTH: { min: 0, max: 120 },
// Pain NRS — the validated 0–10 scale; the closed band pins the integer.
PAIN_NRS: { min: 0, max: 10 },
// Waist circumference cm — paediatric lows ~30, severe obesity ~250.
WAIST_CIRCUMFERENCE: { min: 30, max: 250 },
// Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is
// generous either side to admit any plausible body habitus.
WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 },
};
export function validateMeasurementRange(