From 4ed6f7d1ce04a041ddccf305d5afdf8b441f02e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 08:52:23 +0200 Subject: [PATCH 1/4] Write the Coach read strip's second line in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a German metric page the strip printed its own summary in German and the pattern line under it in English. The pattern line is a finished sentence the server writes, and the route never told the server who was reading, so it wrote English. The route now resolves the reader's language the same way the correlations and analytics routes do and passes it down. The language is a required argument the whole way through — it used to be optional with an English fallback, which is why a caller could stay silent and still get a sentence back. The cycle page's lagged rows had the same gap and are fixed with it. The strip's browser cache is keyed by language too, so switching language no longer re-uses the sentence written for the previous one. --- src/app/api/cycle/insights/route.ts | 7 + src/app/api/insights/coach-read/route.ts | 11 +- .../insights/derived/coach-read-strip.tsx | 6 +- .../correlations-module-gate.test.ts | 12 +- .../coach/__tests__/correlations-read.test.ts | 13 +- .../__tests__/correlations-snapshot.test.ts | 8 +- src/lib/ai/coach/correlations-snapshot.ts | 10 +- src/lib/ai/coach/memory-snapshot.ts | 1 + src/lib/ai/coach/snapshot.ts | 2 +- src/lib/ai/coach/tools/correlations-read.ts | 17 +- src/lib/ai/coach/tools/executor.ts | 8 +- .../cycle/__tests__/phase-crosstab.test.ts | 12 +- src/lib/cycle/phase-crosstab.ts | 12 +- src/lib/i18n/user-locale.ts | 26 ++ .../__tests__/correlation-discovery.test.ts | 262 +++++++++++------- .../correlation-series-builders.test.ts | 10 +- .../metric-correlation-context.test.ts | 4 +- src/lib/insights/blood-pressure-status.ts | 1 + src/lib/insights/correlation-discovery.ts | 42 ++- src/lib/insights/derived/coach-read.ts | 14 +- .../insights/metric-correlation-context.ts | 10 +- src/lib/insights/metric-status.ts | 1 + .../__tests__/period-narrative-db.test.ts | 7 +- .../__tests__/period-narrative.test.ts | 1 + .../narrative/period-narrative-generate.ts | 2 +- .../insights/narrative/period-narrative.ts | 19 +- src/lib/insights/pulse-status.ts | 6 +- src/lib/insights/weight-status.ts | 6 +- src/lib/mcp/rich-reads.ts | 14 +- src/lib/query-keys/insights.ts | 11 +- 30 files changed, 394 insertions(+), 161 deletions(-) create mode 100644 src/lib/i18n/user-locale.ts diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 3f16a02ed..7a9d5fdfc 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -20,6 +20,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { checkRateLimit } from "@/lib/rate-limit"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { @@ -208,10 +209,16 @@ export const GET = apiHandler(async () => { userPriorityJson, }); const headline = selectHeadlinePhaseRow(rows); + // The lagged rows are served with their narrated `interpretation` intact, so + // the reader's language is resolved here the same way the correlations route + // resolves it. This surface had the same silent-English gap the metric-page + // strip had. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); const lagged = discoverPhaseCorrelations({ phaseByDay, measurements, userPriorityJson, + locale, }); // Cycle-NATIVE insight: where each logged symptom clusters across the phases. diff --git a/src/app/api/insights/coach-read/route.ts b/src/app/api/insights/coach-read/route.ts index 58db34f5c..443d1597b 100644 --- a/src/app/api/insights/coach-read/route.ts +++ b/src/app/api/insights/coach-read/route.ts @@ -23,6 +23,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { measurementTypeEnum } from "@/lib/validations/measurement"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { MeasurementType } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; @@ -60,12 +61,20 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as MeasurementType; - const strip = await buildCoachReadStrip(user.id, metric); + // Line 2 of the strip is a finished sentence the clients print verbatim, so + // the reader's language is decided here — same resolution the correlations + // and analytics routes use (cookie, then the stored preference, then the + // Accept-Language header). Without it the strip answered a German page in + // English, one line under the other. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); + + const strip = await buildCoachReadStrip(user.id, metric, locale); annotate({ action: { name: "insights.coach-read" }, meta: { metric, + locale, has_baseline: strip.baseline !== null, learning: strip.learning, has_driver: strip.driver !== null, diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 8dfad52fe..1558ecbbd 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -77,7 +77,7 @@ export function CoachReadStrip({ const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; const { data } = useQuery({ - queryKey: queryKeys.insightsCoachRead(metricType), + queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( `/api/insights/coach-read?metric=${encodeURIComponent(metricType)}`, @@ -120,6 +120,10 @@ export function CoachReadStrip({ }); })(); + // Only the wrapper is translated here. `note` is a finished sentence the + // server wrote in the reader's language, so it goes in as it arrived — which + // is exactly why the route has to know the language, and why this query is + // keyed by locale. const driverLine = data.driver ? t("insights.coach.readStrip.driver", { note: data.driver.note }) : null; diff --git a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts index dba6f3418..50aef2e7c 100644 --- a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts @@ -45,14 +45,14 @@ beforeEach(() => { describe("readCoachCorrelations — insights module gate", () => { it("gates on `insights`, not on a per-domain key", async () => { - await readCoachCorrelations(USER); + await readCoachCorrelations(USER, "en"); expect(isModuleEnabled).toHaveBeenCalledWith(USER, "insights"); }); it("omits with the module OFF, and reads nothing at all", async () => { vi.mocked(isModuleEnabled).mockImplementation(async () => false); - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); expect(res.present).toBe(false); // Distinct from `no_data`: the assistant is told the domain is switched @@ -67,7 +67,7 @@ describe("readCoachCorrelations — insights module gate", () => { }); it("proceeds to the engine with the module ON", async () => { - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); // The synthetic fixture has no paired data, so the honest answer is a // miss — but it must be the ENGINE's miss, reached after the real reads // ran, not the gate's. @@ -85,7 +85,7 @@ describe("readCoachCorrelations — insights module gate", () => { async (_u: string, key: string) => key !== "insights", ); - const res = await readCoachCorrelations(USER); + const res = await readCoachCorrelations(USER, "en"); expect(res.present).toBe(false); expect(res.reason).toBe("module_disabled"); @@ -99,6 +99,8 @@ describe("readCoachCorrelations — insights module gate", () => { // loud, not silently indistinguishable from "no pattern found". vi.mocked(isModuleEnabled).mockRejectedValue(new Error("gate down")); - await expect(readCoachCorrelations(USER)).rejects.toThrow("gate down"); + await expect(readCoachCorrelations(USER, "en")).rejects.toThrow( + "gate down", + ); }); }); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index 14b72f915..98f93b41f 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -91,7 +91,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); expect(result.drivers).toHaveLength(1); expect(result.drivers?.[0]).toMatchObject({ @@ -111,7 +111,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(false); expect(result.reason).toBe("no_significant_pattern"); }); @@ -149,7 +149,7 @@ describe("readCoachCorrelations", () => { computedAt: "x", }, }); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); expect(result.coincident?.fired).toBe(true); expect(result.coincident?.contributing).toEqual([ @@ -160,7 +160,7 @@ describe("readCoachCorrelations", () => { it("degrades to present:false on a read failure", async () => { loadBaselineProfile.mockRejectedValue(new Error("db down")); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(false); expect(result.reason).toBe("retrieval_failed"); }); @@ -175,7 +175,7 @@ describe("readCoachCorrelations", () => { fdrQ: 0.1, minPairs: 20, }); - await readCoachCorrelations("u1"); + await readCoachCorrelations("u1", "en"); const where = measurementFindMany.mock.calls[0]?.[0]?.where as { type: { in: string[] }; }; @@ -198,6 +198,7 @@ describe("readCoachCorrelations", () => { discoverCorrelations.mockImplementation((series) => real.discoverCorrelations( series as Parameters[0], + { locale: "en" }, ), ); @@ -248,7 +249,7 @@ describe("readCoachCorrelations", () => { ]); illnessDayLogFindMany.mockResolvedValue(dayLogs); - const result = await readCoachCorrelations("u1"); + const result = await readCoachCorrelations("u1", "en"); expect(result.present).toBe(true); const symptomDriver = result.drivers?.find( diff --git a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts index ef0ae83f2..cbaa17b37 100644 --- a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts @@ -47,7 +47,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 16, windowDays: 180, }); - const block = await buildCorrelationsSnapshotBlock("user-1"); + const block = await buildCorrelationsSnapshotBlock("user-1", "en"); expect(block).not.toBeNull(); expect(block!.drivers).toHaveLength(2); expect(block!.drivers[0].behaviour).toBe("time in daylight"); @@ -65,7 +65,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 30, windowDays: 180, }); - const block = await buildCorrelationsSnapshotBlock("user-1"); + const block = await buildCorrelationsSnapshotBlock("user-1", "en"); expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP); // Order preserved from the already-ranked tool output (highest effect first). expect(block!.drivers[0].behaviour).toBe("behaviour 0"); @@ -76,7 +76,7 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { present: false, reason: "no_significant_pattern", }); - expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull(); }); it("attaches nothing when present but the driver list is empty (coincident-only)", async () => { @@ -89,6 +89,6 @@ describe("buildCorrelationsSnapshotBlock (D5-5)", () => { pairsTested: 12, windowDays: 180, }); - expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + expect(await buildCorrelationsSnapshotBlock("user-1", "en")).toBeNull(); }); }); diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts index 8f80545b7..489252c76 100644 --- a/src/lib/ai/coach/correlations-snapshot.ts +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -18,6 +18,7 @@ * Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null * return (no surviving driver / read hiccup) attaches nothing. */ +import type { Locale } from "@/lib/i18n/config"; import { readCoachCorrelations, type CoachCorrelationDriver, @@ -45,8 +46,15 @@ export interface CorrelationsSnapshotBlock { */ export async function buildCorrelationsSnapshotBlock( userId: string, + /** + * The Coach's resolved UI locale. The driver notes go into the prompt as + * finished sentences, and a model handed English prose for a German + * conversation echoes English phrasings back. Required — the snapshot knows + * the locale, so it says it. + */ + locale: Locale, ): Promise { - const result = await readCoachCorrelations(userId); + const result = await readCoachCorrelations(userId, locale); if (!result.present || !result.drivers || result.drivers.length === 0) { return null; } diff --git a/src/lib/ai/coach/memory-snapshot.ts b/src/lib/ai/coach/memory-snapshot.ts index 4093b3e28..9c262a8aa 100644 --- a/src/lib/ai/coach/memory-snapshot.ts +++ b/src/lib/ai/coach/memory-snapshot.ts @@ -168,6 +168,7 @@ export async function buildCoachMemoryBlock( const ctx = await buildPeriodNarrativeContext(userId, { period: MEMORY_PERIOD, now, + locale, }); if (ctx.status === "ready") { for (const transition of ctx.bandTransitions) { diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7d7ff7c0b..ca34a5df1 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -843,7 +843,7 @@ async function buildCoachSnapshotImpl( // instead of getting only the coincident flag. Gated on `derivedActive` (it // is the recovery / cross-metric layer) and fail-soft to null. const correlationsBlockPromise = derivedActive - ? buildCorrelationsSnapshotBlock(userId) + ? buildCorrelationsSnapshotBlock(userId, coachLocale) : null; const memoryBlockPromise = buildCoachMemoryBlock( userId, diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 15b322a33..8bf7f8fd4 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -24,6 +24,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -151,6 +152,15 @@ function humanise(key: string): string { */ export async function readCoachCorrelations( userId: string, + /** + * The reader's locale. Every `note` this returns is a finished sentence that + * some surface prints verbatim — the metric page's "Coach read" strip does + * exactly that — so the language has to be decided by whoever knows who is + * reading. No default: a caller that cannot name a locale is a caller that + * does not know, and guessing English is how the strip came to answer a + * German page in English. + */ + locale: Locale, ): Promise { // v1.30.22 — the `insights` gate lives HERE, at the read, not at each // caller. This reader is reached from four places (the Coach @@ -271,7 +281,7 @@ export async function readCoachCorrelations( }, }); - const discovery = discoverCorrelations(series); + const discovery = discoverCorrelations(series, { locale }); const drivers: CoachCorrelationDriver[] = discovery.discovered.map((d) => ({ behaviour: humanise(d.behaviour), outcome: humanise(d.outcome), @@ -292,6 +302,7 @@ export async function readCoachCorrelations( ); const emergingResult = discoverEmergingCorrelations(series, discovery, { recentFromDayKey, + locale, }); const emerging: CoachEmergingDriver[] = emergingResult.emerging.map( (d) => ({ @@ -307,7 +318,9 @@ export async function readCoachCorrelations( ); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). - const labResult = discoverLabOutcomeCorrelations(labDraws, series); + const labResult = discoverLabOutcomeCorrelations(labDraws, series, { + locale, + }); const labDrivers: CoachLabCorrelation[] = labResult.discovered.map((d) => ({ lab: d.lab.startsWith("LAB:") ? d.lab.slice("LAB:".length) : d.lab, outcome: humanise(d.outcome), diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 9ee361bcb..099c8e1cb 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -62,6 +62,7 @@ import { METRIC_SERIES_EXCLUDED_SOURCES, } from "./source-keys"; import { readCoachCorrelations } from "./correlations-read"; +import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; import { resolveEmptyRead, subjectForTool, @@ -599,7 +600,12 @@ async function getCorrelations( // days, and the reader already reports WHY nothing survived (its own reason // code). "No pattern survived the correction" is an honest absence of a // finding, not an absence of data, and nothing older would change it. - const result = await readCoachCorrelations(userId); + // The reader's language, from their stored preference — the executor is + // reached from the chat loop and from MCP, neither of which carries a request + // of its own. The notes below are finished sentences; some MCP clients show + // them to the person verbatim rather than letting the model re-word them. + const locale = await resolveLocaleForUser(userId); + const result = await readCoachCorrelations(userId, locale); if (!result.present) { return { present: false, reason: result.reason ?? "no_pattern" }; } diff --git a/src/lib/cycle/__tests__/phase-crosstab.test.ts b/src/lib/cycle/__tests__/phase-crosstab.test.ts index 14ec830fc..ea8e79147 100644 --- a/src/lib/cycle/__tests__/phase-crosstab.test.ts +++ b/src/lib/cycle/__tests__/phase-crosstab.test.ts @@ -286,7 +286,11 @@ describe("discoverPhaseCorrelations", () => { ), ); } - const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + const result = discoverPhaseCorrelations({ + phaseByDay, + measurements, + locale: "en", + }); const pair = result.discovered.find( (p) => p.behaviour === CYCLE_PHASE_CHANNEL_KEY, ); @@ -315,7 +319,11 @@ describe("discoverPhaseCorrelations", () => { phaseByDay.set(key, phases[i % phases.length]); measurements.push(m("RESTING_HEART_RATE", key, 60)); // constant → no variance } - const result = discoverPhaseCorrelations({ phaseByDay, measurements }); + const result = discoverPhaseCorrelations({ + phaseByDay, + measurements, + locale: "en", + }); expect(result.discovered).toEqual([]); }); }); diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts index 30160944a..3971a22ca 100644 --- a/src/lib/cycle/phase-crosstab.ts +++ b/src/lib/cycle/phase-crosstab.ts @@ -51,6 +51,7 @@ import { import { round } from "@/lib/insights/status-shared"; import type { MeasurementType } from "@/generated/prisma/enums"; import type { CyclePhase } from "@/lib/cycle/types"; +import type { Locale } from "@/lib/i18n/config"; /** Stable channel key for the continuous CYCLE_PHASE discovery series. */ export const CYCLE_PHASE_CHANNEL_KEY = "CYCLE_PHASE"; @@ -292,8 +293,15 @@ export function discoverPhaseCorrelations(args: { phaseByDay: Map; measurements: CrossMetricMeasurement[]; userPriorityJson?: unknown; + /** + * The reader's locale. Each surviving row carries a finished `interpretation` + * sentence that the cycle route serves straight to the client, so it has to + * be written in the reader's language. Required — no default, because a + * default is what let the metric page's driver line ship in English. + */ + locale: Locale; }): CorrelationDiscoveryResult { - const { phaseByDay, measurements } = args; + const { phaseByDay, measurements, locale } = args; const userPriorityJson = args.userPriorityJson ?? null; const phaseSeries = buildPhaseDiscoverySeries(phaseByDay); @@ -307,7 +315,7 @@ export function discoverPhaseCorrelations(args: { outcomeSeries.push({ key: cfg.type, role: "outcome", points }); } - return discoverCorrelations([phaseSeries, ...outcomeSeries]); + return discoverCorrelations([phaseSeries, ...outcomeSeries], { locale }); } /** diff --git a/src/lib/i18n/user-locale.ts b/src/lib/i18n/user-locale.ts new file mode 100644 index 000000000..85b9615c8 --- /dev/null +++ b/src/lib/i18n/user-locale.ts @@ -0,0 +1,26 @@ +/** + * The reader's locale for a server-internal caller that only has a user id. + * + * Route handlers resolve the locale from the request (cookie first, then the + * stored preference, then Accept-Language) via `resolveServerLocale`. A few + * server-internal fan-outs reach a prose-writing engine without a request of + * their own — the Coach tool executor and the MCP rich reads both take a user + * id and nothing else. This looks their stored preference up and hands it to + * the SAME resolver, so there is one place that decides what language a + * sentence is written in, not two. + * + * Deliberately its own module rather than a second export on `server-locale`: + * that file is imported by page components, and it must not start pulling + * Prisma along with it. + */ +import { prisma } from "@/lib/db"; +import type { Locale } from "./config"; +import { resolveServerLocale } from "./server-locale"; + +export async function resolveLocaleForUser(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + return resolveServerLocale({ userLocale: row?.locale ?? null }); +} diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 0e68d24bc..8b09fdc4c 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -56,7 +56,9 @@ describe("discoverCorrelations", () => { points: series([400, 410, 420]), }, ]; - const result = discoverCorrelations([...behaviours, ...outcomes]); + const result = discoverCorrelations([...behaviours, ...outcomes], { + locale: "en", + }); expect(result.discovered).toHaveLength(0); expect(result.pairsTested).toBe(0); }); @@ -79,7 +81,7 @@ describe("discoverCorrelations", () => { }, { key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) }, ], - { fdrQ: 0.1 }, + { fdrQ: 0.1, locale: "en" }, ); expect(result.pairsTested).toBe(1); expect(result.discovered).toHaveLength(1); @@ -100,10 +102,13 @@ describe("discoverCorrelations", () => { { length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50, ); - const result = discoverCorrelations([ - { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, - { key: "WEIGHT", role: "outcome", points: series(noiseB) }, - ]); + const result = discoverCorrelations( + [ + { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, + { key: "WEIGHT", role: "outcome", points: series(noiseB) }, + ], + { locale: "en" }, + ); // It is tested (n ≥ 20) but should not survive p < 0.05 + FDR. expect(result.pairsTested).toBe(1); expect(result.discovered).toHaveLength(0); @@ -122,7 +127,7 @@ describe("discoverCorrelations", () => { { key: "FACTOR:work", role: "behaviour", points: series(factorVals) }, { key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) }, ], - { fdrQ: 0.1 }, + { fdrQ: 0.1, locale: "en" }, ); expect(result.discovered).toHaveLength(1); const pair = result.discovered[0]; @@ -153,10 +158,17 @@ describe("MOOD as outcome (F3)", () => { const driver = i === 0 ? 60 : 60 + ((i - 1) % 10) * 5; return 1 + (driver - 60) / 45 + (i % 2 === 0 ? 0.02 : -0.02); }); - const result = discoverCorrelations([ - { key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(daylight) }, - { key: "MOOD", role: "outcome", points: series(mood) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: series(daylight), + }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "MOOD", ); @@ -167,10 +179,13 @@ describe("MOOD as outcome (F3)", () => { it("never tests the MOOD → MOOD self-pair", () => { const n = 30; const mood = Array.from({ length: n }, (_, i) => 3 + (i % 3)); - const result = discoverCorrelations([ - { key: "MOOD", role: "behaviour", points: series(mood) }, - { key: "MOOD", role: "outcome", points: series(mood) }, - ]); + const result = discoverCorrelations( + [ + { key: "MOOD", role: "behaviour", points: series(mood) }, + { key: "MOOD", role: "outcome", points: series(mood) }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => p.behaviour === "MOOD" && p.outcome === "MOOD", @@ -222,10 +237,13 @@ describe("D2-1 — same-family lagged pairs are excluded from discovery", () => const n = 40; const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5)); const mood = [3, ...factor.slice(0, n - 1).map((v) => v)]; - const result = discoverCorrelations([ - { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, - { key: "MOOD", role: "outcome", points: longSeries(mood) }, - ]); + const result = discoverCorrelations( + [ + { key: "FACTOR:work", role: "behaviour", points: longSeries(factor) }, + { key: "MOOD", role: "outcome", points: longSeries(mood) }, + ], + { locale: "en" }, + ); expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); }); @@ -283,14 +301,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { .slice(0, n - 1) .map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100), ]; - const result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); // Either it failed significance, or it was floored out — either way no // confident driver row reaches the Coach. const surfaced = result.discovered.find( @@ -309,14 +330,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { 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 result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", @@ -343,14 +367,17 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { .slice(0, n - 1) .map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100), ]; - const result = discoverCorrelations([ - { - key: "TIME_IN_DAYLIGHT", - role: "behaviour", - points: longSeries(behaviour), - }, - { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, - ]); + const result = discoverCorrelations( + [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", @@ -462,18 +489,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { // pair is not a perfect line (a real-world-shaped strong link). .map((a, i) => ((100 - a) / 20) * (i % 2 === 0 ? 1.0 : 0.98)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -500,18 +530,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { 0, ...adherence.slice(0, n - 1).map((a) => (100 - a) / 30), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); // Below the floor the pair is never even tested. expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); @@ -520,18 +553,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { it("never tests the symptom → symptom self-lag", () => { const n = 40; const symptom = Array.from({ length: n }, (_, i) => (i % 4) / 2); - const result = discoverCorrelations([ - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "behaviour", - points: longSeries(symptom), - }, - { - key: SYMPTOM_SEVERITY_CHANNEL_KEY, - role: "outcome", - points: longSeries(symptom), - }, - ]); + const result = discoverCorrelations( + [ + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "behaviour", + points: longSeries(symptom), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => @@ -555,18 +591,21 @@ describe("compliance + symptom channels (FDREXTEND)", () => { .slice(0, n - 1) .map((a, i) => 55 + (100 - a) / 6 + (i % 2 === 0 ? 0.1 : -0.1)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - { - key: "RESTING_HEART_RATE", - role: "outcome", - points: longSeries(rhr), - }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: longSeries(rhr), + }, + ], + { locale: "en" }, + ); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -621,17 +660,24 @@ describe("v1.22 — new curated linkages", () => { .slice(0, n - 1) .map((a, i) => 160 - a / 4 + (i % 2 ? 0.2 : -0.2)), ]; - const result = discoverCorrelations([ - { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: longSeries(adherence), - }, - // BP is both a behaviour and an outcome in the real matrix; the - // same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out. - { key: "BLOOD_PRESSURE_SYS", role: "behaviour", points: longSeries(sys) }, - { key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) }, - ]); + const result = discoverCorrelations( + [ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + // BP is both a behaviour and an outcome in the real matrix; the + // same-family guard must keep BP_SYS(behaviour)→BP_SYS(outcome) out. + { + key: "BLOOD_PRESSURE_SYS", + role: "behaviour", + points: longSeries(sys), + }, + { key: "BLOOD_PRESSURE_SYS", role: "outcome", points: longSeries(sys) }, + ], + { locale: "en" }, + ); expect( result.discovered.find( (p) => @@ -681,12 +727,13 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); expect(retrospective.discovered).toHaveLength(0); // n too low for the floor const cutoff = longSeries(beh)[0].day; // whole (short) window const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); const pair = emerging.find( @@ -711,7 +758,7 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); expect( retrospective.discovered.some( (p) => p.behaviour === "MOOD" && p.outcome === "HEART_RATE_VARIABILITY", @@ -724,6 +771,7 @@ describe("discoverEmergingCorrelations (early detection)", () => { .slice(0, 10); const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); expect( @@ -745,10 +793,11 @@ describe("discoverEmergingCorrelations (early detection)", () => { points: longSeries(out), }, ]; - const retrospective = discoverCorrelations(series); + const retrospective = discoverCorrelations(series, { locale: "en" }); const cutoff = longSeries(beh)[Math.max(0, n - EARLY_WINDOW_DAYS)].day; const { emerging } = discoverEmergingCorrelations(series, retrospective, { recentFromDayKey: cutoff, + locale: "en", minPairs: 10, }); expect(emerging).toHaveLength(0); @@ -778,6 +827,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { draws.push({ key: "LAB:HbA1c", day, value: v }); } const result = discoverLabOutcomeCorrelations(draws, [weight], { + locale: "en", minDraws: 5, minWindowPoints: 5, }); @@ -799,6 +849,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { value: 5 + i, })); const result = discoverLabOutcomeCorrelations(draws, [weight], { + locale: "en", minDraws: 5, }); expect(result.discovered).toHaveLength(0); @@ -820,6 +871,7 @@ describe("discoverLabOutcomeCorrelations (labs ↔ outcomes)", () => { value: 5 + i, })); const result = discoverLabOutcomeCorrelations(draws, [hrv], { + locale: "en", minDraws: 5, }); // HRV is not a curated lab-outcome target → no pair tested. diff --git a/src/lib/insights/__tests__/correlation-series-builders.test.ts b/src/lib/insights/__tests__/correlation-series-builders.test.ts index 5c3b9a78c..1bd103d9e 100644 --- a/src/lib/insights/__tests__/correlation-series-builders.test.ts +++ b/src/lib/insights/__tests__/correlation-series-builders.test.ts @@ -185,7 +185,9 @@ describe("integration — built series flow through the discovery gates", () => role: "outcome", }); - const result = discoverCorrelations([compliance, symptom]); + const result = discoverCorrelations([compliance, symptom], { + locale: "en", + }); expect(result.pairsTested).toBe(0); expect(result.discovered).toHaveLength(0); }); @@ -222,7 +224,9 @@ describe("integration — built series flow through the discovery gates", () => role: "outcome", }); - const result = discoverCorrelations([compliance, symptom]); + const result = discoverCorrelations([compliance, symptom], { + locale: "en", + }); const pair = result.discovered.find( (p) => p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && @@ -256,7 +260,7 @@ describe("integration — built series flow through the discovery gates", () => value: 55 + (i > 0 && (i - 1) % 4 === 0 ? 8 : 0) + (i % 2) * 0.1, })), }); - const result = discoverCorrelations(series); + const result = discoverCorrelations(series, { locale: "en" }); for (const p of result.discovered) { expect(p.interpretation).toMatch(/not a cause|never a cause/); } diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index cf8cd9c8f..703af6931 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -42,7 +42,7 @@ beforeEach(() => { describe("getRelevantCorrelationsForMetric", () => { it("returns [] without any DB read for a non-discovery metric", async () => { // VO2_MAX is not part of the curated discovery matrix. - const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX"); + const out = await getRelevantCorrelationsForMetric("u-1", "VO2_MAX", "en"); expect(out).toEqual([]); expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); @@ -70,6 +70,7 @@ describe("getRelevantCorrelationsForMetric", () => { const out = await getRelevantCorrelationsForMetric( "u-1", "RESTING_HEART_RATE", + "en", ); expect(out.length).toBeGreaterThan(0); // Every surfaced relation mentions resting heart rate (the metric's channel). @@ -87,6 +88,7 @@ describe("getRelevantCorrelationsForMetric", () => { const out = await getRelevantCorrelationsForMetric( "u-1", "RESTING_HEART_RATE", + "en", ); expect(out).toEqual([]); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index eddd1425d..0efd5f868 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -678,6 +678,7 @@ export async function prepareBloodPressureStatusForUser( const relations = await getRelevantCorrelationsForMetric( userId, "BLOOD_PRESSURE_SYS", + locale, ); const assessmentContextBlock = buildAssessmentContextBlock( { diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 05ddbbd5d..49852dd93 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -39,7 +39,7 @@ * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; -import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { type Locale } from "@/lib/i18n/config"; import { getServerTranslator } from "@/lib/i18n/server-translator"; /** @@ -447,21 +447,25 @@ export function discoverCorrelations( 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. + * Reader's locale for the narrated `interpretation`. + * + * REQUIRED, and it must stay required. It used to be optional with an + * English default, and the default is what shipped the bug: a caller could + * say nothing and still get a finished sentence back, so the "Coach read" + * strip on a German metric page printed its second line in English under a + * German first line for months. Nobody had to write anything wrong for that + * to happen — silence was enough. With no default, a caller that forgets + * cannot compile, which is the only version of this that stays fixed. */ - locale?: Locale; - } = {}, + 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 { t: translate } = getServerTranslator(opts.locale); const behaviours = series.filter((s) => s.role === "behaviour"); const outcomes = series.filter((s) => s.role === "outcome"); @@ -717,8 +721,12 @@ export function discoverEmergingCorrelations( lagDays?: number; minPairs?: number; fdrQ?: number; - /** Reader's locale, threaded to the inner scan's narration. */ - locale?: Locale; + /** + * Reader's locale, threaded to the inner scan's narration. Required for the + * same reason it is required on {@link discoverCorrelations} — an optional + * locale here would put the English default straight back, one call deeper. + */ + locale: Locale; }, ): EmergingCorrelationResult { const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS; @@ -880,16 +888,20 @@ export function discoverLabOutcomeCorrelations( minDraws?: number; minWindowPoints?: number; fdrQ?: number; - /** Reader's locale for the narrated `interpretation`. Defaults to English. */ - locale?: Locale; - } = {}, + /** + * Reader's locale for the narrated `interpretation`. Required — see + * {@link discoverCorrelations}; a lab sentence leaks English just as + * happily as a daily one. + */ + 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); + const { t: translate } = getServerTranslator(opts.locale); // Group draws by biomarker key (newest-irrelevant; Pearson is order-free). const drawsByLab = new Map(); diff --git a/src/lib/insights/derived/coach-read.ts b/src/lib/insights/derived/coach-read.ts index c18300f82..346df7bae 100644 --- a/src/lib/insights/derived/coach-read.ts +++ b/src/lib/insights/derived/coach-read.ts @@ -23,6 +23,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { loadBaselineProfile, computeVitalsBaseline, @@ -71,6 +72,15 @@ async function readLatestValue( export async function buildCoachReadStrip( userId: string, type: MeasurementType, + /** + * The reader's locale. Line 2 arrives as a finished sentence and is printed + * verbatim by both clients, so it has to be written in the reader's language + * HERE — the strip component only translates the wrapper around it. Required + * on purpose: while this parameter did not exist, the route said nothing and + * the sentence came back in English, which is how a German weight page ended + * up with a German first line and an English second one. + */ + locale: Locale, ): Promise { const profile = await loadBaselineProfile(prisma, userId); @@ -78,7 +88,9 @@ export async function buildCoachReadStrip( computeVitalsBaseline(userId, profile, { type }), readLatestValue(userId, type), // Line 2 is best-effort: a correlation failure must never sink the band. - readCoachCorrelations(userId).catch(() => ({ present: false }) as const), + readCoachCorrelations(userId, locale).catch( + () => ({ present: false }) as const, + ), ]); let baseline: CoachReadBaseline | null = null; diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index b5b269a27..10cea2f33 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -22,6 +22,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { discoverCorrelations, discoveryMeasurementTypes, @@ -82,6 +83,13 @@ function channelKeyForType(type: MeasurementType): string | null { export async function getRelevantCorrelationsForMetric( userId: string, measurementType: MeasurementType, + /** + * The reader's locale. These interpretations are finished sentences that go + * into the assessment prompt as grounded context; handing a German card's + * prompt English prose invites the model to echo it. Required — every caller + * is a status builder that already knows the reader's language. + */ + locale: Locale, ): Promise { const channel = channelKeyForType(measurementType); if (!channel) return []; @@ -162,7 +170,7 @@ export async function getRelevantCorrelationsForMetric( } series.push(...environmentSeries, ...customMetricSeries); - const result = discoverCorrelations(series); + const result = discoverCorrelations(series, { locale }); const decisions = await syncAcceptedPatterns({ userId, family: PATTERN_FAMILIES.discoveryRetrospective, diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index e1e085585..d9defd0d2 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -454,6 +454,7 @@ export async function generateMetricStatus(args: { const relations = await getRelevantCorrelationsForMetric( args.userId, meta.measurementType, + locale, ); const contextBlock = buildAssessmentContextBlock( { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index ccc68d8e5..2415fde27 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -103,6 +103,7 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { const result = await buildPeriodNarrativeContext("u1", { period: "week", now, + locale: "en", }); expect(result.status).toBe("ready"); @@ -122,7 +123,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { const now = new Date("2026-06-30T12:00:00.000Z"); measurementFindMany.mockResolvedValue([]); - await buildPeriodNarrativeContext("u1", { period: "week", now }); + await buildPeriodNarrativeContext("u1", { + period: "week", + now, + locale: "en", + }); expect(measurementFindMany).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 2d2f6e483..292aa268c 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -40,6 +40,7 @@ function monthInput( }, seriesByMetric, discoverySeries, + locale: "en", computedAt: "2026-04-30T12:00:00.000Z", }; } diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index bbcde109c..adacbf36f 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -329,7 +329,7 @@ export async function generatePeriodNarrative( } } - const context = await buildContext(userId, { period, now }); + const context = await buildContext(userId, { period, now, locale }); if (context.status === "insufficient") { annotate({ action: { name: "insights.narrative.insufficient" }, diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 66ddcd8f9..2a160b63e 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -31,6 +31,7 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; import { annotate } from "@/lib/logging/context"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { @@ -286,6 +287,12 @@ export interface AssembleInput { seriesByMetric: Map; /** Named series feeding the discovery matrix (same window). */ discoverySeries: NamedSeries[]; + /** + * The reader's locale for the drivers' narrated `interpretation`. Required — + * the assembler writes sentences, so it has to be told which language they + * are in rather than assuming one. + */ + locale: Locale; /** Compute time (ISO). */ computedAt: string; } @@ -305,6 +312,7 @@ export function assemblePeriodNarrativeContext( window, seriesByMetric, discoverySeries, + locale, computedAt, } = input; @@ -380,7 +388,7 @@ export function assemblePeriodNarrativeContext( } // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── - const discovery = discoverCorrelations(discoverySeries); + const discovery = discoverCorrelations(discoverySeries, { locale }); const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ behaviour: d.behaviour, outcome: d.outcome, @@ -486,6 +494,14 @@ export interface BuildPeriodNarrativeContextOpts { period: NarrativePeriod; /** Injected clock for deterministic behaviour; defaults to now. */ now?: Date; + /** + * The reader's locale, for the drivers' narrated `interpretation`. Required: + * those are finished sentences that reach the prompt and the deterministic + * fallback, and both callers already know which language the narrative is + * being written in. An optional locale here would mean an English driver line + * inside a German summary whenever a caller stayed quiet. + */ + locale: Locale; } /** @@ -673,6 +689,7 @@ export async function buildPeriodNarrativeContext( window: { from: since.toISOString(), to: now.toISOString() }, seriesByMetric, discoverySeries, + locale: opts.locale, computedAt: now.toISOString(), }); } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 66965d70a..8fe30a89c 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -427,7 +427,11 @@ export async function preparePulseStatusForUser( // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). const varietyLead = pickVarietyLead(userId, "pulse", todayKey); const steadyRun = computeSteadyRun(pulseGraded.weekly, pulseGraded.monthly); - const relations = await getRelevantCorrelationsForMetric(userId, "PULSE"); + const relations = await getRelevantCorrelationsForMetric( + userId, + "PULSE", + locale, + ); const assessmentContextBlock = buildAssessmentContextBlock( { varietyLead, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index dcf4ceaf3..607e334bb 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -586,7 +586,11 @@ export async function prepareWeightStatusForUser( // v1.12.7 — diversity / anti-repetition block (see blood-pressure-status). const varietyLead = pickVarietyLead(userId, "weight", todayKey); const steadyRun = computeSteadyRun(weightGraded.weekly, weightGraded.monthly); - const relations = await getRelevantCorrelationsForMetric(userId, "WEIGHT"); + const relations = await getRelevantCorrelationsForMetric( + userId, + "WEIGHT", + locale, + ); const assessmentContextBlock = buildAssessmentContextBlock( { varietyLead, diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 62e9ebe42..14d22261f 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -36,6 +36,7 @@ import { isModuleEnabled } from "@/lib/modules/gate"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; +import { resolveLocaleForUser } from "@/lib/i18n/user-locale"; import { readBestGranularityRollups, aggregateWmyBuckets, @@ -668,7 +669,12 @@ export async function getCorrelation( ): Promise { const a = norm(args.metricA); const b = norm(args.metricB); - const result = await readCoachCorrelations(userId); + // The `note` on each driver is a finished sentence the MCP client shows as + // it stands, so it is written in the account's language, not English. + const result = await readCoachCorrelations( + userId, + await resolveLocaleForUser(userId), + ); if (!result.present || !result.drivers || result.drivers.length === 0) { annotate({ @@ -973,7 +979,11 @@ export async function getMetricBaseline( } const metric = resolved.metric; - const strip = await buildCoachReadStrip(userId, metric.measurementType); + const strip = await buildCoachReadStrip( + userId, + metric.measurementType, + await resolveLocaleForUser(userId), + ); if (!strip.baseline) { annotate({ diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 517528d57..1fdf45da5 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -172,9 +172,16 @@ export const insightsKeys = { * MeasurementType. Pure compute over the baseline + correlation engines — * the `["insights"]` prefix keeps it in the standard invalidation fan-out * (a measurement write busts it so the placement refreshes). + * + * Keyed by locale as well: the strip's second line is prose the server + * writes in the reader's language, so a cache entry belongs to one language + * only. Without the segment, switching language re-used the German page's + * cached English sentence — the fix on the server would have been invisible + * to anyone who switched rather than reloaded. Same reason + * `insightsMetricStatus` carries it. */ - insightsCoachRead: (metric: string) => - ["insights", "coach-read", metric] as const, + insightsCoachRead: (metric: string, locale: string) => + ["insights", "coach-read", metric, locale] as const, /** * v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the From 271a49a8222c9e5d21d847e2113329b948bba55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 09:07:50 +0200 Subject: [PATCH 2/4] Pin the Coach read strip's language, and keep the default out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things are held down here. The route hands the reader's language to the strip builder, for a cookie and for a stored preference. The pattern sentence comes back in German for a German reader, asserted word for word, through the real chain rather than the engine on its own — the engine could always write German, it was simply never asked, so an engine-only test would have stayed green the whole time it was broken. And a cached strip stays with the language it was written in. The guard reads the declarations rather than grepping them, so renaming the argument or moving the fallback into a variable does not slip past it. Its counterpart runs the same detector against those disguises and insists each one is caught, because a check that cannot fail reads like proof and is not. --- src/__tests__/helpers/narrated-locale.ts | 237 ++++++++++++++++++ .../narrated-locale-guard-defeat.test.ts | 185 ++++++++++++++ .../narrated-locale-required-guard.test.ts | 80 ++++++ .../coach-read/__tests__/route.test.ts | 131 ++++++++++ .../coach-read-strip-locale-cache.test.tsx | 108 ++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 13 +- .../insights/__tests__/metric-status.test.ts | 1 + .../__tests__/coach-read-locale.test.ts | 173 +++++++++++++ .../__tests__/rich-reads-module-gate.test.ts | 7 +- src/lib/mcp/__tests__/rich-reads.test.ts | 10 +- 10 files changed, 939 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/helpers/narrated-locale.ts create mode 100644 src/__tests__/narrated-locale-guard-defeat.test.ts create mode 100644 src/__tests__/narrated-locale-required-guard.test.ts create mode 100644 src/app/api/insights/coach-read/__tests__/route.test.ts create mode 100644 src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx create mode 100644 src/lib/insights/derived/__tests__/coach-read-locale.test.ts diff --git a/src/__tests__/helpers/narrated-locale.ts b/src/__tests__/helpers/narrated-locale.ts new file mode 100644 index 000000000..7389fb835 --- /dev/null +++ b/src/__tests__/helpers/narrated-locale.ts @@ -0,0 +1,237 @@ +/** + * Detector for the "a narrated sentence's locale stays required" guard. + * + * Lives beside the guard rather than inside it so the guard's own + * counter-tests can run this exact code against disguised sources without + * re-running the guard itself. See `narrated-locale-required-guard.test.ts` + * for why the rule exists and `narrated-locale-guard-defeat.test.ts` for the + * disguises it has to see through. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import ts from "typescript"; + +const REPO_ROOT = resolve(__dirname, "../../.."); + +/** + * The chain. Each entry is a declaration whose output is, or becomes, prose a + * person reads. Add to it when a new one joins the chain; never remove one + * because the check became inconvenient. + */ +export const CHAIN: ReadonlyArray<{ file: string; declaration: string }> = [ + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverEmergingCorrelations", + }, + { + file: "src/lib/insights/correlation-discovery.ts", + declaration: "discoverLabOutcomeCorrelations", + }, + { + file: "src/lib/ai/coach/tools/correlations-read.ts", + declaration: "readCoachCorrelations", + }, + { + file: "src/lib/insights/derived/coach-read.ts", + declaration: "buildCoachReadStrip", + }, + { + file: "src/lib/insights/metric-correlation-context.ts", + declaration: "getRelevantCorrelationsForMetric", + }, + { + file: "src/lib/cycle/phase-crosstab.ts", + declaration: "discoverPhaseCorrelations", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "buildPeriodNarrativeContext", + }, + { + file: "src/lib/insights/narrative/period-narrative.ts", + declaration: "assemblePeriodNarrativeContext", + }, + { + file: "src/lib/ai/coach/correlations-snapshot.ts", + declaration: "buildCorrelationsSnapshotBlock", + }, +]; + +export const WHY_NO_DEFAULT = + "A locale on this chain must be REQUIRED, with no `?` and no default. " + + "These functions return finished sentences that a page prints verbatim. " + + "An optional locale lets a caller say nothing and still get a sentence — " + + "which is exactly how the metric page's Coach-read strip came to answer a " + + "German page in English, under a German first line, for months. A default " + + "cannot fail loudly; a missing argument can. Pass the reader's locale " + + "instead of restoring the fallback."; + +export interface LocaleSite { + /** Where it sits, for the failure message. */ + where: string; + optional: boolean; + hasInitializer: boolean; +} + +/** The local name `Locale` is bound to in this file (it may be aliased). */ +function localeTypeName(source: ts.SourceFile): string { + let name = "Locale"; + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + if ((element.propertyName ?? element.name).text === "Locale") { + name = element.name.text; + } + } + } + return name; +} + +/** Does this type node denote the locale type (possibly a union with it)? */ +function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean { + if (!node) return false; + if (ts.isTypeReferenceNode(node)) return node.typeName.getText() === alias; + if (ts.isUnionTypeNode(node)) + return node.types.some((t) => isLocaleType(t, alias)); + return false; +} + +/** Find a top-level function/interface declaration by name. */ +function findDeclaration( + source: ts.SourceFile, + name: string, +): ts.FunctionDeclaration | ts.InterfaceDeclaration | undefined { + for (const statement of source.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isInterfaceDeclaration(statement)) && + statement.name?.text === name + ) { + return statement; + } + } + return undefined; +} + +/** + * Every place a locale is accepted by `declaration` — as a parameter in its own + * right, as a member of an inline options object, or as a member of a + * same-file interface used as a parameter type. Keyed on the TYPE, so renaming + * the parameter changes nothing. + */ +export function localeSites( + source: ts.SourceFile, + declarationName: string, +): LocaleSite[] { + const alias = localeTypeName(source); + const declaration = findDeclaration(source, declarationName); + if (!declaration) { + throw new Error( + `${WHY_NO_DEFAULT}\n\nGuard could not find \`${declarationName}\` in ` + + `${source.fileName}. If it was renamed or moved, update the chain ` + + `list — do not delete the entry.`, + ); + } + + const sites: LocaleSite[] = []; + + const collectFromMembers = ( + members: ts.NodeArray, + owner: string, + ownerOptional: boolean, + ownerHasInitializer: boolean, + ) => { + for (const member of members) { + if (!ts.isPropertySignature(member)) continue; + if (!isLocaleType(member.type, alias)) continue; + sites.push({ + where: `${owner}.${member.name.getText()}`, + // The property itself may be required while the object it lives on is + // optional or defaulted — which puts the silence right back. + optional: member.questionToken !== undefined || ownerOptional, + hasInitializer: ownerHasInitializer, + }); + } + }; + + const visitParameters = ( + parameters: ts.NodeArray, + ) => { + for (const parameter of parameters) { + const owner = parameter.name.getText(); + const optional = parameter.questionToken !== undefined; + const hasInitializer = parameter.initializer !== undefined; + if (isLocaleType(parameter.type, alias)) { + sites.push({ where: owner, optional, hasInitializer }); + continue; + } + const type = parameter.type; + if (type && ts.isTypeLiteralNode(type)) { + collectFromMembers(type.members, owner, optional, hasInitializer); + } else if (type && ts.isTypeReferenceNode(type)) { + const referenced = findDeclaration(source, type.typeName.getText()); + if (referenced && ts.isInterfaceDeclaration(referenced)) { + collectFromMembers( + referenced.members, + type.typeName.getText(), + optional, + hasInitializer, + ); + } + } + } + }; + + if (ts.isInterfaceDeclaration(declaration)) { + collectFromMembers( + declaration.members, + declaration.name.text, + false, + false, + ); + } else { + visitParameters(declaration.parameters); + } + + return sites; +} + +/** + * Every `??` / `||` in the file whose left side reads a locale. This is the + * "move the default into a variable" escape: `opts.locale ?? fallback` is the + * same bug as `opts.locale ?? defaultLocale`, and neither is allowed here. + */ +export function localeFallbacks(source: ts.SourceFile): string[] { + const found: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isBinaryExpression(node)) { + const op = node.operatorToken.kind; + if ( + op === ts.SyntaxKind.QuestionQuestionToken || + op === ts.SyntaxKind.BarBarToken + ) { + const left = node.left.getText(); + if (/locale/i.test(left)) found.push(node.getText()); + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(source, visit); + return found; +} + +export function parse(file: string, text?: string): ts.SourceFile { + return ts.createSourceFile( + file, + text ?? readFileSync(resolve(REPO_ROOT, file), "utf8"), + ts.ScriptTarget.Latest, + true, + ); +} diff --git a/src/__tests__/narrated-locale-guard-defeat.test.ts b/src/__tests__/narrated-locale-guard-defeat.test.ts new file mode 100644 index 000000000..475068986 --- /dev/null +++ b/src/__tests__/narrated-locale-guard-defeat.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; + +import { localeSites, localeFallbacks, parse } from "./helpers/narrated-locale"; + +/** + * The guard checked against a guard. + * + * A structural check that cannot fail is worse than no check: it reads as + * proof and is not. So each case below is a disguise someone would plausibly + * reach for while putting the English default back — renaming the parameter, + * aliasing the type, hiding the fallback behind a variable, defaulting the + * whole options object instead of the field — and each one is asserted to be + * caught by the SAME detector the real guard runs. + * + * These sources are strings on purpose. They are the shapes that must not + * ship, so nothing here may live in a file the app can import. + */ + +const FILE = "disguise.ts"; + +describe("the locale guard cannot be talked around", () => { + it("catches the plain optional it was written for", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a renamed parameter — it reads the type, not the name", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.where).toBe("opts.lang"); + expect(site.optional).toBe(true); + }); + + it("catches an aliased type import", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale as L } from "@/lib/i18n/config"; + export function narrate(opts: { lang?: L }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a required field on a DEFAULTED options object", () => { + // The field says `locale: Locale`, so a field-only check reads as clean — + // but `= {}` means the caller can still say nothing. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale } = {} as never) { return opts; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("catches a required field on an OPTIONAL options object", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts?: { locale: Locale }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches an optional locale hidden in a same-file interface", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export interface Opts { period: string; locale?: Locale } + export function narrate(opts: Opts) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a union that merely tolerates undefined", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale?: Locale | undefined }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("refuses to pass a declaration that dropped the locale entirely", () => { + expect(() => + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).not.toThrow(); + expect( + localeSites( + parse( + FILE, + `export function narrate(series: string[]) { return series; }`, + ), + "narrate", + ), + ).toEqual([]); // the guard's own `length > 0` assertion is what fails on this + }); + + it("throws rather than quietly passing when the declaration is gone", () => { + expect(() => + localeSites(parse(FILE, `export const x = 1;`), "narrate"), + ).toThrow(/could not find/); + }); + + it("catches the default moved into a variable", () => { + expect( + localeFallbacks( + parse( + FILE, + `const fallback = "en"; + export function narrate(opts: { locale?: string }) { + return opts.locale ?? fallback; + }`, + ), + ), + ).not.toEqual([]); + }); + + it("catches the default written with || instead of ??", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { locale?: string }) => o.locale || "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("catches a renamed field's fallback when it still reads like a locale", () => { + expect( + localeFallbacks( + parse( + FILE, + `export const l = (o: { readerLocale?: string }) => o.readerLocale ?? "en";`, + ), + ), + ).not.toEqual([]); + }); + + it("does not fire on a fallback that has nothing to do with language", () => { + // A guard that flags everything gets silenced, so it has to stay quiet + // where it should. + expect( + localeFallbacks( + parse( + FILE, + `export const n = (o: { minPairs?: number }) => o.minPairs ?? 20;`, + ), + ), + ).toEqual([]); + }); +}); diff --git a/src/__tests__/narrated-locale-required-guard.test.ts b/src/__tests__/narrated-locale-required-guard.test.ts new file mode 100644 index 000000000..a9fceaee7 --- /dev/null +++ b/src/__tests__/narrated-locale-required-guard.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { queryKeys } from "@/lib/query-keys"; +import { + CHAIN, + WHY_NO_DEFAULT, + localeFallbacks, + localeSites, + parse, +} from "./helpers/narrated-locale"; + +/** + * A function that writes a sentence for a person must be told which language. + * + * The metric page's "Coach read" strip printed its own summary in German and + * the pattern line under it in English, one under the other, for months. The + * translations existed in all six languages the whole time. What broke was the + * shape of the call: the discovery engine took `locale?: Locale` and fell back + * to English, and the route that knew who was reading simply never said. Nobody + * had to write anything wrong — staying quiet was enough, and the result read + * like a finished sentence, so nothing looked broken from the code's side. + * + * A default is what made silence survivable. So on this chain there is none: + * every locale a narrated sentence depends on is a REQUIRED parameter, and a + * caller that cannot name one does not compile. This guard is what keeps that + * true, because "optional with a sensible default" reads like a kindness every + * time someone adds the next caller. + * + * It reads types, not names. Renaming the parameter, or lifting the default + * into a variable, does not get past it — see the disguises in + * `narrated-locale-guard-defeat.test.ts`, which run this same detector. + */ + +describe("a narrated sentence's locale stays required", () => { + for (const { file, declaration } of CHAIN) { + it(`${declaration} (${file}) accepts a locale, and it is required`, () => { + const sites = localeSites(parse(file), declaration); + + // Deleting the parameter must not pass as "nothing optional found". + expect( + sites.length, + `${WHY_NO_DEFAULT}\n\n\`${declaration}\` no longer accepts a locale at ` + + `all. It writes prose; it has to be told the language.`, + ).toBeGreaterThan(0); + + for (const site of sites) { + expect( + site.optional, + `${WHY_NO_DEFAULT}\n\nOptional locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + expect( + site.hasInitializer, + `${WHY_NO_DEFAULT}\n\nDefaulted locale at \`${declaration}\` → ${site.where}.`, + ).toBe(false); + } + }); + } + + for (const file of [...new Set(CHAIN.map((entry) => entry.file))]) { + it(`${file} falls back to no language of its own`, () => { + const fallbacks = localeFallbacks(parse(file)); + expect( + fallbacks, + `${WHY_NO_DEFAULT}\n\nA locale fallback in ${file}: ` + + `${fallbacks.join(", ")}. Naming the fallback something else does ` + + `not change what it does.`, + ).toEqual([]); + }); + } + + it("keys the strip's browser cache by language", () => { + // The server-side fix is invisible to anyone who switches language rather + // than reloads if the cached entry is shared across languages. + const german = queryKeys.insightsCoachRead("WEIGHT", "de"); + const english = queryKeys.insightsCoachRead("WEIGHT", "en"); + expect(german).not.toEqual(english); + expect(german).toContain("de"); + expect(english).toContain("en"); + }); +}); diff --git a/src/app/api/insights/coach-read/__tests__/route.test.ts b/src/app/api/insights/coach-read/__tests__/route.test.ts new file mode 100644 index 000000000..13d083137 --- /dev/null +++ b/src/app/api/insights/coach-read/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * The route has to say who is reading. + * + * The strip's second line is a finished sentence written server-side. The + * builder can write it in any of the six languages; for months it wrote + * English on a German page because this route never passed a language and the + * engine underneath quietly defaulted. So what is pinned here is the handoff + * itself: the resolved locale reaching `buildCoachReadStrip`, from the cookie a + * language switch sets and from the stored preference when there is no cookie. + */ + +const buildCoachReadStrip = vi.fn(); +vi.mock("@/lib/insights/derived/coach-read", () => ({ + buildCoachReadStrip: (userId: string, metric: string, locale: string) => + buildCoachReadStrip(userId, metric, locale), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); +vi.mock("@/lib/feature-flags", () => ({ + requireAssistantSurface: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAnalyticsReadRateLimit: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale, + }, + }; +} + +const REQUEST = () => + new NextRequest("http://localhost/api/insights/coach-read?metric=WEIGHT"); + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ + allowed: true, + } as unknown as Awaited>); + buildCoachReadStrip.mockReset().mockResolvedValue({ + baseline: null, + learning: true, + driver: null, + }); + localeCookie.mockReturnValue(undefined); +}); + +describe("GET /api/insights/coach-read — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(REQUEST()); + + expect(res.status).toBe(200); + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("fr") as unknown as Awaited< + ReturnType + >, + ); + + await GET(REQUEST()); + + expect(buildCoachReadStrip).toHaveBeenCalledWith("user-1", "WEIGHT", "fr"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that pinned + // one would pass this only by accident. + localeCookie.mockReturnValue("es"); + await GET(REQUEST()); + localeCookie.mockReturnValue("de"); + await GET(REQUEST()); + + expect(buildCoachReadStrip.mock.calls.map((call) => call[2])).toEqual([ + "es", + "de", + ]); + }); +}); diff --git a/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx new file mode 100644 index 000000000..d52646886 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-locale-cache.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; + +/** + * A cached strip belongs to ONE language. + * + * The strip's second line is prose the server writes in the reader's language. + * The browser cache used to key it by metric alone, so after a language switch + * the page re-used the sentence written for the previous language and the + * server-side fix would have been invisible to anyone who switched rather than + * reloaded. `/api/analytics` carries the same warning in its own cache key. + * + * Both entries are seeded, so a key that has stopped separating them shows up + * as the WRONG sentence rather than as an empty page — a test that only proved + * "the other locale misses" would also pass if the key stopped matching + * anything at all. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +// The strip paints nothing until it is client-mounted (React #418). +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +const GERMAN_NOTE = + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am Folgetag " + + "nach bisheriger Datenlage eher niedriger auszufallen — ein Muster, das " + + "Beachtung verdient, keine Ursache."; +const ENGLISH_NOTE = + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth watching, " + + "not a cause."; + +function payload(note: string): CoachReadStripData { + return { + baseline: null, + learning: true, + driver: { note, behaviour: "symptom severity", outcome: "sleep duration" }, + }; +} + +const METRIC = "SLEEP_DURATION"; + +/** + * One client holding BOTH languages' entries — the state a person is in the + * instant after switching language on a page they had already loaded. + */ +function seededClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "de"), + payload(GERMAN_NOTE), + ); + client.setQueryData( + queryKeys.insightsCoachRead(METRIC, "en"), + payload(ENGLISH_NOTE), + ); + return client; +} + +function renderStrip(client: QueryClient, locale: "de" | "en"): string { + return renderToStaticMarkup( + + + + + , + ); +} + +describe("CoachReadStrip — a cached strip is not served to another language", () => { + it("reads the German entry for a German reader", () => { + const markup = renderStrip(seededClient(), "de"); + expect(markup).toContain(GERMAN_NOTE); + expect(markup).not.toContain(ENGLISH_NOTE); + }); + + it("reads the English entry for an English reader", () => { + const markup = renderStrip(seededClient(), "en"); + expect(markup).toContain(ENGLISH_NOTE); + expect(markup).not.toContain(GERMAN_NOTE); + }); + + it("does not carry one language's cached sentence into the other on a switch", () => { + // One client, both renders — exactly what a language switch does to a page + // that is already warm. + const client = seededClient(); + const german = renderStrip(client, "de"); + const english = renderStrip(client, "en"); + + expect(german).toContain(GERMAN_NOTE); + expect(english).toContain(ENGLISH_NOTE); + expect(english).not.toContain(GERMAN_NOTE); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index 930c09401..ff751c4ab 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -56,7 +56,15 @@ function emptyAggregate() { const readCoachCorrelations = vi.fn(); vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ - readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), + readCoachCorrelations: (userId: string, locale: string) => + readCoachCorrelations(userId, locale), +})); + +// The executor asks for the account's language before it asks for a narrated +// correlation, so the sentences it hands the model are not silently English. +const resolveLocaleForUser = vi.fn(async (_userId: string) => "de"); +vi.mock("@/lib/i18n/user-locale", () => ({ + resolveLocaleForUser: (userId: string) => resolveLocaleForUser(userId), })); // v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks @@ -357,6 +365,9 @@ describe("executeCoachTool", () => { drivers: [{ behaviour: "time in daylight", n: 42 }], pairsTested: 18, }); + // The account's language reached the reader — the notes are finished + // sentences, and an MCP client shows some of them as they stand. + expect(readCoachCorrelations).toHaveBeenCalledWith("u1", "de"); }); it("returns a clean { present: false } for get_correlations on no pattern", async () => { diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 122f7478a..d930ac00f 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -411,6 +411,7 @@ describe("generateMetricStatus — generation path", () => { expect(getRelevantCorrelationsForMetric).toHaveBeenCalledWith( "user-1", "RESTING_HEART_RATE", + "en", ); }); diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts new file mode 100644 index 000000000..504d628af --- /dev/null +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -0,0 +1,173 @@ +/** + * The "Coach read" strip's second line has to arrive in the reader's language. + * + * On a German metric page the strip used to print its own summary in German + * and the pattern line under it in English, one under the other. Nothing was + * mistranslated: the pattern line is a finished sentence the server writes, + * the route never said who was reading, and the engine's optional locale + * quietly answered in English. + * + * So this drives the REAL pipe — `buildCoachReadStrip` → `readCoachCorrelations` + * → `discoverCorrelations` — with only the database reads faked, and asserts the + * German sentence the German templates produce. Testing the engine alone would + * have stayed green through the whole outage: the engine could always speak + * German, it was simply never asked. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const measurementFindMany = vi.fn(); +const measurementFindFirst = vi.fn(); +const moodFindMany = vi.fn(); +const userFindUnique = vi.fn(); +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); +const labResultFindMany = vi.fn(); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (a: unknown) => measurementFindMany(a), + findFirst: (a: unknown) => measurementFindFirst(a), + }, + moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, + user: { findUnique: (a: unknown) => userFindUnique(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, + labResult: { findMany: (a: unknown) => labResultFindMany(a) }, + }, +})); + +// The band engine is `baseline.test.ts`'s subject; the strip's FIRST line is +// not what is under test here. Only the second line is, so the band degrades to +// "still learning" and the driver line stands alone. +const PROFILE = { ageYears: 40, sex: "MALE", heightCm: 180 }; +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeCoincidentDeviation: async () => null, + }; +}); +vi.mock("@/lib/insights/derived/baseline", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadBaselineProfile: async () => PROFILE, + computeVitalsBaseline: async () => ({ + status: "insufficient" as const, + reason: "not under test", + }), + }; +}); + +import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; + +const DAYS = 45; +const BASE = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → a tz-stable day key + +function dayKey(offset: number): string { + const d = new Date(BASE + offset * 86_400_000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + +/** + * 45 days where today's symptom burden runs against tomorrow's sleep, strongly + * enough to clear n ≥ 20, p < 0.05, the FDR control and the effect-size floor. + * Same fixture shape the correlations reader's own real-engine test uses. + */ +function seedSymptomDrivesSleep() { + const dayLogs: Array<{ + date: string; + functionalImpact: number; + symptomLinks: Array<{ severity: number }>; + }> = []; + const measurements: Array<{ type: string; value: number; measuredAt: Date }> = + []; + for (let i = 0; i < DAYS; i++) { + const burden = i % 4; + dayLogs.push({ + date: dayKey(i), + functionalImpact: burden, + symptomLinks: [], + }); + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - burden, + measuredAt: new Date(BASE + (i + 1) * 86_400_000), + }); + } + measurementFindMany.mockResolvedValue(measurements); + illnessEpisodeFindMany.mockResolvedValue([ + { + id: "ep1", + onsetAt: new Date(BASE), + resolvedAt: new Date(BASE + DAYS * 86_400_000), + }, + ]); + illnessDayLogFindMany.mockResolvedValue(dayLogs); +} + +describe("buildCoachReadStrip — the pattern line speaks the reader's language", () => { + beforeEach(() => { + measurementFindMany.mockReset().mockResolvedValue([]); + measurementFindFirst.mockReset().mockResolvedValue({ value: 7 }); + moodFindMany.mockReset().mockResolvedValue([]); + userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); + labResultFindMany.mockReset().mockResolvedValue([]); + seedSymptomDrivesSleep(); + }); + + it("writes the driver note in German for a German reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + + expect(strip.driver).not.toBeNull(); + const note = strip.driver!.note; + + // The exact German sentence, not merely "different from the English one". + // A sentence that is different but wrong is still a broken page. + expect(note).toBe( + "Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " + + "Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " + + "Muster, das Beachtung verdient, keine Ursache.", + ); + }); + + it("writes the same driver note in English for an English reader", async () => { + const strip = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(strip.driver).not.toBeNull(); + expect(strip.driver!.note).toBe( + "Higher symptom severity looks like it goes with lower next-day sleep " + + "duration in your data, on the evidence so far — a pattern worth " + + "watching, not a cause.", + ); + }); + + it("keeps the driver's matching keys language-independent", async () => { + // Only the sentence is localised. `outcome` is how the strip finds the + // driver that belongs to THIS metric, so it has to stay the same string in + // every language or a German page would find no driver at all. + const de = await buildCoachReadStrip("u1", "SLEEP_DURATION", "de"); + const en = await buildCoachReadStrip("u1", "SLEEP_DURATION", "en"); + + expect(de.driver!.outcome).toBe("sleep duration"); + expect(en.driver!.outcome).toBe("sleep duration"); + expect(de.driver!.behaviour).toBe(en.driver!.behaviour); + expect(de.driver!.note).not.toBe(en.driver!.note); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts index be6811fc2..8e942c4a5 100644 --- a/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts +++ b/src/lib/mcp/__tests__/rich-reads-module-gate.test.ts @@ -32,11 +32,14 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, measurement: { count: vi.fn(async () => 0), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { compareMetric, diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index e6a4f1460..907d9722f 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -18,13 +18,16 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); -const { labResult, measurement } = vi.hoisted(() => ({ +const { labResult, measurement, user } = vi.hoisted(() => ({ labResult: { findMany: vi.fn() }, // `withHrvFallback`'s presence probe (`count`) + the discovery presence // query (`groupBy`). Unused by metrics that don't exercise them. measurement: { count: vi.fn(), groupBy: vi.fn(async () => []) }, + // The rich reads resolve the account's language before asking for a + // narrated sentence, which reads the stored preference. + user: { findUnique: vi.fn(async () => ({ locale: "de" })) }, })); -vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement } })); +vi.mock("@/lib/db", () => ({ prisma: { labResult, measurement, user } })); import { getCorrelation, @@ -396,7 +399,7 @@ describe("get_metric_baseline", () => { const res = await getMetricBaseline(USER, { metric: "hrv" }); expect(res.present).toBe(true); - expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD"); + expect(buildCoachReadStrip).toHaveBeenCalledWith(USER, "HRV_RMSSD", "de"); }); it("stays on SDNN when the user has any HEART_RATE_VARIABILITY rows", async () => { @@ -419,6 +422,7 @@ describe("get_metric_baseline", () => { expect(buildCoachReadStrip).toHaveBeenCalledWith( USER, "HEART_RATE_VARIABILITY", + "de", ); }); }); From 7233845b10ad07b81951acb8312355652dababc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:08:06 +0200 Subject: [PATCH 3/4] Close four ways around the locale guard The guard read a parameter as required whenever it carried no question mark and no default, so `Locale | undefined`, `Locale | null`, a locale widened to `any`, and a default sitting on the destructuring pattern all walked straight past it. Each of those lets a caller say nothing and the sentence come back in English, which is the bug the guard exists to stop. A counter-case pins the other direction: a plainly required locale still reads clean, so the guard stays quiet on the shape the chain is meant to have. The correlation read now also asserts the language it was asked in; nothing else in that file would have noticed it going back to English. --- src/__tests__/helpers/narrated-locale.ts | 75 ++++++++- .../narrated-locale-guard-defeat.test.ts | 74 +++++++++ .../insights/__tests__/route-locale.test.ts | 142 ++++++++++++++++++ src/components/cycle/use-cycle.ts | 6 +- src/lib/mcp/__tests__/rich-reads.test.ts | 4 + src/lib/query-keys/cycle.ts | 9 +- 6 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 src/app/api/cycle/insights/__tests__/route-locale.test.ts diff --git a/src/__tests__/helpers/narrated-locale.ts b/src/__tests__/helpers/narrated-locale.ts index 7389fb835..a7042029b 100644 --- a/src/__tests__/helpers/narrated-locale.ts +++ b/src/__tests__/helpers/narrated-locale.ts @@ -103,6 +103,50 @@ function isLocaleType(node: ts.TypeNode | undefined, alias: string): boolean { return false; } +/** + * Does the type still let a caller say nothing? `Locale | undefined` and + * `Locale | null` carry no `?` and no default, so a check that only looked at + * the question mark read them as required — and they are the original bug + * wearing a different hat: the caller passes `undefined`, the body picks a + * language, and the sentence comes back in English again. `any` and `unknown` + * are the same hole with the door taken off. + */ +function toleratesAbsence(node: ts.TypeNode | undefined): boolean { + if (!node) return false; + if ( + node.kind === ts.SyntaxKind.UndefinedKeyword || + node.kind === ts.SyntaxKind.NullKeyword || + node.kind === ts.SyntaxKind.AnyKeyword || + node.kind === ts.SyntaxKind.UnknownKeyword || + node.kind === ts.SyntaxKind.VoidKeyword + ) { + return true; + } + if (ts.isLiteralTypeNode(node)) { + return node.literal.kind === ts.SyntaxKind.NullKeyword; + } + if (ts.isUnionTypeNode(node)) return node.types.some(toleratesAbsence); + return false; +} + +/** + * Property names given a default inside an object binding pattern — + * `function narrate({ locale = "en" }: { locale: Locale })`. The default sits + * on the binding, not on the parameter, so `parameter.initializer` is empty + * and the signature reads clean. + */ +function defaultedBindingNames( + parameter: ts.ParameterDeclaration, +): Set { + const names = new Set(); + if (!ts.isObjectBindingPattern(parameter.name)) return names; + for (const element of parameter.name.elements) { + if (element.initializer === undefined) continue; + names.add((element.propertyName ?? element.name).getText()); + } + return names; +} + /** Find a top-level function/interface declaration by name. */ function findDeclaration( source: ts.SourceFile, @@ -147,16 +191,23 @@ export function localeSites( owner: string, ownerOptional: boolean, ownerHasInitializer: boolean, + defaultedNames: Set = new Set(), ) => { for (const member of members) { if (!ts.isPropertySignature(member)) continue; if (!isLocaleType(member.type, alias)) continue; + const name = member.name.getText(); sites.push({ - where: `${owner}.${member.name.getText()}`, + where: `${owner}.${name}`, // The property itself may be required while the object it lives on is - // optional or defaulted — which puts the silence right back. - optional: member.questionToken !== undefined || ownerOptional, - hasInitializer: ownerHasInitializer, + // optional or defaulted — which puts the silence right back. So does a + // type that merely admits `undefined` / `null`: no question mark, and a + // caller can still hand over nothing. + optional: + member.questionToken !== undefined || + ownerOptional || + toleratesAbsence(member.type), + hasInitializer: ownerHasInitializer || defaultedNames.has(name), }); } }; @@ -168,13 +219,24 @@ export function localeSites( const owner = parameter.name.getText(); const optional = parameter.questionToken !== undefined; const hasInitializer = parameter.initializer !== undefined; + const defaultedNames = defaultedBindingNames(parameter); if (isLocaleType(parameter.type, alias)) { - sites.push({ where: owner, optional, hasInitializer }); + sites.push({ + where: owner, + optional: optional || toleratesAbsence(parameter.type), + hasInitializer, + }); continue; } const type = parameter.type; if (type && ts.isTypeLiteralNode(type)) { - collectFromMembers(type.members, owner, optional, hasInitializer); + collectFromMembers( + type.members, + owner, + optional, + hasInitializer, + defaultedNames, + ); } else if (type && ts.isTypeReferenceNode(type)) { const referenced = findDeclaration(source, type.typeName.getText()); if (referenced && ts.isInterfaceDeclaration(referenced)) { @@ -183,6 +245,7 @@ export function localeSites( type.typeName.getText(), optional, hasInitializer, + defaultedNames, ); } } diff --git a/src/__tests__/narrated-locale-guard-defeat.test.ts b/src/__tests__/narrated-locale-guard-defeat.test.ts index 475068986..cd94e11b1 100644 --- a/src/__tests__/narrated-locale-guard-defeat.test.ts +++ b/src/__tests__/narrated-locale-guard-defeat.test.ts @@ -107,6 +107,80 @@ describe("the locale guard cannot be talked around", () => { expect(site.optional).toBe(true); }); + it("catches `Locale | undefined` written WITHOUT the question mark", () => { + // The nastiest disguise, because it reads as required at a glance: no `?`, + // no default, and the field is spelled out in the signature. But the caller + // can still pass `undefined`, the body picks a language, and the sentence + // comes back in English — the original bug, one hat later. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | undefined }) { + return opts.locale === undefined ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches `Locale | null` the same way", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | null }) { + return opts.locale === null ? "en" : opts.locale; + }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a locale widened to `any`", () => { + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale | any }) { return opts; }`, + ), + "narrate", + ); + expect(site.optional).toBe(true); + }); + + it("catches a default hidden in the parameter's own binding pattern", () => { + // The field is required in the type and the parameter has no initializer, + // so both of the earlier checks read clean — the default sits on the + // destructuring instead. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate({ locale = "en" }: { locale: Locale }) { return locale; }`, + ), + "narrate", + ); + expect(site.hasInitializer).toBe(true); + }); + + it("leaves a plainly required locale alone", () => { + // The shape the whole chain is supposed to have. If this ever reads as a + // violation the guard stops being a guard and starts being noise. + const [site] = localeSites( + parse( + FILE, + `import type { Locale } from "@/lib/i18n/config"; + export function narrate(opts: { locale: Locale }) { return opts.locale; }`, + ), + "narrate", + ); + expect(site.optional).toBe(false); + expect(site.hasInitializer).toBe(false); + }); + it("refuses to pass a declaration that dropped the locale entirely", () => { expect(() => localeSites( diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts new file mode 100644 index 000000000..9cac955dc --- /dev/null +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * The cycle insights route has to say who is reading, too. + * + * Each surviving lagged row carries a finished `interpretation` sentence that + * this route serves straight to the client. The engine underneath can write it + * in any of the six languages, and it only writes the right one if the route + * resolves the reader's language and hands it over — the same handoff the + * metric page's "Coach read" strip was missing when it answered a German page + * in English. + * + * So what is pinned here is the handoff itself: the resolved locale arriving at + * `discoverPhaseCorrelations`, from the cookie a language switch sets and from + * the stored preference when there is no cookie. Without this the route could + * go back to naming one language outright and nothing in the suite would + * notice. + */ + +const discoverPhaseCorrelations = vi.fn(() => ({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, +})); +vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ + ...(await importOriginal()), + discoverPhaseCorrelations: (args: unknown) => discoverPhaseCorrelations(args), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, + cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + user: { + findUnique: vi.fn().mockResolvedValue({ sourcePriorityJson: null }), + }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cycle/gate", () => ({ + requireCycleEnabled: vi.fn(async () => ({ + enabled: true, + profile: { lutealPhaseLength: 14, averageCycleLength: 28 }, + })), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const localeCookie = vi.fn<() => string | undefined>(() => undefined); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: (name: string) => + name === "healthlog-locale" && localeCookie() !== undefined + ? { value: localeCookie() } + : undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; + +function sessionWithLocale(locale: string | null) { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + gender: "FEMALE" as const, + timezone: "Europe/Berlin", + locale, + }, + }; +} + +beforeEach(() => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale(null) as unknown as Awaited< + ReturnType + >, + ); + discoverPhaseCorrelations.mockClear(); + localeCookie.mockReturnValue(undefined); +}); + +/** The locale the route handed the discovery engine on the last call. */ +function passedLocale(): unknown { + const args = discoverPhaseCorrelations.mock.calls.at(-1)?.[0] as + { locale?: unknown } | undefined; + return args?.locale; +} + +describe("GET /api/cycle/insights — the reader's language", () => { + it("passes the language the switcher's cookie names", async () => { + localeCookie.mockReturnValue("de"); + + const res = await GET(new Request("http://localhost/api/cycle/insights")); + + expect(res.status).toBe(200); + expect(passedLocale()).toBe("de"); + }); + + it("falls back to the stored preference when there is no cookie", async () => { + vi.mocked(getSession).mockResolvedValue( + sessionWithLocale("pl") as unknown as Awaited< + ReturnType + >, + ); + + await GET(new Request("http://localhost/api/cycle/insights")); + + expect(passedLocale()).toBe("pl"); + }); + + it("never hard-codes a language of its own", async () => { + // Two readers, two languages, from the same handler. A route that named + // one outright would pass this only by accident. + localeCookie.mockReturnValue("it"); + await GET(new Request("http://localhost/api/cycle/insights")); + const first = passedLocale(); + localeCookie.mockReturnValue("fr"); + await GET(new Request("http://localhost/api/cycle/insights")); + + expect([first, passedLocale()]).toEqual(["it", "fr"]); + }); +}); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 591971cfd..323a7ee19 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -204,8 +204,12 @@ export function useCycleProfile() { * insights tab. */ export function useCycleInsights() { + // The lagged rows come back with a narrated `interpretation` in the reader's + // language, so the cache cell belongs to one language — otherwise a switch + // would keep serving the sentence written for the previous one. + const { locale } = useTranslations(); return useQuery({ - queryKey: queryKeys.cycleInsights(), + queryKey: queryKeys.cycleInsights(locale), queryFn: () => apiGet("/api/cycle/insights"), staleTime: 5 * 60_000, }); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 907d9722f..923016292 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -218,6 +218,10 @@ describe("get_correlation", () => { expect(res.pair?.r).toBe(-0.42); expect(res.association).toBe("descriptive"); expect(res.windowDays).toBe(180); + // The `note` on the pair is a finished sentence an MCP client shows as it + // stands, so the account's language has to reach the reader. Nothing else + // in this file would notice if the read went back to asking in English. + expect(readCoachCorrelations).toHaveBeenCalledWith(USER, "de"); }); it("honest-null when the pair has no surviving association", async () => { diff --git a/src/lib/query-keys/cycle.ts b/src/lib/query-keys/cycle.ts index c4ca520d9..09e394aa7 100644 --- a/src/lib/query-keys/cycle.ts +++ b/src/lib/query-keys/cycle.ts @@ -22,7 +22,14 @@ export const cycleKeys = { * on-ramp reads this so a non-FEMALE account can opt in before the gated * cycle page is reachable. */ cyclePrefs: () => ["cycle", "prefs"] as const, - cycleInsights: () => ["cycle", "insights"] as const, + /** + * The FDR-guarded phase-correlation read (`/api/cycle/insights`). Keyed by + * locale: each surviving lagged row now carries an `interpretation` the + * server writes in the reader's language, so a cached payload belongs to one + * language only. `["cycle"]` stays the prefix, so every cycle write still + * evicts it through `cycleDependentKeys`. + */ + cycleInsights: (locale: string) => ["cycle", "insights", locale] as const, cycleDayLog: (date: string) => ["cycle", "day-log", date] as const, /** The caller's own custom symptoms (decrypted labels) the log-day sheet * merges into the seeded chip grid. */ From 9d82b25c6f39486df75e6f28be8ab39ac712d904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:15:38 +0200 Subject: [PATCH 4/4] Type the cycle route's locale probe instead of ignoring its argument The stand-in for the discovery engine took an argument it never read, so the lint run flagged it. Give the mock a function type instead: the argument stays inspectable, which is the whole point of the probe, and nothing is declared and then dropped. --- .../insights/__tests__/route-locale.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts index 9cac955dc..a55be82e6 100644 --- a/src/app/api/cycle/insights/__tests__/route-locale.test.ts +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -17,7 +17,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; * notice. */ -const discoverPhaseCorrelations = vi.fn(() => ({ +/** Stands in for the discovery engine so the ARGUMENTS are what is inspected. */ +const discoverPhaseCorrelations = vi.fn< + (args: { locale?: unknown }) => { + discovered: never[]; + pairsTested: number; + fdrQ: number; + minPairs: number; + } +>(() => ({ discovered: [], pairsTested: 0, fdrQ: 0.1, @@ -25,7 +33,8 @@ const discoverPhaseCorrelations = vi.fn(() => ({ })); vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ ...(await importOriginal()), - discoverPhaseCorrelations: (args: unknown) => discoverPhaseCorrelations(args), + discoverPhaseCorrelations: (args: { locale?: unknown }) => + discoverPhaseCorrelations(args), })); vi.mock("@/lib/db", () => ({ @@ -101,16 +110,14 @@ beforeEach(() => { /** The locale the route handed the discovery engine on the last call. */ function passedLocale(): unknown { - const args = discoverPhaseCorrelations.mock.calls.at(-1)?.[0] as - { locale?: unknown } | undefined; - return args?.locale; + return discoverPhaseCorrelations.mock.calls.at(-1)?.[0]?.locale; } describe("GET /api/cycle/insights — the reader's language", () => { it("passes the language the switcher's cookie names", async () => { localeCookie.mockReturnValue("de"); - const res = await GET(new Request("http://localhost/api/cycle/insights")); + const res = await GET(); expect(res.status).toBe(200); expect(passedLocale()).toBe("de"); @@ -123,7 +130,7 @@ describe("GET /api/cycle/insights — the reader's language", () => { >, ); - await GET(new Request("http://localhost/api/cycle/insights")); + await GET(); expect(passedLocale()).toBe("pl"); }); @@ -132,10 +139,10 @@ describe("GET /api/cycle/insights — the reader's language", () => { // Two readers, two languages, from the same handler. A route that named // one outright would pass this only by accident. localeCookie.mockReturnValue("it"); - await GET(new Request("http://localhost/api/cycle/insights")); + await GET(); const first = passedLocale(); localeCookie.mockReturnValue("fr"); - await GET(new Request("http://localhost/api/cycle/insights")); + await GET(); expect([first, passedLocale()]).toEqual(["it", "fr"]); });