mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(insights): correlate intake, alcohol days, training, rings, State of Mind and room climate
The discovery matrix gains the families people log but could not test: water, caffeine and up to four varying micronutrients from the nutrient log, alcohol days from the mood tag, workout minutes (one per session) and the day's training impulse, exercise, stand and mindful minutes, State of Mind as an outcome, and the overnight room climate against that night's sleep only. Every channel runs through the same seasonal adjustment, effective-n p-value, Benjamini-Hochberg control and effect-size floor, is read only while its module is on, and drops out below the paired-day floor. State of Mind, mindful minutes and alcohol days are statistics for the person: the Coach, the period narrative and the assessment cards drop every pair that touches them, after the shared scan so the remaining pairs keep the statistics the correlations page shows. A guard walks the import closure of the analysis modules and refuses any path to a provider.
This commit is contained in:
1 parent
30248efaf1
commit
faa3ef867b
31 files changed
+1706
-34
No files matched your search
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
+26
-2
@@ -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": {
|
||||
|
||||
@@ -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<string>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => {
|
||||
"fetchMoodFactorWindowSeries",
|
||||
"fetchMeasurementDailySeriesTiered",
|
||||
"fetchMeasurementWindowSeries",
|
||||
"fetchNutrientSeries",
|
||||
"fetchAlcoholSeries",
|
||||
"fetchTrainingSeries",
|
||||
"fetchIndoorClimateSeries",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,10 @@ const SQL_READERS: Record<string, Entry> = {
|
||||
"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. */
|
||||
|
||||
@@ -58,6 +58,10 @@ const IDENTITY_OWNER: Record<string, string> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -80,8 +80,28 @@ const CHANNEL_LABEL_KEY: Record<string, string> = {
|
||||
// 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:<code>`) 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)
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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<string, string> = {
|
||||
*/
|
||||
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, {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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<NamedSeries[]> {
|
||||
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<NamedSeries> {
|
||||
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<NamedSeries[]> {
|
||||
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<NamedSeries[]> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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:<catalog code>`. */
|
||||
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<string> = 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,
|
||||
}))
|
||||
|
||||
@@ -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:<code>` 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:<code>`.
|
||||
*
|
||||
* 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<string, Map<string, number>>();
|
||||
for (const row of rows) {
|
||||
if (!Number.isFinite(row.amount)) continue;
|
||||
const days = byNutrient.get(row.nutrient) ?? new Map<string, number>();
|
||||
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<string, number>()).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<string, number>();
|
||||
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<string, number>): 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<string, number>();
|
||||
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<string, number>();
|
||||
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<string, Map<string, { sum: number; n: number }>>();
|
||||
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<string, { sum: number; n: number }>();
|
||||
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)),
|
||||
}));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string>([
|
||||
...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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:<code>` 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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:<code>` 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:<id>`, 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:<code>`, v1.44) rides the nutrient
|
||||
// log like water and caffeine.
|
||||
if (channelKey.startsWith("NUTRIENT:")) return "correlation:NUTRIENT";
|
||||
return `correlation:${channelKey}`;
|
||||
}
|
||||
@@ -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<typeof getPrismaClient>) {
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user