mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(insights): wire grip / pain / waist / waist-to-height across web and MCP
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height existed in the measurement enum, signal registry, and FHIR export but had no web entry, no detail page, single non-sex-aware bands, and a split-brain MCP exposure. This closes those gaps. Web capture: the four signals join the manual measurement form, so a web-only self-hoster can log them (the iOS client and the measurements API already could). Detail pages: flip detailPage on for the four signals and mount each on the shared HealthKit metric template (chart, stat strip, Coach-read strip, assessment card) via routed sub-pages and tab-strip pills, the same detail spine every other metric carries. Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist (WHO men 94 / women 80 cm) now classify by the profile sex through the age/sex norm resolver the assessment already threads; the resolver also returns a sex-specific, age-independent band when no date of birth is set. Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the last in-range value is 0.49). MCP single source of truth: decouple the registry mcp facet from the Coach snapshot — a signal can be MCP-readable without being on the Coach surface. Mark the four physical signals mcp:true, derive the MCP rich-reads clinical set from the registry flag instead of a duplicate allowlist, and relax the invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and the environmental signals stay mcp:false and unreachable. compare_metric and detect_changepoints now advertise clinical-signal support to match get_metric_baseline.
This commit is contained in:
1 parent
4f39058629
commit
a69697b50f
25 files changed
+726
-62
No files matched your search
+14
-2
@@ -6086,6 +6086,10 @@ paths:
|
||||
- AVERAGE_HEART_RATE
|
||||
- MAX_HEART_RATE
|
||||
- ENERGY_EXPENDITURE_KJ
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
description: "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s.
|
||||
The seven specialised metrics are served by their own routes and are not accepted here."
|
||||
required: true
|
||||
@@ -10273,7 +10277,7 @@ components:
|
||||
- order
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
maxItems: 61
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -10287,6 +10291,7 @@ components:
|
||||
- oxygen
|
||||
- body-temperature
|
||||
- respiratory-rate
|
||||
- pain
|
||||
- weight
|
||||
- bmi
|
||||
- body-water
|
||||
@@ -10296,6 +10301,8 @@ components:
|
||||
- muscle-mass
|
||||
- visceral-fat
|
||||
- lean-body-mass
|
||||
- waist
|
||||
- waist-to-height
|
||||
- steps
|
||||
- active-energy
|
||||
- workouts
|
||||
@@ -10312,6 +10319,7 @@ components:
|
||||
- six-minute-walk
|
||||
- stair-ascent-speed
|
||||
- stair-descent-speed
|
||||
- grip-strength
|
||||
- sleep
|
||||
- breathing-disturbances
|
||||
- resting-pulse
|
||||
@@ -25476,7 +25484,7 @@ components:
|
||||
additionalProperties: false
|
||||
tiles:
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
maxItems: 61
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
@@ -25490,6 +25498,7 @@ components:
|
||||
- oxygen
|
||||
- body-temperature
|
||||
- respiratory-rate
|
||||
- pain
|
||||
- weight
|
||||
- bmi
|
||||
- body-water
|
||||
@@ -25499,6 +25508,8 @@ components:
|
||||
- muscle-mass
|
||||
- visceral-fat
|
||||
- lean-body-mass
|
||||
- waist
|
||||
- waist-to-height
|
||||
- steps
|
||||
- active-energy
|
||||
- workouts
|
||||
@@ -25515,6 +25526,7 @@ components:
|
||||
- six-minute-walk
|
||||
- stair-ascent-speed
|
||||
- stair-descent-speed
|
||||
- grip-strength
|
||||
- sleep
|
||||
- breathing-disturbances
|
||||
- resting-pulse
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Griffstärke",
|
||||
"typePainNrs": "Schmerz (0–10)",
|
||||
"typeWaistCircumference": "Taillenumfang",
|
||||
"typeWaistToHeight": "Taille-Größe-Verhältnis"
|
||||
"typeWaistToHeight": "Taille-Größe-Verhältnis",
|
||||
"unitScore": "Pkt.",
|
||||
"unitRatio": "Verhältnis"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "unter dem Bereich",
|
||||
"statusAbove": "über dem Bereich",
|
||||
"directionFlat": "keine Änderung"
|
||||
},
|
||||
"navGripStrength": "Griffkraft",
|
||||
"navPainNrs": "Schmerz",
|
||||
"navWaistCircumference": "Taille",
|
||||
"navWaistToHeight": "Taille/Größe",
|
||||
"gripStrength": {
|
||||
"title": "Griffkraft",
|
||||
"description": "Maximale Handgriffkraft — ein robuster Stellvertreter für die Ganzkörperkraft. Unter dem geschlechtsspezifischen Grenzwert (Männer 27 kg, Frauen 16 kg) deutet sie auf eine mögliche Sarkopenie hin; dein eigener Verlauf führt die Einschätzung.",
|
||||
"chartTitle": "Griffkraft",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Griffkraft",
|
||||
"description": "Erfasse einen Wert mit einem Handdynamometer — notiere den besten von mehreren Versuchen — und der Verlauf erscheint hier.",
|
||||
"cta": "Griffkraft erfassen"
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Griffkraft erfasst — was sagt sie über meine Muskelgesundheit aus, und wie messe und verbessere ich sie?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Schmerz (0–10)",
|
||||
"description": "Selbstberichtete Schmerzintensität auf der numerischen Skala 0–10, wobei 0 keinen Schmerz und 10 den stärksten vorstellbaren Schmerz bedeutet. Eine anhaltende Änderung um 2 Punkte ist das beobachtenswerte Signal.",
|
||||
"chartTitle": "Schmerz (0–10)",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Schmerzwerte",
|
||||
"description": "Erfasse auf der Skala 0–10, wie sich dein Schmerz gerade anfühlt, und der Verlauf erscheint hier.",
|
||||
"cta": "Schmerzwert erfassen"
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Schmerzwerte erfasst — wie sollte ich Schmerz auf der Skala 0–10 verfolgen, und was gilt als bedeutsame Veränderung?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Taillenumfang",
|
||||
"description": "Taillenumfang in Zentimetern — ein Marker für Bauchfett. Der WHO-Grenzwert für erhöhtes Risiko ist geschlechtsspezifisch (Männer 94 cm, Frauen 80 cm); dein eigener Verlauf führt die Einschätzung.",
|
||||
"chartTitle": "Taillenumfang",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Taillenwerte",
|
||||
"description": "Miss deine Taille mittig zwischen der untersten Rippe und dem Hüftknochen am Ende einer normalen Ausatmung und erfasse den Wert hier.",
|
||||
"cta": "Taillenumfang erfassen"
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Taillenwerte erfasst — wie messe ich meine Taille richtig, und was bedeutet der Wert für meine Gesundheit?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Taille-zu-Größe-Verhältnis",
|
||||
"description": "Deine Taille geteilt durch deine Größe — die ethnienunabhängige Einzelzahl. Sie unter 0,5 zu halten (Taille unter der halben Körpergröße) ist das einfache Ziel.",
|
||||
"chartTitle": "Taille-zu-Größe-Verhältnis",
|
||||
"emptyState": {
|
||||
"title": "Noch kein Taille-zu-Größe-Verhältnis",
|
||||
"description": "Erfasse ein Taille-zu-Größe-Verhältnis, und der Verlauf erscheint hier. Faustregel: Halte deine Taille unter der halben Körpergröße.",
|
||||
"cta": "Taille-zu-Größe-Verhältnis erfassen"
|
||||
},
|
||||
"coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst — warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Grip strength",
|
||||
"typePainNrs": "Pain (0–10)",
|
||||
"typeWaistCircumference": "Waist circumference",
|
||||
"typeWaistToHeight": "Waist-to-height ratio"
|
||||
"typeWaistToHeight": "Waist-to-height ratio",
|
||||
"unitScore": "pts",
|
||||
"unitRatio": "ratio"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "below range",
|
||||
"statusAbove": "above range",
|
||||
"directionFlat": "no change"
|
||||
},
|
||||
"navGripStrength": "Grip strength",
|
||||
"navPainNrs": "Pain",
|
||||
"navWaistCircumference": "Waist",
|
||||
"navWaistToHeight": "Waist-to-height",
|
||||
"gripStrength": {
|
||||
"title": "Grip strength",
|
||||
"description": "Maximal handgrip force — a robust proxy for whole-body strength. Below the sex-specific cut-off (men 27 kg, women 16 kg) flags probable sarcopenia; your own trend leads the read.",
|
||||
"chartTitle": "Grip strength",
|
||||
"emptyState": {
|
||||
"title": "No grip strength yet",
|
||||
"description": "Log a reading from a hand dynamometer — record the best of a few squeezes — and the trend will appear here.",
|
||||
"cta": "Log grip strength"
|
||||
},
|
||||
"coachPrefill": "I haven't logged any grip strength yet — what does it tell me about my muscle health, and how do I measure and improve it?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Pain (0–10)",
|
||||
"description": "Patient-reported pain intensity on the 0–10 numeric rating scale, where 0 is no pain and 10 is the worst imaginable. A sustained 2-point change is the signal to watch.",
|
||||
"chartTitle": "Pain (0–10)",
|
||||
"emptyState": {
|
||||
"title": "No pain scores yet",
|
||||
"description": "Log how your pain feels right now on the 0–10 scale and the trend will appear here.",
|
||||
"cta": "Log a pain score"
|
||||
},
|
||||
"coachPrefill": "I haven't logged any pain scores yet — how should I track pain on the 0–10 scale, and what counts as a meaningful change?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Waist circumference",
|
||||
"description": "Waist girth in centimetres — a marker of central fat. The WHO increased-risk threshold is sex-specific (men 94 cm, women 80 cm); your own trend leads the read.",
|
||||
"chartTitle": "Waist circumference",
|
||||
"emptyState": {
|
||||
"title": "No waist measurements yet",
|
||||
"description": "Measure your waist at the midpoint between the lowest rib and the hip bone, at the end of a normal breath, and log it here.",
|
||||
"cta": "Log waist circumference"
|
||||
},
|
||||
"coachPrefill": "I haven't logged any waist measurements yet — how do I measure my waist correctly, and what does the number mean for my health?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Waist-to-height ratio",
|
||||
"description": "Your waist divided by your height — the ancestry-robust single number. Keeping it under 0.5 (waist below half your height) is the simple target.",
|
||||
"chartTitle": "Waist-to-height ratio",
|
||||
"emptyState": {
|
||||
"title": "No waist-to-height ratio yet",
|
||||
"description": "Log a waist-to-height ratio and the trend will appear here. The rule of thumb: keep your waist under half your height.",
|
||||
"cta": "Log waist-to-height ratio"
|
||||
},
|
||||
"coachPrefill": "I haven't logged a waist-to-height ratio yet — why is it a useful number, and how do I keep it under 0.5?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Fuerza de agarre",
|
||||
"typePainNrs": "Dolor (0–10)",
|
||||
"typeWaistCircumference": "Perímetro de cintura",
|
||||
"typeWaistToHeight": "Índice cintura-altura"
|
||||
"typeWaistToHeight": "Índice cintura-altura",
|
||||
"unitScore": "pts",
|
||||
"unitRatio": "ratio"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "por debajo del rango",
|
||||
"statusAbove": "por encima del rango",
|
||||
"directionFlat": "sin cambios"
|
||||
},
|
||||
"navGripStrength": "Fuerza de agarre",
|
||||
"navPainNrs": "Dolor",
|
||||
"navWaistCircumference": "Cintura",
|
||||
"navWaistToHeight": "Cintura/altura",
|
||||
"gripStrength": {
|
||||
"title": "Fuerza de agarre",
|
||||
"description": "Fuerza máxima de agarre de la mano: un indicador robusto de la fuerza de todo el cuerpo. Por debajo del umbral según el sexo (hombres 27 kg, mujeres 16 kg) indica probable sarcopenia; tu propia tendencia guía la lectura.",
|
||||
"chartTitle": "Fuerza de agarre",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay fuerza de agarre",
|
||||
"description": "Registra una lectura con un dinamómetro de mano (anota el mejor de varios apretones) y la tendencia aparecerá aquí.",
|
||||
"cta": "Registrar fuerza de agarre"
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado la fuerza de agarre: ¿qué dice sobre mi salud muscular y cómo la mido y la mejoro?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Dolor (0–10)",
|
||||
"description": "Intensidad del dolor referida por el paciente en la escala numérica 0–10, donde 0 es sin dolor y 10 el peor imaginable. Un cambio sostenido de 2 puntos es la señal a vigilar.",
|
||||
"chartTitle": "Dolor (0–10)",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay puntuaciones de dolor",
|
||||
"description": "Registra cómo sientes el dolor ahora mismo en la escala 0–10 y la tendencia aparecerá aquí.",
|
||||
"cta": "Registrar una puntuación de dolor"
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado puntuaciones de dolor: ¿cómo debo seguir el dolor en la escala 0–10 y qué cuenta como un cambio significativo?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Perímetro de cintura",
|
||||
"description": "Perímetro de la cintura en centímetros: un marcador de grasa central. El umbral de riesgo aumentado de la OMS depende del sexo (hombres 94 cm, mujeres 80 cm); tu propia tendencia guía la lectura.",
|
||||
"chartTitle": "Perímetro de cintura",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay medidas de cintura",
|
||||
"description": "Mide tu cintura en el punto medio entre la costilla más baja y el hueso de la cadera, al final de una espiración normal, y regístrala aquí.",
|
||||
"cta": "Registrar perímetro de cintura"
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado medidas de cintura: ¿cómo mido la cintura correctamente y qué significa el número para mi salud?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Índice cintura-altura",
|
||||
"description": "Tu cintura dividida por tu altura: el número único más robusto entre poblaciones. Mantenerlo por debajo de 0,5 (cintura menor que la mitad de tu altura) es el objetivo sencillo.",
|
||||
"chartTitle": "Índice cintura-altura",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay índice cintura-altura",
|
||||
"description": "Registra un índice cintura-altura y la tendencia aparecerá aquí. Regla práctica: mantén tu cintura por debajo de la mitad de tu altura.",
|
||||
"cta": "Registrar índice cintura-altura"
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado un índice cintura-altura: ¿por qué es un número útil y cómo lo mantengo por debajo de 0,5?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Force de préhension",
|
||||
"typePainNrs": "Douleur (0–10)",
|
||||
"typeWaistCircumference": "Tour de taille",
|
||||
"typeWaistToHeight": "Rapport taille-taille"
|
||||
"typeWaistToHeight": "Rapport taille-taille",
|
||||
"unitScore": "pts",
|
||||
"unitRatio": "ratio"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "sous la plage",
|
||||
"statusAbove": "au-dessus de la plage",
|
||||
"directionFlat": "aucun changement"
|
||||
},
|
||||
"navGripStrength": "Force de préhension",
|
||||
"navPainNrs": "Douleur",
|
||||
"navWaistCircumference": "Tour de taille",
|
||||
"navWaistToHeight": "Taille/hauteur",
|
||||
"gripStrength": {
|
||||
"title": "Force de préhension",
|
||||
"description": "Force maximale de préhension de la main : un indicateur robuste de la force corporelle globale. Sous le seuil selon le sexe (hommes 27 kg, femmes 16 kg), elle signale une sarcopénie probable ; votre propre tendance guide la lecture.",
|
||||
"chartTitle": "Force de préhension",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de force de préhension",
|
||||
"description": "Enregistrez une mesure avec un dynamomètre à main (notez le meilleur de plusieurs serrages) et la tendance apparaîtra ici.",
|
||||
"cta": "Enregistrer la force de préhension"
|
||||
},
|
||||
"coachPrefill": "Je n'ai pas encore enregistré de force de préhension — que dit-elle sur ma santé musculaire, et comment la mesurer et l'améliorer ?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Douleur (0–10)",
|
||||
"description": "Intensité de la douleur rapportée par le patient sur l'échelle numérique 0–10, où 0 signifie aucune douleur et 10 la pire imaginable. Une variation soutenue de 2 points est le signal à surveiller.",
|
||||
"chartTitle": "Douleur (0–10)",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de scores de douleur",
|
||||
"description": "Enregistrez votre douleur actuelle sur l'échelle 0–10 et la tendance apparaîtra ici.",
|
||||
"cta": "Enregistrer un score de douleur"
|
||||
},
|
||||
"coachPrefill": "Je n'ai pas encore enregistré de score de douleur — comment suivre la douleur sur l'échelle 0–10, et qu'est-ce qu'un changement significatif ?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Tour de taille",
|
||||
"description": "Tour de taille en centimètres : un marqueur de la graisse abdominale. Le seuil de risque accru de l'OMS dépend du sexe (hommes 94 cm, femmes 80 cm) ; votre propre tendance guide la lecture.",
|
||||
"chartTitle": "Tour de taille",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de mesures de tour de taille",
|
||||
"description": "Mesurez votre tour de taille à mi-chemin entre la dernière côte et l'os de la hanche, en fin d'expiration normale, et enregistrez-le ici.",
|
||||
"cta": "Enregistrer le tour de taille"
|
||||
},
|
||||
"coachPrefill": "Je n'ai pas encore enregistré de tour de taille — comment le mesurer correctement, et que signifie ce chiffre pour ma santé ?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Rapport taille-hauteur",
|
||||
"description": "Votre tour de taille divisé par votre taille : le nombre unique le plus robuste selon les populations. Le maintenir sous 0,5 (tour de taille inférieur à la moitié de votre taille) est l'objectif simple.",
|
||||
"chartTitle": "Rapport taille-hauteur",
|
||||
"emptyState": {
|
||||
"title": "Pas encore de rapport taille-hauteur",
|
||||
"description": "Enregistrez un rapport taille-hauteur et la tendance apparaîtra ici. Règle simple : gardez votre tour de taille sous la moitié de votre taille.",
|
||||
"cta": "Enregistrer le rapport taille-hauteur"
|
||||
},
|
||||
"coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur — pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Forza di presa",
|
||||
"typePainNrs": "Dolore (0–10)",
|
||||
"typeWaistCircumference": "Circonferenza vita",
|
||||
"typeWaistToHeight": "Rapporto vita-altezza"
|
||||
"typeWaistToHeight": "Rapporto vita-altezza",
|
||||
"unitScore": "pti",
|
||||
"unitRatio": "rapporto"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "sotto l'intervallo",
|
||||
"statusAbove": "sopra l'intervallo",
|
||||
"directionFlat": "nessuna variazione"
|
||||
},
|
||||
"navGripStrength": "Forza di presa",
|
||||
"navPainNrs": "Dolore",
|
||||
"navWaistCircumference": "Vita",
|
||||
"navWaistToHeight": "Vita/altezza",
|
||||
"gripStrength": {
|
||||
"title": "Forza di presa",
|
||||
"description": "Forza massima di presa della mano: un indicatore robusto della forza di tutto il corpo. Sotto la soglia specifica per sesso (uomini 27 kg, donne 16 kg) segnala una probabile sarcopenia; il tuo andamento personale guida la lettura.",
|
||||
"chartTitle": "Forza di presa",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessuna forza di presa",
|
||||
"description": "Registra una misura con un dinamometro manuale (annota la migliore di alcune strette) e l'andamento apparirà qui.",
|
||||
"cta": "Registra la forza di presa"
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato la forza di presa — cosa dice sulla salute dei miei muscoli e come la misuro e la miglioro?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Dolore (0–10)",
|
||||
"description": "Intensità del dolore riferita dal paziente sulla scala numerica 0–10, dove 0 è nessun dolore e 10 il peggiore immaginabile. Una variazione costante di 2 punti è il segnale da osservare.",
|
||||
"chartTitle": "Dolore (0–10)",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun punteggio del dolore",
|
||||
"description": "Registra come senti il dolore in questo momento sulla scala 0–10 e l'andamento apparirà qui.",
|
||||
"cta": "Registra un punteggio del dolore"
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato punteggi del dolore — come dovrei monitorare il dolore sulla scala 0–10 e cosa conta come variazione significativa?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Circonferenza vita",
|
||||
"description": "Circonferenza della vita in centimetri: un marcatore del grasso addominale. La soglia di rischio aumentato dell'OMS dipende dal sesso (uomini 94 cm, donne 80 cm); il tuo andamento personale guida la lettura.",
|
||||
"chartTitle": "Circonferenza vita",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessuna misura della vita",
|
||||
"description": "Misura la vita nel punto medio tra l'ultima costola e l'osso dell'anca, a fine espirazione normale, e registrala qui.",
|
||||
"cta": "Registra la circonferenza vita"
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato misure della vita — come misuro la vita correttamente e cosa significa il numero per la mia salute?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Rapporto vita-altezza",
|
||||
"description": "La tua vita divisa per la tua altezza: il singolo numero più robusto tra le popolazioni. Tenerlo sotto 0,5 (vita inferiore alla metà dell'altezza) è l'obiettivo semplice.",
|
||||
"chartTitle": "Rapporto vita-altezza",
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun rapporto vita-altezza",
|
||||
"description": "Registra un rapporto vita-altezza e l'andamento apparirà qui. Regola pratica: tieni la vita sotto la metà della tua altezza.",
|
||||
"cta": "Registra il rapporto vita-altezza"
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato un rapporto vita-altezza — perché è un numero utile e come lo tengo sotto 0,5?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
+51
-1
@@ -582,7 +582,9 @@
|
||||
"typeGripStrength": "Siła chwytu",
|
||||
"typePainNrs": "Ból (0–10)",
|
||||
"typeWaistCircumference": "Obwód talii",
|
||||
"typeWaistToHeight": "Stosunek talii do wzrostu"
|
||||
"typeWaistToHeight": "Stosunek talii do wzrostu",
|
||||
"unitScore": "pkt",
|
||||
"unitRatio": "stosunek"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -3705,6 +3707,54 @@
|
||||
"statusBelow": "poniżej zakresu",
|
||||
"statusAbove": "powyżej zakresu",
|
||||
"directionFlat": "bez zmian"
|
||||
},
|
||||
"navGripStrength": "Siła chwytu",
|
||||
"navPainNrs": "Ból",
|
||||
"navWaistCircumference": "Talia",
|
||||
"navWaistToHeight": "Talia/wzrost",
|
||||
"gripStrength": {
|
||||
"title": "Siła chwytu",
|
||||
"description": "Maksymalna siła chwytu dłoni — solidny wskaźnik ogólnej siły ciała. Poniżej progu zależnego od płci (mężczyźni 27 kg, kobiety 16 kg) sygnalizuje prawdopodobną sarkopenię; odczyt prowadzi Twój własny trend.",
|
||||
"chartTitle": "Siła chwytu",
|
||||
"emptyState": {
|
||||
"title": "Brak pomiarów siły chwytu",
|
||||
"description": "Zapisz pomiar z dynamometru ręcznego (zanotuj najlepszy z kilku ściśnięć), a trend pojawi się tutaj.",
|
||||
"cta": "Zapisz siłę chwytu"
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze siły chwytu — co mówi o zdrowiu moich mięśni i jak ją zmierzyć oraz poprawić?"
|
||||
},
|
||||
"pain": {
|
||||
"title": "Ból (0–10)",
|
||||
"description": "Zgłaszana przez pacjenta intensywność bólu w skali numerycznej 0–10, gdzie 0 to brak bólu, a 10 najgorszy wyobrażalny. Utrzymująca się zmiana o 2 punkty to sygnał do obserwacji.",
|
||||
"chartTitle": "Ból (0–10)",
|
||||
"emptyState": {
|
||||
"title": "Brak ocen bólu",
|
||||
"description": "Zapisz, jak odczuwasz ból teraz, w skali 0–10, a trend pojawi się tutaj.",
|
||||
"cta": "Zapisz ocenę bólu"
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze ocen bólu — jak śledzić ból w skali 0–10 i co liczy się jako istotna zmiana?"
|
||||
},
|
||||
"waist": {
|
||||
"title": "Obwód talii",
|
||||
"description": "Obwód talii w centymetrach — wskaźnik tłuszczu brzusznego. Próg zwiększonego ryzyka WHO zależy od płci (mężczyźni 94 cm, kobiety 80 cm); odczyt prowadzi Twój własny trend.",
|
||||
"chartTitle": "Obwód talii",
|
||||
"emptyState": {
|
||||
"title": "Brak pomiarów talii",
|
||||
"description": "Zmierz talię w połowie odległości między najniższym żebrem a kością biodrową, na końcu normalnego wydechu, i zapisz tutaj.",
|
||||
"cta": "Zapisz obwód talii"
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze pomiarów talii — jak prawidłowo zmierzyć talię i co oznacza ta liczba dla mojego zdrowia?"
|
||||
},
|
||||
"waistToHeight": {
|
||||
"title": "Stosunek talii do wzrostu",
|
||||
"description": "Twoja talia podzielona przez wzrost — najbardziej uniwersalna pojedyncza liczba. Utrzymanie go poniżej 0,5 (talia mniejsza niż połowa wzrostu) to prosty cel.",
|
||||
"chartTitle": "Stosunek talii do wzrostu",
|
||||
"emptyState": {
|
||||
"title": "Brak stosunku talii do wzrostu",
|
||||
"description": "Zapisz stosunek talii do wzrostu, a trend pojawi się tutaj. Zasada: utrzymuj talię poniżej połowy wzrostu.",
|
||||
"cta": "Zapisz stosunek talii do wzrostu"
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu — dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
|
||||
@@ -87,7 +87,11 @@ const layoutSchema = z.object({
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50)
|
||||
// Cap at the accepted-id universe (canonical slugs + legacy aliases) so a
|
||||
// single PUT can cover every tile the layout knows about — the cap grows
|
||||
// automatically as new sub-page slugs land, rather than tripping a 422
|
||||
// once the slug count crosses a hard-coded ceiling.
|
||||
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Dumbbell } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.25 — `/insights/grip-strength`.
|
||||
*
|
||||
* Grip-strength sub-page. Rides the generic HealthKit metric template so the
|
||||
* signal gets the same detail spine as every other metric (chart, stat strip,
|
||||
* Coach-read strip, assessment card). The assessment threads the sex-aware
|
||||
* EWGSOP2 cut-off (men 27 / women 16 kg) via the `norms.ts` table.
|
||||
*/
|
||||
export default function InsightsGripStrengthPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="GRIP_STRENGTH"
|
||||
statusMetric="GRIP_STRENGTH"
|
||||
insightMetric="GRIP_STRENGTH"
|
||||
chartKey="gripStrength"
|
||||
i18nPrefix="insights.gripStrength"
|
||||
color="#22c55e"
|
||||
unit="kg"
|
||||
yAxisUnit="kg"
|
||||
statIcon={Dumbbell}
|
||||
emptyStateIcon={<Dumbbell className="size-6" />}
|
||||
emptyStateCtaType="GRIP_STRENGTH"
|
||||
coachPrefill={t("insights.gripStrength.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.25 — `/insights/pain`.
|
||||
*
|
||||
* Pain (0–10 NRS) sub-page. Patient-reported pain intensity on the validated
|
||||
* numeric rating scale; rides the generic metric template for its detail
|
||||
* spine + assessment (no-pain / mild band at 0–3).
|
||||
*/
|
||||
export default function InsightsPainPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="PAIN_NRS"
|
||||
statusMetric="PAIN_NRS"
|
||||
insightMetric="PAIN_NRS"
|
||||
chartKey="painNrs"
|
||||
i18nPrefix="insights.pain"
|
||||
color="#f97316"
|
||||
unit=""
|
||||
yAxisUnit="/10"
|
||||
statIcon={Activity}
|
||||
emptyStateIcon={<Activity className="size-6" />}
|
||||
emptyStateCtaType="PAIN_NRS"
|
||||
coachPrefill={t("insights.pain.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Scaling } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.25 — `/insights/waist-to-height`.
|
||||
*
|
||||
* Waist-to-height ratio sub-page. The ancestry-robust single number (NICE:
|
||||
* keep waist under half your height — WHtR ≥ 0.5 flags increased risk). Rides
|
||||
* the generic metric template for its detail spine + assessment.
|
||||
*/
|
||||
export default function InsightsWaistToHeightPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="WAIST_TO_HEIGHT"
|
||||
statusMetric="WAIST_TO_HEIGHT"
|
||||
insightMetric="WAIST_TO_HEIGHT"
|
||||
chartKey="waistToHeight"
|
||||
i18nPrefix="insights.waistToHeight"
|
||||
color="#8b5cf6"
|
||||
unit=""
|
||||
statIcon={Scaling}
|
||||
emptyStateIcon={<Scaling className="size-6" />}
|
||||
emptyStateCtaType="WAIST_TO_HEIGHT"
|
||||
coachPrefill={t("insights.waistToHeight.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { Ruler } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.25 — `/insights/waist`.
|
||||
*
|
||||
* Waist-circumference sub-page. Rides the generic metric template; the
|
||||
* assessment threads the sex-aware WHO cut-off (men 94 / women 80 cm) via the
|
||||
* `norms.ts` table.
|
||||
*/
|
||||
export default function InsightsWaistPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="WAIST_CIRCUMFERENCE"
|
||||
statusMetric="WAIST_CIRCUMFERENCE"
|
||||
insightMetric="WAIST_CIRCUMFERENCE"
|
||||
chartKey="waistCircumference"
|
||||
i18nPrefix="insights.waist"
|
||||
color="#0ea5e9"
|
||||
unit="cm"
|
||||
yAxisUnit="cm"
|
||||
statIcon={Ruler}
|
||||
emptyStateIcon={<Ruler className="size-6" />}
|
||||
emptyStateCtaType="WAIST_CIRCUMFERENCE"
|
||||
coachPrefill={t("insights.waist.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -185,6 +185,8 @@ export const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navRespiratoryRate",
|
||||
metric: "RESPIRATORY_RATE",
|
||||
},
|
||||
// v1.25 — pain (0–10 NRS).
|
||||
pain: { labelKey: "insights.navPainNrs", metric: "PAIN_NRS" },
|
||||
// ── body composition ──
|
||||
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
|
||||
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
|
||||
@@ -207,6 +209,15 @@ export const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navLeanBodyMass",
|
||||
metric: "LEAN_BODY_MASS",
|
||||
},
|
||||
// v1.25 — waist circumference + waist-to-height ratio.
|
||||
waist: {
|
||||
labelKey: "insights.navWaistCircumference",
|
||||
metric: "WAIST_CIRCUMFERENCE",
|
||||
},
|
||||
"waist-to-height": {
|
||||
labelKey: "insights.navWaistToHeight",
|
||||
metric: "WAIST_TO_HEIGHT",
|
||||
},
|
||||
// ── activity ──
|
||||
steps: {
|
||||
labelKey: "insights.navSteps",
|
||||
@@ -270,6 +281,11 @@ export const SUB_PAGE_TABS: Record<
|
||||
labelKey: "insights.navStairDescentSpeed",
|
||||
metric: "STAIR_DESCENT_SPEED",
|
||||
},
|
||||
// v1.25 — grip strength.
|
||||
"grip-strength": {
|
||||
labelKey: "insights.navGripStrength",
|
||||
metric: "GRIP_STRENGTH",
|
||||
},
|
||||
// ── sleep ──
|
||||
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
|
||||
// v1.10.0 — sleep breathing-disturbance index.
|
||||
|
||||
@@ -95,6 +95,32 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "°C",
|
||||
placeholder: "36.6",
|
||||
},
|
||||
// v1.25 — physical / clinical signals. Manual web capture so a web-only
|
||||
// self-hoster can log them (the iOS client + measurements API already do).
|
||||
{
|
||||
value: "WAIST_CIRCUMFERENCE",
|
||||
labelKey: "measurements.typeWaistCircumference",
|
||||
unit: "cm",
|
||||
placeholder: "84",
|
||||
},
|
||||
{
|
||||
value: "WAIST_TO_HEIGHT",
|
||||
labelKey: "measurements.typeWaistToHeight",
|
||||
unitKey: "measurements.unitRatio",
|
||||
placeholder: "0.48",
|
||||
},
|
||||
{
|
||||
value: "GRIP_STRENGTH",
|
||||
labelKey: "measurements.typeGripStrength",
|
||||
unit: "kg",
|
||||
placeholder: "38",
|
||||
},
|
||||
{
|
||||
value: "PAIN_NRS",
|
||||
labelKey: "measurements.typePainNrs",
|
||||
unitKey: "measurements.unitScore",
|
||||
placeholder: "0",
|
||||
},
|
||||
] as const;
|
||||
|
||||
// v1.4.34 IW-G — single source of truth for the `/measurements?add=<TYPE>`
|
||||
|
||||
@@ -280,6 +280,12 @@ export const CHART_OVERLAY_KEYS = [
|
||||
"averageHeartRate",
|
||||
"maxHeartRate",
|
||||
"energyExpenditureKj",
|
||||
// v1.25 — chart-overlay slots for the physical / clinical signal sub-pages.
|
||||
// One per chart surface; camel-case keys mirror the existing convention.
|
||||
"gripStrength",
|
||||
"painNrs",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
] as const;
|
||||
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
|
||||
|
||||
|
||||
@@ -101,6 +101,31 @@ const RESPIRATORY_RATE_NORMS: NormRow[] = [
|
||||
{ minAge: 18, maxAge: 120, sex: null, range: { low: 12, high: 18 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Grip-strength normal band (kg) by sex — the EWGSOP2 "probable sarcopenia"
|
||||
* cut-off (Cruz-Jentoft et al., Age Ageing 2019): men < 27 kg, women < 16 kg.
|
||||
* The band brackets the at-or-above-cut-off "normal" placement; `direction`
|
||||
* is `higher-better`, so the read flags a value below the low bound. A single
|
||||
* adult bracket per sex — the cut-off is sex-specific, not age-banded — so the
|
||||
* resolver returns it whenever the profile sex is known, even with no age.
|
||||
*/
|
||||
const GRIP_STRENGTH_NORMS: NormRow[] = [
|
||||
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 27, high: 60 } },
|
||||
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 16, high: 60 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Waist-circumference normal band (cm) by sex — the WHO European-origin
|
||||
* increased-risk threshold (WHO 2008/2011; NIH/NHLBI 1998): men > 94 cm,
|
||||
* women > 80 cm. `direction` is `lower-better`, so the normal band runs from
|
||||
* 0 up to the sex cut-off. Single adult bracket per sex (sex-specific, not
|
||||
* age-banded). No ethnicity adjustment — HealthLog does not collect ancestry.
|
||||
*/
|
||||
const WAIST_CIRCUMFERENCE_NORMS: NormRow[] = [
|
||||
{ minAge: 0, maxAge: 120, sex: "MALE", range: { low: 0, high: 94 } },
|
||||
{ minAge: 0, maxAge: 120, sex: "FEMALE", range: { low: 0, high: 80 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* The norm tables, keyed by the assessment-registry metric id. A metric
|
||||
* absent from this map has no age/sex sharpening — the caller keeps the
|
||||
@@ -110,6 +135,9 @@ const NORM_TABLES: Partial<Record<MetricStatusMetricId, NormRow[]>> = {
|
||||
VO2_MAX: VO2_MAX_NORMS,
|
||||
RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS,
|
||||
RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS,
|
||||
// v1.25 — sex-specific (age-independent) cut-offs.
|
||||
GRIP_STRENGTH: GRIP_STRENGTH_NORMS,
|
||||
WAIST_CIRCUMFERENCE: WAIST_CIRCUMFERENCE_NORMS,
|
||||
};
|
||||
|
||||
/** The age a bracket row is anchored at for interpolation — its centre. */
|
||||
@@ -161,18 +189,24 @@ export function lookupNormalRange(
|
||||
ageYears: number | null | undefined,
|
||||
sex: NormSex,
|
||||
): NormRange | null {
|
||||
if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) {
|
||||
return null;
|
||||
}
|
||||
const table = NORM_TABLES[metricId];
|
||||
if (!table) return null;
|
||||
|
||||
const rows = resolveSexRows(table, sex);
|
||||
if (!rows || rows.length === 0) return null;
|
||||
|
||||
// Single band — no neighbour to interpolate against.
|
||||
// Single band — no neighbour to interpolate against. A sex-specific,
|
||||
// age-independent cut-off (grip strength, waist circumference) resolves
|
||||
// here without an age, so the sex-aware band still applies when the
|
||||
// profile carries sex but no date of birth.
|
||||
if (rows.length === 1) return { ...rows[0].range };
|
||||
|
||||
// Multi-bracket tables are age-banded — without a usable age there is no
|
||||
// honest placement, so the caller keeps the flat registry anchor.
|
||||
if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clamp below the youngest centre / above the oldest centre: hold the
|
||||
// nearest cited band flat rather than extrapolate past the table.
|
||||
const first = rows[0];
|
||||
|
||||
@@ -112,7 +112,14 @@ export type InsightMetric =
|
||||
| "SIX_MINUTE_WALK_DISTANCE"
|
||||
| "STAIR_ASCENT_SPEED"
|
||||
| "STAIR_DESCENT_SPEED"
|
||||
| "BREATHING_DISTURBANCES";
|
||||
| "BREATHING_DISTURBANCES"
|
||||
// v1.25 — physical / clinical signals. Each key equals its MeasurementType,
|
||||
// so the generic `hasMetricData` fallback gates it on
|
||||
// `summaries[metric].count > 0` (auto-light-up on the first reading).
|
||||
| "GRIP_STRENGTH"
|
||||
| "PAIN_NRS"
|
||||
| "WAIST_CIRCUMFERENCE"
|
||||
| "WAIST_TO_HEIGHT";
|
||||
|
||||
/**
|
||||
* Inputs the gating helper consumes. The `summaries` shape mirrors
|
||||
|
||||
@@ -140,7 +140,14 @@ export type MetricStatusMetricId =
|
||||
| "CARDIO_LOAD"
|
||||
| "AVERAGE_HEART_RATE"
|
||||
| "MAX_HEART_RATE"
|
||||
| "ENERGY_EXPENDITURE_KJ";
|
||||
| "ENERGY_EXPENDITURE_KJ"
|
||||
// v1.25 — physical / clinical signals promoted to detail pages. Grip
|
||||
// strength + waist carry a sex-aware band via the `norms.ts` tables the
|
||||
// generic generator already threads through `lookupNormalRange`.
|
||||
| "GRIP_STRENGTH"
|
||||
| "PAIN_NRS"
|
||||
| "WAIST_CIRCUMFERENCE"
|
||||
| "WAIST_TO_HEIGHT";
|
||||
|
||||
/**
|
||||
* The registry. Keyed by the HealthKit metric id. Each entry's
|
||||
@@ -635,6 +642,54 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
|
||||
direction: "higher-better",
|
||||
archetype: "activity-fitness",
|
||||
},
|
||||
// ── v1.25 — physical / clinical signals ──
|
||||
// Grip strength — EWGSOP2 strength-limb marker. The flat 16 kg floor is the
|
||||
// women's cut-off; the sex-aware `norms.ts` table sharpens it (men 27 /
|
||||
// women 16) when the profile carries sex. Fields mirror the signal-registry
|
||||
// entry exactly so the registry-invariant projection deep-equals this row.
|
||||
GRIP_STRENGTH: {
|
||||
id: "GRIP_STRENGTH",
|
||||
measurementType: "GRIP_STRENGTH",
|
||||
displayName: "Grip strength",
|
||||
unit: "kg",
|
||||
direction: "higher-better",
|
||||
normalRange: { low: 16, high: 60 },
|
||||
archetype: "activity-fitness",
|
||||
},
|
||||
// Pain NRS — the validated 0–10 numeric rating; lower is better, the
|
||||
// no-pain/mild band sits at 0–3.
|
||||
PAIN_NRS: {
|
||||
id: "PAIN_NRS",
|
||||
measurementType: "PAIN_NRS",
|
||||
displayName: "Pain (0–10 NRS)",
|
||||
unit: "score",
|
||||
direction: "lower-better",
|
||||
normalRange: { low: 0, high: 3 },
|
||||
archetype: "physiological-vital",
|
||||
},
|
||||
// Waist circumference — WHO increased-risk threshold. The flat 94 cm anchor
|
||||
// is the men's cut-off; the sex-aware `norms.ts` table sharpens it (men 94 /
|
||||
// women 80) when the profile carries sex.
|
||||
WAIST_CIRCUMFERENCE: {
|
||||
id: "WAIST_CIRCUMFERENCE",
|
||||
measurementType: "WAIST_CIRCUMFERENCE",
|
||||
displayName: "Waist circumference",
|
||||
unit: "cm",
|
||||
direction: "lower-better",
|
||||
normalRange: { low: 0, high: 94 },
|
||||
archetype: "body-composition",
|
||||
},
|
||||
// Waist-to-height ratio — NICE keep-waist-under-half-your-height rule. WHtR
|
||||
// ≥ 0.5 flags increased risk, so the last in-range value is 0.49.
|
||||
WAIST_TO_HEIGHT: {
|
||||
id: "WAIST_TO_HEIGHT",
|
||||
measurementType: "WAIST_TO_HEIGHT",
|
||||
displayName: "Waist-to-height ratio",
|
||||
unit: "ratio",
|
||||
direction: "lower-better",
|
||||
normalRange: { low: 0, high: 0.49 },
|
||||
archetype: "body-composition",
|
||||
},
|
||||
};
|
||||
|
||||
/** Closed set of ids the generic route accepts (Zod enum source). */
|
||||
|
||||
@@ -53,6 +53,9 @@ const SUB_PAGE_METRIC = {
|
||||
"body-temperature": ["BODY_TEMPERATURE"],
|
||||
// v1.7.0 — respiratory rate joins the vitals cluster.
|
||||
"respiratory-rate": ["RESPIRATORY_RATE"],
|
||||
// v1.25 — pain (0–10 NRS) joins the vitals cluster as a patient-reported
|
||||
// signal with its own detail page + assessment.
|
||||
pain: ["PAIN_NRS"],
|
||||
// ── body composition ──
|
||||
weight: ["WEIGHT"],
|
||||
// BMI is derived from WEIGHT + profile height (no separate series).
|
||||
@@ -65,6 +68,9 @@ const SUB_PAGE_METRIC = {
|
||||
"muscle-mass": ["MUSCLE_MASS"],
|
||||
"visceral-fat": ["VISCERAL_FAT"],
|
||||
"lean-body-mass": ["LEAN_BODY_MASS"],
|
||||
// v1.25 — waist circumference + waist-to-height ratio join the body cluster.
|
||||
waist: ["WAIST_CIRCUMFERENCE"],
|
||||
"waist-to-height": ["WAIST_TO_HEIGHT"],
|
||||
// ── activity ──
|
||||
// v1.12 — Steps gains a routed sub-page. Stored as `ACTIVITY_STEPS`;
|
||||
// the tile already surfaced on the dashboard but the metric was never
|
||||
@@ -94,6 +100,9 @@ const SUB_PAGE_METRIC = {
|
||||
"six-minute-walk": ["SIX_MINUTE_WALK_DISTANCE"],
|
||||
"stair-ascent-speed": ["STAIR_ASCENT_SPEED"],
|
||||
"stair-descent-speed": ["STAIR_DESCENT_SPEED"],
|
||||
// v1.25 — grip strength (EWGSOP2 strength-limb marker) joins the activity
|
||||
// cluster as a manual / device physical-fitness signal.
|
||||
"grip-strength": ["GRIP_STRENGTH"],
|
||||
// ── sleep ──
|
||||
sleep: ["SLEEP_DURATION"],
|
||||
// v1.10.0 — sleep breathing-disturbance index joins the sleep cluster.
|
||||
@@ -204,6 +213,13 @@ export const SUB_PAGE_GROUP: Partial<Record<SubPageSlug, SubPageGroup>> = {
|
||||
"cardio-recovery": "cardiovascular",
|
||||
// metabolic cluster (overnight wrist reading)
|
||||
"wrist-temperature": "metabolic",
|
||||
// ── v1.25 — physical / clinical signals ──
|
||||
// Pain rides the vitals popover (patient-reported physiological signal);
|
||||
// waist + WHtR sit with body composition; grip with activity / fitness.
|
||||
pain: "vitals",
|
||||
waist: "body",
|
||||
"waist-to-height": "body",
|
||||
"grip-strength": "activity",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,7 +95,8 @@ describe("resolveRichMetric", () => {
|
||||
|
||||
const whtr = resolveRichMetric("waist-to-height ratio");
|
||||
expect(whtr?.measurementType).toBe("WAIST_TO_HEIGHT");
|
||||
expect(whtr?.band).toEqual({ low: 0, high: 0.5 });
|
||||
// WHtR ≥ 0.5 flags increased risk, so the last in-range value is 0.49.
|
||||
expect(whtr?.band).toEqual({ low: 0, high: 0.49 });
|
||||
|
||||
// The exact registry key resolves too.
|
||||
expect(resolveRichMetric("GRIP_STRENGTH")?.measurementType).toBe(
|
||||
|
||||
+34
-25
@@ -43,7 +43,7 @@ import {
|
||||
getMetricStatusMeta,
|
||||
METRIC_STATUS_IDS,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { getSignal } from "@/lib/signals/registry";
|
||||
import { allSignals, getSignal } from "@/lib/signals/registry";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
|
||||
@@ -182,24 +182,21 @@ function fromRegistry(id: string): RichMetric | null {
|
||||
//
|
||||
// The v1.25 clinical-signals wave added a set of physical / clinical
|
||||
// measurements to the signal registry. They sit OFF the Coach snapshot by
|
||||
// design (`coachSnapshot:false ⇒ surfaces.mcp:false`), so the Coach-driven
|
||||
// reads (`get_metric_series`, the data inventory) never surface them. The MCP
|
||||
// layer exposes them here through the rollup-backed rich reads — `compare_metric`,
|
||||
// `get_metric_baseline`, `detect_changepoints` — and through `search` / `fetch`.
|
||||
// design (`coachSnapshot:false`), so the Coach-driven reads
|
||||
// (`get_metric_series`, the data inventory) never surface them — that path
|
||||
// would report no data for a signal it does not carry. The MCP layer exposes
|
||||
// them here through the rollup-backed rich reads — `compare_metric`,
|
||||
// `get_metric_baseline`, `detect_changepoints` — and through `search` /
|
||||
// `fetch`.
|
||||
//
|
||||
// Exposure is an EXPLICIT allowlist owned by this module, not the registry's own
|
||||
// `surfaces.mcp` flag: the registry keeps these off the Coach surface, while the
|
||||
// MCP read surface opts them in one signal at a time. The registry remains the
|
||||
// single source of truth for grounding (the backing `MeasurementType`, unit, and
|
||||
// population band). Deliberately ABSENT and therefore unreachable over MCP: the
|
||||
// PHQ-9 / GAD-7 mental-health screeners and every environmental (`ENV_*`) signal
|
||||
// — they never reach AI / MCP by construction.
|
||||
export const MCP_CLINICAL_SIGNAL_KEYS = [
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
] as const;
|
||||
// The registry's own `surfaces.mcp` flag is the SINGLE source of truth for
|
||||
// MCP exposure: a signal surfaces here iff it is a measurement marked
|
||||
// `mcp:true` while staying off the Coach snapshot (`coachSnapshot:false`).
|
||||
// The `mcp` and `coachSnapshot` facets are independent — a signal can be
|
||||
// MCP-readable without being on the Coach surface. Deliberately ABSENT and
|
||||
// therefore unreachable over MCP: the PHQ-9 / GAD-7 mental-health screeners
|
||||
// and every environmental (`ENV_*`) signal — they carry `mcp:false` and never
|
||||
// reach AI / MCP by construction.
|
||||
|
||||
/** Build a `RichMetric` from a registry signal key (measurement-kind only). */
|
||||
function richMetricFromSignal(key: string): RichMetric | null {
|
||||
@@ -215,11 +212,23 @@ function richMetricFromSignal(key: string): RichMetric | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved clinical signals, keyed by their registry key (built once). */
|
||||
/**
|
||||
* The MCP-only clinical signals, derived from the registry: every measurement
|
||||
* signal marked `mcp:true` that stays off the Coach snapshot. This is the one
|
||||
* place the registry's `surfaces.mcp` flag is consumed for the rich reads —
|
||||
* no duplicate allowlist to drift against.
|
||||
*/
|
||||
const CLINICAL_SIGNAL_BY_KEY = new Map<string, RichMetric>();
|
||||
for (const key of MCP_CLINICAL_SIGNAL_KEYS) {
|
||||
const metric = richMetricFromSignal(key);
|
||||
if (metric) CLINICAL_SIGNAL_BY_KEY.set(key, metric);
|
||||
for (const sig of allSignals()) {
|
||||
if (
|
||||
sig.kind !== "measurement" ||
|
||||
sig.surfaces.mcp !== true ||
|
||||
sig.surfaces.coachSnapshot !== false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const metric = richMetricFromSignal(sig.key);
|
||||
if (metric) CLINICAL_SIGNAL_BY_KEY.set(sig.key, metric);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,9 +265,9 @@ const CLINICAL_ALIASES: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* Resolve a free-text name to a clinical signal the MCP layer exposes, or
|
||||
* `null`. Closed by construction to the `MCP_CLINICAL_SIGNAL_KEYS` allowlist, so
|
||||
* a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key can never resolve
|
||||
* here even though they exist in the registry.
|
||||
* `null`. Closed by construction to the registry-derived `mcp:true` measurement
|
||||
* set, so a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key — all
|
||||
* `mcp:false` — can never resolve here even though they exist in the registry.
|
||||
*/
|
||||
function resolveClinicalSignal(key: string): RichMetric | null {
|
||||
const aliased = CLINICAL_ALIASES[key];
|
||||
|
||||
@@ -889,7 +889,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "compare_metric",
|
||||
title: "Compare metrics or windows",
|
||||
description:
|
||||
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
|
||||
"Compare one metric against another over the same horizon, OR a single metric across two horizons. A horizon is either one of the five fixed trailing windows OR an explicit {from,to} date range (use `range`/`rangeB` for before-vs-after-a-date). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Returns { present: false } when a side has no data.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
metricB: z.string().min(1).max(60).optional(),
|
||||
@@ -931,7 +931,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "detect_changepoints",
|
||||
title: "Detect level shifts in a metric",
|
||||
description:
|
||||
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
|
||||
"Surface points where a metric's level shifted over a trailing window OR an explicit {from,to} date range (e.g. 'when did my weight trend change?'). Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
|
||||
@@ -175,7 +175,9 @@ const insightsLayoutSchema = z
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50)
|
||||
// Cap at the accepted-id universe so a PUT can cover every tile the
|
||||
// layout knows about; grows automatically as new sub-page slugs land.
|
||||
.max(ACCEPTED_INSIGHTS_TILE_IDS.length)
|
||||
.optional(),
|
||||
})
|
||||
.meta({
|
||||
|
||||
@@ -48,12 +48,45 @@ describe("signal registry — structural sanity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("mcp exposure follows Coach-snapshot inclusion", () => {
|
||||
// The MCP get_metric_series enum is the Coach scope vocabulary, so a signal
|
||||
// is MCP-readable iff it carries a Coach scope (and vice-versa).
|
||||
it("every Coach-snapshot signal is also MCP-readable", () => {
|
||||
// `mcp` and `coachSnapshot` are INDEPENDENT facets: a Coach-scoped signal
|
||||
// is always reachable over MCP (the Coach `get_metric_series` path), but a
|
||||
// signal can be MCP-readable WITHOUT a Coach scope (the v1.25 physical
|
||||
// signals, exposed only through the rollup-backed rich reads). So the
|
||||
// invariant is one-directional — coachSnapshot ⇒ mcp, never the reverse.
|
||||
for (const signal of allSignals()) {
|
||||
const hasScope = signal.surfaces.coachSnapshot !== false;
|
||||
expect(signal.surfaces.mcp).toBe(hasScope);
|
||||
if (signal.surfaces.coachSnapshot !== false) {
|
||||
expect(signal.surfaces.mcp).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("the physical clinical signals are MCP-readable but off the Coach snapshot", () => {
|
||||
for (const key of [
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
]) {
|
||||
const signal = getSignal(key);
|
||||
expect(signal, `${key} missing from registry`).not.toBeNull();
|
||||
expect(signal!.surfaces.mcp).toBe(true);
|
||||
expect(signal!.surfaces.coachSnapshot).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("the mental-health screeners and environmental signals stay off MCP", () => {
|
||||
for (const key of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
const signal = getSignal(key);
|
||||
expect(signal, `${key} missing from registry`).not.toBeNull();
|
||||
expect(signal!.surfaces.mcp).toBe(false);
|
||||
}
|
||||
for (const signal of allSignals()) {
|
||||
if (signal.kind === "environment") {
|
||||
expect(signal.surfaces.mcp, `${signal.key} must stay off MCP`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+24
-13
@@ -1280,13 +1280,18 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
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.
|
||||
// women < 16 kg) and resolved at the display edge by the sex-aware band
|
||||
// resolver (`norms.ts` GRIP_STRENGTH table) the detail-page assessment
|
||||
// threads through `lookupNormalRange`.
|
||||
normalRange: { low: 16, high: 60 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
detailPage: true,
|
||||
correlationEligible: false,
|
||||
// Off the Coach snapshot, but reachable over MCP: the `mcp` facet is
|
||||
// independent of `coachSnapshot`. The MCP rich reads expose it through
|
||||
// the rollup-backed baseline / compare / change-point reads.
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
mcp: true,
|
||||
},
|
||||
fhir: {
|
||||
// TODO(NEEDS-VERIFY): no hand-grip-strength LOINC confirmed this pass —
|
||||
@@ -1307,10 +1312,11 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
archetype: "physiological-vital",
|
||||
normalRange: { low: 0, high: 3 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
detailPage: true,
|
||||
correlationEligible: false,
|
||||
// Off the Coach snapshot, reachable over MCP (independent facets).
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
mcp: true,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "72514-3",
|
||||
@@ -1328,14 +1334,16 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
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.
|
||||
// WHO European-origin increased-risk threshold (men > 94); the sex-aware
|
||||
// resolver (`norms.ts` WAIST_CIRCUMFERENCE table: men 94 / women 80)
|
||||
// sharpens it at the display edge via `lookupNormalRange`.
|
||||
normalRange: { low: 0, high: 94 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
detailPage: true,
|
||||
correlationEligible: false,
|
||||
// Off the Coach snapshot, reachable over MCP (independent facets).
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
mcp: true,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "8280-0",
|
||||
@@ -1352,13 +1360,16 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
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 },
|
||||
// NICE: keep waist under half your height — WHtR ≥ 0.5 flags increased
|
||||
// risk, so the last in-range value sits just below the 0.5 threshold
|
||||
// (`high: 0.49`) and a 0.50 reading classifies as increased, not normal.
|
||||
normalRange: { low: 0, high: 0.49 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
detailPage: true,
|
||||
correlationEligible: false,
|
||||
// Off the Coach snapshot, reachable over MCP (independent facets).
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
mcp: true,
|
||||
},
|
||||
fhir: {
|
||||
// TODO(NEEDS-VERIFY): WHtR LOINC not confirmed this pass — local text concept.
|
||||
|
||||
Reference in new issue
Block a user