mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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';
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user