diff --git a/messages/de.json b/messages/de.json index a72dcb006..dd2a6c8e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag", "ENV_PM25": "der Feinstaub (PM2,5)", "ENV_OZONE_8H": "das Ozon", - "ENV_POLLEN_MAX": "der Pollenflug" + "ENV_POLLEN_MAX": "der Pollenflug", + "HRV_RMSSD": "die Herzratenvariabilität (RMSSD)", + "HYDRATION": "die Trinkmenge", + "CAFFEINE": "die Koffeinmenge", + "ALCOHOL": "der Alkoholkonsum", + "WORKOUT_MINUTES": "die Workout-Dauer", + "TRAINING_LOAD": "die Trainingslast", + "EXERCISE_MINUTES": "die Trainingsminuten der Aktivitätsringe", + "STAND_MINUTES": "die Stehminuten", + "MINDFUL_MINUTES": "die Achtsamkeitsminuten", + "STATE_OF_MIND_VALENCE": "die erfasste Gemütslage", + "INDOOR_CO2": "der CO2-Wert im Raum", + "INDOOR_TEMPERATURE": "die Raumtemperatur", + "INDOOR_HUMIDITY": "die Luftfeuchtigkeit im Raum" }, "daily": { "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", @@ -2716,6 +2729,12 @@ "high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", "faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache." + }, + "channelNutrient": "die Zufuhr von {nutrient}", + "dailyOvernight": { + "high": "Wenn {behaviour} über Nacht höher liegt, fällt {outcome} in derselben Nacht in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "moderate": "Wenn {behaviour} über Nacht höher liegt, scheint {outcome} in derselben Nacht nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "faint": "Wenn {behaviour} über Nacht höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} in derselben Nacht {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache." } }, "emptyTitle": "Noch nicht genug Daten", @@ -4255,7 +4274,12 @@ "outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.", "stat": "{n} gepaarte Tage, r = {r}, q = {q}", "statLabel": "Wie das berechnet wurde", - "explainerLabel": "Wie das berechnet wurde" + "explainerLabel": "Wie das berechnet wurde", + "channel": { + "ALCOHOL": "Alkoholkonsum", + "WORKOUT_MINUTES": "Workout-Dauer", + "TRAINING_LOAD": "Trainingslast" + } }, "inTargetTitle": "Im Zielbereich", "dimensions": { diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..d9a637e04 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "intraday pressure swing", "ENV_PM25": "fine particles (PM2.5)", "ENV_OZONE_8H": "ozone", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "heart-rate variability (RMSSD)", + "HYDRATION": "water intake", + "CAFFEINE": "caffeine intake", + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load", + "EXERCISE_MINUTES": "exercise minutes", + "STAND_MINUTES": "standing minutes", + "MINDFUL_MINUTES": "mindful minutes", + "STATE_OF_MIND_VALENCE": "logged state of mind", + "INDOOR_CO2": "indoor CO2", + "INDOOR_TEMPERATURE": "room temperature", + "INDOOR_HUMIDITY": "room humidity" }, "daily": { "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.", @@ -2716,6 +2729,12 @@ "high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.", "moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.", "faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause." + }, + "channelNutrient": "{nutrient} intake", + "dailyOvernight": { + "high": "Higher {behaviour} overnight tends to go with {direction} {outcome} that night in your data. It is a pattern to watch, not a cause.", + "moderate": "Higher {behaviour} overnight looks like it goes with {direction} {outcome} that night in your data, on the evidence so far. It is a pattern to watch, not a cause.", + "faint": "Higher {behaviour} overnight shows at most a faint hint of {direction} {outcome} that night in your data. It is too small to lean on and never a cause." } }, "noAnalysisYet": "No assessment yet.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.", "stat": "{n} paired days, r = {r}, q = {q}", "statLabel": "How this was computed", - "explainerLabel": "How this was computed" + "explainerLabel": "How this was computed", + "channel": { + "ALCOHOL": "alcohol use", + "WORKOUT_MINUTES": "workout minutes", + "TRAINING_LOAD": "training load" + } }, "inTargetTitle": "In your range", "dimensions": { diff --git a/messages/es.json b/messages/es.json index 3239be811..cf161392a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variación de presión del día", "ENV_PM25": "las partículas finas (PM2,5)", "ENV_OZONE_8H": "el ozono", - "ENV_POLLEN_MAX": "el polen" + "ENV_POLLEN_MAX": "el polen", + "HRV_RMSSD": "la variabilidad de la frecuencia cardiaca (RMSSD)", + "HYDRATION": "la ingesta de agua", + "CAFFEINE": "la ingesta de cafeína", + "ALCOHOL": "el consumo de alcohol", + "WORKOUT_MINUTES": "los minutos de entrenamiento", + "TRAINING_LOAD": "la carga de entrenamiento", + "EXERCISE_MINUTES": "los minutos de ejercicio", + "STAND_MINUTES": "los minutos de pie", + "MINDFUL_MINUTES": "los minutos de atención plena", + "STATE_OF_MIND_VALENCE": "el estado de ánimo registrado", + "INDOOR_CO2": "el CO2 interior", + "INDOOR_TEMPERATURE": "la temperatura interior", + "INDOOR_HUMIDITY": "la humedad interior" }, "daily": { "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.", @@ -2716,6 +2729,12 @@ "high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.", "moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", "faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa." + }, + "channelNutrient": "la ingesta de {nutrient}", + "dailyOvernight": { + "high": "Más {behaviour} durante la noche tiende a ir con {direction} {outcome} esa misma noche en tus datos: un patrón que conviene observar, no una causa.", + "moderate": "Más {behaviour} durante la noche parece ir con {direction} {outcome} esa misma noche en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", + "faint": "Más {behaviour} durante la noche muestra, como mucho, un leve indicio de {direction} {outcome} esa misma noche en tus datos: demasiado pequeño para fiarse, nunca una causa." } }, "noAnalysisYet": "Aún no hay valoración.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.", "stat": "{n} días emparejados, r = {r}, q = {q}", "statLabel": "Cómo se calculó esto", - "explainerLabel": "Cómo se calculó esto" + "explainerLabel": "Cómo se calculó esto", + "channel": { + "ALCOHOL": "consumo de alcohol", + "WORKOUT_MINUTES": "minutos de entrenamiento", + "TRAINING_LOAD": "carga de entrenamiento" + } }, "inTargetTitle": "En tu rango", "dimensions": { diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..1edb3089b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "la variation de pression sur la journée", "ENV_PM25": "les particules fines (PM2,5)", "ENV_OZONE_8H": "l’ozone", - "ENV_POLLEN_MAX": "le pollen" + "ENV_POLLEN_MAX": "le pollen", + "HRV_RMSSD": "la variabilité de la fréquence cardiaque (RMSSD)", + "HYDRATION": "l'apport en eau", + "CAFFEINE": "l'apport en caféine", + "ALCOHOL": "la consommation d'alcool", + "WORKOUT_MINUTES": "les minutes d'entraînement", + "TRAINING_LOAD": "la charge d'entraînement", + "EXERCISE_MINUTES": "les minutes d'exercice", + "STAND_MINUTES": "les minutes debout", + "MINDFUL_MINUTES": "les minutes de pleine conscience", + "STATE_OF_MIND_VALENCE": "l'état d'esprit noté", + "INDOOR_CO2": "le CO2 intérieur", + "INDOOR_TEMPERATURE": "la température intérieure", + "INDOOR_HUMIDITY": "l'humidité intérieure" }, "daily": { "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.", @@ -2716,6 +2729,12 @@ "high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.", "moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", "faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause." + }, + "channelNutrient": "l'apport en {nutrient}", + "dailyOvernight": { + "high": "Plus de {behaviour} pendant la nuit va souvent de pair avec {direction} {outcome} cette nuit-là dans tes données : un schéma à surveiller, pas une cause.", + "moderate": "Plus de {behaviour} pendant la nuit semble aller de pair avec {direction} {outcome} cette nuit-là dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", + "faint": "Plus de {behaviour} pendant la nuit ne montre, au mieux, qu’un faible indice de {direction} {outcome} cette nuit-là dans tes données : trop peu pour s’y fier, jamais une cause." } }, "noAnalysisYet": "Pas encore d’évaluation.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.", "stat": "{n} jours appariés, r = {r}, q = {q}", "statLabel": "Comment ceci est calculé", - "explainerLabel": "Comment ceci est calculé" + "explainerLabel": "Comment ceci est calculé", + "channel": { + "ALCOHOL": "consommation d'alcool", + "WORKOUT_MINUTES": "minutes d'entraînement", + "TRAINING_LOAD": "charge d'entraînement" + } }, "inTargetTitle": "Dans votre plage", "dimensions": { diff --git a/messages/it.json b/messages/it.json index 85e890708..52ef2d137 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata", "ENV_PM25": "il particolato fine (PM2,5)", "ENV_OZONE_8H": "l’ozono", - "ENV_POLLEN_MAX": "i pollini" + "ENV_POLLEN_MAX": "i pollini", + "HRV_RMSSD": "la variabilità della frequenza cardiaca (RMSSD)", + "HYDRATION": "l'assunzione di acqua", + "CAFFEINE": "l'assunzione di caffeina", + "ALCOHOL": "il consumo di alcol", + "WORKOUT_MINUTES": "i minuti di allenamento", + "TRAINING_LOAD": "il carico di allenamento", + "EXERCISE_MINUTES": "i minuti di esercizio", + "STAND_MINUTES": "i minuti in piedi", + "MINDFUL_MINUTES": "i minuti di consapevolezza", + "STATE_OF_MIND_VALENCE": "lo stato d'animo registrato", + "INDOOR_CO2": "la CO2 interna", + "INDOOR_TEMPERATURE": "la temperatura interna", + "INDOOR_HUMIDITY": "l'umidità interna" }, "daily": { "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.", @@ -2716,6 +2729,12 @@ "high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.", "moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", "faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa." + }, + "channelNutrient": "l'assunzione di {nutrient}", + "dailyOvernight": { + "high": "Più {behaviour} durante la notte tende ad accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati: uno schema da osservare, non una causa.", + "moderate": "Più {behaviour} durante la notte sembra accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", + "faint": "Più {behaviour} durante la notte mostra al massimo un debole indizio di {direction} {outcome} quella stessa notte nei tuoi dati: troppo poco per contarci, mai una causa." } }, "noAnalysisYet": "Ancora nessuna valutazione.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.", "stat": "{n} giorni appaiati, r = {r}, q = {q}", "statLabel": "Come è stato calcolato", - "explainerLabel": "Come è stato calcolato" + "explainerLabel": "Come è stato calcolato", + "channel": { + "ALCOHOL": "consumo di alcol", + "WORKOUT_MINUTES": "minuti di allenamento", + "TRAINING_LOAD": "carico di allenamento" + } }, "inTargetTitle": "Nel tuo intervallo", "dimensions": { diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..26c14f484 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "하루 중 기압 변화", "ENV_PM25": "초미세먼지(PM2.5)", "ENV_OZONE_8H": "오존", - "ENV_POLLEN_MAX": "꽃가루" + "ENV_POLLEN_MAX": "꽃가루", + "HRV_RMSSD": "심박 변이도(RMSSD)", + "HYDRATION": "수분 섭취량", + "CAFFEINE": "카페인 섭취량", + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하", + "EXERCISE_MINUTES": "운동하기 시간", + "STAND_MINUTES": "서 있은 시간", + "MINDFUL_MINUTES": "마음챙김 시간", + "STATE_OF_MIND_VALENCE": "기록한 마음 상태", + "INDOOR_CO2": "실내 CO2", + "INDOOR_TEMPERATURE": "실내 온도", + "INDOOR_HUMIDITY": "실내 습도" }, "daily": { "high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.", @@ -2716,6 +2729,12 @@ "high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", "faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." + }, + "channelNutrient": "{nutrient} 섭취량", + "dailyOvernight": { + "high": "밤사이 {behaviour}이(가) 높을수록 내 데이터에서는 그날 밤 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "moderate": "지금까지의 데이터로 보면 밤사이 {behaviour}이(가) 높을수록 그날 밤 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "faint": "밤사이 {behaviour}이(가) 높을 때 내 데이터에서 그날 밤 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." } }, "noAnalysisYet": "아직 평가가 없어요.", @@ -4255,7 +4274,12 @@ "outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.", "stat": "짝지은 {n}일, r = {r}, q = {q}", "statLabel": "계산 방법", - "explainerLabel": "계산 방법" + "explainerLabel": "계산 방법", + "channel": { + "ALCOHOL": "음주", + "WORKOUT_MINUTES": "운동 시간", + "TRAINING_LOAD": "훈련 부하" + } }, "inTargetTitle": "내 범위 안", "dimensions": { diff --git a/messages/nl.json b/messages/nl.json index c44560507..8fd2353e6 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "luchtdrukschommeling gedurende de dag", "ENV_PM25": "fijnstof (PM2.5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pollen" + "ENV_POLLEN_MAX": "pollen", + "HRV_RMSSD": "hartslagvariabiliteit (RMSSD)", + "HYDRATION": "waterinname", + "CAFFEINE": "cafeïne-inname", + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting", + "EXERCISE_MINUTES": "beweegminuten", + "STAND_MINUTES": "staminuten", + "MINDFUL_MINUTES": "mindfulnessminuten", + "STATE_OF_MIND_VALENCE": "vastgelegde gemoedstoestand", + "INDOOR_CO2": "CO2 binnen", + "INDOOR_TEMPERATURE": "binnentemperatuur", + "INDOOR_HUMIDITY": "luchtvochtigheid binnen" }, "daily": { "high": "Bij hogere waarden voor {behaviour} ligt {outcome} de volgende dag in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", @@ -2716,6 +2729,12 @@ "high": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor ligt {outcome} in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor. Het is een patroon om in de gaten te houden, geen oorzaak.", "faint": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." + }, + "channelNutrient": "inname van {nutrient}", + "dailyOvernight": { + "high": "Bij hogere waarden voor {behaviour} 's nachts ligt {outcome} die nacht in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.", + "moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} die nacht in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} 's nachts. Het is een patroon om in de gaten te houden, geen oorzaak.", + "faint": "Bij hogere waarden voor {behaviour} 's nachts is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} die nacht {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak." } }, "noAnalysisYet": "Nog geen beoordeling.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Een hogere stemming gaat in je gegevens vaak samen met lagere waarden voor {factor} de volgende dag.", "stat": "{n} gekoppelde dagen, r = {r}, q = {q}", "statLabel": "Hoe dit is berekend", - "explainerLabel": "Hoe dit is berekend" + "explainerLabel": "Hoe dit is berekend", + "channel": { + "ALCOHOL": "alcoholgebruik", + "WORKOUT_MINUTES": "trainingsminuten", + "TRAINING_LOAD": "trainingsbelasting" + } }, "inTargetTitle": "Binnen je bereik", "dimensions": { diff --git a/messages/pl.json b/messages/pl.json index c1812715f..304d35ac3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2688,7 +2688,20 @@ "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia", "ENV_PM25": "pył drobny (PM2,5)", "ENV_OZONE_8H": "ozon", - "ENV_POLLEN_MAX": "pyłki" + "ENV_POLLEN_MAX": "pyłki", + "HRV_RMSSD": "zmienność rytmu serca (RMSSD)", + "HYDRATION": "spożycie wody", + "CAFFEINE": "spożycie kofeiny", + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe", + "EXERCISE_MINUTES": "minuty ćwiczeń", + "STAND_MINUTES": "minuty na stojąco", + "MINDFUL_MINUTES": "minuty uważności", + "STATE_OF_MIND_VALENCE": "zapisany stan ducha", + "INDOOR_CO2": "CO2 w pomieszczeniu", + "INDOOR_TEMPERATURE": "temperatura w pomieszczeniu", + "INDOOR_HUMIDITY": "wilgotność w pomieszczeniu" }, "daily": { "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.", @@ -2716,6 +2729,12 @@ "high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", "faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." + }, + "channelNutrient": "spożycie: {nutrient}", + "dailyOvernight": { + "high": "Więcej {behaviour} w nocy idzie w twoich danych zwykle w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Więcej {behaviour} w nocy wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.", + "faint": "Więcej {behaviour} w nocy pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} tej samej nocy. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." } }, "noAnalysisYet": "Brak oceny.", @@ -4255,7 +4274,12 @@ "outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.", "stat": "{n} sparowanych dni, r = {r}, q = {q}", "statLabel": "Jak to obliczono", - "explainerLabel": "Jak to obliczono" + "explainerLabel": "Jak to obliczono", + "channel": { + "ALCOHOL": "spożycie alkoholu", + "WORKOUT_MINUTES": "minuty treningu", + "TRAINING_LOAD": "obciążenie treningowe" + } }, "inTargetTitle": "W Twoim zakresie", "dimensions": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts new file mode 100644 index 000000000..0eb5df59f --- /dev/null +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -0,0 +1,101 @@ +/** + * The statistics behind the correlation, trend and changepoint surfaces are + * computed without a model. + * + * These modules answer "what does the record show": correlation channels, + * lab slopes and projected band exits, screener change against the + * clinically meaningful threshold, training load, level shifts. None of that + * may depend on a provider being configured, and none of it may become a way + * to reach one. A capability gate cannot protect a computation that quietly + * imports the provider chain, so this guard walks each module's import + * closure (value imports, `@/` and relative, through re-exports) and fails on + * anything under `lib/ai/` except the byte cipher the symptom decrypt uses, + * and on any provider call in the module's own text. + * + * Limits, stated: a dynamic `import()` or a `require` is not followed, and a + * type-only import is skipped by design (it carries no code). The list below + * is the set this guard covers; a new analysis module joins it by being added. + */ +import { describe, expect, it } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; + +const SRC = join(process.cwd(), "src"); + +/** The analysis modules held to "no model anywhere below". */ +const ANALYSIS_MODULES = [ + "lib/insights/correlation-discovery.ts", + "lib/insights/correlation-series-builders.ts", + "lib/insights/correlation-channel-series.ts", + "lib/insights/discovery-matrix.ts", + "lib/insights/derived/changepoint.ts", +] as const; + +/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ +const AI_TREE_ALLOWED = new Set(["lib/ai/coach/bytes-codec.ts"]); + +/** A provider call, as `ai-egress-capability-guard` defines it. */ +const EGRESS_CALL = + /\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/; + +const VALUE_IMPORT = + /(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/g; + +function resolveImport(fromFile: string, spec: string): string | null { + let base: string; + if (spec.startsWith("@/")) base = join(SRC, spec.slice(2)); + else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +function closure(entry: string): string[] { + const seen = new Set(); + const stack = [join(SRC, entry)]; + while (stack.length > 0) { + const file = stack.pop() as string; + if (seen.has(file)) continue; + seen.add(file); + // The generated Prisma client is never read (several MB, no model in it). + if (file.includes(`${SRC}/generated/`)) continue; + const source = readFileSync(file, "utf8"); + for (const match of source.matchAll(VALUE_IMPORT)) { + const next = resolveImport(file, match[1]); + if (next) stack.push(next); + } + } + return [...seen].map((f) => relative(SRC, f)); +} + +describe("the analysis modules compute without a model", () => { + it.each(ANALYSIS_MODULES)("%s reaches no provider", (entry) => { + const reached = closure(entry); + // A walk that found nothing would pass any assertion below. + expect(reached.length).toBeGreaterThan(1); + const ai = reached.filter( + (f) => f.startsWith("lib/ai/") && !AI_TREE_ALLOWED.has(f), + ); + expect(ai).toEqual([]); + }); + + it.each(ANALYSIS_MODULES)("%s makes no provider call", (entry) => { + expect(readFileSync(join(SRC, entry), "utf8")).not.toMatch(EGRESS_CALL); + }); + + it("would notice a provider import", () => { + // The walk follows the provider module itself into the chain. + expect(closure("lib/ai/provider.ts").length).toBeGreaterThan(1); + expect( + closure("lib/ai/provider.ts").some( + (f) => f === "lib/ai/provider-chain.ts", + ), + ).toBe(true); + }); +}); diff --git a/src/__tests__/discovery-matrix-guard.test.ts b/src/__tests__/discovery-matrix-guard.test.ts index 128b5e8ac..36e7d48d7 100644 --- a/src/__tests__/discovery-matrix-guard.test.ts +++ b/src/__tests__/discovery-matrix-guard.test.ts @@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => { "fetchMoodFactorWindowSeries", "fetchMeasurementDailySeriesTiered", "fetchMeasurementWindowSeries", + "fetchNutrientSeries", + "fetchAlcoholSeries", + "fetchTrainingSeries", + "fetchIndoorClimateSeries", ] as const; /** diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index a94277515..bdd7656ac 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -104,6 +104,10 @@ const SQL_READERS: Record = { "lib/measurements/daily-series-read.ts": HELPER, "lib/rollups/measurement-rollups.ts": HELPER, "lib/rollups/tiered-context.ts": HELPER, + "lib/insights/correlation-channel-series.ts": { + exempt: + "the one SQL mean is the room-climate hour mean, over INDOOR_CO2 / INDOOR_TEMPERATURE / INDOOR_HUMIDITY only; pulse reaches the correlation channels through `dayValueRows` and the rollup compose", + }, }; /** Files that name pulse and hold an in-memory mean or a mean-making fold. */ diff --git a/src/__tests__/workout-canonical-identity-guard.test.ts b/src/__tests__/workout-canonical-identity-guard.test.ts index dfbc5cfbe..56c8c4c6e 100644 --- a/src/__tests__/workout-canonical-identity-guard.test.ts +++ b/src/__tests__/workout-canonical-identity-guard.test.ts @@ -58,6 +58,10 @@ const IDENTITY_OWNER: Record = { "lib/insights/strain-score.ts": "lib/insights/strain-score.ts", "lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts", "lib/day/records.ts": "lib/day/records.ts", + // The workout-minutes correlation channel: one session per day per sport, + // as the list shows it. + "lib/insights/correlation-channel-series.ts": + "lib/insights/correlation-channel-series.ts", }; /** diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 0dad495bb..37759be1d 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -3,6 +3,10 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, auditLog: { create: vi.fn() }, appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, user: { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 7d46a41bb..245c33421 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -245,6 +245,11 @@ async function buildCorrelationsResponse( custom_metric_channels: diagnostics.customMetricChannels, symptom_event_channels: diagnostics.symptomEventChannels, custom_metric_days: diagnostics.customMetricDays, + // v1.44 — reach of the extended families. + nutrient_days: diagnostics.nutrientDays, + alcohol_days: diagnostics.alcoholDays, + training_days: diagnostics.trainingDays, + indoor_nights: diagnostics.indoorNights, }, }); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 6f72d7148..1902f881d 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record = { // v1.43 — the Dawarich movement channels. MOVEMENT_AWAY: "environment.movement.awayFromHome", MOVEMENT_DISTANCE: "environment.movement.distance", + // v1.44 — the extended channel families. + HRV_RMSSD: "measurements.typeHrvRmssd", + EXERCISE_MINUTES: "measurements.typeExerciseMinutes", + STAND_MINUTES: "measurements.typeStandMinutes", + MINDFUL_MINUTES: "measurements.typeMindfulMinutes", + HYDRATION: "nutrients.names.water", + CAFFEINE: "nutrients.names.caffeine", + ALCOHOL: "insights.mood.discovery.channel.ALCOHOL", + WORKOUT_MINUTES: "insights.mood.discovery.channel.WORKOUT_MINUTES", + TRAINING_LOAD: "insights.mood.discovery.channel.TRAINING_LOAD", }; +/** A micronutrient channel (`NUTRIENT:`) reads under its catalog name. */ +const NUTRIENT_PREFIX = "NUTRIENT:"; + +function channelLabelKey(channelKey: string): string { + if (channelKey.startsWith(NUTRIENT_PREFIX)) { + return `nutrients.names.${channelKey.slice(NUTRIENT_PREFIX.length)}`; + } + return CHANNEL_LABEL_KEY[channelKey] ?? channelKey; +} + /** Keep only the discovered pairs that involve the mood channel. */ export function moodPairsOf( discovered: DiscoveredCorrelation[], @@ -131,8 +151,7 @@ export function MoodDiscoveredRelations({ const dynamicLabel = moodIsOutcome ? pair.behaviourLabel : pair.outcomeLabel; - const factorLabel = - dynamicLabel ?? t(CHANNEL_LABEL_KEY[channelKey] ?? channelKey); + const factorLabel = dynamicLabel ?? t(channelLabelKey(channelKey)); if ( patternId && dismissal.isDismissed(patternId, pair.dismissed === true) diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 23e8bd7cd..25ece9e00 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/modules/gate", () => ({ })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, user: { findUnique: (a: unknown) => userFindUnique(a) }, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index b9579386b..2d9fc8f04 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -50,6 +50,10 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, discoverEmergingCorrelations, + withoutModelFencedPairs, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, discoverLabOutcomeCorrelations, EARLY_WINDOW_DAYS, MEDICATION_COMPLIANCE_CHANNEL_KEY, @@ -158,6 +162,8 @@ function tzDayKey(at: Date, tz: string): string { const CHANNEL_LABELS: Record = { [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", + [HYDRATION_CHANNEL_KEY]: "water intake", + [CAFFEINE_CHANNEL_KEY]: "caffeine intake", ...Object.fromEntries( ENVIRONMENT_FIELDS.map((field) => [field.key, field.narrationLabel]), ), @@ -172,6 +178,9 @@ const CHANNEL_LABELS: Record = { */ function humanise(key: string, label?: string): string { if (label && label.trim().length > 0) return label.trim().toLowerCase(); + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + return `${key.slice(NUTRIENT_CHANNEL_PREFIX.length).replace(/_/g, " ")} intake`; + } return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } @@ -281,7 +290,12 @@ export async function readCoachCorrelations( }); const discovery = discoverCorrelations(series, { locale }); - const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ + // The Coach is a model: pairs on a fenced channel never reach it. The + // scan above is the shared one, so the remaining pairs' statistics are the + // ones the correlations page shows. + const drivers: CoachCorrelationDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: humanise(d.behaviour, d.behaviourLabel), outcome: humanise(d.outcome, d.outcomeLabel), direction: d.r >= 0 ? "higher" : "lower", @@ -303,18 +317,18 @@ export async function readCoachCorrelations( recentFromDayKey, locale, }); - const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( - (d) => ({ - behaviour: humanise(d.behaviour, d.behaviourLabel), - outcome: humanise(d.outcome, d.outcomeLabel), - direction: d.r >= 0 ? "higher" : "lower", - lagDays: d.lagDays, - n: d.n, - r: Math.round(d.r * 100) / 100, - note: d.interpretation, - provisional: true, - }), - ); + const emerging: CoachEmergingDriver[] = withoutModelFencedPairs( + emergingResult.emerging, + ).map((d) => ({ + behaviour: humanise(d.behaviour, d.behaviourLabel), + outcome: humanise(d.outcome, d.outcomeLabel), + direction: d.r >= 0 ? "higher" : "lower", + lagDays: d.lagDays, + n: d.n, + r: Math.round(d.r * 100) / 100, + note: d.interpretation, + provisional: true, + })); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). const labResult = discoverLabOutcomeCorrelations(labDraws, series, { diff --git a/src/lib/insights/__tests__/correlation-extended-channels.test.ts b/src/lib/insights/__tests__/correlation-extended-channels.test.ts new file mode 100644 index 000000000..0b0accfa7 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-extended-channels.test.ts @@ -0,0 +1,284 @@ +/** + * v1.44 — the extended correlation channel families: the pure series shapers + * (intake, alcohol days, workout minutes, training load, overnight room + * climate) and the engine's per-channel `targets` / `phrasing`, plus the fence + * that keeps the sensitive channels away from every model-bound surface. + */ +import { describe, expect, it } from "vitest"; + +import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + discoverCorrelations, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TARGETS, + metricFamily, + MODEL_FENCED_CHANNELS, + withoutModelFencedPairs, + type DailySeriesPoint, + type NamedSeries, +} from "../correlation-discovery"; +import { + buildAlcoholDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, + MAX_MICRONUTRIENT_CHANNELS, + MIN_ALCOHOL_DAYS, + MIN_OVERNIGHT_HOURS, +} from "../correlation-series-builders"; +import { shiftDateKey } from "@/lib/tz/format"; + +const DAY0 = "2026-01-01"; +const dayN = (n: number) => shiftDateKey(DAY0, n); + +function daily(values: number[], from = 0): DailySeriesPoint[] { + return values.map((value, i) => ({ day: dayN(from + i), value })); +} + +describe("buildNutrientDailySeries", () => { + it("sums sources per day and maps water and caffeine to their channels", () => { + const out = buildNutrientDailySeries( + [ + { day: dayN(0), nutrient: "water", amount: 1200 }, + { day: dayN(0), nutrient: "water", amount: 300 }, + { day: dayN(1), nutrient: "caffeine", amount: 95 }, + ], + { minDays: 20 }, + ); + expect(out.map((s) => s.key)).toEqual([ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ]); + expect(out[0].points).toEqual([{ day: dayN(0), value: 1500 }]); + expect(out[1].points).toEqual([{ day: dayN(1), value: 95 }]); + }); + + it("admits the most-logged varying micronutrients, capped, never a constant", () => { + const rows = []; + // Six varying micronutrients with 30..25 days, one constant with 40. + const codes = [ + "zinc", + "iron", + "magnesium", + "vitamin_d", + "selenium", + "iodine", + ]; + codes.forEach((code, c) => { + for (let d = 0; d < 30 - c; d += 1) { + rows.push({ day: dayN(d), nutrient: code, amount: 10 + (d % 3) }); + } + }); + for (let d = 0; d < 40; d += 1) { + rows.push({ day: dayN(d), nutrient: "vitamin_c", amount: 500 }); + } + // One varying code below the day floor. + for (let d = 0; d < 10; d += 1) { + rows.push({ day: dayN(d), nutrient: "copper", amount: d }); + } + const keys = buildNutrientDailySeries(rows, { minDays: 20 }) + .map((s) => s.key) + .slice(2); + expect(keys).toHaveLength(MAX_MICRONUTRIENT_CHANNELS); + expect(keys).toEqual([ + "NUTRIENT:zinc", + "NUTRIENT:iron", + "NUTRIENT:magnesium", + "NUTRIENT:vitamin_d", + ]); + }); +}); + +describe("buildAlcoholDailySeries", () => { + const at = (n: number, hour = 20) => + new Date(`${dayN(n)}T${String(hour).padStart(2, "0")}:00:00.000Z`); + + it("marks a day 1 when any entry is tagged, 0 when entries are untagged", () => { + const entries = []; + for (let d = 0; d < 12; d += 1) { + entries.push({ at: at(d, 9), tagged: false }); + entries.push({ at: at(d, 21), tagged: d % 2 === 0 }); + } + const out = buildAlcoholDailySeries(entries, "UTC"); + expect(out.key).toBe(ALCOHOL_CHANNEL_KEY); + expect(out.points).toHaveLength(12); + expect(out.points.filter((p) => p.value === 1)).toHaveLength(6); + expect(out.points[0]).toEqual({ day: dayN(0), value: 1 }); + expect(out.points[1]).toEqual({ day: dayN(1), value: 0 }); + }); + + it("is empty below the tagged-day floor or with nothing to compare against", () => { + const few = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: d < MIN_ALCOHOL_DAYS - 1, + })); + expect(buildAlcoholDailySeries(few, "UTC").points).toEqual([]); + const all = Array.from({ length: 20 }, (_, d) => ({ + at: at(d), + tagged: true, + })); + expect(buildAlcoholDailySeries(all, "UTC").points).toEqual([]); + }); +}); + +describe("buildWorkoutMinutesSeries / buildTrainingLoadSeries", () => { + it("sums a day's sessions and zero-fills only between the first and last", () => { + const out = buildWorkoutMinutesSeries( + [ + { startedAt: new Date(`${dayN(0)}T07:00:00Z`), durationSec: 1800 }, + { startedAt: new Date(`${dayN(0)}T18:00:00Z`), durationSec: 900 }, + { startedAt: new Date(`${dayN(3)}T07:00:00Z`), durationSec: 3600 }, + ], + "UTC", + ); + expect(out.points).toEqual([ + { day: dayN(0), value: 45 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 0 }, + { day: dayN(3), value: 60 }, + ]); + }); + + it("keys a session on the person's day, not UTC's", () => { + const out = buildWorkoutMinutesSeries( + [{ startedAt: new Date(`${dayN(0)}T23:30:00Z`), durationSec: 600 }], + "Europe/Berlin", + ); + expect(out.points).toEqual([{ day: dayN(1), value: 10 }]); + }); + + it("fills a missing strain day with zero impulse", () => { + const out = buildTrainingLoadSeries([ + { day: dayN(0), dayTrimp: 80 }, + { day: dayN(2), dayTrimp: 40 }, + ]); + expect(out.points).toEqual([ + { day: dayN(0), value: 80 }, + { day: dayN(1), value: 0 }, + { day: dayN(2), value: 40 }, + ]); + }); +}); + +describe("buildOvernightClimateSeries", () => { + it("keys 21:00–07:00 on the waking day, averages hours, drops thin nights", () => { + const rows = []; + // Night into day 1: 21–23 on day 0 (600 ppm), 0–4 on day 1 (900 ppm). + for (const h of [21, 22, 23]) { + rows.push({ type: "INDOOR_CO2", day: dayN(0), hour: h, mean: 600 }); + } + for (const h of [0, 1, 2, 3, 4]) { + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: h, mean: 900 }); + } + // Daytime hours never count. + rows.push({ type: "INDOOR_CO2", day: dayN(1), hour: 12, mean: 5000 }); + // A thin night into day 2. + for (let h = 0; h < MIN_OVERNIGHT_HOURS - 1; h += 1) { + rows.push({ type: "INDOOR_CO2", day: dayN(2), hour: h, mean: 700 }); + } + const [co2, temp] = buildOvernightClimateSeries( + rows, + ["INDOOR_CO2", "INDOOR_TEMPERATURE"], + INDOOR_CLIMATE_TARGETS, + ); + // (3·600 + 5·900) / 8 = 787.5 + expect(co2.points).toEqual([{ day: dayN(1), value: 787.5 }]); + expect(co2.lagDays).toBe(0); + expect(co2.phrasing).toBe("overnight"); + expect(co2.targets).toEqual(["SLEEP_DURATION"]); + expect(temp.points).toEqual([]); + }); +}); + +describe("discoverCorrelations — targets and phrasing", () => { + // 60 days of a behaviour alternating in blocks, an outcome that is an exact + // linear function of the previous day's behaviour: r = −1 after any + // detrending, n = 59 lag-joined days. + const x = Array.from({ length: 60 }, (_, d) => (d % 4 < 2 ? 100 : 300)); + const sleepNextDay = [420, ...x.slice(0, 59).map((v) => 480 - 0.5 * v)]; + + it("finds caffeine → next-day sleep with the exact n and r", () => { + const series: NamedSeries[] = [ + { key: CAFFEINE_CHANNEL_KEY, role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe(CAFFEINE_CHANNEL_KEY); + expect(pair.n).toBe(59); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("caffeine intake"); + }); + + it("tests a targeted channel against its targets only", () => { + const series: NamedSeries[] = [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + phrasing: "overnight", + targets: INDOOR_CLIMATE_TARGETS, + points: daily(x), + }, + { + key: "SLEEP_DURATION", + role: "outcome", + points: daily(x.map((v) => 480 - 0.5 * v)), + }, + { key: "MOOD", role: "outcome", points: daily(x.map((v) => v / 100)) }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: daily(x.map((v) => 50 + v / 50)), + }, + ]; + const result = discoverCorrelations(series, { locale: "en" }); + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.outcome).toBe("SLEEP_DURATION"); + expect(pair.lagDays).toBe(0); + expect(pair.n).toBe(60); + expect(pair.r).toBeCloseTo(-1, 9); + expect(pair.interpretation).toContain("overnight"); + expect(pair.interpretation).toContain("that night"); + }); + + it("names a micronutrient by its catalog name in the reader's language", () => { + const series: NamedSeries[] = [ + { key: "NUTRIENT:magnesium", role: "behaviour", points: daily(x) }, + { key: "SLEEP_DURATION", role: "outcome", points: daily(sleepNextDay) }, + ]; + const [pair] = discoverCorrelations(series, { locale: "de" }).discovered; + expect(pair.interpretation).toContain("die Zufuhr von Magnesium"); + }); +}); + +describe("families and the model fence", () => { + it("puts State of Mind in mood's family and the new families in their own", () => { + expect(metricFamily("STATE_OF_MIND_VALENCE")).toBe("MOOD"); + expect(metricFamily("INDOOR_CO2")).toBe("INDOOR"); + expect(metricFamily("TRAINING_LOAD")).toBe("TRAINING"); + expect(metricFamily("WORKOUT_MINUTES")).toBe("TRAINING"); + expect(metricFamily("NUTRIENT:zinc")).toBe("NUTRIENTS"); + expect(metricFamily(CAFFEINE_CHANNEL_KEY)).toBe("NUTRIENTS"); + }); + + it("drops every pair that touches a fenced channel", () => { + const pairs = [ + { behaviour: ALCOHOL_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + { behaviour: "MINDFUL_MINUTES", outcome: "MOOD" }, + { behaviour: "ACTIVITY_STEPS", outcome: "STATE_OF_MIND_VALENCE" }, + { behaviour: CAFFEINE_CHANNEL_KEY, outcome: "SLEEP_DURATION" }, + ]; + expect(withoutModelFencedPairs(pairs)).toEqual([pairs[3]]); + expect([...MODEL_FENCED_CHANNELS].sort()).toEqual([ + "ALCOHOL", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + ]); + }); +}); diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 8f53be251..daba264d1 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -48,6 +48,29 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({ fetchSymptomEventSeries: vi.fn(async () => [ { key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts }, ]), + fetchNutrientSeries: vi.fn(async () => [ + { key: "HYDRATION", role: "behaviour", points: pts }, + { key: "CAFFEINE", role: "behaviour", points: pts }, + { key: "NUTRIENT:zinc", role: "behaviour", points: pts }, + ]), + fetchAlcoholSeries: vi.fn(async () => ({ + key: "ALCOHOL", + role: "behaviour", + points: pts, + })), + fetchTrainingSeries: vi.fn(async () => [ + { key: "WORKOUT_MINUTES", role: "behaviour", points: pts }, + { key: "TRAINING_LOAD", role: "behaviour", points: pts }, + ]), + fetchIndoorClimateSeries: vi.fn(async () => [ + { + key: "INDOOR_CO2", + role: "behaviour", + lagDays: 0, + targets: ["SLEEP_DURATION"], + points: pts, + }, + ]), })); vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(), @@ -58,8 +81,12 @@ import { maskSeriesByModules, } from "@/lib/insights/discovery-matrix"; import { + fetchAlcoholSeries, fetchEnvironmentSeries, + fetchIndoorClimateSeries, fetchMovementSeries, + fetchNutrientSeries, + fetchTrainingSeries, } from "@/lib/insights/correlation-channel-series"; const OPTS = { @@ -177,6 +204,77 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => { }); }); +describe("assembleDiscoveryMatrix — the extended families (v1.44)", () => { + const EXTENDED = [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]; + + it("folds every extended family in after the earlier ones", async () => { + const { series, byMetric } = await assembleDiscoveryMatrix("u1", OPTS); + const keys = series.map((s) => s.key); + for (const key of EXTENDED) expect(keys, key).toContain(key); + // Earlier families keep their place: the first extended key comes after + // the last person-defined symptom. + expect(keys.indexOf("EXERCISE_MINUTES")).toBeGreaterThan( + keys.indexOf("SYMPTOM:def1"), + ); + expect(series.find((s) => s.key === "STATE_OF_MIND_VALENCE")?.role).toBe( + "outcome", + ); + // The extended types feed the scan only, never the narrative's metrics. + for (const key of ["EXERCISE_MINUTES", "STATE_OF_MIND_VALENCE"]) { + expect(byMetric.has(key), key).toBe(false); + } + }); + + it("reads and keeps each family only while its module is on", async () => { + const reads = [ + fetchNutrientSeries, + fetchAlcoholSeries, + fetchTrainingSeries, + fetchIndoorClimateSeries, + ].map((fn) => vi.mocked(fn)); + for (const read of reads) read.mockClear(); + const { series } = await assembleDiscoveryMatrix("u1", { + ...OPTS, + modules: { + nutrients: false, + mood: false, + workouts: false, + environment: false, + }, + }); + for (const read of reads) expect(read).not.toHaveBeenCalled(); + const keys = series.map((s) => s.key); + for (const gone of [ + "HYDRATION", + "CAFFEINE", + "NUTRIENT:zinc", + "ALCOHOL", + "WORKOUT_MINUTES", + "TRAINING_LOAD", + "MINDFUL_MINUTES", + "STATE_OF_MIND_VALENCE", + "INDOOR_CO2", + ]) { + expect(keys, gone).not.toContain(gone); + } + // Exercise and stand minutes are ambient movement and carry no owner. + expect(keys).toContain("EXERCISE_MINUTES"); + expect(keys).toContain("STAND_MINUTES"); + }); +}); + describe("maskSeriesByModules", () => { it("keeps the fold order of what remains", () => { const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({ diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 0afd64e98..03273cfa8 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: vi.fn() }, measurement: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 47ea68dbb..cce0f970f 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -30,6 +30,8 @@ import { } from "@/lib/analytics/compliance"; import { FACTOR_CHANNEL_PREFIX, + INDOOR_CLIMATE_TARGETS, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, @@ -46,7 +48,12 @@ import { } from "@/lib/environment/fields"; import { isAirQualityActive } from "@/lib/environment/open-meteo-air-quality"; import { + buildAlcoholDailySeries, buildComplianceDailySeries, + buildNutrientDailySeries, + buildOvernightClimateSeries, + buildTrainingLoadSeries, + buildWorkoutMinutesSeries, buildSymptomEventDailySeries, buildSymptomSeverityDailySeries, type SymptomDayLogRow, @@ -62,7 +69,14 @@ import { pickMainNightAndNaps, type SleepStageRow, } from "@/lib/analytics/sleep-night"; -import { isNearUtc, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { + isNearUtc, + isValidTimezone, + shiftDateKey, + userDayKey, +} from "@/lib/tz/format"; +import { MIN_PAIRED_N } from "@/lib/insights/correlations"; +import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { @@ -1155,3 +1169,171 @@ export async function fetchLabDraws( } return draws; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** + * Upper bound on nutrient rows read for one scan: 180 days × 28 codes × two + * sources is about ten thousand; the cap falls on the oldest rows. + */ +const NUTRIENT_READ_CAP = 12_000; + +/** + * HYDRATION, CAFFEINE and up to four micronutrients from the nutrient log. + * The caller skips this read when the opt-in `nutrients` module is off. + */ +export async function fetchNutrientSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.nutrientIntakeDay.findMany({ + where: { userId, day: { gte: userDayKey(since, tz) } }, + orderBy: { day: "desc" }, + take: NUTRIENT_READ_CAP, + select: { day: true, nutrient: true, amount: true }, + }); + return buildNutrientDailySeries(rows, { minDays: MIN_PAIRED_N }); +} + +/** The mood read the alcohol channel shares its cap with. */ +const ALCOHOL_TAG_KEY = "alcohol"; + +/** + * The ALCOHOL channel from the mood log's `alcohol` tag: a structured tag + * link, or the key in the legacy flat `tags` list. Only the timestamp and the + * tag's presence are read; no note, no score. + */ +export async function fetchAlcoholSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "desc" }, + take: MOOD_READ_CAP, + select: { + moodLoggedAt: true, + tags: true, + tagLinks: { + where: { moodTag: { key: ALCOHOL_TAG_KEY } }, + select: { moodTagId: true }, + take: 1, + }, + }, + }); + return buildAlcoholDailySeries( + rows.map((row) => ({ + at: row.moodLoggedAt, + tagged: row.tagLinks.length > 0 || flatTagsInclude(row.tags), + })), + tz, + ); +} + +/** Whether a legacy flat `tags` JSON array names the alcohol tag. */ +function flatTagsInclude(raw: string | null): boolean { + if (!raw) return false; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) && parsed.includes(ALCOHOL_TAG_KEY); + } catch { + return false; + } +} + +/** Upper bound on workout rows read for one scan. */ +const WORKOUT_READ_CAP = 3000; + +/** + * WORKOUT_MINUTES (sessions collapsed across sources by the workout list's + * picker) and TRAINING_LOAD (the strain engine's stored day TRIMP). The caller + * skips this read when the `workouts` module is off. + */ +export async function fetchTrainingSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const [workouts, account, trimp] = await Promise.all([ + prisma.workout.findMany({ + where: { userId, startedAt: { gte: since } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: WORKOUT_READ_CAP, + select: { + id: true, + source: true, + sportType: true, + startedAt: true, + durationSec: true, + }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { sourcePriorityJson: true }, + }), + prisma.strainTrimpCache.findMany({ + // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day (scoreDayKey) + where: { userId, day: { gte: since.toISOString().slice(0, 10) } }, + orderBy: { day: "asc" }, + take: 1000, + select: { day: true, dayTrimp: true }, + }), + ]); + const sessions = pickCanonicalWorkoutRows( + workouts, + account?.sourcePriorityJson ?? null, + ); + return [ + buildWorkoutMinutesSeries(sessions, tz), + buildTrainingLoadSeries(trimp), + ]; +} + +/** + * The room-climate channels, one per type, as overnight means keyed on the + * waking day. Postgres folds the readings to one row per local hour (a sensor + * reporting every minute is ~260k rows over the window; the hours are at most + * 180 × 24 per type), so the process never holds the raw readings. The caller + * skips this read when the `environment` module is off. + */ +export async function fetchIndoorClimateSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const zone = isValidTimezone(tz) ? tz : "UTC"; + const rows = await prisma.$queryRaw< + Array<{ type: string; day: string; hour: number; mean: number }> + >` + WITH src AS ( + SELECT + m."type"::text AS type, + (m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${zone} AS local_at, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${userId} + AND m."type" IN ('INDOOR_CO2'::measurement_type, 'INDOOR_TEMPERATURE'::measurement_type, 'INDOOR_HUMIDITY'::measurement_type) + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${since} + ) + SELECT + type, + to_char(local_at, 'YYYY-MM-DD') AS day, + EXTRACT(HOUR FROM local_at)::int AS hour, + AVG(value)::double precision AS mean + FROM src + GROUP BY type, day, hour + `; + return buildOvernightClimateSeries( + rows.map((r) => ({ + type: r.type, + day: r.day, + hour: Number(r.hour), + mean: Number(r.mean), + })), + INDOOR_CLIMATE_TYPES, + INDOOR_CLIMATE_TARGETS, + ); +} diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index c08f20f70..11e833f89 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -138,6 +138,20 @@ export function metricFamily(key: string): string { if (key.startsWith("ENV_")) return "ENVIRONMENT"; if (key.startsWith("MOVEMENT_")) return "MOVEMENT"; if (key.startsWith("CUSTOM_METRIC:")) return key; + // v1.44 — State of Mind is a mood self-report from another app: pairing it + // with the logged mood would correlate one feeling with itself. + if (key === "STATE_OF_MIND_VALENCE") return "MOOD"; + // The room-climate readings are one room's air; the training channels one + // training record; the intake channels one supplement log. + if (key.startsWith("INDOOR_")) return "INDOOR"; + if (key === WORKOUT_MINUTES_CHANNEL_KEY || key === TRAINING_LOAD_CHANNEL_KEY) + return "TRAINING"; + if ( + key === HYDRATION_CHANNEL_KEY || + key === CAFFEINE_CHANNEL_KEY || + key.startsWith(NUTRIENT_CHANNEL_PREFIX) + ) + return "NUTRIENTS"; // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form // their OWN single-channel family, so the loop's same-family guard collapses // only the self-lag (compliance→compliance, symptom→symptom). The returned @@ -196,6 +210,19 @@ export interface NamedSeries { * there means the averaged 0–1 window, tested as one hypothesis. */ lagDays?: number; + /** + * Behaviour channels only: the outcomes this channel is tested against. + * Absent means every outcome. A channel with one plausible question (the + * bedroom's air and the night's sleep) names it, so the pairs nobody asked + * about do not widen the false-discovery family for every other pair. + */ + targets?: readonly string[]; + /** + * Behaviour channels only: how the sentence names the exposure window when + * it is not the default next day. `overnight` is the night's mean, keyed on + * the day the person woke up (`lagDays: 0`). + */ + phrasing?: "overnight"; } /** One discovered, FDR-surviving correlation pair. */ @@ -325,6 +352,7 @@ interface RawPair { lagDays: number; behaviourLabel?: string; outcomeLabel?: string; + phrasing?: "overnight"; } /** @@ -374,6 +402,7 @@ function interpret( t: Translate, behaviourLabel?: string, outcomeLabel?: string, + phrasing?: "overnight", ): string { const params = { behaviour: behaviourLabel ?? humanise(behaviour, t), @@ -389,7 +418,13 @@ function interpret( tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate"; // Lag 0 is the averaged same-day window of the exposure channels ("on the // day and the day before"); every other pair reads as next-day. - const template = lagDays === 0 ? "dailySameDay" : "daily"; + // The overnight room climate reads as "overnight … that night". + const template = + phrasing === "overnight" + ? "dailyOvernight" + : lagDays === 0 + ? "dailySameDay" + : "daily"; return t(`insights.correlation.${template}.${tierKey}`, params); } @@ -417,6 +452,12 @@ function humanise(key: string, t: Translate): string { .toLowerCase(); return t("insights.correlation.channelFactor", { factor }); } + if (key.startsWith(NUTRIENT_CHANNEL_PREFIX)) { + const code = key.slice(NUTRIENT_CHANNEL_PREFIX.length); + const nutrient = + translateOrNull(t, `nutrients.names.${code}`) ?? code.replace(/_/g, " "); + return t("insights.correlation.channelNutrient", { nutrient }); + } const labelKey = `insights.correlation.channel.${key}`; // An unmapped channel falls back to a readable prettified form, never a // raw key. @@ -463,6 +504,95 @@ export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY"; export const MOVEMENT_AWAY_CHANNEL_KEY = "MOVEMENT_AWAY"; export const MOVEMENT_DISTANCE_CHANNEL_KEY = "MOVEMENT_DISTANCE"; +// ─── v1.44 — the extended channel families ───────────────────────────────── +// +// Folded by the assembler beside the declared matrix rather than into +// `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES`: those two lists also feed the +// per-metric status fingerprint (`status-cache.ts`), and a channel added there +// would make every written assessment card go stale on a reading of a type no +// card is about. Every family below is a behaviour except State of Mind, and +// each degrades to absent below the n ≥ 20 floor like every other channel. + +/** Daily water intake (mL), from the nutrient log. Owned by `nutrients`. */ +export const HYDRATION_CHANNEL_KEY = "HYDRATION"; +/** Daily caffeine intake (mg), from the nutrient log. Owned by `nutrients`. */ +export const CAFFEINE_CHANNEL_KEY = "CAFFEINE"; +/** One micronutrient's daily intake: `NUTRIENT:`. */ +export const NUTRIENT_CHANNEL_PREFIX = "NUTRIENT:"; +/** + * Days with alcohol, from the `alcohol` tag on mood entries: 1 on a day with a + * tagged entry, 0 on a day with entries and no tag, nothing on a day without + * an entry. Owned by `mood`. AUDIT-C is not a channel: it is a questionnaire + * answered every few months about the past year, and has no day to pair. + */ +export const ALCOHOL_CHANNEL_KEY = "ALCOHOL"; +/** Minutes of workout per day, one per session. Owned by `workouts`. */ +export const WORKOUT_MINUTES_CHANNEL_KEY = "WORKOUT_MINUTES"; +/** The day's heart-rate training impulse (TRIMP). Owned by `workouts`. */ +export const TRAINING_LOAD_CHANNEL_KEY = "TRAINING_LOAD"; + +/** Apple Health day totals read as behaviours (minutes per day). */ +export const EXTENDED_BEHAVIOUR_TYPES = [ + "EXERCISE_MINUTES", + "STAND_MINUTES", + "MINDFUL_MINUTES", +] as const; + +/** Apple Health readings read as outcomes. */ +export const EXTENDED_OUTCOME_TYPES = ["STATE_OF_MIND_VALENCE"] as const; + +/** + * Room-climate readings, paired with the night's sleep only. The value is the + * mean of the readings between 21:00 and 07:00 local time, keyed on the day + * the person woke up, so it pairs with that night's sleep at lag 0. + */ +export const INDOOR_CLIMATE_TYPES = [ + "INDOOR_CO2", + "INDOOR_TEMPERATURE", + "INDOOR_HUMIDITY", +] as const; +export const INDOOR_CLIMATE_TARGETS: readonly string[] = ["SLEEP_DURATION"]; + +/** Every fixed channel key the extended families can write. */ +export const EXTENDED_CHANNEL_KEYS: readonly string[] = [ + HYDRATION_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + ALCOHOL_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ...INDOOR_CLIMATE_TYPES, +]; + +/** + * Channels whose pairs are statistics for the person only and never reach a + * model: the Coach tool, the period narrative and the assessment cards drop + * every pair that touches one. State of Mind and mindfulness sit in the + * report's sensitive group and are kept out of the Coach snapshot and MCP; + * alcohol reaches a model only through the health profile's own opt-in. + * + * The scan itself is the same on every surface, so the statistics of the + * remaining pairs do not depend on who is reading; the fence is applied to + * the result. + */ +export const MODEL_FENCED_CHANNELS: ReadonlySet = new Set([ + "STATE_OF_MIND_VALENCE", + "MINDFUL_MINUTES", + ALCOHOL_CHANNEL_KEY, +]); + +/** Drop the pairs that touch a {@link MODEL_FENCED_CHANNELS} channel. */ +export function withoutModelFencedPairs< + T extends { behaviour: string; outcome: string }, +>(pairs: readonly T[]): T[] { + return pairs.filter( + (p) => + !MODEL_FENCED_CHANNELS.has(p.behaviour) && + !MODEL_FENCED_CHANNELS.has(p.outcome), + ); +} + /** * v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum * values: each is backed by a different model (MOOD → MoodEntry, @@ -543,6 +673,7 @@ export function discoverCorrelations( // auto-correlation tautology, not a cross-domain insight; excluding it // before testing stops it crowding out genuine cross-metric links. if (metricFamily(b.key) === metricFamily(o.key)) continue; + if (b.targets && !b.targets.includes(o.key)) continue; const pairLag = b.lagDays ?? lagDays; const pairs = lagJoinPairs(b.points, o.points, pairLag); if (pairs.length < minPairs) continue; @@ -558,6 +689,7 @@ export function discoverCorrelations( lagDays: pairLag, behaviourLabel: b.label, outcomeLabel: o.label, + ...(b.phrasing ? { phrasing: b.phrasing } : {}), }); } } @@ -612,6 +744,7 @@ export function discoverCorrelations( translate, t.behaviourLabel, t.outcomeLabel, + t.phrasing, ), lagDays: t.lagDays, })) diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts index a34095560..dc3381884 100644 --- a/src/lib/insights/correlation-series-builders.ts +++ b/src/lib/insights/correlation-series-builders.ts @@ -38,8 +38,14 @@ import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { shiftDateKey } from "@/lib/tz/format"; import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; import { + ALCOHOL_CHANNEL_KEY, + CAFFEINE_CHANNEL_KEY, + HYDRATION_CHANNEL_KEY, MEDICATION_COMPLIANCE_CHANNEL_KEY, + NUTRIENT_CHANNEL_PREFIX, SYMPTOM_SEVERITY_CHANNEL_KEY, + TRAINING_LOAD_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -244,3 +250,239 @@ export function buildSymptomEventDailySeries(args: { points, }; } + +// ─── v1.44 — the extended channel families ───────────────────────────────── + +/** One stored nutrient day total (one row per day, nutrient and source). */ +export interface NutrientDayRow { + day: string; + nutrient: string; + amount: number; +} + +/** Ceiling on `NUTRIENT:` channels in one scan. */ +export const MAX_MICRONUTRIENT_CHANNELS = 4; + +/** + * The intake channels from the nutrient log: water as HYDRATION, caffeine as + * CAFFEINE, and at most {@link MAX_MICRONUTRIENT_CHANNELS} micronutrients as + * `NUTRIENT:`. + * + * A day's amount is the sum across its sources (a manual water entry and the + * Apple Health total are two rows, and reads sum them). A day without a row is + * no point: the log does not say whether nothing was taken or nothing was + * recorded. The micronutrients admitted are those with at least `minDays` + * logged days and any day-to-day variance, the most-logged first (code as the + * tie-break): a daily supplement at a fixed dose is a constant, and a + * constant cannot correlate with anything. Pure. + */ +export function buildNutrientDailySeries( + rows: readonly NutrientDayRow[], + opts: { minDays: number }, +): NamedSeries[] { + const byNutrient = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.amount)) continue; + const days = byNutrient.get(row.nutrient) ?? new Map(); + days.set(row.day, (days.get(row.day) ?? 0) + row.amount); + byNutrient.set(row.nutrient, days); + } + const pointsOf = (code: string): DailySeriesPoint[] => + [...(byNutrient.get(code) ?? new Map()).entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + + const out: NamedSeries[] = [ + { + key: HYDRATION_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("water"), + }, + { + key: CAFFEINE_CHANNEL_KEY, + role: "behaviour", + points: pointsOf("caffeine"), + }, + ]; + const candidates = [...byNutrient.keys()] + .filter((code) => code !== "water" && code !== "caffeine") + .map((code) => ({ code, points: pointsOf(code) })) + .filter( + ({ points }) => + points.length >= opts.minDays && + points.some((p) => p.value !== points[0].value), + ) + .sort( + (a, b) => b.points.length - a.points.length || (a.code < b.code ? -1 : 1), + ) + .slice(0, MAX_MICRONUTRIENT_CHANNELS); + for (const { code, points } of candidates) { + out.push({ + key: `${NUTRIENT_CHANNEL_PREFIX}${code}`, + role: "behaviour", + points, + }); + } + return out; +} + +/** One mood entry as the alcohol channel reads it. */ +export interface AlcoholTagEntry { + at: Date; + tagged: boolean; +} + +/** Fewest tagged days before the alcohol channel is offered at all. */ +export const MIN_ALCOHOL_DAYS = 5; + +/** + * The ALCOHOL behaviour series: per day with at least one mood entry, 1 when + * any entry that day carries the `alcohol` tag and 0 otherwise. A day without + * an entry is no point. Below {@link MIN_ALCOHOL_DAYS} tagged days (or with no + * untagged day to compare against) the series is empty: a handful of ones is + * what a spurious point-biserial r is made of. Pure. + */ +export function buildAlcoholDailySeries( + entries: readonly AlcoholTagEntry[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const entry of entries) { + const day = dayKey(entry.at, tz); + byDay.set(day, Math.max(byDay.get(day) ?? 0, entry.tagged ? 1 : 0)); + } + const points = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); + const tagged = points.filter((p) => p.value === 1).length; + const usable = tagged >= MIN_ALCOHOL_DAYS && tagged < points.length; + return { + key: ALCOHOL_CHANNEL_KEY, + role: "behaviour", + points: usable ? points : [], + }; +} + +/** One canonical workout session as the training channels read it. */ +export interface WorkoutSessionRow { + startedAt: Date; + durationSec: number; +} + +/** + * Fill the days between the first and the last key with 0. Inside that span + * the person was recording training, so a day without a session is a rest + * day; outside it nothing says whether they were recording at all. Pure. + */ +function zeroFillSpan(byDay: ReadonlyMap): DailySeriesPoint[] { + const keys = [...byDay.keys()].sort(); + const points: DailySeriesPoint[] = []; + if (keys.length === 0) return points; + const last = keys[keys.length - 1]; + for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) { + points.push({ day, value: byDay.get(day) ?? 0 }); + } + return points; +} + +/** + * WORKOUT_MINUTES: the day's summed session minutes (user zone, by start), + * over sessions the caller has already collapsed across sources, zero-filled + * between the first and the last session. Pure. + */ +export function buildWorkoutMinutesSeries( + sessions: readonly WorkoutSessionRow[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const s of sessions) { + if (!Number.isFinite(s.durationSec) || s.durationSec <= 0) continue; + const day = dayKey(s.startedAt, tz); + byDay.set(day, (byDay.get(day) ?? 0) + s.durationSec / 60); + } + return { + key: WORKOUT_MINUTES_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** + * TRAINING_LOAD: the stored day-total TRIMP, zero-filled between the first and + * the last stored day. The nightly strain run writes a row only on a scored + * day, so a missing day inside the span carried no training impulse. The day + * keys are the strain engine's UTC days (the same tolerance the DAY rollups + * carry). Pure. + */ +export function buildTrainingLoadSeries( + rows: ReadonlyArray<{ day: string; dayTrimp: number }>, +): NamedSeries { + const byDay = new Map(); + for (const row of rows) { + if (!Number.isFinite(row.dayTrimp) || row.dayTrimp < 0) continue; + byDay.set(row.day, row.dayTrimp); + } + return { + key: TRAINING_LOAD_CHANNEL_KEY, + role: "behaviour", + points: zeroFillSpan(byDay), + }; +} + +/** One local hour's mean of one room-climate type. */ +export interface ClimateHourRow { + type: string; + /** Local calendar day of the hour, YYYY-MM-DD. */ + day: string; + /** Local hour, 0–23. */ + hour: number; + mean: number; +} + +/** The overnight window, local time: from this hour … */ +export const OVERNIGHT_FROM_HOUR = 21; +/** … until (exclusive) this hour of the next morning. */ +export const OVERNIGHT_TO_HOUR = 7; +/** Fewest hours inside the window before a night has a value. */ +export const MIN_OVERNIGHT_HOURS = 4; + +/** + * One series per room-climate type: the night's mean of its hourly means, the + * night running 21:00 to 07:00 local time and keyed on the morning's day, the + * key the sleep series uses. Averaging hours rather than readings keeps a + * sensor that reports every minute from outweighing one that reports every + * quarter hour. A night with fewer than {@link MIN_OVERNIGHT_HOURS} hours has + * no point. Each series pairs with the night's sleep only, at lag 0. Pure. + */ +export function buildOvernightClimateSeries( + rows: readonly ClimateHourRow[], + types: readonly string[], + targets: readonly string[], +): NamedSeries[] { + const nights = new Map>(); + for (const row of rows) { + if (!Number.isFinite(row.mean)) continue; + let night: string; + if (row.hour >= OVERNIGHT_FROM_HOUR) night = shiftDateKey(row.day, 1); + else if (row.hour < OVERNIGHT_TO_HOUR) night = row.day; + else continue; + const byNight = + nights.get(row.type) ?? new Map(); + const acc = byNight.get(night) ?? { sum: 0, n: 0 }; + acc.sum += row.mean; + acc.n += 1; + byNight.set(night, acc); + nights.set(row.type, byNight); + } + return types.map((type) => ({ + key: type, + role: "behaviour" as const, + lagDays: 0, + phrasing: "overnight" as const, + targets, + points: [...(nights.get(type) ?? new Map()).entries()] + .filter(([, acc]) => acc.n >= MIN_OVERNIGHT_HOURS) + .map(([day, acc]) => ({ day, value: acc.sum / acc.n })) + .sort((a, b) => (a.day < b.day ? -1 : 1)), + })); +} diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index cfbc2e8eb..78a594444 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -35,6 +35,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], measurement: { findMany: (a: unknown) => measurementFindMany(a), findFirst: (a: unknown) => measurementFindFirst(a), diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index dd7ee058a..9cddb927f 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -36,18 +36,28 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { + ALCOHOL_CHANNEL_KEY, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, discoveryMeasurementTypes, + EXTENDED_BEHAVIOUR_TYPES, + EXTENDED_OUTCOME_TYPES, + HYDRATION_CHANNEL_KEY, + INDOOR_CLIMATE_TYPES, MEDICATION_COMPLIANCE_CHANNEL_KEY, MOVEMENT_AWAY_CHANNEL_KEY, + WORKOUT_MINUTES_CHANNEL_KEY, SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { buildMeasurementDailySeries, + fetchAlcoholSeries, fetchComplianceSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, fetchCustomMetricBehaviourSeries, fetchSymptomEventSeries, fetchEnvironmentSeries, @@ -162,6 +172,14 @@ export interface DiscoveryMatrixDiagnostics { moodFactorChannels: number; /** `SYMPTOM:*` channels read (before the module mask). */ symptomEventChannels: number; + /** Stored daily points summed across the intake channels (v1.44). */ + nutrientDays: number; + /** Days in the ALCOHOL series (0 below its tagged-day floor). */ + alcoholDays: number; + /** Daily points summed across the two training channels. */ + trainingDays: number; + /** Nights summed across the room-climate channels. */ + indoorNights: number; } export interface DiscoveryMatrix { @@ -260,6 +278,11 @@ async function readMeasurementDaily( }; } +/** Daily points summed across a family's series. */ +function daysIn(family: readonly NamedSeries[]): number { + return family.reduce((sum, s) => sum + s.points.length, 0); +} + /** * Build the full discovery matrix for a user over a window. * @@ -292,8 +315,18 @@ export async function assembleDiscoveryMatrix( ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), ...(opts.extraMeasurementTypes ?? []), + // v1.44 — the Apple Health day totals and State of Mind read through the + // same measurement path; they are left out of `byMetric` below. + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, ]), ) as MeasurementType[]; + const extendedTypes = new Set([ + ...EXTENDED_BEHAVIOUR_TYPES, + ...EXTENDED_OUTCOME_TYPES, + ]); + const visible = (channelKey: string): boolean => + isSurfaceVisible(correlationChannelSurfaceId(channelKey), opts.modules); const [ measurements, @@ -304,6 +337,10 @@ export async function assembleDiscoveryMatrix( customMetricSeries, symptomEventSeries, movementSeries, + nutrientSeries, + alcoholSeries, + trainingSeries, + indoorSeries, ] = await Promise.all([ readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode), readMood(userId, tz, since, opts.includeMoodFactors === true), @@ -329,6 +366,20 @@ export async function assembleDiscoveryMatrix( ) ? fetchMovementSeries(userId, tz, since) : Promise.resolve([] as NamedSeries[]), + // v1.44 — each extended family is read only while its module is on; the + // mask below would drop it anyway. + visible(HYDRATION_CHANNEL_KEY) + ? fetchNutrientSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(ALCOHOL_CHANNEL_KEY) + ? fetchAlcoholSeries(userId, tz, since) + : Promise.resolve(null), + visible(WORKOUT_MINUTES_CHANNEL_KEY) + ? fetchTrainingSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), + visible(INDOOR_CLIMATE_TYPES[0]) + ? fetchIndoorClimateSeries(userId, tz, since) + : Promise.resolve([] as NamedSeries[]), ]); const { factorSeries } = mood; @@ -366,6 +417,21 @@ export async function assembleDiscoveryMatrix( // channel and its own family, capped at eight; the illness module owns them // through the `SYMPTOM:` prefix rule in the surface map. series.push(...symptomEventSeries); + // v1.44 — the extended families, folded after every earlier family so the + // ranking of the pairs that existed before is unchanged on a tie. The + // intake, alcohol and training channels pair D → D+1; the room climate is + // the overnight mean keyed on the waking day and pairs with that night's + // sleep only. Exercise, stand and mindful minutes are behaviours; State of + // Mind is an outcome in the mood family. + for (const key of EXTENDED_BEHAVIOUR_TYPES) { + series.push({ key, role: "behaviour", points: points(key) }); + } + for (const key of EXTENDED_OUTCOME_TYPES) { + series.push({ key, role: "outcome", points: points(key) }); + } + series.push(...nutrientSeries); + if (alcoholSeries) series.push(alcoholSeries); + series.push(...trainingSeries, ...indoorSeries); // A factor is plausibly both a lag source ("rated work today → next-day // sleep") and a lag target ("steps today → next-day rated energy"), so it // enters as both roles. The engine skips self-pairs — every factor shares @@ -376,6 +442,9 @@ export async function assembleDiscoveryMatrix( } const byMetric = new Map(measurements.byType); + // The extended types feed the scan only. `byMetric` is what the narrative + // cites deltas from, and none of them is a metric it describes. + for (const key of extendedTypes) byMetric.delete(key); if (mood.moodDaily.length > 0) byMetric.set("MOOD", mood.moodDaily); for (const [key, factorPoints] of factorSeries) { byMetric.set(key, factorPoints); @@ -408,6 +477,10 @@ export async function assembleDiscoveryMatrix( ), moodFactorChannels: factorSeries.size, symptomEventChannels: symptomEventSeries.length, + nutrientDays: daysIn(nutrientSeries), + alcoholDays: alcoholSeries?.points.length ?? 0, + trainingDays: daysIn(trainingSeries), + indoorNights: daysIn(indoorSeries), }, }; } diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index e9cfb3bd0..9a9ea9354 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -32,6 +32,7 @@ import { discoverCorrelations, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + withoutModelFencedPairs, } from "@/lib/insights/correlation-discovery"; import { resolveModuleMap } from "@/lib/modules/gate"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; @@ -133,6 +134,9 @@ export async function getRelevantCorrelationsForMetric( // written in its place rather than after it; the docstring, the name // and the `channelKeyForType` guard above all kept saying otherwise. .filter((d) => d.behaviour === channel || d.outcome === channel) + // The card's prompt is model input: a pair on a fenced channel stays + // on the correlations page. + .filter((d) => withoutModelFencedPairs([d]).length === 1) .filter((d) => { const decision = decisionForEvidence(decisions, { factorKey: d.behaviour, diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index d208195f0..92125c7c2 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -38,6 +38,11 @@ vi.mock("@/lib/modules/gate", () => ({ vi.mock("@/lib/db", () => ({ prisma: { + // v1.44 — the extended channel families (intake, training, room climate). + nutrientIntakeDay: { findMany: async () => [] }, + workout: { findMany: async () => [] }, + strainTrimpCache: { findMany: async () => [] }, + $queryRaw: async () => [], user: { findUnique: (a: unknown) => userFindUnique(a) }, measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index d51cdec1b..3a9311a5e 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -48,6 +48,7 @@ import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + withoutModelFencedPairs, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; @@ -422,7 +423,10 @@ export function assemblePeriodNarrativeContext( // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── const discovery = discoverCorrelations(discoverySeries, { locale }); - const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ + // The narrative is model input: pairs on a fenced channel stay out of it. + const drivers: NarrativeDriver[] = withoutModelFencedPairs( + discovery.discovered, + ).map((d) => ({ behaviour: d.behaviour, outcome: d.outcome, r: d.r, diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 7bbf00579..efcd35cbc 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -60,6 +60,8 @@ import { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, DISCOVERY_OUTCOMES, + EXTENDED_CHANNEL_KEYS, + NUTRIENT_CHANNEL_PREFIX, FACTOR_CHANNEL_PREFIX, } from "@/lib/insights/correlation-discovery"; import { DERIVED_METRIC_IDS } from "@/lib/insights/derived/registry"; @@ -197,6 +199,9 @@ describe("every surface id names a surface that exists", () => { MOVEMENT_AWAY_CHANNEL_KEY, MOVEMENT_DISTANCE_CHANNEL_KEY, "LAB_DRAWS", + ...EXTENDED_CHANNEL_KEYS, + // The fold target of every `NUTRIENT:` channel. + "NUTRIENT", ]); for (const id of localIds("correlation")) { expect(channels.has(id), id).toBe(true); @@ -209,6 +214,21 @@ describe("every surface id names a surface that exists", () => { expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe( "correlation:SYMPTOM_SEVERITY", ); + expect(correlationChannelSurfaceId(`${NUTRIENT_CHANNEL_PREFIX}zinc`)).toBe( + "correlation:NUTRIENT", + ); + }); + + it("every extended channel has an owner or is reviewed as unowned", () => { + const unowned = new Set(["EXERCISE_MINUTES", "STAND_MINUTES"]); + for (const key of EXTENDED_CHANNEL_KEYS) { + if (unowned.has(key)) { + expect(surfaceModule(`correlation:${key}`), key).toBeUndefined(); + } else { + expect(surfaceModule(`correlation:${key}`), key).toBeDefined(); + } + } + expect(surfaceModule("correlation:NUTRIENT")).toBe("nutrients"); }); }); diff --git a/src/lib/modules/surface.ts b/src/lib/modules/surface.ts index 8d0c9b029..b70d84516 100644 --- a/src/lib/modules/surface.ts +++ b/src/lib/modules/surface.ts @@ -228,6 +228,24 @@ const STATIC_SURFACE_MODULE = { // v1.43 — the Dawarich movement channels ride the environment module. "correlation:MOVEMENT_AWAY": "environment", "correlation:MOVEMENT_DISTANCE": "environment", + // v1.44 — the extended families. Intake rides the opt-in nutrient log (the + // `NUTRIENT:` micronutrient channels fold onto `correlation:NUTRIENT`), + // alcohol days are read from mood entries' tag, and the two training + // channels from the workout record. State of Mind and mindfulness are + // self-reports about feeling, so they follow mood. The room climate rides + // the environment module like the weather. Exercise and stand minutes are + // ambient movement like steps and carry no owner. + "correlation:HYDRATION": "nutrients", + "correlation:CAFFEINE": "nutrients", + "correlation:NUTRIENT": "nutrients", + "correlation:ALCOHOL": "mood", + "correlation:WORKOUT_MINUTES": "workouts", + "correlation:TRAINING_LOAD": "workouts", + "correlation:MINDFUL_MINUTES": "mood", + "correlation:STATE_OF_MIND_VALENCE": "mood", + "correlation:INDOOR_CO2": "environment", + "correlation:INDOOR_TEMPERATURE": "environment", + "correlation:INDOOR_HUMIDITY": "environment", // ── Day view sections (v1.42) ── // The readings carry no owner here: each value follows its own type's @@ -355,5 +373,8 @@ export function correlationChannelSurfaceId(channelKey: string): string { // A person-defined symptom (`SYMPTOM:`, v1.40) rides the illness // module, like the symptom-burden channel it sits beside. if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY"; + // One micronutrient's intake (`NUTRIENT:`, v1.44) rides the nutrient + // log like water and caffeine. + if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT"; return `correlation:${channelKey}`; } diff --git a/tests/integration/correlation-extended-channels.test.ts b/tests/integration/correlation-extended-channels.test.ts new file mode 100644 index 000000000..30c5c8bf0 --- /dev/null +++ b/tests/integration/correlation-extended-channels.test.ts @@ -0,0 +1,244 @@ +/** + * v1.44 — the extended correlation channel reads against a real Postgres. + * + * The unit battery (`correlation-extended-channels.test.ts`) pins the pure + * shapers; this suite proves the reads that feed them: the room-climate + * hour-mean SQL and its zone conversion, the nutrient day sum across sources, + * the alcohol tag read from the legacy flat list, and the training read with + * a session stored by two services. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { + fetchAlcoholSeries, + fetchIndoorClimateSeries, + fetchNutrientSeries, + fetchTrainingSeries, +} from "@/lib/insights/correlation-channel-series"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const DAY_MS = 24 * 60 * 60 * 1000; +const TZ = "Europe/Berlin"; + +let seq = 0; +async function seedUser(prisma: ReturnType) { + seq += 1; + return prisma.user.create({ + data: { + username: `corr-extended-${seq}`, + email: `corr-extended-${seq}@example.test`, + role: "USER", + timezone: TZ, + }, + }); +} + +/** A UTC day key `daysAgo` days back. */ +function dayKey(daysAgo: number): string { + return new Date(Date.now() - daysAgo * DAY_MS).toISOString().slice(0, 10); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("extended correlation channels — reads", () => { + it("folds the room climate to overnight means on the waking day, in the person's zone", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const wake = dayKey(5); + const evening = dayKey(6); + // Berlin is UTC+1 or +2; 20:30 UTC on the evening is at least 21:30 + // local, 02:00 UTC on the waking day at least 03:00 local, both inside + // the 21:00–07:00 window. Four hours each side, one reading per quarter + // hour at 600 ppm before midnight and 1000 ppm after. + const rows = []; + for (let h = 0; h < 2; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 600, + measuredAt: new Date( + `${evening}T2${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + for (let h = 1; h < 4; h += 1) { + for (let q = 0; q < 4; q += 1) { + rows.push({ + value: 1000, + measuredAt: new Date( + `${wake}T0${h}:${String(q * 15).padStart(2, "0")}:00Z`, + ), + }); + } + } + // Midday on the waking day is outside the window. + rows.push({ value: 5000, measuredAt: new Date(`${wake}T11:00:00Z`) }); + await prisma.measurement.createMany({ + data: rows.map((r) => ({ + userId: user.id, + type: "INDOOR_CO2" as const, + unit: "ppm", + source: "EXTERNAL" as const, + ...r, + })), + }); + + const series = await fetchIndoorClimateSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + const co2 = series.find((s) => s.key === "INDOOR_CO2"); + // Two evening hours at 600, three morning hours at 1000: (1200 + 3000) / 5. + expect(co2?.points).toEqual([{ day: wake, value: 840 }]); + expect(co2?.targets).toEqual(["SLEEP_DURATION"]); + }); + + it("sums a nutrient day across sources", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + await prisma.nutrientIntakeDay.createMany({ + data: [ + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 1500, + unit: "ml", + source: "APPLE_HEALTH", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "water", + amount: 250, + unit: "ml", + source: "MANUAL", + }, + { + userId: user.id, + day: dayKey(3), + nutrient: "caffeine", + amount: 160, + unit: "mg", + source: "APPLE_HEALTH", + }, + ], + }); + const series = await fetchNutrientSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.find((s) => s.key === "HYDRATION")?.points).toEqual([ + { day: dayKey(3), value: 1750 }, + ]); + expect(series.find((s) => s.key === "CAFFEINE")?.points).toEqual([ + { day: dayKey(3), value: 160 }, + ]); + }); + + it("reads alcohol days from the flat tag list and skips deleted entries", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const data = []; + for (let d = 1; d <= 12; d += 1) { + data.push({ + userId: user.id, + date: dayKey(d), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(d)}T12:00:00Z`), + tags: d % 2 === 0 ? JSON.stringify(["alcohol"]) : null, + }); + } + data.push({ + userId: user.id, + date: dayKey(13), + mood: "GUT", + score: 4, + source: "MANUAL", + moodLoggedAt: new Date(`${dayKey(13)}T12:00:00Z`), + tags: JSON.stringify(["alcohol"]), + deletedAt: new Date(), + }); + await prisma.moodEntry.createMany({ data }); + const series = await fetchAlcoholSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(series.points).toHaveLength(12); + expect(series.points.filter((p) => p.value === 1)).toHaveLength(6); + }); + + it("counts a workout stored by two services once and reads the stored impulse", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + const start = new Date(`${dayKey(4)}T10:00:00Z`); + await prisma.workout.createMany({ + data: [ + { + userId: user.id, + sportType: "RUNNING", + source: "APPLE_HEALTH", + startedAt: start, + endedAt: new Date(start.getTime() + 1800_000), + durationSec: 1800, + externalId: "a", + }, + { + userId: user.id, + sportType: "RUNNING", + source: "STRAVA", + startedAt: new Date(start.getTime() + 30_000), + endedAt: new Date(start.getTime() + 1830_000), + durationSec: 1795, + externalId: "s", + }, + ], + }); + await prisma.strainTrimpCache.createMany({ + data: [ + { + userId: user.id, + day: dayKey(4), + dayTrimp: 70, + refPersonal: 150, + anchor: "population", + trainingDays: 1, + }, + { + userId: user.id, + day: dayKey(2), + dayTrimp: 30, + refPersonal: 150, + anchor: "population", + trainingDays: 2, + }, + ], + }); + const [minutes, load] = await fetchTrainingSeries( + user.id, + TZ, + new Date(Date.now() - 30 * DAY_MS), + ); + expect(minutes.points).toEqual([{ day: dayKey(4), value: 30 }]); + expect(load.points).toEqual([ + { day: dayKey(4), value: 70 }, + { day: dayKey(3), value: 0 }, + { day: dayKey(2), value: 30 }, + ]); + }); +});