From a69697b50fbb01415119dcd7c79f1eecc2181d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 18:33:53 +0200 Subject: [PATCH] fix(insights): wire grip / pain / waist / waist-to-height across web and MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 16 ++++- messages/de.json | 52 +++++++++++++++- messages/en.json | 52 +++++++++++++++- messages/es.json | 52 +++++++++++++++- messages/fr.json | 52 +++++++++++++++- messages/it.json | 52 +++++++++++++++- messages/pl.json | 52 +++++++++++++++- src/app/api/insights/layout/route.ts | 6 +- src/app/insights/grip-strength/page.tsx | 34 +++++++++++ src/app/insights/pain/page.tsx | 33 +++++++++++ src/app/insights/waist-to-height/page.tsx | 32 ++++++++++ src/app/insights/waist/page.tsx | 33 +++++++++++ .../insights/insights-tab-strip.tsx | 16 +++++ .../measurements/measurement-form.tsx | 26 ++++++++ src/lib/dashboard-layout.ts | 6 ++ src/lib/insights/derived/norms.ts | 42 +++++++++++-- src/lib/insights/metric-availability.ts | 9 ++- src/lib/insights/metric-status-registry.ts | 57 +++++++++++++++++- src/lib/insights/sub-page-metric.ts | 16 +++++ src/lib/mcp/__tests__/rich-reads.test.ts | 3 +- src/lib/mcp/rich-reads.ts | 59 +++++++++++-------- src/lib/mcp/tools.ts | 4 +- src/lib/openapi/routes/coach.ts | 4 +- .../__tests__/registry-invariants.test.ts | 43 ++++++++++++-- src/lib/signals/registry.ts | 37 ++++++++---- 25 files changed, 726 insertions(+), 62 deletions(-) create mode 100644 src/app/insights/grip-strength/page.tsx create mode 100644 src/app/insights/pain/page.tsx create mode 100644 src/app/insights/waist-to-height/page.tsx create mode 100644 src/app/insights/waist/page.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 476d242d2..0d2e03e00 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index dc0e00086..d8afc5509 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index e6e776db1..1deef2018 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index b9687c4c0..f33a502ba 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 7df786db9..a21666ad9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index e4f800775..28bb7a4b5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 52381c2ba..ac57f5b7c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 5fb3c20ad..1598ab829 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -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(), }); diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx new file mode 100644 index 000000000..895fbdedb --- /dev/null +++ b/src/app/insights/grip-strength/page.tsx @@ -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 ( + } + emptyStateCtaType="GRIP_STRENGTH" + coachPrefill={t("insights.gripStrength.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/pain/page.tsx b/src/app/insights/pain/page.tsx new file mode 100644 index 000000000..895f25b68 --- /dev/null +++ b/src/app/insights/pain/page.tsx @@ -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 ( + } + emptyStateCtaType="PAIN_NRS" + coachPrefill={t("insights.pain.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/waist-to-height/page.tsx b/src/app/insights/waist-to-height/page.tsx new file mode 100644 index 000000000..abae1bbf8 --- /dev/null +++ b/src/app/insights/waist-to-height/page.tsx @@ -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 ( + } + emptyStateCtaType="WAIST_TO_HEIGHT" + coachPrefill={t("insights.waistToHeight.coachPrefill")} + /> + ); +} diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx new file mode 100644 index 000000000..e838df0ee --- /dev/null +++ b/src/app/insights/waist/page.tsx @@ -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 ( + } + emptyStateCtaType="WAIST_CIRCUMFERENCE" + coachPrefill={t("insights.waist.coachPrefill")} + /> + ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index fdcbda4b0..5a9559166 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -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. diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index ab6cfba5d..85733c329 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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=` diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index 40d16536f..7c37f9dae 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -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]; diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index 75c7c6a70..e43fc8979 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -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> = { 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]; diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index 663bc7489..1d8dc20ba 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -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 diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index df8ea7737..8f018dac7 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -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 = { 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). */ diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 73967b7da..f85ef3454 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -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> = { "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", }; /** diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 0c8cfc34e..6677b7a3b 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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( diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 4f06697a7..d8a7b6446 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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(); -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 = { /** * 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]; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 6c6c16dac..40d81952d 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -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(), diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index de3137eb2..d8b172e8f 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -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({ diff --git a/src/lib/signals/__tests__/registry-invariants.test.ts b/src/lib/signals/__tests__/registry-invariants.test.ts index 337b17f27..7a0ed9de3 100644 --- a/src/lib/signals/__tests__/registry-invariants.test.ts +++ b/src/lib/signals/__tests__/registry-invariants.test.ts @@ -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, + ); + } } }); diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 3c51b5049..56f25f8e9 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1280,13 +1280,18 @@ export const SIGNALS: Record = { 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 = { 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 = { 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 = { 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.