mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'fix/v1475-speed' into release/v1.47.5
# Conflicts: # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
b5e118d086
88 files changed
+1475
-337
No files matched your search
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine HRV-Werte",
|
||||
"description": "HRV-Werte erscheinen hier, sobald die Apple Watch (oder ein kompatibles Withings-Gerät) nächtliche Messungen synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine HRV-Daten erfasst. Was sagt mir die Herzfrequenzvariabilität, und wie erfasse ich sie?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Ruhepuls",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine SpO₂-Werte",
|
||||
"description": "SpO₂-Werte erscheinen, sobald ein kompatibles Wearable (Apple Watch, Withings ScanWatch) eine Messung erfasst."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Sauerstoffsättigung erfasst. Was sagt mir die SpO₂, und welchen Bereich sollte ich anstreben?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Körpertemperatur",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Noch keine Körpertemperatur",
|
||||
"description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren. Der Verlauf erscheint dann hier.",
|
||||
"cta": "Körpertemperatur erfassen"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Körpertemperatur erfasst. Was ist der gesunde Bereich, und was sollte ich tun, wenn sie abweicht?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Schritte",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Schrittdaten",
|
||||
"description": "Tägliche Schritt-Summen erscheinen hier, sobald Apple Health oder ein Aktivitätsgerät einen Tagesumfang synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Aktive Energie",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Aktive-Energie-Werte",
|
||||
"description": "Aktive-Energie-Summen erscheinen hier, sobald Apple Health oder ein Withings-Aktivitätsgerät einen Tagesumfang synchronisiert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine aktive Energie erfasst. Was ist ein sinnvolles Tagesziel, und wie verhält es sich zum gesamten Kalorienverbrauch?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Diese Messgröße konnte gerade nicht geladen werden.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights",
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts."
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.",
|
||||
"emptyCoachPrefill": "{metric}: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "Schritte",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Noch keine Blutdruckwerte",
|
||||
"description": "Sobald du Blutdruck loggst, erscheinen hier Trend, Zielbereichs-Anteil und WHO-Klassifizierung.",
|
||||
"cta": "Blutdruck loggen"
|
||||
"cta": "Blutdruck loggen",
|
||||
"coachPrefill": "Ich habe noch keinen Blutdruck erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Noch keine Gewichtswerte",
|
||||
"description": "Logge ein Gewicht, und Verlauf, gleitender Durchschnitt und Zielbereich erscheinen automatisch.",
|
||||
"cta": "Gewicht loggen"
|
||||
"cta": "Gewicht loggen",
|
||||
"coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist es wichtig, und was sollte ich wissen, bevor ich mit dem Erfassen anfange?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Noch keine Pulswerte",
|
||||
"description": "Logge einen Puls, dann erscheinen Ruhepuls, HRV und VO₂ max in dieser Ansicht.",
|
||||
"cta": "Puls loggen"
|
||||
"cta": "Puls loggen",
|
||||
"coachPrefill": "Ich habe noch keinen Ruhepuls erfasst. Warum ist er wichtig, und was sollte ich wissen, bevor ich anfange?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI noch nicht berechnet",
|
||||
"description": "Sobald Größe und mindestens ein Gewichtswert vorliegen, erscheint hier der BMI-Verlauf samt WHO-Zonen.",
|
||||
"cta": "Gewicht loggen"
|
||||
"cta": "Gewicht loggen",
|
||||
"coachPrefill": "Ich habe noch kein Gewicht erfasst. Warum ist der BMI für mich wichtig, und was sollte ich wissen, bevor ich ihn verfolge?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Noch keine Stimmungseinträge",
|
||||
"description": "Logge eine Stimmung, dann erscheinen Verlauf, Stabilitätsband und Korrelationen.",
|
||||
"cta": "Stimmung loggen"
|
||||
"cta": "Stimmung loggen",
|
||||
"coachPrefill": "Ich habe noch keine Stimmung erfasst. Warum lohnt es sich, die Stimmung zu erfassen, und wie fange ich am besten an?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Noch keine Medikamente angelegt",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Noch keine Schlafwerte",
|
||||
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
|
||||
"cta": "Quelle verbinden"
|
||||
"cta": "Quelle verbinden",
|
||||
"coachPrefill": "Ich habe noch keine Schlafdaten. Warum lohnt es sich, Schlaf zu erfassen, und was sollte ich wissen, bevor ich eine Quelle verbinde?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten, quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
"description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen."
|
||||
"description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen.",
|
||||
"coachPrefill": "Ich habe noch keine Workouts erfasst. Warum lohnt es sich, sie zu erfassen, und worauf sollte ich mich zuerst konzentrieren?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Zurück zu Workouts",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Noch keine Sturzdaten",
|
||||
"description": "Erkannte Stürze erscheinen hier, sobald Apple Health synchronisiert. Keine Stürze ist das Ziel."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ich habe noch keine Sturzdaten erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie senke ich mein Sturzrisiko?"
|
||||
},
|
||||
"navFalls": "Stürze",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "No HRV samples yet",
|
||||
"description": "HRV samples land here once Apple Watch (or a compatible Withings device) starts syncing nightly readings."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Resting heart rate",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "No SpO₂ samples yet",
|
||||
"description": "SpO₂ samples land here once a compatible wearable (Apple Watch, Withings ScanWatch) captures a reading."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Body temperature",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "No body temperature yet",
|
||||
"description": "Log a body-temperature reading or let your wearable sync one, and the trend will appear here.",
|
||||
"cta": "Log body temperature"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Steps",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "No step data yet",
|
||||
"description": "Daily step totals appear here once Apple Health or an activity device syncs a day's worth of movement."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Active energy",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "No active-energy data yet",
|
||||
"description": "Active-energy totals land here once Apple Health or a Withings activity device syncs a day's worth of movement."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Could not load this metric right now.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights",
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result."
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.",
|
||||
"emptyCoachPrefill": "{metric}: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "steps",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "No blood pressure entries yet",
|
||||
"description": "Log a blood pressure reading and the trend chart, in-range share, and WHO classification will appear here.",
|
||||
"cta": "Log blood pressure"
|
||||
"cta": "Log blood pressure",
|
||||
"coachPrefill": "I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "No weight entries yet",
|
||||
"description": "Log a weight and the trend, moving average, and target band will appear automatically.",
|
||||
"cta": "Log weight"
|
||||
"cta": "Log weight",
|
||||
"coachPrefill": "I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "No pulse entries yet",
|
||||
"description": "Log a pulse reading and resting pulse, HRV and VO₂ max will appear in this view.",
|
||||
"cta": "Log pulse"
|
||||
"cta": "Log pulse",
|
||||
"coachPrefill": "I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI not yet computed",
|
||||
"description": "Once height and at least one weight entry are present, the BMI trend with WHO zones will appear here.",
|
||||
"cta": "Log weight"
|
||||
"cta": "Log weight",
|
||||
"coachPrefill": "I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "No mood entries yet",
|
||||
"description": "Log a mood entry and the trend, stability band and correlations will appear.",
|
||||
"cta": "Log mood"
|
||||
"cta": "Log mood",
|
||||
"coachPrefill": "I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "No medications set up yet",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "No sleep entries yet",
|
||||
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
|
||||
"cta": "Connect a source"
|
||||
"cta": "Connect a source",
|
||||
"coachPrefill": "I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
"description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand."
|
||||
"description": "Workouts sync from Apple Health on your iPhone and from Withings. You can also log one by hand.",
|
||||
"coachPrefill": "I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Back to workouts",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "No fall data yet",
|
||||
"description": "Detected falls appear here once Apple Health syncs. No falls is the goal."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
|
||||
},
|
||||
"navFalls": "Falls",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay valores de VFC",
|
||||
"description": "Los valores de VFC aparecerán aquí en cuanto Apple Watch (o un dispositivo Withings compatible) sincronice las mediciones nocturnas."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado datos de VFC: ¿qué me dice la variabilidad de la frecuencia cardíaca y cómo la registro?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC en reposo",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay muestras de SpO₂",
|
||||
"description": "Las muestras de SpO₂ llegan aquí en cuanto un wearable compatible (Apple Watch, Withings ScanWatch) capture una lectura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado la saturación de oxígeno: ¿qué me dice la SpO₂ y a qué rango debería aspirar?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporal",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Aún no hay temperatura corporal",
|
||||
"description": "Registra una lectura o deja que tu wearable sincronice una, y la tendencia aparecerá aquí.",
|
||||
"cta": "Registrar temperatura corporal"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado la temperatura corporal: ¿cuál es el rango saludable y qué debo hacer si se desvía?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Pasos",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de pasos",
|
||||
"description": "Los totales diarios de pasos aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad sincronice un día completo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado pasos: ¿cuál es un objetivo diario de pasos razonable y cómo ayuda a mi salud caminar más?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energía activa",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de energía activa",
|
||||
"description": "Los totales de energía activa aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad Withings sincronice un día completo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado energía activa: ¿cuál es un objetivo diario razonable y cómo se compara con las calorías totales?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "No se pudo cargar esta métrica ahora mismo.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights",
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo."
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: aún no he registrado nada aquí. ¿Qué dice esta métrica sobre mi salud y cómo la mejoro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "pasos",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Aún no hay registros de presión arterial",
|
||||
"description": "Registra una medición de presión y aquí aparecerán la tendencia, el porcentaje en rango y la clasificación de la OMS.",
|
||||
"cta": "Registrar presión"
|
||||
"cta": "Registrar presión",
|
||||
"coachPrefill": "Aún no he registrado la presión arterial: ¿por qué importa y qué debería saber antes de empezar?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Aún no hay registros de peso",
|
||||
"description": "Registra un peso y aparecerán automáticamente la tendencia, la media móvil y la zona objetivo.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Aún no he registrado mi peso: ¿por qué importa y qué debería saber antes de empezar a registrarlo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Aún no hay registros de pulso",
|
||||
"description": "Registra un pulso y aquí aparecerán pulso en reposo, VFC y VO₂ máx.",
|
||||
"cta": "Registrar pulso"
|
||||
"cta": "Registrar pulso",
|
||||
"coachPrefill": "Aún no he registrado el pulso en reposo: ¿por qué importa y qué debería saber antes de empezar?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC aún no calculado",
|
||||
"description": "Cuando haya altura y al menos un registro de peso, aquí aparecerá la tendencia del IMC con las zonas de la OMS.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Aún no he registrado mi peso: ¿por qué me importa el IMC y qué debería saber antes de empezar a seguirlo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Aún no hay entradas de estado de ánimo",
|
||||
"description": "Registra un estado de ánimo y aparecerán la tendencia, la banda de estabilidad y las correlaciones.",
|
||||
"cta": "Registrar ánimo"
|
||||
"cta": "Registrar ánimo",
|
||||
"coachPrefill": "Aún no he registrado mi estado de ánimo: ¿por qué importa registrarlo y cómo debería empezar?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Aún no hay medicamentos configurados",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Aún no hay registros de sueño",
|
||||
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
|
||||
"cta": "Conectar una fuente"
|
||||
"cta": "Conectar una fuente",
|
||||
"coachPrefill": "Aún no tengo datos de sueño: ¿por qué importa registrar el sueño y qué debería saber antes de conectar una fuente?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entrenamientos",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano."
|
||||
"description": "Los entrenamientos llegan desde Apple Salud en el iPhone y desde Withings. También puedes registrar uno a mano.",
|
||||
"coachPrefill": "Aún no he registrado entrenamientos: ¿por qué importa registrarlos y en qué debería centrarme primero?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Volver a entrenamientos",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aún no hay datos de caídas",
|
||||
"description": "Las caídas detectadas aparecen aquí cuando Apple Health se sincroniza. Cero caídas es el objetivo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Aún no he registrado datos de caídas: ¿qué dice esta métrica sobre mi salud y cómo reduzco mi riesgo de caídas?"
|
||||
},
|
||||
"navFalls": "Caídas",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune mesure de VFC",
|
||||
"description": "Les mesures de VFC apparaîtront ici dès que l'Apple Watch (ou un appareil Withings compatible) synchronisera des relevés nocturnes."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de données de VFC. Que m’apprend la variabilité de la fréquence cardiaque, et comment la mesurer ?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC au repos",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune mesure SpO₂",
|
||||
"description": "Les mesures SpO₂ apparaîtront dès qu'un wearable compatible (Apple Watch, Withings ScanWatch) en capturera une."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de saturation en oxygène. Que m’apprend la SpO₂, et quelle plage viser ?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Température corporelle",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Aucune température corporelle",
|
||||
"description": "Enregistrez une mesure ou laissez votre wearable en synchroniser une, et la tendance apparaîtra ici.",
|
||||
"cta": "Enregistrer la température"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de température corporelle. Quelle est la plage normale, et que faire si elle s’en écarte ?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Pas",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée de pas",
|
||||
"description": "Les totaux de pas quotidiens apparaîtront ici dès qu'Apple Santé ou un appareil d'activité synchronise une journée complète."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de pas. Quel objectif quotidien de pas est raisonnable, et en quoi marcher davantage aide-t-il ma santé ?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Énergie active",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Aucune donnée d'énergie active",
|
||||
"description": "Les totaux d'énergie active apparaîtront dès qu'Apple Santé ou un appareil Withings activité synchronise une journée complète."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’énergie active. Quel objectif quotidien est raisonnable, et comment se compare-t-il aux calories totales ?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Impossible de charger cette mesure pour le moment.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights",
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil."
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.",
|
||||
"emptyCoachPrefill": "{metric} : je n’ai encore rien enregistré ici. Que dit cette mesure sur ma santé, et comment l’améliorer ?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "pas",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Aucune mesure de tension artérielle",
|
||||
"description": "Enregistrez une mesure de tension : la tendance, la part dans la cible et la classification OMS s'afficheront ici.",
|
||||
"cta": "Enregistrer une tension"
|
||||
"cta": "Enregistrer une tension",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de tension artérielle. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Aucune mesure de poids",
|
||||
"description": "Enregistrez un poids : la tendance, la moyenne glissante et la zone cible apparaîtront automatiquement.",
|
||||
"cta": "Enregistrer un poids"
|
||||
"cta": "Enregistrer un poids",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi est-ce important, et que dois-je savoir avant de commencer le suivi ?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Aucune mesure de pouls",
|
||||
"description": "Enregistrez un pouls : pouls au repos, VFC et VO₂ max apparaîtront dans cette vue.",
|
||||
"cta": "Enregistrer un pouls"
|
||||
"cta": "Enregistrer un pouls",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de pouls au repos. Pourquoi est-ce important, et que dois-je savoir avant de commencer ?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC pas encore calculé",
|
||||
"description": "Dès qu'une taille et au moins un poids sont enregistrés, la tendance IMC avec les zones OMS s'affichera ici.",
|
||||
"cta": "Enregistrer un poids"
|
||||
"cta": "Enregistrer un poids",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de poids. Pourquoi l’IMC compte-t-il pour moi, et que dois-je savoir avant de le suivre ?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Aucune entrée d'humeur",
|
||||
"description": "Enregistrez une humeur : la tendance, la bande de stabilité et les corrélations s'afficheront.",
|
||||
"cta": "Enregistrer une humeur"
|
||||
"cta": "Enregistrer une humeur",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’humeur. Pourquoi suivre mon humeur, et comment commencer ?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Aucun médicament configuré",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Aucune donnée de sommeil",
|
||||
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
|
||||
"cta": "Connecter une source"
|
||||
"cta": "Connecter une source",
|
||||
"coachPrefill": "Je n’ai pas encore de données de sommeil. Pourquoi suivre mon sommeil, et que dois-je savoir avant de connecter une source ?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entraînements",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aucun entraînement",
|
||||
"description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main."
|
||||
"description": "Vos entraînements arrivent d'Apple Santé sur l'iPhone et de Withings. Vous pouvez aussi en saisir un à la main.",
|
||||
"coachPrefill": "Je n’ai pas encore enregistré d’entraînement. Pourquoi les suivre, et sur quoi me concentrer en premier ?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Retour aux entraînements",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Pas encore de données de chutes",
|
||||
"description": "Les chutes détectées apparaissent ici dès qu'Apple Santé se synchronise. L'objectif est zéro chute."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Je n’ai pas encore enregistré de données de chute. Que dit cette mesure sur ma santé, et comment réduire mon risque de chute ?"
|
||||
},
|
||||
"navFalls": "Chutes",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun valore HRV",
|
||||
"description": "I valori HRV appariranno qui non appena Apple Watch (o un dispositivo Withings compatibile) sincronizza le misurazioni notturne."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato dati HRV. Cosa mi dice la variabilità della frequenza cardiaca e come la rilevo?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "FC a riposo",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun campione SpO₂",
|
||||
"description": "I campioni SpO₂ appariranno qui non appena un wearable compatibile (Apple Watch, Withings ScanWatch) cattura una lettura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato la saturazione di ossigeno. Cosa mi dice la SpO₂ e a quale intervallo dovrei puntare?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporea",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Ancora nessuna temperatura corporea",
|
||||
"description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una: l'andamento apparirà qui.",
|
||||
"cta": "Registra temperatura corporea"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato la temperatura corporea. Qual è l’intervallo sano e cosa dovrei fare se si discosta?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Passi",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun dato sui passi",
|
||||
"description": "I totali giornalieri dei passi appariranno qui non appena Apple Salute o un dispositivo di attività sincronizza un'intera giornata."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato passi. Qual è un obiettivo giornaliero di passi ragionevole e in che modo camminare di più aiuta la mia salute?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia attiva",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ancora nessun dato di energia attiva",
|
||||
"description": "I totali di energia attiva appariranno qui non appena Apple Salute o un dispositivo di attività Withings sincronizza un'intera giornata."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato energia attiva. Qual è un obiettivo giornaliero ragionevole e come si confronta con le calorie totali?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Impossibile caricare questa metrica al momento.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights",
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo."
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: non ho ancora registrato nulla qui. Cosa dice questo valore sulla mia salute e come lo miglioro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "passi",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Nessuna misurazione della pressione",
|
||||
"description": "Registra una misurazione e qui appariranno l'andamento, la percentuale in target e la classificazione OMS.",
|
||||
"cta": "Registra pressione"
|
||||
"cta": "Registra pressione",
|
||||
"coachPrefill": "Non ho ancora registrato la pressione arteriosa. Perché è importante e cosa dovrei sapere prima di iniziare?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Nessuna misurazione del peso",
|
||||
"description": "Registra un peso: andamento, media mobile e fascia obiettivo appariranno automaticamente.",
|
||||
"cta": "Registra peso"
|
||||
"cta": "Registra peso",
|
||||
"coachPrefill": "Non ho ancora registrato il peso. Perché è importante e cosa dovrei sapere prima di iniziare a monitorarlo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Nessuna misurazione del polso",
|
||||
"description": "Registra una misurazione: polso a riposo, HRV e VO₂ max appariranno in questa vista.",
|
||||
"cta": "Registra polso"
|
||||
"cta": "Registra polso",
|
||||
"coachPrefill": "Non ho ancora registrato il polso a riposo. Perché è importante e cosa dovrei sapere prima di iniziare?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI non ancora calcolato",
|
||||
"description": "Una volta inseriti altezza e almeno un peso, qui apparirà l'andamento del BMI con le zone OMS.",
|
||||
"cta": "Registra peso"
|
||||
"cta": "Registra peso",
|
||||
"coachPrefill": "Non ho ancora registrato il peso. Perché il BMI conta per me e cosa dovrei sapere prima di iniziare a monitorarlo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nessuna voce d'umore",
|
||||
"description": "Registra un umore: appariranno andamento, fascia di stabilità e correlazioni.",
|
||||
"cta": "Registra umore"
|
||||
"cta": "Registra umore",
|
||||
"coachPrefill": "Non ho ancora registrato l’umore. Perché conta monitorarlo e come dovrei iniziare?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Nessun farmaco configurato",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Nessuna voce di sonno",
|
||||
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
|
||||
"cta": "Collega una fonte"
|
||||
"cta": "Collega una fonte",
|
||||
"coachPrefill": "Non ho ancora dati sul sonno. Perché conta monitorare il sonno e cosa dovrei sapere prima di collegare una fonte?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Allenamenti",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
"description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano."
|
||||
"description": "Gli allenamenti arrivano da Salute di Apple sull'iPhone e da Withings. Puoi anche registrarne uno a mano.",
|
||||
"coachPrefill": "Non ho ancora registrato allenamenti. Perché conta monitorarli e su cosa dovrei concentrarmi per primo?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Torna agli allenamenti",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nessun dato sulle cadute",
|
||||
"description": "Le cadute rilevate compaiono qui quando Apple Health si sincronizza. Nessuna caduta è l'obiettivo."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Non ho ancora registrato dati sulle cadute. Cosa dice questo valore sulla mia salute e come riduco il rischio di cadute?"
|
||||
},
|
||||
"navFalls": "Cadute",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 심박변이도 기록이 없어요",
|
||||
"description": "Apple Watch(또는 호환 Withings 기기)가 밤마다 측정값을 동기화하기 시작하면 심박변이도 기록이 여기에 쌓여요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 심박변이도 데이터를 기록한 적이 없어요. 심박변이도는 무엇을 알려주고, 어떻게 측정하나요?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "안정 시 심박수",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 SpO₂ 기록이 없어요",
|
||||
"description": "호환 웨어러블(Apple Watch, Withings ScanWatch)이 측정하면 SpO₂ 기록이 여기에 쌓여요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 산소포화도를 기록한 적이 없어요. SpO₂는 무엇을 알려주고, 어느 범위를 목표로 해야 하나요?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "체온",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "아직 체온 기록이 없어요",
|
||||
"description": "체온을 기록하거나 웨어러블이 동기화하면 여기에 추이가 나타나요.",
|
||||
"cta": "체온 기록"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 체온을 기록한 적이 없어요. 건강한 범위는 어느 정도이고, 벗어나면 어떻게 해야 하나요?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "걸음 수",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 걸음 수 데이터가 없어요",
|
||||
"description": "Apple Health나 활동 기기가 하루치 움직임을 동기화하면 여기에 하루 걸음 수가 나타나요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 걸음 수를 기록한 적이 없어요. 적당한 하루 걸음 목표는 얼마이고, 더 많이 걸으면 건강에 어떻게 도움이 되나요?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "활동 에너지",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 활동 에너지 데이터가 없어요",
|
||||
"description": "Apple Health나 Withings 활동 기기가 하루치 움직임을 동기화하면 여기에 활동 에너지 합계가 나타나요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 활동 에너지를 기록한 적이 없어요. 적당한 하루 목표는 얼마이고, 총 칼로리와는 어떻게 비교되나요?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "지금은 이 지표를 불러오지 못했어요.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "인사이트로 돌아가기",
|
||||
"valuesBackToMetric": "돌아가기: {metric}",
|
||||
"scoresBack": "인사이트로 돌아가기",
|
||||
"ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요."
|
||||
"ecgDescription": "기기에서 기록한 단일 유도 ECG로, 각각 기기가 낸 결과가 함께 있어요.",
|
||||
"emptyCoachPrefill": "{metric}: 아직 여기에 기록한 게 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 어떻게 개선하나요?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "걸음",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "아직 혈압 기록이 없어요",
|
||||
"description": "혈압을 기록하면 추이 차트, 정상 범위 비율, WHO 분류가 여기에 나타나요.",
|
||||
"cta": "혈압 기록"
|
||||
"cta": "혈압 기록",
|
||||
"coachPrefill": "아직 혈압을 기록한 적이 없어요. 혈압이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "아직 체중 기록이 없어요",
|
||||
"description": "체중을 기록하면 추이, 이동 평균, 목표 구간이 자동으로 나타나요.",
|
||||
"cta": "체중 기록"
|
||||
"cta": "체중 기록",
|
||||
"coachPrefill": "아직 체중을 기록한 적이 없어요. 체중이 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "아직 맥박 기록이 없어요",
|
||||
"description": "맥박을 기록하면 안정 시 맥박, 심박변이도, VO₂ max가 이 화면에 나타나요.",
|
||||
"cta": "맥박 기록"
|
||||
"cta": "맥박 기록",
|
||||
"coachPrefill": "아직 안정 시 맥박을 기록한 적이 없어요. 맥박이 왜 중요하고, 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "아직 BMI를 계산하지 못했어요",
|
||||
"description": "키와 체중 기록이 하나 이상 있으면 WHO 구간과 함께 BMI 추이가 여기에 나타나요.",
|
||||
"cta": "체중 기록"
|
||||
"cta": "체중 기록",
|
||||
"coachPrefill": "아직 체중을 기록한 적이 없어요. BMI가 내게 왜 중요하고, 기록을 시작하기 전에 무엇을 알아야 하나요?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "아직 기분 기록이 없어요",
|
||||
"description": "기분을 기록하면 추이, 안정성 구간, 상관관계가 나타나요.",
|
||||
"cta": "기분 기록"
|
||||
"cta": "기분 기록",
|
||||
"coachPrefill": "아직 기분을 기록한 적이 없어요. 기분 기록이 왜 중요하고, 어떻게 시작하면 좋을까요?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "아직 등록한 약이 없어요",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "아직 수면 기록이 없어요",
|
||||
"description": "수면 데이터가 들어오면(직접 기록하거나 Withings / Apple Health를 통해) 밤별 단계 구성이 여기에 나타나요.",
|
||||
"cta": "소스 연결"
|
||||
"cta": "소스 연결",
|
||||
"coachPrefill": "아직 수면 데이터가 없어요. 수면 기록이 왜 중요하고, 데이터 소스를 연결하기 전에 무엇을 알아야 하나요?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "운동",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "최근 달리기, 자전거, 걷기, 근력 운동 세션이에요. Apple Watch와 Withings 소스 사이 중복은 정리했어요.",
|
||||
"emptyState": {
|
||||
"title": "아직 운동 기록이 없어요",
|
||||
"description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요."
|
||||
"description": "운동은 iPhone의 Apple Health와 Withings에서 동기화돼요. 직접 기록할 수도 있어요.",
|
||||
"coachPrefill": "아직 운동을 기록한 적이 없어요. 운동 기록이 왜 중요하고, 무엇부터 신경 써야 하나요?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "운동으로 돌아가기",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "아직 낙상 데이터가 없어요",
|
||||
"description": "Apple Health가 동기화하면 감지된 낙상이 여기에 표시돼요. 낙상이 없는 게 가장 좋아요."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "아직 낙상 데이터를 기록한 적이 없어요. 이 지표는 내 건강에 대해 무엇을 알려주고, 낙상 위험은 어떻게 줄이나요?"
|
||||
},
|
||||
"navFalls": "낙상",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen HRV-metingen",
|
||||
"description": "HRV-metingen verschijnen hier zodra Apple Watch (of een compatibel Withings-apparaat) nachtelijke metingen begint te synchroniseren."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen HRV-gegevens vastgelegd. Wat zegt hartslagvariabiliteit me, en hoe leg ik die vast?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Rusthartslag",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen SpO₂-metingen",
|
||||
"description": "SpO₂-metingen verschijnen hier zodra een compatibele wearable (Apple Watch, Withings ScanWatch) een meting vastlegt."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen zuurstofverzadiging vastgelegd. Wat zegt SpO₂ me, en naar welk bereik moet ik streven?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Lichaamstemperatuur",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Nog geen lichaamstemperatuur",
|
||||
"description": "Leg een temperatuurmeting vast of laat je wearable er een synchroniseren, dan verschijnt de trend hier.",
|
||||
"cta": "Lichaamstemperatuur vastleggen"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen lichaamstemperatuur vastgelegd. Wat is het gezonde bereik, en wat moet ik doen als die afwijkt?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Stappen",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen stappengegevens",
|
||||
"description": "Dagtotalen van je stappen verschijnen hier zodra Apple Health of een activiteitsapparaat een volledige dag beweging synchroniseert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen stappen vastgelegd. Wat is een redelijk dagelijks stappendoel, en hoe helpt meer lopen mijn gezondheid?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Actieve energie",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen gegevens over actieve energie",
|
||||
"description": "Dagtotalen van actieve energie verschijnen hier zodra Apple Health of een Withings-activiteitsapparaat een volledige dag beweging synchroniseert."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen actieve energie vastgelegd. Wat is een redelijk dagdoel, en hoe verhoudt het zich tot het totale aantal calorieën?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Deze meetwaarde kan nu niet worden geladen.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Terug naar inzichten",
|
||||
"valuesBackToMetric": "Terug naar {metric}",
|
||||
"scoresBack": "Terug naar inzichten",
|
||||
"ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat."
|
||||
"ecgDescription": "Eenkanaals-ECG-opnamen van je apparaat, elk met de eigen uitslag van het apparaat.",
|
||||
"emptyCoachPrefill": "{metric}: ik heb hier nog niets vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verbeter ik die?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "stappen",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Nog geen bloeddrukmetingen",
|
||||
"description": "Leg een bloeddrukmeting vast, dan verschijnen hier de trendgrafiek, het aandeel binnen het bereik en de WHO-classificatie.",
|
||||
"cta": "Bloeddruk vastleggen"
|
||||
"cta": "Bloeddruk vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen bloeddruk vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Nog geen gewichtsmetingen",
|
||||
"description": "Leg je gewicht vast, dan verschijnen de trend, het voortschrijdend gemiddelde en de doelzone automatisch.",
|
||||
"cta": "Gewicht vastleggen"
|
||||
"cta": "Gewicht vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is dat belangrijk, en wat moet ik weten voordat ik het ga bijhouden?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Nog geen hartslagmetingen",
|
||||
"description": "Leg een hartslagmeting vast, dan verschijnen rusthartslag, HRV en VO₂max in deze weergave.",
|
||||
"cta": "Hartslag vastleggen"
|
||||
"cta": "Hartslag vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen rustpols vastgelegd. Waarom is die belangrijk, en wat moet ik weten voordat ik begin?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI nog niet berekend",
|
||||
"description": "Zodra je lengte en minstens één gewichtsmeting bekend zijn, verschijnt hier de BMI-trend met de WHO-zones.",
|
||||
"cta": "Gewicht vastleggen"
|
||||
"cta": "Gewicht vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen gewicht vastgelegd. Waarom is BMI belangrijk voor mij, en wat moet ik weten voordat ik het ga bijhouden?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nog geen stemmingsregistraties",
|
||||
"description": "Leg je stemming vast, dan verschijnen de trend, de stabiliteitszone en de correlaties.",
|
||||
"cta": "Stemming vastleggen"
|
||||
"cta": "Stemming vastleggen",
|
||||
"coachPrefill": "Ik heb nog geen stemming vastgelegd. Waarom is stemming bijhouden belangrijk, en hoe begin ik?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Nog geen medicatie ingesteld",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Nog geen slaapregistraties",
|
||||
"description": "Zodra er slaapgegevens binnenkomen (handmatig of via Withings / Apple Health), verschijnt hier de verdeling over slaapfasen per nacht.",
|
||||
"cta": "Bron koppelen"
|
||||
"cta": "Bron koppelen",
|
||||
"coachPrefill": "Ik heb nog geen slaapgegevens. Waarom is slaap bijhouden belangrijk, en wat moet ik weten voordat ik een bron koppel?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Recente hardloop-, fiets-, wandel- en krachttrainingen, ontdubbeld over bronnen van Apple Watch en Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nog geen workouts",
|
||||
"description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen."
|
||||
"description": "Workouts worden gesynchroniseerd vanuit Apple Health op je iPhone en vanuit Withings. Je kunt er ook zelf een vastleggen.",
|
||||
"coachPrefill": "Ik heb nog geen workouts vastgelegd. Waarom is bijhouden belangrijk, en waar moet ik me eerst op richten?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Terug naar workouts",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Nog geen valgegevens",
|
||||
"description": "Gedetecteerde valpartijen verschijnen hier zodra Apple Health synchroniseert. Geen valpartijen is het doel."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ik heb nog geen valgegevens vastgelegd. Wat zegt deze waarde over mijn gezondheid, en hoe verklein ik mijn valrisico?"
|
||||
},
|
||||
"navFalls": "Valpartijen",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak wartości HRV",
|
||||
"description": "Wartości HRV pojawią się tutaj, gdy Apple Watch (lub zgodne urządzenie Withings) zsynchronizuje pomiary nocne."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze danych HRV. Co mówi mi zmienność rytmu serca i jak ją rejestrować?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Tętno spoczynkowe",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak próbek SpO₂",
|
||||
"description": "Próbki SpO₂ trafią tutaj, gdy zgodne wearable (Apple Watch, Withings ScanWatch) wykona pomiar."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze saturacji tlenu. Co mówi mi SpO₂ i do jakiego zakresu powinienem dążyć?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura ciała",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Brak temperatury ciała",
|
||||
"description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden, a trend pojawi się tutaj.",
|
||||
"cta": "Zarejestruj temperaturę ciała"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze temperatury ciała. Jaki zakres jest prawidłowy i co powinienem zrobić, gdy od niego odbiega?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Kroki",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o krokach",
|
||||
"description": "Dzienne sumy kroków pojawią się tutaj, gdy Apple Zdrowie lub urządzenie aktywności zsynchronizuje pełny dzień."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze kroków. Jaki dzienny cel kroków jest rozsądny i jak więcej chodzenia pomaga mojemu zdrowiu?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia czynna",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o energii czynnej",
|
||||
"description": "Sumy energii czynnej pojawią się tutaj, gdy Apple Zdrowie lub urządzenie Withings zsynchronizuje pełny dzień."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze energii czynnej. Jaki dzienny cel jest rozsądny i jak ma się on do łącznej liczby kalorii?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Nie udało się teraz wczytać tej metryki.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights",
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia."
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.",
|
||||
"emptyCoachPrefill": "{metric}: nie zapisałem tu jeszcze niczego. Co ten wskaźnik mówi o moim zdrowiu i jak mogę go poprawić?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "kroki",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Brak pomiarów ciśnienia krwi",
|
||||
"description": "Zarejestruj pomiar ciśnienia, a pojawi się tu trend, udział w zakresie docelowym i klasyfikacja WHO.",
|
||||
"cta": "Zarejestruj ciśnienie"
|
||||
"cta": "Zarejestruj ciśnienie",
|
||||
"coachPrefill": "Nie zapisałem jeszcze ciśnienia krwi. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Brak pomiarów wagi",
|
||||
"description": "Zarejestruj wagę, a trend, średnia krocząca i zakres docelowy pojawią się automatycznie.",
|
||||
"cta": "Zarejestruj wagę"
|
||||
"cta": "Zarejestruj wagę",
|
||||
"coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego ma ona znaczenie i co powinienem wiedzieć, zanim zacznę ją śledzić?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Brak pomiarów tętna",
|
||||
"description": "Zarejestruj tętno, a w tym widoku pojawi się tętno spoczynkowe, HRV i VO₂ max.",
|
||||
"cta": "Zarejestruj tętno"
|
||||
"cta": "Zarejestruj tętno",
|
||||
"coachPrefill": "Nie zapisałem jeszcze tętna spoczynkowego. Dlaczego ma ono znaczenie i co powinienem wiedzieć, zanim zacznę?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "BMI jeszcze nie obliczone",
|
||||
"description": "Po wpisaniu wzrostu i co najmniej jednej wagi pojawi się tu trend BMI z zakresami WHO.",
|
||||
"cta": "Zarejestruj wagę"
|
||||
"cta": "Zarejestruj wagę",
|
||||
"coachPrefill": "Nie zapisałem jeszcze masy ciała. Dlaczego BMI ma dla mnie znaczenie i co powinienem wiedzieć, zanim zacznę je śledzić?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Brak wpisów nastroju",
|
||||
"description": "Zarejestruj nastrój, a pojawi się trend, pasmo stabilności i korelacje.",
|
||||
"cta": "Zarejestruj nastrój"
|
||||
"cta": "Zarejestruj nastrój",
|
||||
"coachPrefill": "Nie zapisałem jeszcze nastroju. Dlaczego warto go śledzić i jak powinienem zacząć?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Brak skonfigurowanych leków",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Brak danych o śnie",
|
||||
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
|
||||
"cta": "Połącz źródło"
|
||||
"cta": "Połącz źródło",
|
||||
"coachPrefill": "Nie mam jeszcze danych o śnie. Dlaczego warto śledzić sen i co powinienem wiedzieć, zanim podłączę źródło?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Treningi",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe, zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
"description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie."
|
||||
"description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie.",
|
||||
"coachPrefill": "Nie zapisałem jeszcze treningów. Dlaczego warto je śledzić i na czym powinienem się najpierw skupić?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Powrót do treningów",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Brak danych o upadkach",
|
||||
"description": "Wykryte upadki pojawią się tutaj, gdy Apple Health się zsynchronizuje. Celem jest brak upadków."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Nie zapisałem jeszcze danych o upadkach. Co ten wskaźnik mówi o moim zdrowiu i jak mogę zmniejszyć ryzyko upadku?"
|
||||
},
|
||||
"navFalls": "Upadki",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há amostras de VFC",
|
||||
"description": "As amostras de VFC chegam aqui assim que o Apple Watch (ou um dispositivo Withings compatível) começa a sincronizar leituras noturnas."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei dados de VFC. O que a variabilidade da frequência cardíaca me diz, e como eu a registro?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "Frequência cardíaca em repouso",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há amostras de SpO₂",
|
||||
"description": "As amostras de SpO₂ chegam aqui assim que um wearable compatível (Apple Watch, Withings ScanWatch) captura uma leitura."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei saturação de oxigênio. O que a SpO₂ me diz, e qual faixa devo buscar?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Temperatura corporal",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Ainda não há temperatura corporal",
|
||||
"description": "Registre uma leitura de temperatura corporal ou deixe seu wearable sincronizar uma, e a tendência aparecerá aqui.",
|
||||
"cta": "Registrar temperatura corporal"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei temperatura corporal. Qual é a faixa saudável, e o que devo fazer se ela se desviar?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Passos",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de passos",
|
||||
"description": "Os totais diários de passos aparecem aqui assim que o Apple Health ou um dispositivo de atividade sincroniza um dia de movimento."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei passos. Qual é uma meta diária de passos razoável, e como caminhar mais ajuda minha saúde?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Energia ativa",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de energia ativa",
|
||||
"description": "Os totais de energia ativa chegam aqui assim que o Apple Health ou um dispositivo de atividade Withings sincroniza um dia de movimento."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei energia ativa. Qual é uma meta diária razoável, e como ela se compara ao total de calorias?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Não foi possível carregar esta métrica agora.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Voltar aos insights",
|
||||
"valuesBackToMetric": "Voltar para {metric}",
|
||||
"scoresBack": "Voltar aos insights",
|
||||
"ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo."
|
||||
"ecgDescription": "Registros de ECG de uma derivação do seu dispositivo, cada um com o resultado do próprio dispositivo.",
|
||||
"emptyCoachPrefill": "{metric}: ainda não registrei nada aqui. O que essa métrica me diz sobre minha saúde, e como eu a melhoro?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "passos",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Ainda não há registros de pressão arterial",
|
||||
"description": "Registre uma leitura de pressão arterial e o gráfico de tendência, a parcela dentro da faixa e a classificação da OMS aparecerão aqui.",
|
||||
"cta": "Registrar pressão arterial"
|
||||
"cta": "Registrar pressão arterial",
|
||||
"coachPrefill": "Ainda não registrei a pressão arterial. Por que ela importa, e o que devo saber antes de começar?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Ainda não há registros de peso",
|
||||
"description": "Registre um peso e a tendência, a média móvel e a faixa-alvo aparecerão automaticamente.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Ainda não registrei meu peso. Por que ele importa, e o que devo saber antes de começar a acompanhá-lo?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Ainda não há registros de pulso",
|
||||
"description": "Registre uma leitura de pulso e o pulso em repouso, a VFC e o VO₂ máx aparecerão nesta visão.",
|
||||
"cta": "Registrar pulso"
|
||||
"cta": "Registrar pulso",
|
||||
"coachPrefill": "Ainda não registrei o pulso em repouso. Por que ele importa, e o que devo saber antes de começar?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "IMC ainda não calculado",
|
||||
"description": "Assim que a altura e pelo menos um registro de peso estiverem presentes, a tendência do IMC com as zonas da OMS aparecerá aqui.",
|
||||
"cta": "Registrar peso"
|
||||
"cta": "Registrar peso",
|
||||
"coachPrefill": "Ainda não registrei meu peso. Por que o IMC importa para mim, e o que devo saber antes de começar a acompanhá-lo?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Ainda não há registros de humor",
|
||||
"description": "Registre uma entrada de humor e a tendência, a faixa de estabilidade e as correlações aparecerão.",
|
||||
"cta": "Registrar humor"
|
||||
"cta": "Registrar humor",
|
||||
"coachPrefill": "Ainda não registrei meu humor. Por que acompanhar o humor importa, e como devo começar?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Ainda não há medicamentos configurados",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Ainda não há registros de sono",
|
||||
"description": "Assim que os dados de sono chegarem (manual ou via Withings / Apple Health), a divisão por estágio por noite aparecerá aqui.",
|
||||
"cta": "Conectar uma fonte"
|
||||
"cta": "Conectar uma fonte",
|
||||
"coachPrefill": "Ainda não tenho dados de sono. Por que acompanhar o sono importa, e o que devo saber antes de conectar uma fonte?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Treinos",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Corridas, pedaladas, caminhadas e sessões de força recentes, deduplicadas entre as fontes Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Ainda não há treinos",
|
||||
"description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente."
|
||||
"description": "Os treinos sincronizam do Apple Health no seu iPhone e da Withings. Você também pode registrar um manualmente.",
|
||||
"coachPrefill": "Ainda não registrei treinos. Por que acompanhá-los importa, e no que devo focar primeiro?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Voltar aos treinos",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Ainda não há dados de quedas",
|
||||
"description": "As quedas detectadas aparecem aqui assim que o Apple Health sincroniza. Nenhuma queda é a meta."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Ainda não registrei dados de quedas. O que essa métrica me diz sobre minha saúde, e como reduzo meu risco de queda?"
|
||||
},
|
||||
"navFalls": "Quedas",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
+28
-14
@@ -3554,7 +3554,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных HRV",
|
||||
"description": "Данные HRV появятся здесь, как только Apple Watch (или совместимое устройство Withings) начнёт синхронизировать ночные измерения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал данные HRV. Что говорит вариабельность сердечного ритма и как её измерять?"
|
||||
},
|
||||
"restingHr": {
|
||||
"title": "ЧСС в покое",
|
||||
@@ -3573,7 +3574,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных SpO₂",
|
||||
"description": "Данные SpO₂ появятся здесь, как только совместимое носимое устройство (Apple Watch, Withings ScanWatch) сделает измерение."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал насыщение кислородом. Что говорит SpO₂ и к какому диапазону стоит стремиться?"
|
||||
},
|
||||
"bodyTemperature": {
|
||||
"title": "Температура тела",
|
||||
@@ -3583,7 +3585,8 @@
|
||||
"title": "Пока нет температуры тела",
|
||||
"description": "Запишите температуру тела или дайте носимому устройству синхронизировать её, и динамика появится здесь.",
|
||||
"cta": "Записать температуру тела"
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал температуру тела. Какой диапазон считается нормальным и что делать, если она отклоняется?"
|
||||
},
|
||||
"steps": {
|
||||
"title": "Шаги",
|
||||
@@ -3592,7 +3595,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных о шагах",
|
||||
"description": "Ежедневные итоги шагов появятся здесь, как только Apple Health или устройство активности синхронизирует день движения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал шаги. Какая дневная цель по шагам разумна и как больше ходьбы помогает здоровью?"
|
||||
},
|
||||
"activeEnergy": {
|
||||
"title": "Активная энергия",
|
||||
@@ -3601,7 +3605,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных об активной энергии",
|
||||
"description": "Итоги активной энергии появятся здесь, как только Apple Health или устройство активности Withings синхронизирует день движения."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал активную энергию. Какая дневная цель разумна и как она соотносится с общим расходом калорий?"
|
||||
},
|
||||
"subPage": {
|
||||
"loadError": "Не удалось загрузить этот показатель прямо сейчас.",
|
||||
@@ -3696,7 +3701,8 @@
|
||||
"valuesBack": "Назад к аналитике",
|
||||
"valuesBackToMetric": "Назад к {metric}",
|
||||
"scoresBack": "Назад к аналитике",
|
||||
"ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства."
|
||||
"ecgDescription": "Записи ЭКГ с одним отведением с вашего устройства, каждая с собственным результатом устройства.",
|
||||
"emptyCoachPrefill": "{metric}: я ещё ничего здесь не записывал. Что этот показатель говорит о моём здоровье и как его улучшить?"
|
||||
},
|
||||
"units": {
|
||||
"steps": "шаги",
|
||||
@@ -3796,27 +3802,32 @@
|
||||
"bloodPressure": {
|
||||
"title": "Пока нет записей АД",
|
||||
"description": "Запишите измерение АД, и здесь появятся график динамики, доля значений в диапазоне и классификация ВОЗ.",
|
||||
"cta": "Записать АД"
|
||||
"cta": "Записать АД",
|
||||
"coachPrefill": "Я ещё не записывал давление. Почему оно важно и что нужно знать, прежде чем начать?"
|
||||
},
|
||||
"weight": {
|
||||
"title": "Пока нет записей веса",
|
||||
"description": "Запишите вес, и динамика, скользящее среднее и целевой диапазон появятся автоматически.",
|
||||
"cta": "Записать вес"
|
||||
"cta": "Записать вес",
|
||||
"coachPrefill": "Я ещё не записывал вес. Почему он важен и что нужно знать, прежде чем начать его отслеживать?"
|
||||
},
|
||||
"pulse": {
|
||||
"title": "Пока нет записей пульса",
|
||||
"description": "Запишите измерение пульса, и в этом виде появятся пульс в покое, HRV и VO₂ max.",
|
||||
"cta": "Записать пульс"
|
||||
"cta": "Записать пульс",
|
||||
"coachPrefill": "Я ещё не записывал пульс в покое. Почему он важен и что нужно знать, прежде чем начать?"
|
||||
},
|
||||
"bmi": {
|
||||
"title": "ИМТ ещё не рассчитан",
|
||||
"description": "Как только появятся рост и хотя бы одна запись веса, здесь появится динамика ИМТ с зонами ВОЗ.",
|
||||
"cta": "Записать вес"
|
||||
"cta": "Записать вес",
|
||||
"coachPrefill": "Я ещё не записывал вес. Почему ИМТ важен для меня и что нужно знать, прежде чем начать его отслеживать?"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Пока нет записей настроения",
|
||||
"description": "Запишите настроение, и появятся динамика, диапазон стабильности и корреляции.",
|
||||
"cta": "Записать настроение"
|
||||
"cta": "Записать настроение",
|
||||
"coachPrefill": "Я ещё не записывал настроение. Почему важно его отслеживать и с чего начать?"
|
||||
},
|
||||
"medication": {
|
||||
"title": "Пока не настроено ни одного лекарства",
|
||||
@@ -3826,7 +3837,8 @@
|
||||
"sleep": {
|
||||
"title": "Пока нет записей сна",
|
||||
"description": "Как только появятся данные о сне (вручную или через Withings / Apple Health), здесь появится разбивка по фазам за каждую ночь.",
|
||||
"cta": "Подключить источник"
|
||||
"cta": "Подключить источник",
|
||||
"coachPrefill": "У меня ещё нет данных о сне. Почему важно отслеживать сон и что нужно знать, прежде чем подключить источник?"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Тренировки",
|
||||
@@ -3836,7 +3848,8 @@
|
||||
"description": "Недавние пробежки, поездки на велосипеде, прогулки и силовые занятия, без дублей между источниками Apple Watch и Withings.",
|
||||
"emptyState": {
|
||||
"title": "Пока нет тренировок",
|
||||
"description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную."
|
||||
"description": "Тренировки синхронизируются из Apple Health на вашем iPhone и из Withings. Вы также можете записать одну вручную.",
|
||||
"coachPrefill": "Я ещё не записывал тренировки. Почему важно их отслеживать и на чём сосредоточиться в первую очередь?"
|
||||
},
|
||||
"detail": {
|
||||
"backToList": "Назад к тренировкам",
|
||||
@@ -4716,7 +4729,8 @@
|
||||
"emptyState": {
|
||||
"title": "Пока нет данных о падениях",
|
||||
"description": "Обнаруженные падения появятся здесь, когда Apple Health синхронизирует их. Цель — ноль падений."
|
||||
}
|
||||
},
|
||||
"coachPrefill": "Я ещё не записывал данные о падениях. Что этот показатель говорит о моём здоровье и как снизить риск падений?"
|
||||
},
|
||||
"navFalls": "Падения",
|
||||
"sixMinuteWalk": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 84,
|
||||
"total": 93833,
|
||||
"total": 93830,
|
||||
"entries": {
|
||||
"app/medications/page-client.tsx": 839,
|
||||
"components/charts/mood-chart.tsx": 1166,
|
||||
@@ -11,7 +11,7 @@
|
||||
"components/insights/coach-panel/use-coach.ts": 1486,
|
||||
"components/insights/derived/vitals-dashboard.tsx": 1033,
|
||||
"components/insights/health-score-card.tsx": 1023,
|
||||
"components/insights/insights-tab-strip.tsx": 1087,
|
||||
"components/insights/insights-tab-strip.tsx": 1084,
|
||||
"components/measurements/measurement-form.tsx": 815,
|
||||
"components/medications/detail/medication-detail-tabs.tsx": 1157,
|
||||
"components/medications/medication-table.tsx": 853,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 336,
|
||||
"total": 109345,
|
||||
"total": 109328,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
|
||||
@@ -27,7 +27,7 @@
|
||||
"app/api/mcp/oauth/token/route.ts :: POST": 250,
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 239,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 241,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250,
|
||||
"app/api/mental-health/assessments/route.ts :: postAssessment": 249,
|
||||
@@ -80,7 +80,7 @@
|
||||
"components/cycle/bbt-chart.tsx :: BbtChart": 244,
|
||||
"components/cycle/cycle-calendar.tsx :: CycleCalendar": 292,
|
||||
"components/cycle/cycle-settings.tsx :: CycleSettings": 228,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 405,
|
||||
"components/cycle/cycle-view.tsx :: CycleView": 403,
|
||||
"components/cycle/log-day-sheet.tsx :: LogDaySheet": 753,
|
||||
"components/daily/today-hero.tsx :: TodayHero": 488,
|
||||
"components/dashboard/dashboard-header.tsx :: DashboardHeader": 215,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The health status card reads two engines that walk every vital over up to
|
||||
* 180 days. The answer is kept in the record's analytics cell: a second open
|
||||
* of Insights serves it, and a write that sweeps the record's cells, or a
|
||||
* module switch, computes it again.
|
||||
*/
|
||||
|
||||
const computeCoincidentDeviation = vi.fn();
|
||||
const buildExtendedChangepointSignals = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
|
||||
vi.mock("@/lib/insights/derived/coincident-deviation", () => ({
|
||||
computeCoincidentDeviation: (...args: unknown[]) =>
|
||||
computeCoincidentDeviation(...args),
|
||||
}));
|
||||
vi.mock("@/lib/insights/derived/changepoint", () => ({
|
||||
buildExtendedChangepointSignals: (...args: unknown[]) =>
|
||||
buildExtendedChangepointSignals(...args),
|
||||
}));
|
||||
vi.mock("@/lib/insights/derived", () => ({
|
||||
loadBaselineProfile: vi.fn().mockResolvedValue({ ageYears: 40 }),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
auditLog: { create: vi.fn() },
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
|
||||
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
|
||||
const call = () => GET();
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getSession>>);
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
} as unknown as Awaited<ReturnType<typeof checkAnalyticsReadRateLimit>>);
|
||||
computeCoincidentDeviation
|
||||
.mockReset()
|
||||
.mockResolvedValue({ status: "insufficient" });
|
||||
buildExtendedChangepointSignals.mockReset().mockResolvedValue([]);
|
||||
resolveModuleMap.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("GET /api/insights/health-status — the cached answer", () => {
|
||||
it("computes once for two opens", async () => {
|
||||
const first = await call();
|
||||
const second = await call();
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(1);
|
||||
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(1);
|
||||
expect((await second.json()).data).toEqual((await first.json()).data);
|
||||
});
|
||||
|
||||
it("computes again after a measurement write", async () => {
|
||||
await call();
|
||||
invalidateUserMeasurements("user-1", { evict: true });
|
||||
await call();
|
||||
|
||||
expect(computeCoincidentDeviation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("computes again when a module is switched off", async () => {
|
||||
await call();
|
||||
resolveModuleMap.mockResolvedValue({ sleep: false });
|
||||
await call();
|
||||
|
||||
expect(buildExtendedChangepointSignals).toHaveBeenCalledTimes(2);
|
||||
expect(buildExtendedChangepointSignals).toHaveBeenLastCalledWith(
|
||||
"user-1",
|
||||
expect.any(Date),
|
||||
{ sleep: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,8 +24,10 @@ import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import { buildExtendedChangepointSignals } from "@/lib/insights/derived/changepoint";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { MODULE_KEYS, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -39,36 +41,56 @@ export const GET = apiHandler(async () => {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Both engines walk every vital over up to 180 days, so the answer goes
|
||||
// through the analytics cell its sibling reads use. Measurement and mood
|
||||
// writes sweep the record's cells; the reader's day and the switched-off
|
||||
// modules are part of the key, so a new day or a module switch never serves
|
||||
// the other answer.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
|
||||
const now = new Date();
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
|
||||
const coincident = await computeCoincidentDeviation(user.id, profile, {
|
||||
tz: user.timezone,
|
||||
now,
|
||||
});
|
||||
// v1.44 — the wider scan: every vital with a stable daily level over 180
|
||||
// days, a metric whose module is off left unread.
|
||||
const shifts = await buildExtendedChangepointSignals(
|
||||
user.id,
|
||||
now,
|
||||
await resolveModuleMap(user.id),
|
||||
const day = userDayKey(now, user.timezone ?? DEFAULT_TIMEZONE);
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<HealthStatusBody>,
|
||||
`${user.id}|health-status|${day}|off:${modulesOff.join(",")}`,
|
||||
() => buildHealthStatus(user.id, user.timezone, now, modules),
|
||||
annotate,
|
||||
);
|
||||
|
||||
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
|
||||
const summary = summariseHealthStatus(vitals, shifts);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.health-status.read" },
|
||||
meta: {
|
||||
deviations: summary.deviations.length,
|
||||
shifts: summary.shifts.length,
|
||||
deviations: body.deviations.length,
|
||||
shifts: body.shifts.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
type HealthStatusBody = Awaited<ReturnType<typeof buildHealthStatus>>;
|
||||
|
||||
async function buildHealthStatus(
|
||||
userId: string,
|
||||
timezone: string | undefined,
|
||||
now: Date,
|
||||
modules: Awaited<ReturnType<typeof resolveModuleMap>>,
|
||||
) {
|
||||
const profile = await loadBaselineProfile(prisma, userId);
|
||||
const coincident = await computeCoincidentDeviation(userId, profile, {
|
||||
tz: timezone,
|
||||
now,
|
||||
});
|
||||
// v1.44 — the wider scan: every vital with a stable daily level over 180
|
||||
// days, a metric whose module is off left unread.
|
||||
const shifts = await buildExtendedChangepointSignals(userId, now, modules);
|
||||
|
||||
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
|
||||
const summary = summariseHealthStatus(vitals, shifts);
|
||||
return {
|
||||
present: summary.present,
|
||||
deviations: summary.deviations,
|
||||
shifts: summary.shifts,
|
||||
generatedAt: now.toISOString(),
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { NextRequest } from "next/server";
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -567,3 +567,74 @@ describe("GET /api/medications/[id]/dose-history — slots before creation (#102
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The history reads the window's rows, not every intake the medication ever
|
||||
* had: for a medication taken for years each open of the tab read thousands
|
||||
* of rows to show at most a year. Of the rows before the window only the
|
||||
* latest take is read, because a rolling grid re-starts from it.
|
||||
*/
|
||||
describe("GET /api/medications/[id]/dose-history — the read is bounded to the window", () => {
|
||||
const FROM = new Date("2026-06-01T00:00:00Z");
|
||||
const TO = new Date("2026-06-15T00:00:00Z");
|
||||
const windowQuery = `?from=${encodeURIComponent(FROM.toISOString())}&to=${encodeURIComponent(TO.toISOString())}`;
|
||||
|
||||
it("asks only for rows anchored or taken in the window", async () => {
|
||||
await GET(getReq(windowQuery), ROUTE_PARAMS);
|
||||
|
||||
const where = vi.mocked(prisma.medicationIntakeEvent.findMany).mock
|
||||
.calls[0][0]?.where;
|
||||
expect(where).toMatchObject({
|
||||
OR: [{ scheduledFor: { gte: FROM } }, { takenAt: { gte: FROM } }],
|
||||
});
|
||||
const before = vi.mocked(prisma.medicationIntakeEvent.findFirst).mock
|
||||
.calls[0][0];
|
||||
expect(before?.where).toMatchObject({
|
||||
skipped: false,
|
||||
scheduledFor: { lt: FROM },
|
||||
takenAt: { lt: FROM },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-starts a rolling grid from the latest take before the window", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "08:00",
|
||||
daysOfWeek: null,
|
||||
timesOfDay: ["08:00"],
|
||||
reminderGraceMinutes: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 7,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
// Taken four days before the window opens, nothing since: the next dose
|
||||
// was due inside the window and reads as missed there.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
|
||||
takenAt: new Date("2026-05-28T06:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await GET(getReq(windowQuery), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const rows = (await res.json()).data.rows as Array<{
|
||||
at: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
}>;
|
||||
|
||||
expect(rows.map((r) => [r.at.slice(0, 10), r.kind, r.status])).toEqual([
|
||||
["2026-06-04", "slot", "missed"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,61 @@ interface SerializedDoseHistoryRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The intakes a window needs: the rows anchored in it or taken in it (a late
|
||||
* take on an older slot is an intake instant of the window), and of all the
|
||||
* rows before it only the latest take, the anchor a rolling grid re-starts
|
||||
* from and the last intake when none falls in the window. Earlier takes place
|
||||
* no slot in the window. The whole history used to be read on every open, so
|
||||
* a medication taken for years read thousands of rows to show at most a year.
|
||||
*/
|
||||
async function readWindowIntakes(
|
||||
medicationId: string,
|
||||
userId: string,
|
||||
from: Date,
|
||||
) {
|
||||
const [events, takenBefore] = await Promise.all([
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ scheduledFor: { gte: from } }, { takenAt: { gte: from } }],
|
||||
},
|
||||
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
// v1.42.2 — the history labels an automatically recorded dose.
|
||||
autoRecorded: true,
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findFirst({
|
||||
where: {
|
||||
medicationId,
|
||||
userId,
|
||||
deletedAt: null,
|
||||
skipped: false,
|
||||
scheduledFor: { lt: from },
|
||||
takenAt: { lt: from },
|
||||
},
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { takenAt: true },
|
||||
}),
|
||||
]);
|
||||
return { events, anchorBefore: takenBefore?.takenAt ?? null };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -165,26 +220,7 @@ export const GET = apiHandler(
|
||||
Math.max(requestedFrom.getTime(), spanFloor.getTime()),
|
||||
);
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { medicationId: id, userId: user.id, deletedAt: null },
|
||||
orderBy: [{ scheduledFor: "desc" }, { id: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
scheduledFor: true,
|
||||
takenAt: true,
|
||||
skipped: true,
|
||||
autoMissed: true,
|
||||
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
|
||||
attributionSource: true,
|
||||
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
|
||||
doseTaken: true,
|
||||
// v1.32.8 (iOS #64) — write provenance so the client can label how
|
||||
// each dose was recorded (Web / API / Reminder / Import / AppleHealth).
|
||||
source: true,
|
||||
// v1.42.2 — the history labels an automatically recorded dose.
|
||||
autoRecorded: true,
|
||||
},
|
||||
});
|
||||
const { events, anchorBefore } = await readWindowIntakes(id, user.id, from);
|
||||
|
||||
const mapped = events.map((e) => ({
|
||||
id: e.id,
|
||||
@@ -198,7 +234,10 @@ export const GET = apiHandler(
|
||||
autoRecorded: e.autoRecorded,
|
||||
}));
|
||||
|
||||
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
|
||||
const lastIntakeAt = lastNonSkippedTakenAt([
|
||||
...mapped,
|
||||
...(anchorBefore ? [{ takenAt: anchorBefore, skipped: false }] : []),
|
||||
]);
|
||||
|
||||
// A dose may be recorded before its slot's own time: the dose window opens
|
||||
// up to two hours early, and a skip can be logged ahead. Such a row is
|
||||
@@ -263,6 +302,7 @@ export const GET = apiHandler(
|
||||
const intakeInstants = mapped
|
||||
.filter((e) => !e.skipped && e.takenAt !== null && e.takenAt <= to)
|
||||
.map((e) => e.takenAt as Date)
|
||||
.concat(anchorBefore ? [anchorBefore] : [])
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
const groups = buildBandsForSchedulesWithEras({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/active-energy`.
|
||||
@@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* imports collapse cleanly via the v1.4.30 drain script.
|
||||
*/
|
||||
export default function InsightsActiveEnergyPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="ACTIVE_ENERGY_BURNED"
|
||||
@@ -27,7 +29,7 @@ export default function InsightsActiveEnergyPage() {
|
||||
yAxisUnit="kcal"
|
||||
emptyStateIcon={<Flame className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any active energy yet. What's a reasonable daily target, and how does it compare to total calories?"
|
||||
coachPrefill={t("insights.activeEnergy.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsLaermereignissePage() {
|
||||
yAxisUnit=""
|
||||
emptyStateIcon={<Volume2 className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any loud-environment events yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,6 @@ export default function InsightsBlutzuckerPage() {
|
||||
captureType="BLOOD_GLUCOSE"
|
||||
targetSummarySlug="blood-glucose"
|
||||
afterChart={<GlucoseClinicalPanel />}
|
||||
coachPrefill="I haven't logged any blood glucose yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export default function InsightsBlutdruckPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?"
|
||||
coachPrefill={t("insights.emptyState.bloodPressure.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function InsightsBmiPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?"
|
||||
coachPrefill={t("insights.emptyState.bmi.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Thermometer } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/body-temperature`.
|
||||
@@ -13,6 +14,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* them on separate enum values per the v1.4.30 categorisation overlay.
|
||||
*/
|
||||
export default function InsightsBodyTemperaturePage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BODY_TEMPERATURE"
|
||||
@@ -24,7 +26,7 @@ export default function InsightsBodyTemperaturePage() {
|
||||
color="var(--chart-3)"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType="BODY_TEMPERATURE"
|
||||
coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?"
|
||||
coachPrefill={t("insights.bodyTemperature.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function InsightsKoerperwasserPage() {
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="TOTAL_BODY_WATER"
|
||||
coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function InsightsKnochenmassePage() {
|
||||
emptyStateIcon={<Bone className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="BONE_MASS"
|
||||
coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export default function InsightsBreathingDisturbancesPage() {
|
||||
unitKey="insights.units.breathingEvents"
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,6 @@ export default function InsightsCardioFitnessPage() {
|
||||
statIcon={Gauge}
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsCardioRecoveryPage() {
|
||||
yAxisUnit="bpm"
|
||||
emptyStateIcon={<HeartPulse className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsTageslichtPage() {
|
||||
yAxisUnit="min"
|
||||
emptyStateIcon={<Sun className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsDoppelstandphasePage() {
|
||||
yAxisUnit="%"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsLaermbelastungPage() {
|
||||
yAxisUnit="dBA"
|
||||
emptyStateIcon={<Volume2 className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { PersonStanding } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.0 — `/insights/falls`.
|
||||
@@ -11,6 +12,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* better. The series arrives from Apple Health sync — no manual-entry CTA.
|
||||
*/
|
||||
export default function InsightsFallsPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="FALL_COUNT"
|
||||
@@ -23,7 +25,7 @@ export default function InsightsFallsPage() {
|
||||
unitKey="insights.units.falls"
|
||||
emptyStateIcon={<PersonStanding className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?"
|
||||
coachPrefill={t("insights.falls.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export default function InsightsFettfreieMassePage() {
|
||||
color="var(--chart-1)"
|
||||
emptyStateIcon={<Scale className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export default function InsightsFettmassePage() {
|
||||
color="var(--chart-5)"
|
||||
emptyStateIcon={<Droplets className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function InsightsStockwerkePage() {
|
||||
unitKey="insights.units.flights"
|
||||
emptyStateIcon={<TrendingUp className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsKopfhoererpegelPage() {
|
||||
yAxisUnit="dBA"
|
||||
emptyStateIcon={<Headphones className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
|
||||
* own, labelled, because the two are not one measure.
|
||||
*/
|
||||
export default function InsightsHrvPage() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="HEART_RATE_VARIABILITY"
|
||||
@@ -37,7 +39,7 @@ export default function InsightsHrvPage() {
|
||||
yAxisUnit="ms"
|
||||
emptyStateIcon={<Activity className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?"
|
||||
coachPrefill={t("insights.hrv.coachPrefill")}
|
||||
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
|
||||
// HRV / heart context. Self-gates to nothing without recordings.
|
||||
afterAssessment={<EcgCrossLink />}
|
||||
|
||||
@@ -24,7 +24,6 @@ export default function InsightsMagermassePage() {
|
||||
color="var(--chart-1)"
|
||||
emptyStateIcon={<Scale className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Insights > Medications: the efficacy summary sits below every medication
|
||||
* card and asks one efficacy read per medication. On entry it holds its slot
|
||||
* with a sentinel and mounts only when scrolled near, so opening the page
|
||||
* sends the cards' reads and not the summary's.
|
||||
*/
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) =>
|
||||
queryKey[1] === "comprehensive"
|
||||
? {
|
||||
data: {
|
||||
medications: [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "BLOOD_PRESSURE",
|
||||
compliance7: 100,
|
||||
compliance30: 95,
|
||||
streak: 7,
|
||||
taken7: 7,
|
||||
skipped7: 0,
|
||||
missed7: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
: { data: undefined, isLoading: false, isError: false },
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: false }),
|
||||
useAiProviderState: () => ({ responseTimeoutMs: null }),
|
||||
}));
|
||||
vi.mock("@/components/insights/medication-efficacy-summary", () => ({
|
||||
MedicationEfficacySummary: () => <div data-slot="efficacy-summary-mounted" />,
|
||||
}));
|
||||
vi.mock("@/components/insights/sub-page-shell", () => ({
|
||||
SubPageShell: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/insights/glp1-substance-curve-summary", () => ({
|
||||
Glp1SubstanceCurveSummary: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/metric-target-summary", () => ({
|
||||
MetricTargetSummary: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/insight-status-card", () => ({
|
||||
InsightStatusCard: () => null,
|
||||
}));
|
||||
vi.mock("@/components/insights/coach-launch-button", () => ({
|
||||
CoachLaunchButton: () => null,
|
||||
}));
|
||||
vi.mock("@/components/charts/compliance-heatmap", () => ({
|
||||
ComplianceHeatmap: () => null,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import InsightsMedikamentePage from "../page";
|
||||
|
||||
describe("/insights/medications on entry", () => {
|
||||
it("renders the cards and holds the efficacy summary until scrolled near", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightsMedikamentePage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Ramipril");
|
||||
expect(html).toContain('data-deferred-section="medication-efficacy"');
|
||||
expect(html).not.toContain("efficacy-summary-mounted");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ 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 { MedicationEfficacySummary } from "@/components/insights/medication-efficacy-summary";
|
||||
import { DeferUntilNear } from "@/components/insights/defer-until-near";
|
||||
import { Glp1SubstanceCurveSummary } from "@/components/insights/glp1-substance-curve-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -321,12 +322,12 @@ export default function InsightsMedikamentePage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* v1.28 — compact efficacy summary: reuses the same server-computed
|
||||
efficacy DTO the detail-page "Wirkung" tab reads (never a second
|
||||
computation), association-only, linking through to the full view. */}
|
||||
<MedicationEfficacySummary
|
||||
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
|
||||
/>
|
||||
{/* v1.28 — one efficacy read per medication, below every card: mounted when near. */}
|
||||
<DeferUntilNear id="medication-efficacy">
|
||||
<MedicationEfficacySummary
|
||||
medications={medications.map((m) => ({ id: m.id, name: m.name }))}
|
||||
/>
|
||||
</DeferUntilNear>
|
||||
|
||||
{/* v1.28.20 — estimated active-substance curve for GLP-1 medications
|
||||
(additive; renders nothing when the user has no GLP-1 medication). */}
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function InsightsMoodPageClient() {
|
||||
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?"
|
||||
coachPrefill={t("insights.emptyState.mood.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ export default function InsightsMuskelmassePage() {
|
||||
color="var(--chart-1)"
|
||||
emptyStateIcon={<Dumbbell className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ValueBand } from "@/lib/analytics/value-bands";
|
||||
|
||||
@@ -37,7 +38,9 @@ function bandsRendered(thresholds: unknown): ValueBand[] {
|
||||
}
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<InsightsOxygenSaturationPage />
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightsOxygenSaturationPage />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const match = /<pre[^>]*>([^<]*)<\/pre>/.exec(html);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands";
|
||||
import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
|
||||
|
||||
@@ -20,6 +21,7 @@ import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
|
||||
* `/targets`, and the clinical bands otherwise.
|
||||
*/
|
||||
export default function InsightsOxygenSaturationPage() {
|
||||
const { t } = useTranslations();
|
||||
const ownRange = useOwnTargetRange("OXYGEN_SATURATION");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
@@ -38,7 +40,7 @@ export default function InsightsOxygenSaturationPage() {
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="OXYGEN_SATURATION"
|
||||
coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?"
|
||||
coachPrefill={t("insights.oxygenSaturation.coachPrefill")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsPulswellengeschwindigkeitPage() {
|
||||
yAxisUnit="m/s"
|
||||
emptyStateIcon={<Activity className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any pulse wave velocity yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export default function InsightsPulsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?"
|
||||
coachPrefill={t("insights.emptyState.pulse.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function InsightsAtemfrequenzPage() {
|
||||
unitKey="insights.units.respiratoryRate"
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function InsightsSixMinuteWalkPage() {
|
||||
yAxisUnit="m"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ export default function InsightsHauttemperaturPage() {
|
||||
color="var(--chart-5)"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
afterChart={
|
||||
<ConnectedDeviceScoreTile
|
||||
type="BODY_TEMPERATURE_DEVIATION"
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function InsightsSchlafPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?"
|
||||
coachPrefill={t("insights.emptyState.sleep.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function InsightsStairAscentSpeedPage() {
|
||||
yAxisUnit="m/s"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default function InsightsStairDescentSpeedPage() {
|
||||
yAxisUnit="m/s"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsSchrittlaengePage() {
|
||||
yAxisUnit="m"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function InsightsStepsPage() {
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="ACTIVITY_STEPS"
|
||||
coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?"
|
||||
coachPrefill={t("insights.steps.coachPrefill")}
|
||||
afterChart={
|
||||
calendarVisible ? (
|
||||
<CrossLinkCard
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function InsightsGefaessalterPage() {
|
||||
unitKey="insights.units.years"
|
||||
emptyStateIcon={<HeartPulse className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsViszeralfettPage() {
|
||||
yAxisUnit=""
|
||||
emptyStateIcon={<Droplets className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsGangasymmetriePage() {
|
||||
yAxisUnit="%"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export default function InsightsGehstreckePage() {
|
||||
color="var(--chart-2)"
|
||||
emptyStateIcon={<Footprints className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsGehpulsPage() {
|
||||
yAxisUnit="bpm"
|
||||
emptyStateIcon={<HeartPulse className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ export default function InsightsGehgeschwindigkeitPage() {
|
||||
color="var(--chart-2)"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export default function InsightsGangstabilitaetPage() {
|
||||
yAxisUnit="%"
|
||||
emptyStateIcon={<Gauge className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -122,7 +122,7 @@ export default function InsightsGewichtPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?"
|
||||
coachPrefill={t("insights.emptyState.weight.coachPrefill")}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function InsightsWorkoutsPageClient() {
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
coachPrefill="I haven't logged any workouts yet. Why does tracking them matter, and what should I focus on first?"
|
||||
coachPrefill={t("insights.workouts.emptyState.coachPrefill")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -24,7 +24,6 @@ export default function InsightsWristTemperaturePage() {
|
||||
color="var(--chart-5)"
|
||||
emptyStateIcon={<Thermometer className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
coachPrefill="I haven't logged any wrist-temperature data yet. What does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
}
|
||||
+16
-2
@@ -8,6 +8,7 @@ import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
|
||||
@@ -86,7 +87,12 @@ export default async function RootLayout({
|
||||
? ((await headers()).get("x-nonce") ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
// The monitoring switches ride the document render instead of a request
|
||||
// from every page load; the key-mismatch page reads no data.
|
||||
const [initialLocale, monitoring] = await Promise.all([
|
||||
resolveInitialLocale(),
|
||||
isKeyMismatch() ? null : getPublicMonitoringSettings(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
@@ -139,7 +145,15 @@ export default async function RootLayout({
|
||||
<KeyMismatchPage locale={initialLocale} />
|
||||
) : (
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<MonitoringBootstrap
|
||||
settings={
|
||||
monitoring && {
|
||||
umamiEnabled: monitoring.umamiEnabled,
|
||||
umamiWebsiteId: monitoring.umamiWebsiteId,
|
||||
glitchtipEnabled: monitoring.glitchtipEnabled,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<WebVitalsReporter />
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
block mutations. Resolve it here (server component) and
|
||||
|
||||
@@ -102,6 +102,19 @@ export default async function DashboardPage() {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
|
||||
// The mood chart's series rides the mood tile (same engine as
|
||||
// `/api/mood/analytics`); seeded with readings only, where the tile's
|
||||
// summary is the route's.
|
||||
const mood = (
|
||||
wireBody.tiles as
|
||||
{ mood?: { entries: unknown[]; summary: unknown } } | undefined
|
||||
)?.mood;
|
||||
if (mood?.summary && mood.entries.length > 0) {
|
||||
queryClient.setQueryData(queryKeys.moodAnalytics(), {
|
||||
entries: mood.entries,
|
||||
summary: mood.summary,
|
||||
});
|
||||
}
|
||||
|
||||
// Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives
|
||||
// its chart-type list from analytics queries the RSC did not run, so there
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { cachesToEvict, resolveVersionPollDecision } from "../version-poller";
|
||||
import {
|
||||
cachesToEvict,
|
||||
resolveVersionPollDecision,
|
||||
versionCheckDueOnReturn,
|
||||
} from "../version-poller";
|
||||
|
||||
describe("resolveVersionPollDecision", () => {
|
||||
it("is up-to-date when live matches the shell", () => {
|
||||
@@ -172,3 +176,20 @@ describe("cachesToEvict", () => {
|
||||
expect(cachesToEvict(keys, "1.43.0")).not.toContain("another-app-cache");
|
||||
});
|
||||
});
|
||||
|
||||
describe("versionCheckDueOnReturn", () => {
|
||||
const NOW = 1_000_000_000;
|
||||
|
||||
it("asks at once when the tab comes back after a full interval away", () => {
|
||||
expect(versionCheckDueOnReturn("visible", NOW - 60_000, NOW)).toBe(true);
|
||||
expect(versionCheckDueOnReturn("visible", 0, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not ask again on a quick switch back", () => {
|
||||
expect(versionCheckDueOnReturn("visible", NOW - 5_000, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("never asks while the tab is hidden", () => {
|
||||
expect(versionCheckDueOnReturn("hidden", 0, NOW)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiQualitySection } from "../ai-quality-section";
|
||||
import { CoachFeedbackSection } from "../coach-feedback-section";
|
||||
|
||||
function render() {
|
||||
@@ -138,3 +139,25 @@ describe("CoachFeedbackSection", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Coach admin page's two quality cards", () => {
|
||||
it("read one cache cell, so the page asks for the summary once", () => {
|
||||
useQueryMock.mockReset().mockReturnValue({
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: null,
|
||||
});
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiQualitySection />
|
||||
<CoachFeedbackSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const keys = useQueryMock.mock.calls.map(
|
||||
([options]) => (options as { queryKey: unknown }).queryKey,
|
||||
);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[1]).toEqual(keys[0]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,10 @@ import type {
|
||||
ReadinessSignal,
|
||||
RecoveryReadiness,
|
||||
} from "@/components/admin/use-recovery-readiness";
|
||||
import { READINESS_FIX_PATHS } from "@/lib/recovery/readiness-model";
|
||||
import {
|
||||
READINESS_FIX_PATHS,
|
||||
READINESS_SIGNALS,
|
||||
} from "@/lib/recovery/readiness-model";
|
||||
import { primeLocales } from "@/__tests__/helpers/prime-locales";
|
||||
|
||||
primeLocales("de");
|
||||
@@ -97,6 +100,23 @@ function row(html: string, id: string): string {
|
||||
}
|
||||
|
||||
describe("RecoveryReadinessCard", () => {
|
||||
it("holds one row per signal while it loads, so the cards below stay put", () => {
|
||||
// A read that has not answered: no data, no error.
|
||||
const client = new QueryClient();
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<RecoveryReadinessCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const start = html.indexOf('data-testid="recovery-readiness-loading"');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
expect(html.slice(start).match(/<li/g)?.length).toBe(
|
||||
READINESS_SIGNALS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every signal with its state and sentence", () => {
|
||||
pathname.current = "/admin";
|
||||
const html = render(READINESS);
|
||||
|
||||
@@ -51,7 +51,9 @@ export function CoachFeedbackSection() {
|
||||
const fmt = useFormatters();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.adminCoachFeedback(),
|
||||
// The same read as the AI quality section beside it, so one request
|
||||
// serves both cards.
|
||||
queryKey: queryKeys.adminAiQuality(),
|
||||
queryFn: async () => {
|
||||
const data = await apiGet<{ summary: FeedbackSummary | null } | null>(
|
||||
"/api/admin/ai-quality",
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
LifeBuoy,
|
||||
Loader2,
|
||||
MinusCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -34,6 +33,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InlineAction } from "@/components/ui/inline-action";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -44,6 +44,17 @@ import {
|
||||
type RecoveryReadiness,
|
||||
} from "./use-recovery-readiness";
|
||||
|
||||
/** One placeholder row per readiness signal the card always lists. */
|
||||
const READINESS_SIGNAL_SLOTS = [
|
||||
"database_dump",
|
||||
"weekly_copy",
|
||||
"offhost_copies",
|
||||
"restore_drill",
|
||||
"offhost_setup",
|
||||
"dump_retention",
|
||||
"recovery_kit",
|
||||
] as const satisfies readonly ReadinessSignal["id"][];
|
||||
|
||||
const STATE_ICON: Record<
|
||||
ReadinessState,
|
||||
React.ComponentType<{ className?: string; "aria-hidden"?: boolean }>
|
||||
@@ -194,14 +205,27 @@ export function RecoveryReadinessCard() {
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
// Holds the list's height while it loads. The card opens the backups
|
||||
// page, and a one-line spinner here pushed every card below it down
|
||||
// by the whole list once the signals landed.
|
||||
<div data-testid="recovery-readiness-loading">
|
||||
<span className="sr-only">
|
||||
{t("admin.recoveryReadiness.loading")}
|
||||
</span>
|
||||
<ul className="divide-border divide-y" aria-hidden="true">
|
||||
{READINESS_SIGNAL_SLOTS.map((slot) => (
|
||||
<li
|
||||
key={slot}
|
||||
className="flex items-start gap-3 py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Skeleton className="mt-0.5 size-4 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Skeleton className="h-5 w-40 max-w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
@@ -73,8 +73,8 @@ async function readDailySeries({
|
||||
async function fetchSleepNights() {
|
||||
const sleepParams = new URLSearchParams();
|
||||
sleepParams.set("kind", "sleep");
|
||||
// The series route caps sleep at 365 days; clamp the request so a
|
||||
// longer comparison window still resolves.
|
||||
// The series route caps `days` at 3650 (the "All" tab); clamp the
|
||||
// request so a window stretched by a comparison shift still resolves.
|
||||
sleepParams.set(
|
||||
"days",
|
||||
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { CycleCalendar } from "../cycle-calendar";
|
||||
import { CycleCalendar, CycleCalendarSkeleton } from "../cycle-calendar";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CalendarDay } from "../types";
|
||||
|
||||
@@ -242,3 +242,20 @@ describe("<CycleCalendar>", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CycleCalendarSkeleton", () => {
|
||||
// November 2026 starts on a Sunday and spans six weeks; February 2027
|
||||
// starts on a Monday and spans four.
|
||||
for (const today of ["2026-11-11", "2027-02-10"]) {
|
||||
it(`holds as many weeks as the month it stands in for (${today})`, () => {
|
||||
const calendar = render(<CycleCalendar days={[]} today={today} />);
|
||||
const skeleton = render(<CycleCalendarSkeleton today={today} />);
|
||||
|
||||
// Every row but the weekday header is one week.
|
||||
const weeks = (calendar.match(/role="row"/g)?.length ?? 0) - 1;
|
||||
const cells = skeleton.match(/aspect-square/g)?.length ?? 0;
|
||||
expect(weeks).toBeGreaterThan(3);
|
||||
expect(cells).toBe(weeks * 7);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
@@ -65,6 +66,43 @@ function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar's frame while the page's read is in flight: the month header
|
||||
* and the weeks of `today`'s month in the grid's own cell size, so the card
|
||||
* keeps the height the month takes instead of growing from a line of text.
|
||||
* The loading text stays for screen readers.
|
||||
*/
|
||||
export function CycleCalendarSkeleton({ today }: { today: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [y, m] = today.split("-").map(Number);
|
||||
const daysInMonth = new Date(y, m, 0).getDate();
|
||||
const weeks = Math.ceil(
|
||||
(mondayIndex(new Date(y, m - 1, 1)) + daysInMonth) / 7,
|
||||
);
|
||||
return (
|
||||
<div data-slot="cycle-calendar-skeleton">
|
||||
<span className="sr-only">{t("cycle.calendar.loading")}</span>
|
||||
<div aria-hidden="true" className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="size-10 rounded-md pointer-coarse:min-h-11 pointer-coarse:min-w-11" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="size-10 rounded-md pointer-coarse:min-h-11 pointer-coarse:min-w-11" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<div key={`weekday-${i}`} className="text-2xs pb-1">
|
||||
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: weeks * 7 }, (_, i) => (
|
||||
<Skeleton key={i} className="aspect-square min-h-11 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
const CELL_DATE_FORMATS = new Map<Locale, Intl.DateTimeFormat>();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
import { CycleCalendar } from "./cycle-calendar";
|
||||
import { CycleCalendar, CycleCalendarSkeleton } from "./cycle-calendar";
|
||||
import { CycleHistoryChart } from "./cycle-history-chart";
|
||||
import { LogDaySheet, sheetDayContext } from "./log-day-sheet";
|
||||
import { PhaseEducationCard } from "./phase-education-card";
|
||||
@@ -347,9 +347,7 @@ export function CycleView() {
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.calendar.loading")}
|
||||
</p>
|
||||
<CycleCalendarSkeleton today={today} />
|
||||
) : calendarError ? (
|
||||
// Inside the calendar card already — a borderless row keeps
|
||||
// it in line with the plain loading line above it (§3/§6).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { primeMessages } from "@/lib/i18n/load-locale";
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
primeMessages("de", deMessages);
|
||||
|
||||
/**
|
||||
* A metric page with no data offers the Coach a question in the reader's
|
||||
* language. The pages used to pass their own English sentence, and most of
|
||||
* them were one sentence with the metric's name swapped in; the page now asks
|
||||
* that one, translated, with its own title, and a metric with a question of
|
||||
* its own reads it from the catalog too.
|
||||
*/
|
||||
|
||||
const prefills: Array<string | null | undefined> = [];
|
||||
vi.mock("@/components/insights/metric-empty-state", () => ({
|
||||
MetricEmptyState: ({ coachPrefill }: { coachPrefill?: string | null }) => {
|
||||
prefills.push(coachPrefill);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: true, reason: null }),
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({ default: () => () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/insights/visceral-fat",
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { timezone: "UTC", dateOfBirth: null, gender: null },
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({ data: { summaries: {} }, isEmpty: true }),
|
||||
}));
|
||||
|
||||
import InsightsViszeralfettPage from "@/app/insights/visceral-fat/page";
|
||||
import InsightsStepsPage from "@/app/insights/steps/page";
|
||||
|
||||
function emptyStatePrefill(node: React.ReactNode, locale: "en" | "de") {
|
||||
prefills.length = 0;
|
||||
renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return prefills.at(-1);
|
||||
}
|
||||
|
||||
describe("the empty metric page's Coach question", () => {
|
||||
it("is the shared sentence with the page's title, in the reader's language", () => {
|
||||
expect(emptyStatePrefill(<InsightsViszeralfettPage />, "de")).toBe(
|
||||
"Viszeralfett: Ich habe hier noch nichts erfasst. Was sagt dieser Wert über meine Gesundheit aus, und wie verbessere ich ihn?",
|
||||
);
|
||||
expect(emptyStatePrefill(<InsightsViszeralfettPage />, "en")).toBe(
|
||||
"Visceral Fat: I haven't logged anything here yet. What does this metric tell me about my health, and how do I improve it?",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a metric's own question, translated", () => {
|
||||
expect(emptyStatePrefill(<InsightsStepsPage />, "de")).toBe(
|
||||
"Ich habe noch keine Schritte erfasst. Was ist ein sinnvolles tägliches Schrittziel, und wie hilft mehr Gehen meiner Gesundheit?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The layout shell wraps every Insights page. It owns the regenerate button
|
||||
* on the tab strip but shows no briefing, so it must not read the briefing
|
||||
* on entry: on a metric page that read was a request (and the schema chunk
|
||||
* that parses its answer) for a button. The button itself stays.
|
||||
*/
|
||||
|
||||
const queries: Array<{ queryKey: readonly unknown[]; enabled?: boolean }> = [];
|
||||
const stripProps: Array<{ onRegenerate?: unknown }> = [];
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: { queryKey: readonly unknown[]; enabled?: boolean }) => {
|
||||
queries.push(options);
|
||||
return { data: undefined, isLoading: false, isError: false };
|
||||
},
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
|
||||
useQueryClient: () => ({
|
||||
getQueryCache: () => ({ find: () => undefined }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true, user: { modules: {} } }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({ available: true }),
|
||||
useAiProviderState: () => ({ responseTimeoutMs: null }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-surface-visible", () => ({
|
||||
useSurfaceVisible: () => true,
|
||||
}));
|
||||
vi.mock("@/hooks/use-workouts", () => ({
|
||||
useWorkouts: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/lib/queries/use-analytics-query", () => ({
|
||||
useAnalyticsQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/hooks/use-insights-layout", () => ({
|
||||
useInsightsLayoutQuery: () => ({
|
||||
layout: { tiles: [] },
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/components/insights/use-ecg-list", () => ({
|
||||
useEcgList: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/components/insights/insights-tab-strip", () => ({
|
||||
InsightsTabStrip: (props: { onRegenerate?: unknown }) => {
|
||||
stripProps.push(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { InsightsLayoutShell } from "../insights-layout-shell";
|
||||
|
||||
describe("InsightsLayoutShell — what it reads on entry", () => {
|
||||
it("keeps the regenerate button and sends no briefing read", () => {
|
||||
renderToStaticMarkup(
|
||||
<InsightsLayoutShell>
|
||||
<div />
|
||||
</InsightsLayoutShell>,
|
||||
);
|
||||
|
||||
const advisorKey = JSON.stringify(queryKeys.insightsAdvisor());
|
||||
const advisor = queries.filter(
|
||||
(q) => JSON.stringify(q.queryKey) === advisorKey,
|
||||
);
|
||||
expect(advisor).toHaveLength(1);
|
||||
expect(advisor[0].enabled).toBe(false);
|
||||
expect(typeof stripProps[0]?.onRegenerate).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The tab strip owns the regenerate button on every Insights page but shows
|
||||
* no briefing, so it no longer reads one on entry. A regenerate from a page
|
||||
* that never read the briefing still needs the briefing it replaces as the
|
||||
* baseline a timed-out attempt settles against; without it the old briefing
|
||||
* would read as the fresh one. `ensureAdvisorBaseline` reads it first, and
|
||||
* only when the cache holds none.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ensureAdvisorBaseline } from "../use-insights-advisor";
|
||||
|
||||
const CACHED_AT = "2026-11-10T05:00:00.000Z";
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchRaw.mockReset().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: { insights: {}, cached: true, cachedAt: CACHED_AT },
|
||||
}),
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
describe("ensureAdvisorBaseline", () => {
|
||||
it("reads the briefing a regenerate replaces when nothing has read it yet", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
|
||||
expect(apiFetchRaw).toHaveBeenCalledTimes(1);
|
||||
expect(apiFetchRaw.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||
const cached = queryClient.getQueryData<{
|
||||
payload: { cachedAt: string } | null;
|
||||
}>(queryKeys.insightsAdvisor());
|
||||
expect(cached?.payload?.cachedAt).toBe(CACHED_AT);
|
||||
});
|
||||
|
||||
it("asks nothing when the overview already read it", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.insightsAdvisor(), {
|
||||
payload: { cachedAt: CACHED_AT },
|
||||
outcome: "fresh",
|
||||
});
|
||||
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
|
||||
expect(apiFetchRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the baseline empty when the read fails", async () => {
|
||||
apiFetchRaw.mockRejectedValue(new Error("offline"));
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await expect(ensureAdvisorBaseline(queryClient)).resolves.toBeUndefined();
|
||||
expect(
|
||||
queryClient.getQueryData(queryKeys.insightsAdvisor()),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -148,9 +148,8 @@ export interface HealthKitMetricPageProps {
|
||||
*/
|
||||
statIcon?: ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Optional Coach prefill for the empty-state launch. Falls back to a
|
||||
* generic onboarding prompt threaded through `<CoachLaunchButton>`'s
|
||||
* default.
|
||||
* The empty state's Coach question, for a metric that asks its own. Without
|
||||
* it the page asks the shared, translated one with its own title.
|
||||
*/
|
||||
coachPrefill?: string;
|
||||
/**
|
||||
@@ -223,6 +222,17 @@ export interface HealthKitMetricPageProps {
|
||||
afterAssessment?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The empty state's Coach question for a metric that asks none of its own:
|
||||
* one translated sentence around the page's title.
|
||||
*/
|
||||
function sharedCoachPrefill(
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
metric: string,
|
||||
): string {
|
||||
return t("insights.subPage.emptyCoachPrefill", { metric });
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
measurementType,
|
||||
fallbackMeasurementType,
|
||||
@@ -424,7 +434,7 @@ export function HealthKitMetricPage({
|
||||
title={t(`${i18nPrefix}.emptyState.title`)}
|
||||
description={t(`${i18nPrefix}.emptyState.description`)}
|
||||
cta={ctaNode}
|
||||
coachPrefill={coachPrefill ?? null}
|
||||
coachPrefill={coachPrefill ?? sharedCoachPrefill(t, title)}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -24,12 +24,12 @@ import { useEcgList } from "@/components/insights/use-ecg-list";
|
||||
/**
|
||||
* v1.4.25 — client shell for `src/app/insights/layout.tsx`.
|
||||
*
|
||||
* Owns the advisor query so the regenerate button on the sticky tab
|
||||
* strip works from every Insights surface (mother + the seven sub-
|
||||
* pages). TanStack Query dedup keeps the cost negligible: the mother
|
||||
* page's own `useInsightsAdvisorQuery` consumer shares the same
|
||||
* `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
|
||||
* same cache entry without extra network traffic.
|
||||
* Owns the regenerate button on the sticky tab strip, so it works from
|
||||
* every Insights surface. The strip shows no briefing, so the shell does
|
||||
* not read it: the overview's own `useInsightsAdvisorQuery` does, and the
|
||||
* shell's hook reads it only while its regenerate settles. Every metric
|
||||
* page used to send the briefing read (and load the schema chunk that
|
||||
* parses it) for a button.
|
||||
*
|
||||
* v1.4.27 — also owns the analytics + comprehensive reads that
|
||||
* power the tab-strip availability gate. The analytics consumer goes
|
||||
@@ -55,8 +55,8 @@ interface ComprehensivePayload {
|
||||
|
||||
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
// The briefing read and the regenerate button follow the `briefing`
|
||||
// capability on `/api/auth/me`: the server resolves the operator's switch,
|
||||
// The regenerate button follows the `briefing` capability on
|
||||
// `/api/auth/me`: the server resolves the operator's switch,
|
||||
// the person's AI analysis opt-out, provider presence and consent into one
|
||||
// answer, and the strip offers regenerate only while that answer is yes.
|
||||
// Unavailable while `/me` loads, so nothing fires before the answer lands.
|
||||
@@ -68,7 +68,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
const mounted = useMounted();
|
||||
const advisorEnabled = mounted && isAuthenticated && briefing.available;
|
||||
|
||||
const advisor = useInsightsAdvisorQuery(advisorEnabled);
|
||||
const advisor = useInsightsAdvisorQuery(false);
|
||||
|
||||
// Shared analytics fetch — the layout shell only reads
|
||||
// `summaries[METRIC].count` for the tab-strip availability gate, so
|
||||
|
||||
@@ -61,20 +61,17 @@ import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
* - Active pill gets `aria-current="page"`.
|
||||
* - The regenerate button has an `aria-label` so the icon-only
|
||||
* affordance is announced. The button only renders when
|
||||
* `onRegenerate` is wired — sub-pages don't carry the regenerate
|
||||
* handler today (the mother page owns the advisor query and
|
||||
* forwards the trigger via the layout slot).
|
||||
* `onRegenerate` is wired; the layout shell wires it on every
|
||||
* Insights page while the briefing capability is available.
|
||||
* - `prefers-reduced-motion` honoured at the global level (no
|
||||
* scroll-into-view here — Next.js routing handles focus + scroll).
|
||||
*/
|
||||
|
||||
export interface InsightsTabStripProps {
|
||||
/**
|
||||
* Optional regenerate handler. Only the mother page passes this;
|
||||
* sub-pages render the strip without the right-slot button.
|
||||
* Wiring the strip in `layout.tsx` means we'd need a layout-level
|
||||
* advisor query — for v1.4.25 we keep the strip stateless and the
|
||||
* regenerate affordance on the mother page only.
|
||||
* Optional regenerate handler. The layout shell passes it on every
|
||||
* Insights page while the briefing capability is available; without
|
||||
* it the strip renders no customise or regenerate control.
|
||||
*/
|
||||
onRegenerate?: () => void;
|
||||
/** Spinner state — disables the button and swaps the icon. */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { pollAttemptCount } from "@/lib/queries/refetch-interval";
|
||||
import type { InsightResult } from "@/lib/ai/types";
|
||||
// Type-only — the runtime schemas load lazily inside `fetchAdvisor` so
|
||||
@@ -429,6 +434,27 @@ export async function fetchAdvisor(
|
||||
return { payload, outcome: "fresh" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload a regenerate settles against. A surface that shows no briefing
|
||||
* (the tab strip on a metric page) has not read it, and a timed-out
|
||||
* regenerate without a baseline takes whatever briefing the cache holds for
|
||||
* the fresh one. So the read runs first when the cache is empty; a failed
|
||||
* read leaves the baseline empty, as it was before.
|
||||
*/
|
||||
export async function ensureAdvisorBaseline(
|
||||
queryClient: QueryClient,
|
||||
): Promise<void> {
|
||||
if (queryClient.getQueryData(queryKeys.insightsAdvisor()) !== undefined) {
|
||||
return;
|
||||
}
|
||||
await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: queryKeys.insightsAdvisor(),
|
||||
queryFn: () => fetchAdvisor(),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export interface UseInsightsAdvisorResult {
|
||||
payload: InsightAdvisorPayload | null;
|
||||
isLoading: boolean;
|
||||
@@ -485,7 +511,9 @@ export interface UseInsightsAdvisorResult {
|
||||
* available, whatever the caller passes: every consumer (the overview, the
|
||||
* tab strip) gets the same gate, and none can re-enable the read behind the
|
||||
* other's back. While `/me` loads the capability reads unavailable, so the
|
||||
* first paint never fires a request only to have it refused.
|
||||
* first paint never fires a request only to have it refused. The tab strip
|
||||
* passes `false`: it shows no briefing and only owns the regenerate button,
|
||||
* so it reads the briefing only while its own regenerate settles.
|
||||
*/
|
||||
export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
@@ -506,7 +534,9 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome) so the read path can surface WHY a payload is missing
|
||||
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
|
||||
queryFn: () => fetchAdvisor(),
|
||||
enabled: enabled && briefing.available,
|
||||
// A caller that shows no briefing still reads it while its own
|
||||
// regenerate settles: the settle is decided on these polls.
|
||||
enabled: (enabled || settling !== null) && briefing.available,
|
||||
// 24h cache window matches the server-side `insightsCachedAt` TTL.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
@@ -556,7 +586,10 @@ export function useInsightsAdvisorQuery(
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }),
|
||||
mutationFn: async () => {
|
||||
await ensureAdvisorBaseline(queryClient);
|
||||
return fetchAdvisor({ force: true, responseTimeoutMs });
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.payload) {
|
||||
// A genuinely fresh generation landed — write it into the shared
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The monitoring switches come from the root layout's render, not from a
|
||||
* request every page load sent to `/api/monitoring/settings` for three
|
||||
* instance-wide values. The first render already mounts what they turn on.
|
||||
*/
|
||||
|
||||
const apiGet = vi.fn();
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: (...args: unknown[]) => apiGet(...args),
|
||||
}));
|
||||
vi.mock("@/components/monitoring/umami-script", () => ({
|
||||
UmamiScript: (props: { enabled: boolean; websiteId: string | null }) => (
|
||||
<i data-umami={`${props.enabled}:${props.websiteId}`} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/monitoring/glitchtip-reporter", () => ({
|
||||
GlitchtipReporter: (props: { enabled: boolean }) => (
|
||||
<i data-glitchtip={String(props.enabled)} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { MonitoringBootstrap } from "../bootstrap";
|
||||
|
||||
describe("MonitoringBootstrap", () => {
|
||||
it("mounts what the layout's switches turn on, without asking the server", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MonitoringBootstrap
|
||||
settings={{
|
||||
umamiEnabled: true,
|
||||
umamiWebsiteId: "site-1",
|
||||
glitchtipEnabled: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-umami="true:site-1"');
|
||||
expect(html).toContain('data-glitchtip="true"');
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mounts nothing without settings", () => {
|
||||
expect(renderToStaticMarkup(<MonitoringBootstrap settings={null} />)).toBe(
|
||||
"",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { UmamiScript } from "@/components/monitoring/umami-script";
|
||||
import { GlitchtipReporter } from "@/components/monitoring/glitchtip-reporter";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface MonitoringSettings {
|
||||
umamiEnabled: boolean;
|
||||
umamiWebsiteId: string | null;
|
||||
glitchtipEnabled: boolean;
|
||||
}
|
||||
|
||||
export function MonitoringBootstrap() {
|
||||
const [settings, setSettings] = useState<MonitoringSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
apiGet<MonitoringSettings | undefined>("/api/monitoring/settings", {
|
||||
cache: "no-store",
|
||||
})
|
||||
.then((data) => {
|
||||
if (!active || !data) return;
|
||||
setSettings(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setSettings({
|
||||
umamiEnabled: false,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
import type { PublicMonitoringSettings } from "@/lib/monitoring-settings";
|
||||
|
||||
/**
|
||||
* Mounts the operator's analytics script and error reporter. The root layout
|
||||
* reads the instance-wide switches on the server and passes them in, so no
|
||||
* page load asks `/api/monitoring/settings` for three values the document
|
||||
* render already had. `null` (the key-mismatch page) mounts nothing.
|
||||
*/
|
||||
export function MonitoringBootstrap({
|
||||
settings,
|
||||
}: {
|
||||
settings: Pick<
|
||||
PublicMonitoringSettings,
|
||||
"umamiEnabled" | "umamiWebsiteId" | "glitchtipEnabled"
|
||||
> | null;
|
||||
}) {
|
||||
if (!settings) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,7 +19,8 @@ import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
|
||||
* the user trips the error — typically in the middle of a
|
||||
* navigation flow.
|
||||
*
|
||||
* This component runs the check pro-actively. Every 60 s it fetches
|
||||
* This component runs the check pro-actively. Every 60 s while the tab
|
||||
* is in view (and once on coming back to it) it fetches
|
||||
* `/api/version` and compares the live version string against
|
||||
* `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build
|
||||
* time by `next.config.ts`). When the live version moves ahead of
|
||||
@@ -93,6 +94,19 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tab that comes back into view checks the version now. A hidden
|
||||
* tab skips its ticks, so the first look after a full interval away is due at
|
||||
* once; a quick switch back and forth is not a reason to ask again.
|
||||
*/
|
||||
export function versionCheckDueOnReturn(
|
||||
visibility: DocumentVisibilityState,
|
||||
lastCheckedAt: number,
|
||||
now: number,
|
||||
): boolean {
|
||||
return visibility === "visible" && now - lastCheckedAt >= POLL_INTERVAL_MS;
|
||||
}
|
||||
|
||||
// In-memory mirror of the session guard: under strict-privacy modes
|
||||
// sessionStorage throws, and without a fallback a persistent
|
||||
// shell/live mismatch (misconfigured build) would reload every poll.
|
||||
@@ -165,8 +179,10 @@ export function VersionPoller(): null {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let lastCheckedAt = 0;
|
||||
|
||||
async function checkOnce(): Promise<void> {
|
||||
lastCheckedAt = Date.now();
|
||||
const live = await fetchLiveVersion(controller.signal);
|
||||
let lastReloadedFor: string | null = null;
|
||||
try {
|
||||
@@ -201,16 +217,31 @@ export function VersionPoller(): null {
|
||||
}
|
||||
|
||||
// First check 5 s after mount — gives the app a chance to settle
|
||||
// before we surface anything. Then on the recurring interval.
|
||||
const initial = window.setTimeout(() => void checkOnce(), 5_000);
|
||||
const interval = window.setInterval(
|
||||
() => void checkOnce(),
|
||||
POLL_INTERVAL_MS,
|
||||
);
|
||||
// before we surface anything. Then on the recurring interval, but only
|
||||
// while the tab is in view: a background tab asks nothing and checks
|
||||
// once when it comes back.
|
||||
const tick = () => {
|
||||
if (document.visibilityState === "visible") void checkOnce();
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (
|
||||
versionCheckDueOnReturn(
|
||||
document.visibilityState,
|
||||
lastCheckedAt,
|
||||
Date.now(),
|
||||
)
|
||||
) {
|
||||
void checkOnce();
|
||||
}
|
||||
};
|
||||
const initial = window.setTimeout(tick, 5_000);
|
||||
const interval = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
controller.abort();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The "Coach read" strip reads its pattern line from one cached scan per
|
||||
* record and language, not one scan per metric page: the scan covers every
|
||||
* channel and does not depend on the metric. A failed scan is not cached, a
|
||||
* write that sweeps the record's analytics cells forces a new one, and a
|
||||
* switched-off module reads under its own cell.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readCoachCorrelations = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
|
||||
}));
|
||||
vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/insights/derived/baseline")>()),
|
||||
loadBaselineProfile: async () => ({ ageYears: 40, sex: "MALE" }),
|
||||
// The band is not what this file is about: no band, the strip says
|
||||
// "still learning" and only the pattern line varies.
|
||||
computeVitalsBaseline: async () => ({ status: "insufficient" }),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
|
||||
readCoachCorrelations: (userId: string, locale: string) =>
|
||||
readCoachCorrelations(userId, locale),
|
||||
}));
|
||||
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
|
||||
const SLEEP_DRIVER = {
|
||||
behaviour: "symptom severity",
|
||||
outcome: "sleep duration",
|
||||
direction: "lower" as const,
|
||||
lagDays: 1,
|
||||
n: 40,
|
||||
r: -0.6,
|
||||
note: "A pattern to watch, not a cause.",
|
||||
};
|
||||
const PULSE_DRIVER = { ...SLEEP_DRIVER, outcome: "pulse" };
|
||||
|
||||
const strip = (type: "SLEEP_DURATION" | "PULSE", locale: "en" | "de" = "en") =>
|
||||
buildCoachReadStrip("u1", type, locale, { tz: "UTC" });
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAllCachesForTests();
|
||||
readCoachCorrelations.mockReset().mockResolvedValue({
|
||||
present: true,
|
||||
drivers: [SLEEP_DRIVER, PULSE_DRIVER],
|
||||
});
|
||||
resolveModuleMap.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("coach read — the pattern scan behind the strip", () => {
|
||||
it("scans once for two metric pages and picks each page's own driver", async () => {
|
||||
const sleep = await strip("SLEEP_DURATION");
|
||||
const pulse = await strip("PULSE");
|
||||
|
||||
expect(readCoachCorrelations).toHaveBeenCalledTimes(1);
|
||||
expect(sleep.driver?.outcome).toBe("sleep duration");
|
||||
expect(pulse.driver?.outcome).toBe("pulse");
|
||||
});
|
||||
|
||||
it("scans again per language, since the note is a finished sentence", async () => {
|
||||
await strip("SLEEP_DURATION", "en");
|
||||
await strip("SLEEP_DURATION", "de");
|
||||
|
||||
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
|
||||
expect(readCoachCorrelations).toHaveBeenLastCalledWith("u1", "de");
|
||||
});
|
||||
|
||||
it("does not keep a failed scan", async () => {
|
||||
readCoachCorrelations.mockResolvedValueOnce({
|
||||
present: false,
|
||||
reason: "retrieval_failed",
|
||||
});
|
||||
|
||||
const failed = await strip("SLEEP_DURATION");
|
||||
const retried = await strip("SLEEP_DURATION");
|
||||
|
||||
expect(failed.driver).toBeNull();
|
||||
expect(retried.driver?.outcome).toBe("sleep duration");
|
||||
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scans again after a write sweeps the record's analytics cells", async () => {
|
||||
await strip("SLEEP_DURATION");
|
||||
invalidateUserMood("u1");
|
||||
await strip("SLEEP_DURATION");
|
||||
|
||||
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reads a switched-off module under its own cell", async () => {
|
||||
await strip("SLEEP_DURATION");
|
||||
resolveModuleMap.mockResolvedValue({ insights: false });
|
||||
readCoachCorrelations.mockResolvedValue({
|
||||
present: false,
|
||||
reason: "module_disabled",
|
||||
});
|
||||
|
||||
const off = await strip("SLEEP_DURATION");
|
||||
|
||||
expect(readCoachCorrelations).toHaveBeenCalledTimes(2);
|
||||
expect(off.driver).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,8 +25,12 @@
|
||||
* component and the unit test can share them without pulling Prisma.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import {
|
||||
loadBaselineProfile,
|
||||
computeVitalsBaseline,
|
||||
@@ -42,7 +46,10 @@ import {
|
||||
isFromToday,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
|
||||
import {
|
||||
readCoachCorrelations,
|
||||
type CoachCorrelationDriver,
|
||||
} from "@/lib/ai/coach/tools/correlations-read";
|
||||
import {
|
||||
humaniseType,
|
||||
placeAgainstBand,
|
||||
@@ -88,6 +95,34 @@ function latestDay(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The discovered drivers, through the analytics cache. The scan covers every
|
||||
* channel over 180 days and does not depend on the metric, so without the cell
|
||||
* each metric page paid the whole scan again for the one line it picks. Same
|
||||
* cell contract as `/api/insights/correlations`: measurement, mood, medication,
|
||||
* illness, custom-metric and pattern writes sweep the `${userId}|` prefix, and
|
||||
* the switched-off modules are part of the key. A failed read is not cached.
|
||||
*/
|
||||
async function readDrivers(
|
||||
userId: string,
|
||||
locale: Locale,
|
||||
): Promise<CoachCorrelationDriver[] | null> {
|
||||
const modules = await resolveModuleMap(userId);
|
||||
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
|
||||
return cachedSwr(
|
||||
caches.analytics as ServerCache<CoachCorrelationDriver[] | null>,
|
||||
`${userId}|coach-read-drivers|${locale}|off:${modulesOff.join(",")}`,
|
||||
async () => {
|
||||
const result = await readCoachCorrelations(userId, locale);
|
||||
if (result.reason === "retrieval_failed") {
|
||||
throw new Error("coach read correlations failed");
|
||||
}
|
||||
return result.drivers ?? null;
|
||||
},
|
||||
annotate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Trailing window the band engine reads; the day-mean placement matches it. */
|
||||
const BAND_WINDOW_DAYS = 30;
|
||||
|
||||
@@ -165,13 +200,11 @@ export async function buildCoachReadStrip(
|
||||
const now = opts.now ?? new Date();
|
||||
const profile = await loadBaselineProfile(prisma, userId);
|
||||
|
||||
const [baselineDerived, latest, correlations] = await Promise.all([
|
||||
const [baselineDerived, latest, drivers] = await Promise.all([
|
||||
computeVitalsBaseline(userId, profile, { type, now }),
|
||||
readLatestValue(userId, type),
|
||||
// Line 2 is best-effort: a correlation failure must never sink the band.
|
||||
readCoachCorrelations(userId, locale).catch(
|
||||
() => ({ present: false }) as const,
|
||||
),
|
||||
readDrivers(userId, locale).catch(() => null),
|
||||
]);
|
||||
|
||||
let baseline: CoachReadBaseline | null = null;
|
||||
@@ -207,11 +240,8 @@ export async function buildCoachReadStrip(
|
||||
}
|
||||
|
||||
const driver = ((): CoachReadDriver | null => {
|
||||
if (!("drivers" in correlations) || !correlations.drivers) return null;
|
||||
const picked = pickDriverForMetric(
|
||||
correlations.drivers,
|
||||
humaniseType(type),
|
||||
);
|
||||
if (!drivers) return null;
|
||||
const picked = pickDriverForMetric(drivers, humaniseType(type));
|
||||
if (!picked) return null;
|
||||
return {
|
||||
note: picked.note,
|
||||
|
||||
@@ -143,6 +143,46 @@ describe("useDashboardSnapshot — auto-refresh on an open page", () => {
|
||||
await (opts.queryFn as () => Promise<unknown>)();
|
||||
expect(setQueryDataMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seeds the mood chart's series from the mood tile, so the chart asks for nothing", async () => {
|
||||
const entries = [{ date: "2026-11-10", score: 4, samples: 1 }];
|
||||
const summary = { count: 1, latest: 4 };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
envelopeResponse({
|
||||
layout: { widgets: [] },
|
||||
tiles: { mood: { entries, summary } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
getQueryDataMock.mockReturnValue(undefined);
|
||||
useDashboardSnapshot();
|
||||
await (lastOpts().queryFn as () => Promise<unknown>)();
|
||||
expect(setQueryDataMock).toHaveBeenCalledWith(queryKeys.moodAnalytics(), {
|
||||
entries,
|
||||
summary,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the mood chart to its own read when the tile has no readings", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
envelopeResponse({
|
||||
layout: { widgets: [] },
|
||||
tiles: { mood: { entries: [], summary: null } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
getQueryDataMock.mockReturnValue(undefined);
|
||||
useDashboardSnapshot();
|
||||
await (lastOpts().queryFn as () => Promise<unknown>)();
|
||||
expect(setQueryDataMock).not.toHaveBeenCalledWith(
|
||||
queryKeys.moodAnalytics(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefetchDashboardSnapshot — hydration-safe promise handoff", () => {
|
||||
|
||||
@@ -194,6 +194,21 @@ function makeSnapshotQueryFn(queryClient: QueryClient) {
|
||||
if (!queryClient.getQueryData(queryKeys.dashboardWidgets())) {
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), snap.layout);
|
||||
}
|
||||
// The mood tile carries the series `/api/mood/analytics` answers (one
|
||||
// engine, `buildMoodDailySeries`), which is what the mood chart reads.
|
||||
// Same seed-when-empty rule, and only with readings, the one case where
|
||||
// the tile's summary is the route's.
|
||||
const mood = snap.tiles?.mood;
|
||||
if (
|
||||
mood?.summary &&
|
||||
mood.entries.length > 0 &&
|
||||
!queryClient.getQueryData(queryKeys.moodAnalytics())
|
||||
) {
|
||||
queryClient.setQueryData(queryKeys.moodAnalytics(), {
|
||||
entries: mood.entries,
|
||||
summary: mood.summary,
|
||||
});
|
||||
}
|
||||
return snap;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user