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 01/16] 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 02/16] 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 63c7f63f99c7e7a572e1313dc9ce76128ce00be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 11:02:21 +0200 Subject: [PATCH 03/16] Put the health score back beside the greeting The score returns to the right-hand column of the hero band, where it sat for eleven weeks: the number at the top, then one thin line per pillar that actually counts toward it. The card that was pinned below the greeting is gone, so the score has one place on the page again. Each contributor line now takes its own colour. A pillar scoring 92 used to be painted red just because the overall score was red. Anything not contributing is one quiet line instead of a block: a failed read is named once for all the pillars it took down and carries the retry, safety guidance is named in full, and everything simply not recorded yet is counted, with the names a tap away under the score. --- src/app/insights/page-client.tsx | 48 +- src/components/insights/health-score-card.tsx | 618 +++++++++++------- src/components/insights/hero-strip.tsx | 74 ++- 3 files changed, 459 insertions(+), 281 deletions(-) diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 769efcc54..7d7d1dc9e 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -26,10 +26,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { HeroStrip } from "@/components/insights/hero-strip"; -import { - HealthScoreCard, - HealthScoreCardSkeleton, -} from "@/components/insights/health-score-card"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -561,33 +557,27 @@ export default function InsightsPageClient() { // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // The Health Score lives in the band's right column and NOWHERE else + // on this page. It is deliberately not a registry section: an id a + // saved layout has never seen merges in as default-INVISIBLE + // (`orderedVisibleSectionIds`), so registering it would silently + // delete the score for every account that has ever customised this + // page. It is also outside the daily-briefing branch, so an instance + // with the briefing switched off keeps the whole breakdown. + healthScore={analytics?.healthScore ?? null} + // `isPending` settles false on success AND error, so a no-score + // account drops the reserve exactly once. + scorePending={analyticsQuery.isPending && !analytics} + // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- + // baseline off the dashboard snapshot. The panel localises the keys; + // null leaves it quiet. + tension={heroTension} + returnToBand={heroReturnToBand} + // A pillar whose read FAILED is a named line inside the panel, and the + // retry there refetches the payload that carries every pillar. + onScoreRetry={() => void analyticsQuery.refetch()} /> - {/* The Health Score is PINNED here, between the greeting and the - customizable region, and is deliberately NOT a registry section: an - id a saved layout has never seen merges in as default-INVISIBLE - (`orderedVisibleSectionIds`), so registering it would silently delete - the score for every account that has ever customised this page. It - also used to render inside the daily-briefing branch, which meant an - instance with the briefing switched off lost the whole breakdown. - `isPending` settles false on success AND error, so a no-score account - drops the reserve exactly once. */} - {analyticsQuery.isPending && !analytics ? ( - - ) : analytics?.healthScore ? ( - void analyticsQuery.refetch()} - /> - ) : null} - {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the overview (section show/hide + order) and sorting the nav pills now live ONLY in Settings → Insights, reached via the top-right cog on the diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 587056d8a..fa25c1587 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,22 +1,21 @@ "use client"; import { useId, useState } from "react"; -import { ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronDown, RotateCw } from "lucide-react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; import { InfoPopover } from "@/components/ui/info-popover"; import { LearningGate } from "@/components/ui/learning-gate"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; -import { TileHeader } from "@/components/insights/tile-header"; import { + BAND_BORDER_CLASS, + BAND_NUMBER_CLASS, BAND_PROGRESS_CLASS, bandForScore, clampScore, } from "@/components/insights/derived/band-tokens"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer"; -import { ScoreRing } from "@/components/insights/derived/score-ring"; import { METRIC_PROVENANCE } from "@/components/insights/derived/standards"; import { pillarDetailLines, @@ -36,23 +35,33 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; /** - * The Health Score surface: one card, one score, one place. + * The Health Score panel: the hero band's right-hand column. * - * Pre-rework the score had two mounts — a compact face in the hero band and a - * nine-block stack further down the overview that only rendered while the - * daily-briefing flag was on. The stack is now a single progressive-disclosure - * card pinned directly under the hero, and the hero band is the full-width - * greeting again. + * The score belongs beside the greeting, not under it. It sat there for + * eleven weeks — big number at the top of a narrow column, thin contributor + * rows directly beneath it inside the same card — and three arrangements in + * four days moved it away, ending in a full-width card pinned below the + * greeting that spent most of its height on things that do not count toward + * the number. This is the original arrangement, restored, with the eight + * pillars the composite now carries instead of the four it had then. * - * Collapsed reads as the score face: the ring, the band sentence, the weekly - * delta (or the honest reason there is none), Rest Mode, the two ambient - * context lines, and the coverage meter. Expanded adds the pillar grid, the - * named list of pillars that are not scored yet, the personal weight goal, and - * the method footer with its cited standard. + * The rules the intervening work established are all kept: * - * Absence stays absence: a pillar the server gated as `insufficient` is listed - * by name with its reason instead of being drawn as a zero, and a pillar whose - * read FAILED is a visible error row with a retry, never silence. + * - One mount. This panel is the only score surface on the overview. + * - It renders whether or not the daily briefing is switched on. + * - It is not a customisable section, so a saved layout cannot hide it. + * - The column reserves its own height while the payload is in flight, + * so the band does not reflow when the score lands. + * + * Only a pillar that carries a score gets a row. A pillar that contributes + * nothing is one quiet line: a failed read is named with a retry, safety + * signposting is named in full, and everything else that is simply not + * recorded yet is counted, with the names and reasons one tap away in the + * foot disclosure. Each pillar has exactly one home — a row, the failed-read + * line, the safety line, or the count — and never two. + * + * Each contributor row is tinted by ITS OWN band. The old rows took the + * composite's colour, which painted a pillar scoring 92 red on a red day. */ const PILLAR_LABEL_KEY: Record = { @@ -82,6 +91,30 @@ const REASON_KEY: Record = { stale: "insufficientHistory", }; +/** + * The column geometry, shared by the panel and by its loading reserve so the + * two can never drift apart. `basis` rather than a width so the column flexes + * inside the hero band's `md:flex-row` split (~36 % at md, ~40 % at xl), and + * `w-full` below `md` where the column stacks under the greeting. + */ +const PANEL_COLUMN_CLASS = + "w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"; + +/** The card chrome the panel wears inside the gradient band. */ +const PANEL_CHROME_CLASS = + "bg-card/65 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6"; + +/** + * Height the loading reserve holds so the greeting does not reflow when the + * analytics payload lands. Measured against the built panel at 352 px (the + * `md` column) with a five-row, one-failed-read report: 431 px including the + * card padding, of which the widest realistic case is what matters. Held a + * little under the measurement so a shorter report does not paint a gap: + * a three-row report measures 341 px, and 21rem/336px is the floor both sit + * near. See `health-score-card-geometry.test.ts` for the numbers. + */ +const PANEL_RESERVE_CLASS = "min-h-[21rem]"; + /** * v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. * Kept as a local string union so the client never imports the server @@ -202,24 +235,34 @@ export function HealthScoreCard({ return t(`insights.healthScore.reason.${key}`); } + function labelsOf(pillars: ScorePillarResult[]): string { + return pillars.map((p) => t(PILLAR_LABEL_KEY[p.id])).join(", "); + } + const composite = report.composite; + // Four homes, one pillar each. A pillar that scores gets a row; a pillar + // whose read failed joins the one coalesced failure line; a pillar gated + // for safety signposting is named in full at rest; everything else gated + // is counted, and named a tap away. The sets are disjoint by construction + // and each pillar falls into exactly one of them. const scored = report.pillars.filter((p) => p.result.status === "ok"); const readFailed = report.pillars.filter( (p) => gateReason(p) === "read_failed", ); + const crisis = report.pillars.filter( + (p) => gateReason(p) === "crisis_signposting", + ); // A failed read is not absence, so it never joins the "not scored yet" list; - // it gets its own error row above. Safety copy leads the list — signposting - // is content, never buried meta. - const notScored = report.pillars - .filter((p) => { - const reason = gateReason(p); - return reason !== null && reason !== "read_failed"; - }) - .sort( - (a, b) => - Number(gateReason(b) === "crisis_signposting") - - Number(gateReason(a) === "crisis_signposting"), + // it gets its own line above. Safety copy is content, so it is named at rest + // rather than counted — which is also why it is not in this list twice. + const notScored = report.pillars.filter((p) => { + const reason = gateReason(p); + return ( + reason !== null && + reason !== "read_failed" && + reason !== "crisis_signposting" ); + }); // Only forwarded when a real disagreement is present on BOTH sides; the // server resolver already enforces that, but the guard keeps the card from @@ -245,122 +288,252 @@ export function HealthScoreCard({ }) : null; + const compositeScore = + composite.status === "ok" ? clampScore(composite.value.score) : null; + const compositeBand = composite.status === "ok" ? composite.value.band : null; + return ( - - - - - - {/* The score face. The ring keeps its own identity block; everything - that qualifies the number sits beside it, so a person reads the - score and the reason for it in one pass. */} -
-
- -
-
- {composite.status === "ok" ? ( -

- {t(`insights.healthScore.band.${composite.value.band}`)} -

- ) : null} - {report.delta != null ? ( -

- {t("insights.healthScore.delta", { - delta: report.delta > 0 ? `+${report.delta}` : report.delta, - })} -

- ) : report.deltaReason ? ( -

- {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} -

- ) : null} - {report.restMode?.active ? ( -

- {t("insights.healthScore.restMode", { - since: - report.restMode.since ?? t("insights.healthScore.none"), - })} -

- ) : null} - {tensionLine ? ( -

- {tensionLine} -

- ) : null} - {returnToBandLine ? ( -

- {returnToBandLine} -

- ) : null} -
-
+ {/* 1 — the label, and the weekly gain when there is one. */} +
+

+ {t("insights.healthScore.label")} +

+ {report.delta != null && report.delta > 0 ? ( + + +{report.delta} + + ) : null} +
- {composite.status !== "ok" ? ( + {/* 2 — the number. The reason the panel exists. */} +
+ + {compositeScore == null ? "—" : Math.round(compositeScore)} + + {compositeScore == null ? null : ( + + )} +
+ + {/* 3 — the composite bar. */} + {compositeScore != null && compositeBand ? ( +
+
+
+ ) : null} + + {/* 4 — the lines that qualify the number, one each, only when true. */} +
+ {compositeBand ? ( +

+ {t(`insights.healthScore.band.${compositeBand}`)} +

+ ) : ( + )} + {report.delta != null ? ( +

+ {t("insights.healthScore.delta", { + delta: report.delta > 0 ? `+${report.delta}` : report.delta, + })} +

+ ) : report.deltaReason ? ( +

+ {t(`insights.healthScore.deltaReason.${report.deltaReason}`)} +

) : null} + {report.restMode?.active ? ( +

+ {t("insights.healthScore.restMode", { + since: report.restMode.since ?? t("insights.healthScore.none"), + })} +

+ ) : null} + {tensionLine ? ( +

+ {tensionLine} +

+ ) : null} + {returnToBandLine ? ( +

+ {returnToBandLine} +

+ ) : null} +
+ {/* 5 — the contributing factors, always visible, directly under the + number. Server registry order; no re-ranking. */} + {scored.length > 0 ? ( +
    + {scored.map((pillar) => ( + + ))} +
+ ) : null} + + {/* 6 — what is not contributing. One weight class, two faces: the + failure carries the glyph and the only retry on the surface, the + absence is muted and actionless. */} + {readFailed.length > 0 || crisis.length > 0 || notScored.length > 0 ? ( +
+ {readFailed.length > 0 ? ( +
p.id).join(",")} + role="alert" + className="text-foreground flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" + > +
+ ) : null} + + {crisis.length > 0 ? ( +

p.id).join(",")} + className="text-foreground text-xs" + > + {labelsOf(crisis)} + {": "} + {reasonText("crisis_signposting")} +

+ ) : null} + + {notScored.length > 0 ? ( +

+ {t("insights.healthScore.notScored", { + count: notScored.length, + })} +

+ ) : null} +
+ ) : null} + + {/* 7 — the foot. `mt-auto` collects the slack here rather than under the + number, so a short report and a long one both end on the same line. */} +
{/* Progressive disclosure. No Collapsible primitive exists in the UI kit, so this is the same accessible button + region shape the glucose panel ships: `aria-expanded` on the trigger, `aria-controls` + `hidden` on the region. */} -
+
- - +
+ ); } /** - * One scored pillar: name + score, a band-tinted impact bar, and the observed - * line with its detail popover and coverage meter. The reference band, the - * personal target and the source live in the popover — the same strings as - * before, one tap away instead of four permanent lines per pillar. + * One scored pillar, one line: a truncating name, a thin fill bar, the score, + * and the (i) that opens everything else. The bar takes the pillar's OWN band + * colour — a pillar at 92 reads green even when the composite is red, which + * is the point of showing the contributors at all. + * + * The observed value, the reference band, the personal target, the source and + * the per-pillar confidence all live in the popover. That is one tap more + * than the pinned card charged for the observed line, and it is what buys the + * column back. */ -function PillarCell({ +function PillarRow({ pillar, detail, }: { @@ -494,25 +629,21 @@ function PillarCell({ if (pillar.result.status !== "ok") return null; const score = clampScore(pillar.result.value.score); const band = bandForScore(score); + const observed = pillarObservedText(pillar, detail); const lines = pillarDetailLines(pillar, detail); return (
  • -
    - - {t(PILLAR_LABEL_KEY[pillar.id])} - - - {Math.round(score)} - -
    + + {t(PILLAR_LABEL_KEY[pillar.id])} +
  • ); } /** * The personal weight goal: an inner tile inside the disclosure, deliberately - * NOT a pillar cell, because it is context the user set for themselves and it - * never moves the score. + * NOT a contributor row, because it is context the user set for themselves and + * it never moves the score. That is also why it left the at-rest column. */ function WeightGoalRow({ report }: { report: HealthScoreReport }) { const { t } = useTranslations(); @@ -610,38 +746,42 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) { } /** - * The pinned slot's reserve while the analytics payload is in flight. Mirrors - * the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger - * row — so first paint lands at the card's real height and the resolved card - * is a swap rather than a push. Decorative: the card announces nothing while - * it loads. + * The hero column's reserve while the analytics payload is in flight. Wears + * the panel's own column classes and chrome and holds a measured minimum + * height, so the greeting beside it does not reflow when the score lands — + * the reason this reserve was added in the first place. */ export function HealthScoreCardSkeleton({ className }: { className?: string }) { return ( -