diff --git a/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql b/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql new file mode 100644 index 000000000..3c97dfc7e --- /dev/null +++ b/prisma/migrations/0214_v125_physical_clinical_measurements/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index abc2d1249..75c095418 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:`) 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 diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index f27dd5ef2..aabef8a6d 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -122,6 +122,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -245,6 +252,15 @@ export const MEASUREMENT_TYPE_ICONS: Record = { // 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 = { @@ -359,4 +375,13 @@ export const MEASUREMENT_TYPE_COLORS: Record = { // 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", }; diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index a8ac5ef1c..6f98364cd 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/fhir/lab-loinc.ts b/src/lib/fhir/lab-loinc.ts index 7493cb545..17eaf927f 100644 --- a/src/lib/fhir/lab-loinc.ts +++ b/src/lib/fhir/lab-loinc.ts @@ -139,6 +139,36 @@ const LAB_LOINC_BY_KEY: Record = { 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 = { 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", }; /** diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 31c9324cb..814ca0558 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -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). */ diff --git a/src/lib/labs/__tests__/homa-ir.test.ts b/src/lib/labs/__tests__/homa-ir.test.ts new file mode 100644 index 000000000..205827936 --- /dev/null +++ b/src/lib/labs/__tests__/homa-ir.test.ts @@ -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"); + }); +}); diff --git a/src/lib/labs/biomarker-catalog.ts b/src/lib/labs/biomarker-catalog.ts index 3771d4a88..6b33f8569 100644 --- a/src/lib/labs/biomarker-catalog.ts +++ b/src/lib/labs/biomarker-catalog.ts @@ -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", diff --git a/src/lib/labs/homa-ir.ts b/src/lib/labs/homa-ir.ts new file mode 100644 index 000000000..289f15e9d --- /dev/null +++ b/src/lib/labs/homa-ir.ts @@ -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"; +} diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index f9457adf2..b9065d0a2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 0058a5131..097905945 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index ef811a6a1..19cd326e6 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -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" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 4f26dda81..cbb4f5948 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1199,6 +1199,310 @@ export const SIGNALS: Record = { 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. */ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 2c11ba6f0..f8fc675f2 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { // 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 = { // 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(