mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.
Route every correlation narrative through the server translator:
- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
the reader's locale, resolved from the correlations route the same way
other insight generators resolve it. Sentence templates, the direction
word, and every channel label live under a new insights.correlation.*
namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
(BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
localised the same way; the analytics cache key now includes the locale
so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
causal framing ("not a cause" / "an association, never a cause") and the
faint/moderate/high hedging tiers are preserved in every language.
English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
This commit is contained in:
1 parent
36f73233b0
commit
04320c415d
12 files changed
+566
-67
No files matched your search
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "höher",
|
||||
"lower": "niedriger"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "über",
|
||||
"below": "unter"
|
||||
},
|
||||
"channelFactor": "die Bewertung von {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "das Gewicht",
|
||||
"BLOOD_GLUCOSE": "der Blutzucker",
|
||||
"BLOOD_PRESSURE_SYS": "der systolische Blutdruck",
|
||||
"BLOOD_PRESSURE_DIA": "der diastolische Blutdruck",
|
||||
"ACTIVITY_STEPS": "die tägliche Schrittzahl",
|
||||
"SLEEP_DURATION": "die Schlafdauer",
|
||||
"HEART_RATE_VARIABILITY": "die Herzratenvariabilität",
|
||||
"RESTING_HEART_RATE": "die Ruheherzfrequenz",
|
||||
"TIME_IN_DAYLIGHT": "die Zeit im Tageslicht",
|
||||
"MOOD": "die Stimmung",
|
||||
"PULSE": "der Ruhepuls",
|
||||
"MEDICATION_COMPLIANCE": "die Medikamententreue",
|
||||
"SYMPTOM_SEVERITY": "die Symptomstärke",
|
||||
"ENV_TEMP_MEAN": "die Tagestemperatur",
|
||||
"ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur",
|
||||
"ENV_SUNSHINE": "der Sonnenschein",
|
||||
"ENV_DAYLIGHT": "das Tageslicht",
|
||||
"ENV_PRECIP": "der Niederschlag",
|
||||
"ENV_PRESSURE_MEAN": "der Luftdruck",
|
||||
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
|
||||
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
|
||||
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
|
||||
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
|
||||
"emptyAddMeasurement": "Messwert erfassen",
|
||||
|
||||
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for adherence"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "higher",
|
||||
"lower": "lower"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "above",
|
||||
"below": "below"
|
||||
},
|
||||
"channelFactor": "rated {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "weight",
|
||||
"BLOOD_GLUCOSE": "blood glucose",
|
||||
"BLOOD_PRESSURE_SYS": "systolic blood pressure",
|
||||
"BLOOD_PRESSURE_DIA": "diastolic blood pressure",
|
||||
"ACTIVITY_STEPS": "daily steps",
|
||||
"SLEEP_DURATION": "sleep duration",
|
||||
"HEART_RATE_VARIABILITY": "heart-rate variability",
|
||||
"RESTING_HEART_RATE": "resting heart rate",
|
||||
"TIME_IN_DAYLIGHT": "time in daylight",
|
||||
"MOOD": "mood",
|
||||
"PULSE": "resting pulse",
|
||||
"MEDICATION_COMPLIANCE": "medication compliance",
|
||||
"SYMPTOM_SEVERITY": "symptom severity",
|
||||
"ENV_TEMP_MEAN": "daily temperature",
|
||||
"ENV_TEMP_MIN": "overnight low temperature",
|
||||
"ENV_SUNSHINE": "sunshine",
|
||||
"ENV_DAYLIGHT": "daylight",
|
||||
"ENV_PRECIP": "precipitation",
|
||||
"ENV_PRESSURE_MEAN": "barometric pressure",
|
||||
"ENV_PRESSURE_DELTA": "intraday pressure swing"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
|
||||
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
|
||||
"none": "Compliance and systolic do not move together strongly in this window."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
|
||||
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
|
||||
"none": "Mood and resting pulse do not move together strongly in this window."
|
||||
},
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "No assessment yet.",
|
||||
"assessmentPreparing": "Preparing your assessment…",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
|
||||
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "subir",
|
||||
"lower": "bajar"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "por encima de",
|
||||
"below": "por debajo de"
|
||||
},
|
||||
"channelFactor": "la valoración de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "el peso",
|
||||
"BLOOD_GLUCOSE": "la glucosa en sangre",
|
||||
"BLOOD_PRESSURE_SYS": "la presión sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "la presión diastólica",
|
||||
"ACTIVITY_STEPS": "los pasos diarios",
|
||||
"SLEEP_DURATION": "la duración del sueño",
|
||||
"HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca",
|
||||
"RESTING_HEART_RATE": "la frecuencia cardíaca en reposo",
|
||||
"TIME_IN_DAYLIGHT": "el tiempo con luz natural",
|
||||
"MOOD": "el estado de ánimo",
|
||||
"PULSE": "el pulso en reposo",
|
||||
"MEDICATION_COMPLIANCE": "la adherencia a la medicación",
|
||||
"SYMPTOM_SEVERITY": "la intensidad de los síntomas",
|
||||
"ENV_TEMP_MEAN": "la temperatura diaria",
|
||||
"ENV_TEMP_MIN": "la temperatura mínima nocturna",
|
||||
"ENV_SUNSHINE": "las horas de sol",
|
||||
"ENV_DAYLIGHT": "la luz natural",
|
||||
"ENV_PRECIP": "las precipitaciones",
|
||||
"ENV_PRESSURE_MEAN": "la presión atmosférica",
|
||||
"ENV_PRESSURE_DELTA": "la variación de presión del día"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
|
||||
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
|
||||
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
|
||||
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
|
||||
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
|
||||
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
|
||||
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
|
||||
},
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Aún no hay valoración.",
|
||||
"assessmentPreparing": "Preparando tu valoración…",
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
|
||||
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "augmenter",
|
||||
"lower": "diminuer"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "au-dessus de",
|
||||
"below": "en dessous de"
|
||||
},
|
||||
"channelFactor": "l'évaluation de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "le poids",
|
||||
"BLOOD_GLUCOSE": "la glycémie",
|
||||
"BLOOD_PRESSURE_SYS": "la pression systolique",
|
||||
"BLOOD_PRESSURE_DIA": "la pression diastolique",
|
||||
"ACTIVITY_STEPS": "le nombre de pas quotidien",
|
||||
"SLEEP_DURATION": "la durée de sommeil",
|
||||
"HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque",
|
||||
"RESTING_HEART_RATE": "la fréquence cardiaque au repos",
|
||||
"TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour",
|
||||
"MOOD": "l'humeur",
|
||||
"PULSE": "le pouls au repos",
|
||||
"MEDICATION_COMPLIANCE": "l'observance des médicaments",
|
||||
"SYMPTOM_SEVERITY": "la sévérité des symptômes",
|
||||
"ENV_TEMP_MEAN": "la température quotidienne",
|
||||
"ENV_TEMP_MIN": "la température minimale nocturne",
|
||||
"ENV_SUNSHINE": "l'ensoleillement",
|
||||
"ENV_DAYLIGHT": "la lumière du jour",
|
||||
"ENV_PRECIP": "les précipitations",
|
||||
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
|
||||
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
|
||||
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
|
||||
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
|
||||
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
|
||||
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
|
||||
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
|
||||
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
|
||||
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Pas encore d’évaluation.",
|
||||
"assessmentPreparing": "Préparation de votre évaluation…",
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
|
||||
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "salire",
|
||||
"lower": "scendere"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "sopra",
|
||||
"below": "sotto"
|
||||
},
|
||||
"channelFactor": "la valutazione di {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "il peso",
|
||||
"BLOOD_GLUCOSE": "la glicemia",
|
||||
"BLOOD_PRESSURE_SYS": "la pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "la pressione diastolica",
|
||||
"ACTIVITY_STEPS": "i passi giornalieri",
|
||||
"SLEEP_DURATION": "la durata del sonno",
|
||||
"HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca",
|
||||
"RESTING_HEART_RATE": "la frequenza cardiaca a riposo",
|
||||
"TIME_IN_DAYLIGHT": "il tempo alla luce del giorno",
|
||||
"MOOD": "l'umore",
|
||||
"PULSE": "il polso a riposo",
|
||||
"MEDICATION_COMPLIANCE": "l'aderenza ai farmaci",
|
||||
"SYMPTOM_SEVERITY": "la gravità dei sintomi",
|
||||
"ENV_TEMP_MEAN": "la temperatura giornaliera",
|
||||
"ENV_TEMP_MIN": "la temperatura minima notturna",
|
||||
"ENV_SUNSHINE": "il soleggiamento",
|
||||
"ENV_DAYLIGHT": "la luce del giorno",
|
||||
"ENV_PRECIP": "le precipitazioni",
|
||||
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
|
||||
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
|
||||
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
|
||||
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
|
||||
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
|
||||
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
|
||||
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
|
||||
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
|
||||
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Ancora nessuna valutazione.",
|
||||
"assessmentPreparing": "Preparazione della valutazione…",
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
|
||||
@@ -1979,6 +1979,61 @@
|
||||
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "wzrostu",
|
||||
"lower": "spadku"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "powyżej",
|
||||
"below": "poniżej"
|
||||
},
|
||||
"channelFactor": "ocena czynnika {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "waga",
|
||||
"BLOOD_GLUCOSE": "poziom cukru we krwi",
|
||||
"BLOOD_PRESSURE_SYS": "ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe",
|
||||
"ACTIVITY_STEPS": "dzienna liczba kroków",
|
||||
"SLEEP_DURATION": "długość snu",
|
||||
"HEART_RATE_VARIABILITY": "zmienność rytmu serca",
|
||||
"RESTING_HEART_RATE": "spoczynkowe tętno",
|
||||
"TIME_IN_DAYLIGHT": "czas w świetle dziennym",
|
||||
"MOOD": "nastrój",
|
||||
"PULSE": "tętno spoczynkowe",
|
||||
"MEDICATION_COMPLIANCE": "przyjmowanie leków",
|
||||
"SYMPTOM_SEVERITY": "nasilenie objawów",
|
||||
"ENV_TEMP_MEAN": "temperatura dobowa",
|
||||
"ENV_TEMP_MIN": "nocna temperatura minimalna",
|
||||
"ENV_SUNSHINE": "nasłonecznienie",
|
||||
"ENV_DAYLIGHT": "światło dzienne",
|
||||
"ENV_PRECIP": "opady",
|
||||
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
|
||||
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
|
||||
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
|
||||
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
|
||||
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
|
||||
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
|
||||
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Brak oceny.",
|
||||
"assessmentPreparing": "Przygotowywanie oceny…",
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
|
||||
@@ -22,6 +22,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
|
||||
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
|
||||
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { type Locale } from "@/lib/i18n/config";
|
||||
import {
|
||||
computeGlucoseClinicalMetrics,
|
||||
GLUCOSE_PANEL_WINDOW_DAYS,
|
||||
@@ -96,12 +98,18 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// thick body is the 30-query chain feeding the dashboard + insights
|
||||
// hero score; after a measurement sync the next mount serves the
|
||||
// prior body instantly while one background recompute refreshes it.
|
||||
// v1.30.10 — the `correlations` block carries user-facing narration that is
|
||||
// localised per reader, so the cache key MUST include the locale; otherwise a
|
||||
// language switch would serve another locale's cached interpretation for up to
|
||||
// the TTL. Every other block is locale-independent, so per-locale entries only
|
||||
// cost a little duplication (most users have one locale).
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
`${user.id}|default|${locale}`,
|
||||
() => buildAnalyticsResponse(user, locale),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -173,7 +181,7 @@ function enrichLastSeenDaysAgo<
|
||||
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
|
||||
* `buildCoachSnapshotImpl` shape.
|
||||
*/
|
||||
async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
|
||||
// v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check.
|
||||
// Awaiting it on the read path can stall the Node event loop for
|
||||
// 30–60 s on a power-user account whose iOS step samples keep the
|
||||
@@ -405,6 +413,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
|
||||
userTz,
|
||||
now: new Date(),
|
||||
coverage,
|
||||
locale,
|
||||
});
|
||||
|
||||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||||
|
||||
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
@@ -82,6 +83,11 @@ export const GET = apiHandler(async () => {
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
// Reader's locale for the narrated `interpretation` — the correlation cards
|
||||
// render this string verbatim, so it MUST be localised (cookie / User.locale /
|
||||
// Accept-Language). Without it the never-causal sentence leaked English into a
|
||||
// non-English UI.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
@@ -165,7 +171,7 @@ export const GET = apiHandler(async () => {
|
||||
series.push(envSeries);
|
||||
}
|
||||
|
||||
const result = discoverCorrelations(series);
|
||||
const result = discoverCorrelations(series, { locale });
|
||||
|
||||
// v1.22 — rolling early-detection pass over the trailing window, re-using
|
||||
// the already-built series (no extra DB read). Emerging pairs exclude anything
|
||||
@@ -176,11 +182,14 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
const emerging = discoverEmergingCorrelations(series, result, {
|
||||
recentFromDayKey,
|
||||
locale,
|
||||
});
|
||||
|
||||
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades
|
||||
// to absent when the user has too few draws to clear the per-pair floor.
|
||||
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series);
|
||||
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series, {
|
||||
locale,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.correlations.discover" },
|
||||
|
||||
@@ -63,6 +63,8 @@ import {
|
||||
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
|
||||
import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
/**
|
||||
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
|
||||
@@ -120,6 +122,12 @@ export interface CorrelationsFastPathInput {
|
||||
userTz: string;
|
||||
now: Date;
|
||||
coverage?: RollupCoverageMap;
|
||||
/**
|
||||
* Reader's locale for the three hypotheses' narrated `interpretation`. The
|
||||
* correlation cards render these verbatim, so they must be localised. Defaults
|
||||
* to English.
|
||||
*/
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export async function computeCorrelationHypothesesFastPath(
|
||||
@@ -127,6 +135,7 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
): Promise<CorrelationHypothesesResult> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const { userId, userTz, now } = input;
|
||||
const { t } = getServerTranslator(input.locale ?? defaultLocale);
|
||||
const since = new Date(now.getTime() - CORRELATION_WINDOW_DAYS * DAY_MS);
|
||||
|
||||
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
|
||||
@@ -303,7 +312,7 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
compliancePct,
|
||||
});
|
||||
}
|
||||
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs });
|
||||
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs }, t);
|
||||
|
||||
// ── Hypothesis 2: Mood × resting pulse ──────────────────────
|
||||
const dailyMood = new Map<string, number[]>();
|
||||
@@ -328,7 +337,7 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
restingPulse,
|
||||
});
|
||||
}
|
||||
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs });
|
||||
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }, t);
|
||||
|
||||
// ── Hypothesis 3: Weight × weekday ──────────────────────────
|
||||
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
|
||||
@@ -339,7 +348,10 @@ export async function computeCorrelationHypothesesFastPath(
|
||||
weight: row.value,
|
||||
});
|
||||
}
|
||||
const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs });
|
||||
const weightWeekday = correlateWeightWeekday(
|
||||
{ daily: weightWeekdayPairs },
|
||||
t,
|
||||
);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
|
||||
@@ -365,6 +365,53 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
|
||||
expect(pair!.interpretation).not.toMatch(/tends to go with/);
|
||||
expect(pair!.interpretation).toMatch(/never a cause/);
|
||||
});
|
||||
|
||||
// ── i18n regression guard (v1.30.10) ─────────────────────────────────
|
||||
//
|
||||
// The generated-insight narrative layer built its user-facing sentences by
|
||||
// TypeScript string interpolation and never routed through the i18n bundle,
|
||||
// so a German UI rendered an English sentence spliced into German text. This
|
||||
// pins the fix: with `locale: "de"` the interpretation is the German template
|
||||
// (localised metric label + correctly-negated "keine Ursache"), NOT English.
|
||||
it("narrates in the reader's locale — German, never the English string", () => {
|
||||
const n = 90;
|
||||
const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4));
|
||||
const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)];
|
||||
const seriesInput: NamedSeries[] = [
|
||||
{
|
||||
key: "TIME_IN_DAYLIGHT",
|
||||
role: "behaviour",
|
||||
points: longSeries(behaviour),
|
||||
},
|
||||
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
|
||||
];
|
||||
const find = (r: ReturnType<typeof discoverCorrelations>) =>
|
||||
r.discovered.find(
|
||||
(p) =>
|
||||
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
|
||||
);
|
||||
|
||||
const de = find(discoverCorrelations(seriesInput, { locale: "de" }));
|
||||
const en = find(discoverCorrelations(seriesInput, { locale: "en" }));
|
||||
expect(de).toBeDefined();
|
||||
expect(en).toBeDefined();
|
||||
|
||||
// German narration: localised metric labels + the German "high" template.
|
||||
expect(de!.interpretation).toContain("die Zeit im Tageslicht");
|
||||
expect(de!.interpretation).toContain("die Schlafdauer");
|
||||
// The load-bearing, correctly-negated "not a cause" survives in German.
|
||||
expect(de!.interpretation).toMatch(/keine Ursache/);
|
||||
// And it is NOT the English leak the maintainer saw.
|
||||
expect(de!.interpretation).not.toMatch(/tends to go with/);
|
||||
expect(de!.interpretation).not.toMatch(
|
||||
/a pattern worth watching, not a cause/,
|
||||
);
|
||||
expect(de!.interpretation).not.toEqual(en!.interpretation);
|
||||
|
||||
// English default stays exactly the established wording.
|
||||
expect(en!.interpretation).toMatch(/tends to go with/);
|
||||
expect(en!.interpretation).toMatch(/not a cause/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── FDREXTEND — medication compliance + symptom severity channels ─────
|
||||
|
||||
@@ -39,10 +39,19 @@
|
||||
* link. The series builders live in `correlation-series-builders.ts`.
|
||||
*/
|
||||
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
getEnvironmentField,
|
||||
isEnvironmentChannelKey,
|
||||
} from "@/lib/environment/fields";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
/**
|
||||
* Locale-aware string builder — the same signature the server translator
|
||||
* exposes. Every user-facing narration in this module routes through one of
|
||||
* these so the sentence is emitted in the reader's language, not hardcoded
|
||||
* English. Falls back to English (then the raw key) inside the translator.
|
||||
*/
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/** Default Benjamini-Hochberg target false-discovery rate. */
|
||||
export const FDR_Q = 0.1;
|
||||
@@ -288,46 +297,64 @@ export function benjaminiHochberg(
|
||||
* Sign of `r` flips the direction; the `tier` (RECON1 D2-2 / D2-6) flips the
|
||||
* CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if
|
||||
* anything" rather than a confident "tends to go with". Never claims causation.
|
||||
*
|
||||
* The sentence is assembled from locale-keyed templates under
|
||||
* `insights.correlation.daily.*` (+ a per-locale direction word) so the calm,
|
||||
* never-causal framing survives verbatim in every language rather than leaking
|
||||
* English into a non-English UI.
|
||||
*/
|
||||
function interpret(
|
||||
behaviour: string,
|
||||
outcome: string,
|
||||
r: number,
|
||||
tier: ConfidenceTier,
|
||||
t: Translate,
|
||||
): string {
|
||||
const b = humanise(behaviour);
|
||||
const o = humanise(outcome);
|
||||
const lower = r < 0 ? "lower" : "higher";
|
||||
if (tier === "faint") {
|
||||
// Below the confident effect-size threshold: a real-but-small signal,
|
||||
// hedged so the narrated confidence never outruns the effect.
|
||||
return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never a cause.`;
|
||||
}
|
||||
const lead =
|
||||
tier === "high"
|
||||
? `Higher ${b} tends to go with ${lower} next-day ${o} in your data`
|
||||
: `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`;
|
||||
return `${lead} — a pattern worth watching, not a cause.`;
|
||||
const params = {
|
||||
behaviour: humanise(behaviour, t),
|
||||
outcome: humanise(outcome, t),
|
||||
direction: t(
|
||||
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
|
||||
),
|
||||
};
|
||||
// faint = below the confident effect-size threshold (a real-but-small signal,
|
||||
// hedged); high = confident "tends to go with"; moderate = the in-between
|
||||
// "on the evidence so far" phrasing. Each tier keeps its own template.
|
||||
const tierKey =
|
||||
tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate";
|
||||
return t(`insights.correlation.daily.${tierKey}`, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-case, space-separated label from a channel key.
|
||||
* Localised, human-readable label for a channel key.
|
||||
*
|
||||
* v1.14.0 — a `FACTOR:<key>` channel (a RATED mood factor folded into the
|
||||
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
|
||||
* rather than leaking the raw key. The prefix exists only to keep a factor
|
||||
* key from colliding with a `MeasurementType` in the channel set.
|
||||
* key from colliding with a `MeasurementType` in the channel set. The factor
|
||||
* name itself is user data, kept verbatim (lower-cased) inside the localised
|
||||
* `channelFactor` frame.
|
||||
*
|
||||
* Every other channel (measurement types, MOOD, medication compliance, symptom
|
||||
* severity, and the ENV_* exposure channels) resolves through a stable
|
||||
* `insights.correlation.channel.<KEY>` label, translated in all six locales.
|
||||
* An unknown key falls back to its prettified form so a newly-added channel
|
||||
* never blanks.
|
||||
*/
|
||||
function humanise(key: string): string {
|
||||
if (key.startsWith("FACTOR:")) {
|
||||
return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`;
|
||||
function humanise(key: string, t: Translate): string {
|
||||
if (key.startsWith(FACTOR_CHANNEL_PREFIX)) {
|
||||
const factor = key
|
||||
.slice(FACTOR_CHANNEL_PREFIX.length)
|
||||
.replace(/[_-]/g, " ")
|
||||
.toLowerCase();
|
||||
return t("insights.correlation.channelFactor", { factor });
|
||||
}
|
||||
// v1.25 (W-ENV) — env channels read with their descriptive label ("daily
|
||||
// temperature", "daylight") rather than the raw "env temp mean" key.
|
||||
if (isEnvironmentChannelKey(key)) {
|
||||
return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase();
|
||||
}
|
||||
return key.replace(/_/g, " ").toLowerCase();
|
||||
const labelKey = `insights.correlation.channel.${key}`;
|
||||
const label = t(labelKey);
|
||||
// The server translator echoes the key on a miss; fall back to a readable
|
||||
// prettified form so an unmapped channel degrades gracefully, never English-
|
||||
// leaks a raw key.
|
||||
return label === labelKey ? key.replace(/_/g, " ").toLowerCase() : label;
|
||||
}
|
||||
|
||||
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
|
||||
@@ -402,11 +429,26 @@ export function discoveryMeasurementTypes(keys: readonly string[]): string[] {
|
||||
*/
|
||||
export function discoverCorrelations(
|
||||
series: NamedSeries[],
|
||||
opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {},
|
||||
opts: {
|
||||
lagDays?: number;
|
||||
minPairs?: number;
|
||||
fdrQ?: number;
|
||||
/**
|
||||
* Reader's locale for the narrated `interpretation`. Defaults to English
|
||||
* so the many server callers that feed the string into an LLM prompt (which
|
||||
* re-localises) keep their existing behaviour; the user-facing correlations
|
||||
* route passes the resolved locale so the card renders in the reader's
|
||||
* language.
|
||||
*/
|
||||
locale?: Locale;
|
||||
} = {},
|
||||
): CorrelationDiscoveryResult {
|
||||
const lagDays = opts.lagDays ?? 1;
|
||||
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
|
||||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||||
// Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to
|
||||
// the RawPair element, so the translator must not shadow-collide with them.
|
||||
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
|
||||
|
||||
const behaviours = series.filter((s) => s.role === "behaviour");
|
||||
const outcomes = series.filter((s) => s.role === "outcome");
|
||||
@@ -469,7 +511,7 @@ export function discoverCorrelations(
|
||||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||||
shrunkR: Math.round(t.shrunkR * 1000) / 1000,
|
||||
tier: t.tier,
|
||||
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier),
|
||||
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier, translate),
|
||||
lagDays,
|
||||
}))
|
||||
// RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong
|
||||
@@ -650,6 +692,8 @@ export function discoverEmergingCorrelations(
|
||||
lagDays?: number;
|
||||
minPairs?: number;
|
||||
fdrQ?: number;
|
||||
/** Reader's locale, threaded to the inner scan's narration. */
|
||||
locale?: Locale;
|
||||
},
|
||||
): EmergingCorrelationResult {
|
||||
const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS;
|
||||
@@ -661,6 +705,7 @@ export function discoverEmergingCorrelations(
|
||||
lagDays: opts.lagDays,
|
||||
minPairs,
|
||||
fdrQ,
|
||||
locale: opts.locale,
|
||||
});
|
||||
|
||||
const established = new Set(
|
||||
@@ -810,12 +855,16 @@ export function discoverLabOutcomeCorrelations(
|
||||
minDraws?: number;
|
||||
minWindowPoints?: number;
|
||||
fdrQ?: number;
|
||||
/** Reader's locale for the narrated `interpretation`. Defaults to English. */
|
||||
locale?: Locale;
|
||||
} = {},
|
||||
): LabCorrelationResult {
|
||||
const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS;
|
||||
const minDraws = opts.minDraws ?? LAB_MIN_DRAWS;
|
||||
const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS;
|
||||
const fdrQ = opts.fdrQ ?? FDR_Q;
|
||||
// Named `translate` — the pair-mapping callback below binds `t` to its element.
|
||||
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
|
||||
|
||||
// Group draws by biomarker key (newest-irrelevant; Pearson is order-free).
|
||||
const drawsByLab = new Map<string, LabDrawPoint[]>();
|
||||
@@ -902,7 +951,7 @@ export function discoverLabOutcomeCorrelations(
|
||||
pValue: t.pValue,
|
||||
qValue: Math.round(t.qValue * 1000) / 1000,
|
||||
windowDays,
|
||||
interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier),
|
||||
interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier, translate),
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.r) - Math.abs(a.r) || a.qValue - b.qValue);
|
||||
|
||||
@@ -928,12 +977,16 @@ function interpretLab(
|
||||
outcome: string,
|
||||
r: number,
|
||||
tier: ConfidenceTier,
|
||||
t: Translate,
|
||||
): string {
|
||||
const l = humaniseLab(lab);
|
||||
const o = humanise(outcome);
|
||||
const dir = r < 0 ? "lower" : "higher";
|
||||
if (tier === "faint") {
|
||||
return `Higher ${l} readings show a faint hint, if anything, of ${dir} ${o} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause.`;
|
||||
}
|
||||
return `Higher ${l} readings line up with ${dir} ${o} over the same periods in your data — an association worth watching with your clinician, never a cause.`;
|
||||
const params = {
|
||||
// The analyte name is user data — kept verbatim inside the localised frame.
|
||||
lab: humaniseLab(lab),
|
||||
outcome: humanise(outcome, t),
|
||||
direction: t(
|
||||
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
|
||||
),
|
||||
};
|
||||
const tierKey = tier === "faint" ? "faint" : "default";
|
||||
return t(`insights.correlation.lab.${tierKey}`, params);
|
||||
}
|
||||
@@ -34,8 +34,44 @@
|
||||
*
|
||||
* Conservative phrasing — interpretations always read as "a pattern worth
|
||||
* watching" or similar, never "X causes Y". Causation language is banned
|
||||
* from this module by code-review convention.
|
||||
* from this module by code-review convention. Every user-facing interpretation
|
||||
* is assembled from locale-keyed templates under `insights.correlation.*` so
|
||||
* the never-causal framing survives verbatim in all six locales rather than
|
||||
* leaking English into a non-English UI.
|
||||
*/
|
||||
import { defaultLocale } from "@/lib/i18n/config";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
/** Locale-aware string builder — mirrors the server translator's `t`. */
|
||||
type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* English by default so the pure callers + unit tests that pass no locale keep
|
||||
* their existing (English) output; the analytics route threads the reader's
|
||||
* locale through the fast-path so the cards render in the reader's language.
|
||||
*/
|
||||
function defaultTranslate(): Translate {
|
||||
return getServerTranslator(defaultLocale).t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekday index (0 = Monday … 6 = Sunday, the order the ANOVA outlier index
|
||||
* uses) → the shared `charts.weekdaysFull.*` label key, reused so the weekday
|
||||
* name is already localised in every bundle.
|
||||
*/
|
||||
const WEEKDAY_LABEL_KEYS = [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun",
|
||||
] as const;
|
||||
|
||||
export type CorrelationKind = "bp-compliance" | "mood-pulse" | "weight-weekday";
|
||||
|
||||
export interface CorrelationConfidenceBand {
|
||||
@@ -612,6 +648,7 @@ export interface BpComplianceInput {
|
||||
*/
|
||||
export function correlateBpCompliance(
|
||||
input: BpComplianceInput,
|
||||
t: Translate = defaultTranslate(),
|
||||
): CorrelationResult {
|
||||
const points = input.daily.map((d) => ({
|
||||
x: d.compliancePct,
|
||||
@@ -640,10 +677,10 @@ export function correlateBpCompliance(
|
||||
}
|
||||
const interpretation =
|
||||
r.r < -0.1
|
||||
? "Higher medication compliance lines up with lower systolic readings — a pattern worth watching."
|
||||
? t("insights.correlation.hypothesis.bpCompliance.support")
|
||||
: r.r > 0.1
|
||||
? "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything."
|
||||
: "Compliance and systolic do not move together strongly in this window.";
|
||||
? t("insights.correlation.hypothesis.bpCompliance.surprising")
|
||||
: t("insights.correlation.hypothesis.bpCompliance.none");
|
||||
return {
|
||||
kind: "bp-compliance",
|
||||
status: "ok",
|
||||
@@ -673,7 +710,10 @@ export interface MoodPulseInput {
|
||||
* Runs Pearson on (mood, restingPulse). A negative r supports the
|
||||
* hypothesis (mood down → pulse up).
|
||||
*/
|
||||
export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult {
|
||||
export function correlateMoodPulse(
|
||||
input: MoodPulseInput,
|
||||
t: Translate = defaultTranslate(),
|
||||
): CorrelationResult {
|
||||
const points = input.daily.map((d) => ({ x: d.mood, y: d.restingPulse }));
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
@@ -698,10 +738,10 @@ export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult {
|
||||
}
|
||||
const interpretation =
|
||||
r.r < -0.1
|
||||
? "Lower-mood days line up with higher resting pulse — a pattern worth watching."
|
||||
? t("insights.correlation.hypothesis.moodPulse.support")
|
||||
: r.r > 0.1
|
||||
? "Higher-mood days line up with higher resting pulse in your data — surprising."
|
||||
: "Mood and resting pulse do not move together strongly in this window.";
|
||||
? t("insights.correlation.hypothesis.moodPulse.surprising")
|
||||
: t("insights.correlation.hypothesis.moodPulse.none");
|
||||
return {
|
||||
kind: "mood-pulse",
|
||||
status: "ok",
|
||||
@@ -726,16 +766,6 @@ export interface WeightWeekdayInput {
|
||||
daily: ReadonlyArray<{ weekday: number; weight: number }>;
|
||||
}
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Hypothesis #3 — weight has a weekly pattern (e.g. Monday spikes
|
||||
* after weekends).
|
||||
@@ -745,6 +775,7 @@ const WEEKDAY_LABELS = [
|
||||
*/
|
||||
export function correlateWeightWeekday(
|
||||
input: WeightWeekdayInput,
|
||||
t: Translate = defaultTranslate(),
|
||||
): CorrelationResult {
|
||||
const points = input.daily.map((d) => ({ x: d.weekday, y: d.weight }));
|
||||
const anova = weekdayAnova(
|
||||
@@ -768,13 +799,21 @@ export function correlateWeightWeekday(
|
||||
points,
|
||||
};
|
||||
}
|
||||
const dayLabel = WEEKDAY_LABELS[anova.outlierIndex];
|
||||
const dayLabel = t(
|
||||
`charts.weekdaysFull.${WEEKDAY_LABEL_KEYS[anova.outlierIndex]}`,
|
||||
);
|
||||
const grandMean =
|
||||
input.daily.reduce((s, p) => s + p.weight, 0) / input.daily.length;
|
||||
const outlierMean = anova.means[anova.outlierIndex] ?? grandMean;
|
||||
const direction = outlierMean > grandMean ? "above" : "below";
|
||||
const direction = t(
|
||||
`insights.correlation.weekdayDirection.${outlierMean > grandMean ? "above" : "below"}`,
|
||||
);
|
||||
const delta = Math.abs(outlierMean - grandMean).toFixed(1);
|
||||
const interpretation = `${dayLabel} weights run ${delta} kg ${direction} your other-day average — a pattern worth watching.`;
|
||||
const interpretation = t("insights.correlation.hypothesis.weightWeekday", {
|
||||
weekday: dayLabel,
|
||||
delta,
|
||||
direction,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "weight-weekday",
|
||||
|
||||
Reference in new issue
Block a user