feat(insights): surface target range and explainer on category pages

Each insights category page now carries two additions placed beside the
chart and assessment:

- a target-range card (MetricTargetSummary) showing the numeric goal
  band and the share of logged days within it, read from the shared
  /api/insights/targets payload so the bands match the /targets editor
  exactly. Blood pressure stitches the systolic and diastolic bands into
  one S/D string. A discreet link routes to /targets to adjust the band.
- a question-mark explainer next to each heading (MetricExplainer in
  SubPageShell) opening a static, layperson 'What is X?' read. Popover on
  desktop, bottom-sheet on phones; keyboard- and touch-reachable with a
  44px hit surface. Copy ships in all six locales.

The /targets page stays the canonical place to tune the bands.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 21:32:32 +02:00
1 parent 24ffca7220
commit 16b4768fd1
25 files changed
+842 -10

No files matched your search

+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.",
"medikamenteEmptyAction": "Medikamente öffnen",
"therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.",
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung."
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.",
"target": {
"range": "Ziel: {min}–{max} {unit}",
"bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% der erfassten Tage im Zielbereich",
"adjustLink": "Zielbereich anpassen"
},
"explainer": {
"trigger": "Was ist {title}?",
"blutdruckTitle": "Blutdruck",
"blutdruckBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.",
"pulsTitle": "Puls",
"pulsBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"gewichtTitle": "Gewicht",
"gewichtBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiBody": "Der BMI ist das Gewicht geteilt durch die Körpergröße zum Quadrat und ordnet Sie einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"schlafTitle": "Schlaf",
"schlafBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"stimmungTitle": "Stimmung",
"stimmungBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie Sie sich fühlen. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medikamenteTitle": "Medikamenten-Therapietreue",
"medikamenteBody": "Die Therapietreue zeigt, wie zuverlässig Sie Ihre Medikamente wie verordnet einnehmen, hier als Anteil der erwarteten Dosen, die Sie erfasst haben. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrTitle": "Ruhepuls",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
"oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.",
"bodyTemperatureTitle": "Körpertemperatur",
"bodyTemperatureBody": "Die Körpertemperatur spiegelt Ihren Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
"activeEnergyBody": "Die aktive Energie sind die Kalorien, die Sie durch Bewegung verbrennen, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv Ihr Tag war.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die Ihre Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit Ihren anderen Gesundheitssignalen zusammenhängt."
}
},
"sleep": {
"title": "Schlaf",
+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.",
"medikamenteEmptyAction": "Open medications",
"therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.",
"bmiDescription": "BMI derived from your weight and height, with a written assessment."
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
"target": {
"range": "Target: {min}–{max} {unit}",
"bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% of logged days within target",
"adjustLink": "Adjust target range"
},
"explainer": {
"trigger": "What is {title}?",
"blutdruckTitle": "Blood pressure",
"blutdruckBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.",
"pulsTitle": "Pulse",
"pulsBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.",
"gewichtTitle": "Weight",
"gewichtBody": "Body weight is one of the simplest signals of long-term energy balance and overall health. The trend over weeks matters far more than any single reading, which fluctuates with hydration, food, and time of day.",
"bmiTitle": "Body mass index (BMI)",
"bmiBody": "BMI is your weight divided by your height squared, giving a single number that places you in a broad weight category. It is a population-level screen, not a diagnosis, and does not distinguish muscle from fat.",
"schlafTitle": "Sleep",
"schlafBody": "Sleep duration is how long you actually slept across the night, split into light, deep, and REM stages. Most adults need roughly seven to nine hours; both consistent quantity and quality support recovery, mood, and metabolism.",
"stimmungTitle": "Mood",
"stimmungBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns — how sleep, activity, or medication line up with how you feel — that a single day never shows.",
"medikamenteTitle": "Medication adherence",
"medikamenteBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.",
"restingHrTitle": "Resting heart rate",
"restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness.",
"hrvTitle": "Heart rate variability (HRV)",
"hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.",
"oxygenSaturationTitle": "Oxygen saturation (SpO₂)",
"oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.",
"bodyTemperatureTitle": "Body temperature",
"bodyTemperatureBody": "Body temperature reflects your core thermal state, normally around 37 °C with small daily swings. A sustained rise can indicate infection, while overnight wrist readings mainly track relative changes rather than a clinical value.",
"activeEnergyTitle": "Active energy",
"activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals."
}
},
"sleep": {
"title": "Sleep",
+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Los insights de adherencia necesitan al menos un medicamento registrado.",
"medikamenteEmptyAction": "Abrir medicamentos",
"therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.",
"bmiDescription": "IMC derivado de tu peso y altura, con valoración."
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
"target": {
"range": "Objetivo: {min}–{max} {unit}",
"bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% de los días registrados dentro del objetivo",
"adjustLink": "Ajustar el rango objetivo"
},
"explainer": {
"trigger": "¿Qué es {title}?",
"blutdruckTitle": "Presión arterial",
"blutdruckBody": "La presión arterial es la fuerza que ejerce la sangre sobre las paredes de las arterias, expresada como sistólica sobre diastólica (el pico durante un latido sobre el valor en reposo entre latidos). Los valores altos de forma constante sobrecargan el corazón y los vasos con el tiempo.",
"pulsTitle": "Pulso",
"pulsBody": "El pulso es cuántas veces late el corazón por minuto. Una medición puntual refleja lo que hacías en ese momento: en reposo suele ser de 60 a 100 latidos por minuto y sube de forma natural con el movimiento, el estrés o la cafeína.",
"gewichtTitle": "Peso",
"gewichtBody": "El peso corporal es una de las señales más simples del balance energético a largo plazo y de la salud general. La tendencia a lo largo de las semanas importa mucho más que cualquier lectura individual, que varía con la hidratación, la comida y la hora del día.",
"bmiTitle": "Índice de masa corporal (IMC)",
"bmiBody": "El IMC es tu peso dividido por tu altura al cuadrado, un único número que te sitúa en una categoría amplia de peso. Es un cribado poblacional, no un diagnóstico, y no distingue el músculo de la grasa.",
"schlafTitle": "Sueño",
"schlafBody": "La duración del sueño es cuánto dormiste realmente durante la noche, dividido en fases ligera, profunda y REM. La mayoría de los adultos necesita entre siete y nueve horas; tanto la cantidad como la calidad constantes favorecen la recuperación, el ánimo y el metabolismo.",
"stimmungTitle": "Estado de ánimo",
"stimmungBody": "El registro del estado de ánimo anota cómo te sientes con el tiempo en una escala sencilla. Anotarlo con regularidad revela patrones —cómo el sueño, la actividad o la medicación se relacionan con cómo te sientes— que un solo día nunca muestra.",
"medikamenteTitle": "Adherencia a la medicación",
"medikamenteBody": "La adherencia es la fiabilidad con la que tomas tu medicación según lo prescrito, aquí como la proporción de dosis previstas que registraste. Una toma constante suele ser lo que hace que un tratamiento funcione como se pretende.",
"restingHrTitle": "Frecuencia cardíaca en reposo",
"restingHrBody": "La frecuencia cardíaca en reposo es tu ritmo cardíaco cuando estás completamente en reposo, normalmente medido durante la noche. Una frecuencia más baja suele indicar mejor forma cardiovascular, y una tendencia al alza puede ser una señal temprana de estrés o enfermedad.",
"hrvTitle": "Variabilidad de la frecuencia cardíaca (VFC)",
"hrvBody": "La VFC es la pequeña variación en el tiempo entre latidos consecutivos. Una mayor variabilidad suele reflejar un sistema nervioso bien recuperado y adaptable, mientras que una caída sostenida puede señalar fatiga, estrés o recuperación insuficiente.",
"oxygenSaturationTitle": "Saturación de oxígeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ es el porcentaje de glóbulos rojos que transportan oxígeno. Los valores de 95 a 100 % son normales en reposo; los valores que se mantienen por debajo del 90 % merecen atención, aunque las bajadas breves nocturnas son frecuentes.",
"bodyTemperatureTitle": "Temperatura corporal",
"bodyTemperatureBody": "La temperatura corporal refleja tu estado térmico central, normalmente en torno a 37 °C con pequeñas variaciones diarias. Un aumento sostenido puede indicar una infección, mientras que las lecturas nocturnas en la muñeca reflejan sobre todo cambios relativos, no un valor clínico.",
"activeEnergyTitle": "Energía activa",
"activeEnergyBody": "La energía activa son las calorías que quemas con el movimiento, además del consumo basal en reposo. Es una medida directa de lo físicamente activo que fue tu día.",
"workoutsTitle": "Entrenamientos",
"workoutsBody": "Los entrenamientos son las sesiones de ejercicio estructurado que registran tus dispositivos, con tipo, duración y energía. Registrarlos muestra cómo el entrenamiento regular se acumula a lo largo de las semanas y cómo se relaciona con tus otras señales de salud."
}
},
"sleep": {
"title": "Sueño",
+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Les insights d’observance ont besoin d’au moins un médicament enregistré.",
"medikamenteEmptyAction": "Ouvrir les médicaments",
"therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.",
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation."
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
"target": {
"range": "Cible : {min}–{max} {unit}",
"bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% des jours enregistrés dans la cible",
"adjustLink": "Ajuster la plage cible"
},
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"blutdruckTitle": "Tension artérielle",
"blutdruckBody": "La tension artérielle est la force exercée par le sang sur les parois des artères, exprimée en systolique sur diastolique (le pic lors d'un battement sur la valeur de repos entre les battements). Des valeurs durablement élevées sollicitent le cœur et les vaisseaux à la longue.",
"pulsTitle": "Pouls",
"pulsBody": "Le pouls correspond au nombre de battements du cœur par minute. Une mesure ponctuelle reflète ce que vous faisiez à l'instant : au repos, il est généralement de 60 à 100 battements par minute et augmente naturellement avec le mouvement, le stress ou la caféine.",
"gewichtTitle": "Poids",
"gewichtBody": "Le poids corporel est l'un des signaux les plus simples de l'équilibre énergétique à long terme et de la santé globale. La tendance sur plusieurs semaines compte bien plus qu'une mesure isolée, qui varie avec l'hydratation, l'alimentation et l'heure de la journée.",
"bmiTitle": "Indice de masse corporelle (IMC)",
"bmiBody": "L'IMC correspond à votre poids divisé par votre taille au carré, un seul chiffre qui vous situe dans une large catégorie de poids. C'est un dépistage de population, pas un diagnostic, et il ne distingue pas le muscle de la graisse.",
"schlafTitle": "Sommeil",
"schlafBody": "La durée du sommeil est le temps réellement dormi au cours de la nuit, réparti entre sommeil léger, profond et paradoxal. La plupart des adultes ont besoin d'environ sept à neuf heures ; une quantité et une qualité régulières soutiennent la récupération, l'humeur et le métabolisme.",
"stimmungTitle": "Humeur",
"stimmungBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances — comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti — qu'une seule journée ne montre jamais.",
"medikamenteTitle": "Observance du traitement",
"medikamenteBody": "L'observance mesure la fiabilité avec laquelle vous prenez votre traitement comme prescrit, ici sous forme de la part des doses attendues que vous avez enregistrées. Une prise régulière est souvent ce qui permet à un traitement d'agir comme prévu.",
"restingHrTitle": "Fréquence cardiaque au repos",
"restingHrBody": "La fréquence cardiaque au repos est votre rythme cardiaque lorsque vous êtes totalement au repos, généralement mesuré la nuit. Une fréquence plus basse indique souvent une meilleure forme cardiovasculaire, et une tendance à la hausse peut être un signe précoce de stress ou de maladie.",
"hrvTitle": "Variabilité de la fréquence cardiaque (VFC)",
"hrvBody": "La VFC est la petite variation du temps entre des battements consécutifs. Une variabilité plus élevée reflète généralement un système nerveux bien récupéré et adaptable, tandis qu'une baisse durable peut signaler de la fatigue, du stress ou une récupération insuffisante.",
"oxygenSaturationTitle": "Saturation en oxygène (SpO₂)",
"oxygenSaturationBody": "La SpO₂ est le pourcentage de globules rouges transportant de l'oxygène. Des valeurs de 95 à 100 % sont normales au repos ; des valeurs qui restent sous 90 % méritent attention, bien que de brèves baisses nocturnes soient fréquentes.",
"bodyTemperatureTitle": "Température corporelle",
"bodyTemperatureBody": "La température corporelle reflète votre état thermique central, normalement autour de 37 °C avec de petites variations quotidiennes. Une hausse durable peut indiquer une infection, tandis que les mesures nocturnes au poignet suivent surtout des variations relatives plutôt qu'une valeur clinique.",
"activeEnergyTitle": "Énergie active",
"activeEnergyBody": "L'énergie active correspond aux calories que vous brûlez par le mouvement, en plus de la dépense de base au repos. C'est une mesure directe du niveau d'activité physique de votre journée.",
"workoutsTitle": "Entraînements",
"workoutsBody": "Les entraînements sont les séances d'exercice structurées que vos appareils enregistrent, avec le type, la durée et l'énergie. Les suivre montre comment un entraînement régulier s'accumule au fil des semaines et comment il se relie à vos autres signaux de santé."
}
},
"sleep": {
"title": "Sommeil",
+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Gli insights di aderenza richiedono almeno un farmaco registrato.",
"medikamenteEmptyAction": "Apri farmaci",
"therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.",
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione."
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
"target": {
"range": "Obiettivo: {min}–{max} {unit}",
"bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% dei giorni registrati nell'obiettivo",
"adjustLink": "Modifica l'intervallo obiettivo"
},
"explainer": {
"trigger": "Che cos'è {title}?",
"blutdruckTitle": "Pressione arteriosa",
"blutdruckBody": "La pressione arteriosa è la forza che il sangue esercita sulle pareti delle arterie, indicata come sistolica su diastolica (il picco durante un battito sul valore a riposo tra i battiti). Valori costantemente alti affaticano il cuore e i vasi nel tempo.",
"pulsTitle": "Polso",
"pulsBody": "Il polso indica quante volte il cuore batte al minuto. Una misurazione singola riflette ciò che stavi facendo in quel momento: a riposo è di solito tra 60 e 100 battiti al minuto e aumenta naturalmente con il movimento, lo stress o la caffeina.",
"gewichtTitle": "Peso",
"gewichtBody": "Il peso corporeo è uno dei segnali più semplici dell'equilibrio energetico a lungo termine e della salute generale. L'andamento nelle settimane conta molto più di una singola misurazione, che varia con l'idratazione, il cibo e l'ora del giorno.",
"bmiTitle": "Indice di massa corporea (IMC)",
"bmiBody": "L'IMC è il peso diviso per l'altezza al quadrato, un singolo numero che ti colloca in un'ampia categoria di peso. È uno screening di popolazione, non una diagnosi, e non distingue il muscolo dal grasso.",
"schlafTitle": "Sonno",
"schlafBody": "La durata del sonno è quanto hai effettivamente dormito nella notte, suddivisa in fasi leggera, profonda e REM. La maggior parte degli adulti necessita di circa sette-nove ore; quantità e qualità costanti favoriscono recupero, umore e metabolismo.",
"stimmungTitle": "Umore",
"stimmungBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi — come sonno, attività o farmaci si allineano a come ti senti — che un singolo giorno non mostra mai.",
"medikamenteTitle": "Aderenza alla terapia",
"medikamenteBody": "L'aderenza misura quanto regolarmente assumi i farmaci come prescritto, qui come quota delle dosi previste che hai registrato. Un'assunzione costante è spesso ciò che fa funzionare una terapia come previsto.",
"restingHrTitle": "Frequenza cardiaca a riposo",
"restingHrBody": "La frequenza cardiaca a riposo è il battito quando sei completamente a riposo, di solito misurato durante la notte. Una frequenza più bassa indica in genere una migliore forma cardiovascolare, mentre un andamento in aumento può essere un segnale precoce di stress o malattia.",
"hrvTitle": "Variabilità della frequenza cardiaca (HRV)",
"hrvBody": "L'HRV è la piccola variazione del tempo tra battiti consecutivi. Una variabilità più alta riflette di solito un sistema nervoso ben recuperato e adattabile, mentre un calo prolungato può segnalare affaticamento, stress o recupero insufficiente.",
"oxygenSaturationTitle": "Saturazione di ossigeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ è la percentuale di globuli rossi che trasportano ossigeno. Valori tra 95 e 100% sono normali a riposo; valori che restano sotto il 90% meritano attenzione, anche se brevi cali notturni sono comuni.",
"bodyTemperatureTitle": "Temperatura corporea",
"bodyTemperatureBody": "La temperatura corporea riflette il tuo stato termico centrale, normalmente intorno ai 37 °C con piccole oscillazioni giornaliere. Un aumento prolungato può indicare un'infezione, mentre le misurazioni notturne al polso seguono soprattutto variazioni relative, non un valore clinico.",
"activeEnergyTitle": "Energia attiva",
"activeEnergyBody": "L'energia attiva sono le calorie che bruci con il movimento, oltre al consumo di base a riposo. È una misura diretta di quanto è stata fisicamente attiva la tua giornata.",
"workoutsTitle": "Allenamenti",
"workoutsBody": "Gli allenamenti sono le sessioni di esercizio strutturato che i tuoi dispositivi registrano, con tipo, durata ed energia. Tracciarli mostra come l'allenamento regolare si accumula nel corso delle settimane e come si collega agli altri segnali di salute."
}
},
"sleep": {
"title": "Sonno",
+36 -1
View File
@@ -1800,7 +1800,42 @@
"medikamenteEmptyDescription": "Insights przestrzegania wymagają przynajmniej jednego zarejestrowanego leku.",
"medikamenteEmptyAction": "Otwórz leki",
"therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.",
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną."
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
"target": {
"range": "Cel: {min}–{max} {unit}",
"bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}",
"inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu",
"adjustLink": "Dostosuj zakres docelowy"
},
"explainer": {
"trigger": "Czym jest {title}?",
"blutdruckTitle": "Ciśnienie krwi",
"blutdruckBody": "Ciśnienie krwi to siła, z jaką krew napiera na ściany tętnic, zapisywana jako skurczowe do rozkurczowego (szczyt podczas uderzenia serca do wartości spoczynkowej między uderzeniami). Stale wysokie wartości z czasem obciążają serce i naczynia.",
"pulsTitle": "Tętno",
"pulsBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili — w spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.",
"gewichtTitle": "Waga",
"gewichtBody": "Masa ciała to jeden z najprostszych sygnałów długoterminowego bilansu energetycznego i ogólnego zdrowia. Trend na przestrzeni tygodni liczy się znacznie bardziej niż pojedynczy odczyt, który zmienia się wraz z nawodnieniem, jedzeniem i porą dnia.",
"bmiTitle": "Wskaźnik masy ciała (BMI)",
"bmiBody": "BMI to waga podzielona przez wzrost do kwadratu — jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.",
"schlafTitle": "Sen",
"schlafBody": "Długość snu to czas, który faktycznie przespałeś w nocy, podzielony na fazy lekką, głęboką i REM. Większość dorosłych potrzebuje około siedmiu do dziewięciu godzin; stała ilość i jakość wspierają regenerację, nastrój i metabolizm.",
"stimmungTitle": "Nastrój",
"stimmungBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce — jak sen, aktywność czy leki łączą się z samopoczuciem — których pojedynczy dzień nigdy nie pokaże.",
"medikamenteTitle": "Przestrzeganie zaleceń lekowych",
"medikamenteBody": "Przestrzeganie zaleceń to rzetelność przyjmowania leków zgodnie z zaleceniami, pokazana tu jako odsetek oczekiwanych dawek, które zarejestrowałeś. Regularne przyjmowanie często decyduje o tym, czy terapia działa zgodnie z zamierzeniem.",
"restingHrTitle": "Tętno spoczynkowe",
"restingHrBody": "Tętno spoczynkowe to częstość uderzeń serca w pełnym spoczynku, zwykle mierzona w nocy. Niższe tętno zwykle wskazuje na lepszą wydolność krążeniową, a rosnący trend może być wczesnym sygnałem stresu lub choroby.",
"hrvTitle": "Zmienność rytmu serca (HRV)",
"hrvBody": "HRV to drobna zmienność czasu między kolejnymi uderzeniami serca. Wyższa zmienność zwykle odzwierciedla dobrze zregenerowany, elastyczny układ nerwowy, a utrzymujący się spadek może sygnalizować zmęczenie, stres lub niewystarczającą regenerację.",
"oxygenSaturationTitle": "Saturacja tlenem (SpO₂)",
"oxygenSaturationBody": "SpO₂ to odsetek czerwonych krwinek przenoszących tlen. Wartości 95–100% są normalne w spoczynku; wartości utrzymujące się poniżej 90% wymagają uwagi, choć krótkie nocne spadki są częste.",
"bodyTemperatureTitle": "Temperatura ciała",
"bodyTemperatureBody": "Temperatura ciała odzwierciedla stan cieplny rdzenia, zwykle około 37 °C z niewielkimi dobowymi wahaniami. Utrzymujący się wzrost może wskazywać na infekcję, podczas gdy nocne pomiary na nadgarstku śledzą głównie zmiany względne, a nie wartość kliniczną.",
"activeEnergyTitle": "Energia aktywna",
"activeEnergyBody": "Energia aktywna to kalorie spalane przez ruch, ponad podstawowy wydatek w spoczynku. To bezpośrednia miara tego, jak aktywny fizycznie był Twój dzień.",
"workoutsTitle": "Treningi",
"workoutsBody": "Treningi to ustrukturyzowane sesje ćwiczeń rejestrowane przez Twoje urządzenia, wraz z typem, czasem trwania i energią. Ich śledzenie pokazuje, jak regularny trening kumuluje się przez tygodnie i jak wiąże się z innymi sygnałami zdrowotnymi."
}
},
"sleep": {
"title": "Sen",
+1
View File
@@ -20,6 +20,7 @@ export default function InsightsActiveEnergyPage() {
insightMetric="ACTIVE_ENERGY_BURNED"
chartKey="activeEnergy"
i18nPrefix="insights.activeEnergy"
explainerMetric="activeEnergy"
color="#ffb86c"
unit="kcal"
yAxisUnit="kcal"
+5
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
@@ -51,6 +52,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="blutdruck"
>
<MetricEmptyState
icon={<HeartPulse className="size-6" />}
@@ -98,6 +100,7 @@ export default function InsightsBlutdruckPage() {
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
explainerMetric="blutdruck"
>
<HealthChartDynamic
chartKey="bp"
@@ -111,6 +114,8 @@ export default function InsightsBlutdruckPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="blutdruck" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
+6
View File
@@ -14,6 +14,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -58,6 +59,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<MetricEmptyState
icon={<Ruler className="size-6" />}
@@ -81,6 +83,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<EmptyState
icon={<Ruler className="size-6" />}
@@ -106,6 +109,7 @@ export default function InsightsBmiPage() {
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
explainerMetric="bmi"
>
<HealthChartDynamic
chartKey="bmi"
@@ -119,6 +123,8 @@ export default function InsightsBmiPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="bmi" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
+5
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
@@ -43,6 +44,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="gewicht"
>
<MetricEmptyState
icon={<Scale className="size-6" />}
@@ -72,6 +74,7 @@ export default function InsightsGewichtPage() {
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="gewicht"
>
<HealthChartDynamic
chartKey="weight"
@@ -84,6 +87,8 @@ export default function InsightsGewichtPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="gewicht" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
+1
View File
@@ -20,6 +20,7 @@ export default function InsightsHrvPage() {
insightMetric="HEART_RATE_VARIABILITY"
chartKey="hrv"
i18nPrefix="insights.hrv"
explainerMetric="hrv"
color="#bd93f9"
unit="ms"
yAxisUnit="ms"
@@ -19,6 +19,7 @@ export default function InsightsBodyTemperaturePage() {
insightMetric="BODY_TEMPERATURE"
chartKey="bodyTemperature"
i18nPrefix="insights.bodyTemperature"
explainerMetric="bodyTemperature"
color="#ff79c6"
unit="°C"
yAxisUnit="°C"
+12 -2
View File
@@ -15,6 +15,7 @@ import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
@@ -111,7 +112,10 @@ export default function InsightsMedikamentePage() {
if (isComprehensiveLoading) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medikamente"
>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
@@ -124,7 +128,10 @@ export default function InsightsMedikamentePage() {
// gate reads `medications.length > 0`. CTA targets `/medications`
// (the dedicated medication-management surface).
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<SubPageShell
title={t("insights.medicationCompliance")}
explainerMetric="medikamente"
>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.emptyState.medication.title")}
@@ -149,6 +156,7 @@ export default function InsightsMedikamentePage() {
<SubPageShell
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
explainerMetric="medikamente"
>
<div
className={
@@ -235,6 +243,8 @@ export default function InsightsMedikamentePage() {
})}
</div>
<MetricTargetSummary slug="medikamente" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
+5
View File
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import {
@@ -61,6 +62,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="puls"
>
<MetricEmptyState
icon={<Heart className="size-6" />}
@@ -111,6 +113,7 @@ export default function InsightsPulsPage() {
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="puls"
>
<HealthChartDynamic
chartKey="pulse"
@@ -123,6 +126,8 @@ export default function InsightsPulsPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="puls" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
+1
View File
@@ -20,6 +20,7 @@ export default function InsightsRestingHrPage() {
insightMetric="RESTING_HEART_RATE"
chartKey="restingHr"
i18nPrefix="insights.restingHr"
explainerMetric="restingHr"
color="#ff5555"
unit="bpm"
yAxisUnit="bpm"
+1
View File
@@ -21,6 +21,7 @@ export default function InsightsOxygenSaturationPage() {
insightMetric="OXYGEN_SATURATION"
chartKey="oxygenSaturation"
i18nPrefix="insights.oxygenSaturation"
explainerMetric="oxygenSaturation"
color="#8be9fd"
unit="%"
yAxisUnit="%"
+5
View File
@@ -8,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Button } from "@/components/ui/button";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
@@ -37,6 +38,7 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="schlaf"
>
<MetricEmptyState
icon={<Moon className="size-6" />}
@@ -59,9 +61,12 @@ export default function InsightsSchlafPage() {
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
explainerMetric="schlaf"
>
<SleepOverview />
<MetricTargetSummary slug="schlaf" />
{/*
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
mount `<InsightStatusCard>` underneath the chart so the user
+5
View File
@@ -15,6 +15,7 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -75,6 +76,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="stimmung"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
@@ -95,6 +97,7 @@ export default function InsightsStimmungPage() {
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="stimmung"
>
<MoodChart
chartKey="mood"
@@ -102,6 +105,8 @@ export default function InsightsStimmungPage() {
userTimezone={user?.timezone}
/>
<MetricTargetSummary slug="stimmung" />
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
+1
View File
@@ -35,6 +35,7 @@ export default function InsightsWorkoutsPage() {
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
explainerMetric="workouts"
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { MetricExplainer } from "../metric-explainer";
/**
* v1.8.0 — `<MetricExplainer>` unit tests.
*
* The explainer is the `?` glyph next to a metric sub-page heading that
* opens a static "What is X?" read on tap / Enter / Space. Tests run
* through SSR so they don't depend on a browser surface: the trigger
* button is always present, the popover / sheet body mounts only after
* the user activates it. The load-bearing constraints pinned here are
* the a11y contract (real button, labelled, disclosure semantics, 44 px
* hit surface) and that every locale ships the title + body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Probe that resolves the explainer keys through the same `t()` calls
* the component uses, so a dropped locale key fails here at the call
* site instead of silently rendering the literal key path on the page.
*/
function KeyProbe({ metric }: { metric: string }) {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-title">
{t(`insights.subPage.explainer.${metric}Title`)}
</span>
<span data-slot="probe-body">
{t(`insights.subPage.explainer.${metric}Body`)}
</span>
</div>
);
}
const METRICS = [
"blutdruck",
"puls",
"gewicht",
"bmi",
"schlaf",
"stimmung",
"medikamente",
"restingHr",
"hrv",
"oxygenSaturation",
"bodyTemperature",
"activeEnergy",
"workouts",
] as const;
describe("<MetricExplainer>", () => {
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
const html = ssr(<MetricExplainer metric="blutdruck" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 14 px but the click surface inflates to 44 px via
// `min-h-11 min-w-11`; the surrounding heading row keeps its stride
// because `-my-3 -mx-2` swallows the extra reach. Pinned so a future
// refactor can't collapse the hit surface back to the visual chip.
const html = ssr(<MetricExplainer metric="puls" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads disclosure semantics onto the trigger button", () => {
const html = ssr(<MetricExplainer metric="hrv" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default: the body lives in a portal that mounts after
// the user activates the trigger. Pinning the negative keeps a
// future "open on render" refactor from leaking copy into the
// static markup.
const html = ssr(<MetricExplainer metric="gewicht" />);
expect(html).not.toContain('data-slot="metric-explainer-body"');
expect(html).not.toContain('data-slot="metric-explainer-title"');
});
it("localises the trigger label in German", () => {
const html = ssr(<MetricExplainer metric="schlaf" />, "de");
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"');
});
it("ships the title + body copy for every category metric in every locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const metric of METRICS) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<KeyProbe metric={metric} />
</I18nProvider>,
);
// A missing key surfaces as the literal `insights.subPage…`
// path, which never contains a space.
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
}
}
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<MetricTargetSummary>` unit tests.
*
* The card surfaces the numeric target range + the in-target share on
* each insights category page, reading the same `/api/insights/targets`
* payload that powers `/targets`. The component depends on `useAuth`
* (gate) and TanStack Query (`useQuery`) for the payload, so both are
* mocked here and the assertions run through SSR — the load-bearing
* behaviour is the range/share rendering + the slug→type mapping, not
* the live fetch.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
interface TargetsFixture {
targets: Array<{
type: string;
current: number | null;
unit: string;
range: { min: number; max: number } | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}>;
bpDiastolic: { current: number | null; range: { min: number; max: number } | null };
}
function renderWith(slug: string, data: TargetsFixture | undefined) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug={slug} />
</I18nProvider>,
);
}
const WEIGHT_DATA: TargetsFixture = {
targets: [
{
type: "WEIGHT",
current: 72,
unit: "kg",
range: { min: 60.1, max: 80.9 },
daysInRange30d: 18,
daysLogged30d: 24,
insufficientData: false,
},
],
bpDiastolic: { current: null, range: null },
};
const BP_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_PRESSURE",
current: 124,
unit: "mmHg",
range: { min: 120, max: 129 },
daysInRange30d: 10,
daysLogged30d: 20,
insufficientData: false,
},
],
bpDiastolic: { current: 78, range: { min: 70, max: 79 } },
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
const html = renderWith("gewicht", WEIGHT_DATA);
// Trailing `.0` trimmed, single-decimal kept.
expect(html).toContain("Target: 60.1–80.9 kg");
// round(18/24*100) = 75
expect(html).toContain("75% of logged days within target");
});
it("stitches the systolic + diastolic bands for blood pressure", () => {
const html = renderWith("blutdruck", BP_DATA);
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
expect(html).toContain("50% of logged days within target");
});
it("renders the adjust-target link to /targets", () => {
const html = renderWith("gewicht", WEIGHT_DATA);
expect(html).toContain('href="/targets"');
expect(html).toContain("Adjust target range");
});
it("suppresses the share when the route flags insufficient data", () => {
const html = renderWith("gewicht", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }],
});
expect(html).toContain("Target: 60.1–80.9 kg");
expect(html).not.toContain("of logged days within target");
});
it("renders nothing for a slug without a numeric target", () => {
const html = renderWith("hrv", undefined);
expect(html).toBe("");
});
it("renders nothing before the payload resolves", () => {
const html = renderWith("gewicht", undefined);
expect(html).toBe("");
});
it("renders nothing when the matching target has no range", () => {
const html = renderWith("gewicht", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], range: null }],
});
expect(html).toBe("");
});
});
@@ -80,6 +80,12 @@ export interface HealthKitMetricPageProps {
* (identity) so every existing page renders unchanged.
*/
valueScale?: number;
/**
* v1.8.0 — metric key threaded into `<SubPageShell explainerMetric>`
* so the `?` heading glyph opens the static "What is X?" explainer.
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
}
export function HealthKitMetricPage({
@@ -95,6 +101,7 @@ export function HealthKitMetricPage({
emptyStateIcon,
coachPrefill,
valueScale,
explainerMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -115,7 +122,11 @@ export function HealthKitMetricPage({
</Button>
) : null;
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<MetricEmptyState
icon={emptyStateIcon}
title={t(`${i18nPrefix}.emptyState.title`)}
@@ -128,7 +139,11 @@ export function HealthKitMetricPage({
}
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -0,0 +1,123 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.8.0 — per-category "What is X?" explainer.
*
* A question-mark glyph that sits next to a metric sub-page heading and,
* on tap / Enter / Space, surfaces one to two layperson sentences that
* define the metric. The copy is static — there is no network call — so
* the explainer paints instantly and works offline. Each metric resolves
* its title + body off a stable i18n key pair:
*
* insights.subPage.explainer.<metric>Title
* insights.subPage.explainer.<metric>Body
*
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: a popover on
* `md+`, a bottom-sheet on phone-class viewports. Both branches share the
* same body copy so the read is identical regardless of viewport.
*
* a11y: the trigger is a real `<button>` (Enter / Space activate it
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
* it reads at the heading's height, but the hit surface lifts to the
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
* collapse the surrounding row back to its natural stride so the chip
* stays visually small while the reach is full-size.
*/
interface MetricExplainerProps {
/**
* The metric key. Drives the title + body lookup under
* `insights.subPage.explainer.<metric>{Title,Body}`.
*/
metric: string;
/** Optional className forwarded to the trigger button. */
className?: string;
}
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const bodyId = useId();
const title = t(`insights.subPage.explainer.${metric}Title`);
const body = t(`insights.subPage.explainer.${metric}Body`);
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
const trigger = (
<button
type="button"
data-slot="metric-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={bodyId}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={() => setOpen(true)}
>
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="metric-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="metric-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-[11px] leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,172 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
* `/targets` editing surface — this component reads the shared
* `queryKeys.insightsTargets()` cache, so on a session that has already
* visited `/targets` the read is free, and on a cold sub-page it warms a
* cache the `/targets` page then reuses. We never recompute the ranges
* here: the route is the single source of truth for the age-based ESH
* blood-pressure band, the WHO weight / BMI band, the Karvonen pulse
* band, the AASM sleep band, and the medication / mood / step targets.
*
* Each insights slug maps to one target `type`. Blood pressure is the one
* composite case: the route emits the systolic band on the
* `BLOOD_PRESSURE` target and the diastolic band on the top-level
* `bpDiastolic` slot, so we stitch them back into a `S/D` range string.
*
* The "in target" share reads `daysInRange30d / daysLogged30d` — the
* fraction of logged days over the last 30 whose mean reading landed in
* the green band. We hide the share (and the range) entirely when the
* route flags `insufficientData`, matching the `/targets` consistency
* strip so the two surfaces never disagree.
*
* A discreet "Adjust target range" link routes to `/targets`, which
* stays the canonical place to view / tune the bands.
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
current: number | null;
unit: string;
range: TargetRange | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}
interface TargetsResponse {
targets: TargetItem[];
bpDiastolic: {
current: number | null;
range: TargetRange | null;
};
}
/**
* Map each insights category slug to the target `type` the route emits.
* Slugs without a numeric target (HRV, oxygen saturation, body
* temperature, active energy, workouts, resting HR) are absent — the
* component renders nothing for them, so the sub-page collapses cleanly.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
blutdruck: "BLOOD_PRESSURE",
gewicht: "WEIGHT",
bmi: "BMI",
puls: "PULSE",
schlaf: "SLEEP_DURATION",
stimmung: "MOOD_SCORE",
medikamente: "MEDICATION_COMPLIANCE",
};
interface MetricTargetSummaryProps {
/** Insights category slug, e.g. `"blutdruck"`. */
slug: string;
}
/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */
function fmt(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const targetType = SLUG_TO_TARGET_TYPE[slug];
const { data } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// No numeric target for this slug, or the payload hasn't resolved yet.
if (!targetType || !data) return null;
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
const { range, unit } = target;
// Compose the range string. Blood pressure stitches the systolic band
// (on this target) with the diastolic band (on `bpDiastolic`) so the
// user reads the familiar `S/D` pair.
let rangeLabel: string;
if (targetType === "BLOOD_PRESSURE" && data.bpDiastolic.range) {
const dia = data.bpDiastolic.range;
rangeLabel = t("insights.subPage.target.bpRange", {
sysMin: fmt(range.min),
sysMax: fmt(range.max),
diaMin: fmt(dia.min),
diaMax: fmt(dia.max),
unit,
});
} else {
rangeLabel = t("insights.subPage.target.range", {
min: fmt(range.min),
max: fmt(range.max),
unit,
});
}
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page mirrors the `/targets` strip exactly.
const showShare =
!target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
) : null}
</div>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</CardContent>
</Card>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
@@ -34,6 +35,14 @@ export interface SubPageShellProps {
* page does not pass it; sub-pages use it to anchor the surface.
*/
description?: string;
/**
* v1.8.0 — when set, a `?` glyph next to the heading opens a static
* "What is X?" explainer (popover on desktop, bottom-sheet on phones).
* The value is the metric key feeding
* `insights.subPage.explainer.<metric>{Title,Body}`. Omitted on the
* mother page; every routed metric sub-page passes its key.
*/
explainerMetric?: string;
/**
* v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount.
*
@@ -56,6 +65,7 @@ export function SubPageShell({
title,
badge,
description,
explainerMetric,
focusOnMount = false,
children,
}: SubPageShellProps) {
@@ -99,6 +109,9 @@ export function SubPageShell({
>
{title}
</h1>
{explainerMetric ? (
<MetricExplainer metric={explainerMetric} />
) : null}
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}