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:
Marc-André BombeckandClaude Opus 4.8 committed 2026-07-18 16:05:17 +02:00
1 parent 36f73233b0
commit 04320c415d
12 files changed
+566 -67

No files matched your search

+12 -3
View File
@@ -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
+11 -2
View File
@@ -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" },
+15 -3
View File
@@ -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 ─────
+91 -38
View File
@@ -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);
}
+60 -21
View File
@@ -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",