diff --git a/messages/de.json b/messages/de.json index 743eecfdc..960b9ba9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 60fa17d56..e10174340 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 30ed1b10d..c5737c5a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 7187478d0..0c9a1502e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 308233826..ffee8f63f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 0f6a7dd5d..cee3d96ed 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 7225c11d0..b33064a19 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -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> >, - `${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 diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 63a4624fe..763ea82c8 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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" }, diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 79370b404..b2a412044 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -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 { 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(); @@ -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: { diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 02c7c9060..0e68d24bc 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -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) => + 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 ───── diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index b7bd338b4..77db6f0d3 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -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; /** 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:` channel (a RATED mood factor folded into the * matrix) strips its namespace prefix so the phrasing reads "rated " * 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.` 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(); @@ -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); } diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index a4dc3b366..dffe8766b 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -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; + +/** + * 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",