diff --git a/e2e/coach-results.spec.ts b/e2e/coach-results.spec.ts new file mode 100644 index 000000000..a0f4d67af --- /dev/null +++ b/e2e/coach-results.spec.ts @@ -0,0 +1,368 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * The tables and charts under a Coach answer, on a persisted conversation. + * + * Contracts under test: + * + * 1. A table the answer referenced opens under the prose as its chart: a + * figure whose plot is one image named after the table, pointing at + * the table view for every value. + * 2. The chart/table toggle switches to the captioned table and back; the + * pressed segment follows. + * 3. Two series (blood pressure) carry legend buttons that hide and show + * a series; the last visible one stays. + * 4. A table the answer only used sits in the evidence disclosure under + * "Data used (1)", opening as a table, with its chart one tap away. + * 5. axe finds nothing on either view, in the light and the dark theme. + * + * The conversation, its stored tables and the Coach side surfaces are + * mocked; the charts are drawn by the real chart runtime. + */ + +const CONVERSATION_ID = "coach-results-e2e"; +const MESSAGE_ID = "coach-results-e2e-assistant"; + +const BP_VALUES: Array<[number, number] | null> = [ + [128, 84], + [125, 82], + null, + [131, 85], + [122, 80], + [124, 81], + null, + [127, 83], + [121, 79], + [126, 82], + [123, 80], + [129, 84], + [120, 78], + [124, 81], +]; + +function dayKey(index: number): string { + return new Date(Date.UTC(2026, 8, 10 + index)).toISOString().slice(0, 10); +} + +const BP_TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: BP_VALUES.length, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: BP_VALUES.map((pair, index) => + pair + ? [dayKey(index), pair[0], pair[1], 1] + : [dayKey(index), null, null, null], + ), + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, +}; + +const SPORTS: Array<[string, number]> = [ + ["Walking", 12], + ["Cycling", 7], + ["Running", 5], + ["Swimming", 3], + ["Yoga", 3], + ["Hiking", 2], + ["Rowing", 1], +]; + +const WORKOUT_TABLE = { + ref: "r2", + source: { + tool: "get_workouts", + domain: "workouts", + window: "last30days", + period: "current", + }, + shape: "categoryCounts", + titleKey: "coach.result.title.workoutsBySport", + title: "Workouts by sport", + rowCount: SPORTS.length, + chartKind: "bar", + displayed: false, + columns: [ + { + key: "sport", + kind: "category", + labelKey: "coach.result.column.sport", + label: "Sport", + }, + { + key: "sessions", + kind: "count", + labelKey: "coach.result.column.sessions", + label: "Sessions", + }, + { + key: "duration", + kind: "number", + labelKey: "coach.result.column.duration", + label: "Duration", + unit: "min", + decimals: 0, + }, + ], + rows: SPORTS.map(([sport, count]) => [sport, count, count * 35]), + truncated: false, + chart: { + kind: "bar", + x: "sport", + series: ["sessions"], + orientation: "horizontal", + }, +}; + +function meta(table: typeof BP_TABLE | typeof WORKOUT_TABLE) { + return { + ref: table.ref, + source: table.source, + shape: table.shape, + titleKey: table.titleKey, + title: table.title, + rowCount: table.rowCount, + chartKind: table.chartKind, + displayed: table.displayed, + }; +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname === + `/api/insights/chat/${CONVERSATION_ID}/messages/${MESSAGE_ID}/results` + ) { + return fulfilJson(route, { results: [BP_TABLE, WORKOUT_TABLE] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + id: CONVERSATION_ID, + title: "Blood pressure and workouts", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + attachmentCount: 0, + summary: null, + messages: [ + { + id: "coach-results-e2e-user", + role: "user", + content: "How was my blood pressure this month?", + createdAt: "2026-09-24T09:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: MESSAGE_ID, + role: "assistant", + content: + "Your readings stayed in a narrow band this month; the table below lists each day.", + createdAt: "2026-09-24T09:01:00.000Z", + metricSource: { + windows: ["last30days"], + metrics: ["bp", "workouts"], + results: [meta(BP_TABLE), meta(WORKOUT_TABLE)], + }, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 42, + model: "mock", + }, + ], + }); + } + return fulfilJson(route, { + conversations: [ + { + id: CONVERSATION_ID, + title: "Blood pressure and workouts", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }, + ], + nextCursor: null, + }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +async function useTheme(page: Page, theme: "light" | "dark") { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); +} + +async function expectNoAxeViolations(page: Page, label: string) { + const result = await new AxeBuilder({ page }) + .include('[data-slot="coach-bubble-assistant"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + result.violations.map((violation) => ({ + id: violation.id, + nodes: violation.nodes.map((node) => node.target.join(" ")), + })), + `${label} accessibility violations`, + ).toEqual([]); +} + +test.describe("Coach result tables and charts", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + }); + + for (const theme of ["light", "dark"] as const) { + test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({ + page, + }) => { + await useTheme(page, theme); + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("html")).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + + const bubble = page.locator('[data-slot="coach-bubble-assistant"]'); + const figure = bubble.locator( + '[data-slot="coach-result-chart"][data-ref="r1"]', + ); + await expect(figure).toBeVisible({ timeout: 15_000 }); + await expect(figure).toHaveAttribute("data-chart-kind", "line"); + const plot = figure.getByRole("img", { + name: "Blood pressure by day, shown as a chart. The table view lists every value.", + }); + await expect(plot).toBeVisible(); + await expect(plot.locator(".recharts-line")).toHaveCount(2); + + // Legend buttons hide one series; the last visible one stays. + const systolic = figure.getByRole("button", { name: "Systolic" }); + const diastolic = figure.getByRole("button", { name: "Diastolic" }); + await expect(systolic).toHaveAttribute("aria-pressed", "true"); + await diastolic.click(); + await expect(diastolic).toHaveAttribute("aria-pressed", "false"); + await expect(plot.locator(".recharts-line")).toHaveCount(1); + await systolic.click(); + await expect(systolic).toHaveAttribute("aria-pressed", "true"); + await diastolic.click(); + await expect(plot.locator(".recharts-line")).toHaveCount(2); + + await expectNoAxeViolations(page, `${theme} chart view`); + + // The toggle opens the captioned table and comes back. + await figure.locator('[data-slot="coach-result-view-table"]').click(); + const table = bubble.locator( + '[data-slot="coach-result-table"][data-ref="r1"]', + ); + await expect(table).toBeVisible(); + await expect(table.locator("caption")).toContainText( + "Blood pressure by day", + ); + await expect( + table.locator('[data-slot="coach-result-view-table"]'), + ).toHaveAttribute("aria-pressed", "true"); + await expectNoAxeViolations(page, `${theme} table view`); + await table.locator('[data-slot="coach-result-view-chart"]').click(); + await expect(figure).toBeVisible(); + + // The table the answer only used waits in the evidence disclosure. + await expect( + bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'), + ).toBeHidden(); + await bubble.locator('[data-slot="coach-evidence-summary"]').click(); + const dataUsed = bubble.locator('[data-slot="coach-data-used"]'); + await expect(dataUsed).toContainText("Data used (1)"); + const workouts = dataUsed.locator( + '[data-slot="coach-result-table"][data-ref="r2"]', + ); + await expect(workouts).toBeVisible(); + await workouts.locator('[data-slot="coach-result-view-chart"]').click(); + const workoutChart = dataUsed.locator( + '[data-slot="coach-result-chart"][data-ref="r2"]', + ); + await expect(workoutChart).toHaveAttribute("data-chart-kind", "bar"); + await expect(workoutChart.locator(".recharts-bar-rectangle")).toHaveCount( + SPORTS.length, + ); + + await expectNoAxeViolations(page, `${theme} data used`); + }); + } +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 10070eaa6..13102ce22 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -145,6 +145,7 @@ const eslintConfig = defineConfig([ "src/components/mental-health/assessment-history-chart.tsx", "src/components/charts/workout-hr-chart.tsx", "src/components/charts/workout-elevation-chart.tsx", + "src/components/insights/coach-panel/result-chart.tsx", ], rules: { "no-restricted-imports": [ diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 788235dc0..56dd4ab60 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -47,3 +47,4 @@ export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart"; export { WorkoutHrChart } from "./workout-hr-chart"; export { WorkoutElevationChart } from "./workout-elevation-chart"; +export { CoachResultChart } from "@/components/insights/coach-panel/result-chart"; diff --git a/src/components/insights/coach-panel/__tests__/coach-results.test.tsx b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx new file mode 100644 index 000000000..a20b8e685 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx @@ -0,0 +1,220 @@ +/** + * v1.39.4 — where a reply's tables go and how they first show: the ones the + * answer referenced under the prose, the rest under "Data used", a chart + * first only for a referenced table that has one, and the chart's plot named + * as an image that points at the table. SSR harness, like the other Coach + * panel tests; the toggle itself is exercised in the browser suite. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachResultTable as Table } from "@/lib/ai/coach/types"; + +import { CoachResults, countResultsInSection } from "../coach-results"; +import { CoachResultChart } from "../result-chart"; +import { selectCoachChartTokens } from "../chat-bubble"; + +function table(overrides: Partial = {}): Table { + return { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + ], + rows: [ + ["2026-09-01", 124, 81], + ["2026-09-02", 121, 79], + ], + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, + ...overrides, + }; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function results(live: Table[], section: "displayed" | "dataUsed") { + return render( + , + ); +} + +describe("CoachResults", () => { + it("opens a referenced table with a chart on the chart", () => { + const html = results([table()], "displayed"); + expect(html).toContain('data-slot="coach-result-chart"'); + expect(html).toMatch(/]*aria-labelledby="([^"]+)"/); + expect(html).toMatch(/ { + const html = results([table()], "displayed"); + const chartButton = html.match( + /]*data-slot="coach-result-view-chart"[^>]*>/, + )?.[0]; + const tableButton = html.match( + /]*data-slot="coach-result-view-table"[^>]*>/, + )?.[0]; + expect(chartButton).toContain('aria-pressed="true"'); + expect(tableButton).toContain('aria-pressed="false"'); + }); + + it("opens a table without a chart as a table, with no toggle", () => { + const html = results( + [table({ chart: null, chartKind: null })], + "displayed", + ); + expect(html).toContain('data-slot="coach-result-table"'); + expect(html).not.toContain("coach-result-view-"); + }); + + it("keeps an unreferenced table out of the answer", () => { + const html = results([table({ displayed: false })], "displayed"); + expect(html).toBe(""); + }); + + it("lists an unreferenced table under Data used, as a table first", () => { + const html = results( + [table(), table({ ref: "r2", displayed: false })], + "dataUsed", + ); + expect(html).toContain("Data used (1)"); + expect(html).toContain('data-ref="r2"'); + expect(html).not.toContain('data-ref="r1"'); + expect(html).toContain('data-slot="coach-result-table"'); + // The chart stays one tap away. + expect(html).toContain('data-slot="coach-result-view-chart"'); + }); + + it("renders no Data used block when the answer referenced every table", () => { + expect(results([table()], "dataUsed")).toBe(""); + }); + + it("counts a section from the metadata alone", () => { + const metas = [ + { displayed: true }, + { displayed: false }, + { displayed: false }, + ]; + expect(countResultsInSection(metas, "displayed")).toBe(1); + expect(countResultsInSection(metas, "dataUsed")).toBe(2); + }); +}); + +describe("CoachResultChart", () => { + const label = + "Blood pressure by day, shown as a chart. The table view lists every value."; + + it("names the plot as one image that points at the table", () => { + const html = render( + , + ); + expect(html).toMatch(new RegExp(`
{ + const html = render( + , + ); + const legend = html.match( + /]*data-slot="coach-result-chart-legend"[\s\S]*?<\/div>/, + )?.[0]; + expect(legend?.match(/aria-pressed="true"/g)).toHaveLength(2); + expect(legend).toContain("Systolic"); + expect(legend).toContain("Diastolic"); + expect(html.indexOf("coach-result-chart-legend")).toBeLessThan( + html.indexOf('role="img"'), + ); + }); + + it("draws one series without a legend", () => { + const html = render( + , + ); + expect(html).not.toContain("coach-result-chart-legend"); + }); + + it("draws nothing without a spec", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); + +describe("selectCoachChartTokens beside result tables", () => { + it("drops a metric token whose metric a shown table already covers", () => { + expect( + selectCoachChartTokens( + "metric:BLOOD_PRESSURE_SYS and metric:WEIGHT", + ["bp", "weight"], + new Set(["bp"]), + ), + ).toEqual(["metric:WEIGHT"]); + }); + + it("keeps the token when no table is shown", () => { + expect( + selectCoachChartTokens("metric:WEIGHT", ["weight"], new Set()), + ).toEqual(["metric:WEIGHT"]); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 7cd012496..24c5bce53 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -42,7 +42,7 @@ import { SuggestedActionCard } from "./suggested-action-card"; import { StreamedProse } from "./streamed-prose"; import { MessageTokenFooter } from "./message-token-footer"; import { CoachTurnSteps } from "./turn-steps"; -import { CoachResults } from "./coach-results"; +import { CoachResults, countResultsInSection } from "./coach-results"; import { CoachMethodLine } from "./method-line"; import { COACH_ICON_BUTTON, @@ -204,10 +204,15 @@ const MAX_COACH_CHARTS = 2; * allowlist-parsed, intersected with the turn's grounded provenance topics, * de-duplicated by metric, and capped. Exported for unit tests so the * grounding contract is pinned without standing up the chart component. + * + * v1.39.4 — `shownDomains` are the domains of the result tables the answer + * shows under the prose; a token for one of them is dropped, since the + * turn's own table (and its chart) already shows that metric. */ export function selectCoachChartTokens( content: string, metrics: readonly CoachProvenanceMetric[] | undefined, + shownDomains?: ReadonlySet, ): ChartToken[] { const grounded = new Set(metrics ?? []); const out: ChartToken[] = []; @@ -215,6 +220,7 @@ export function selectCoachChartTokens( for (const token of parseChartTokens(content)) { const topic = CHART_TOKEN_PROVENANCE[token]; if (!topic || !grounded.has(topic)) continue; + if (shownDomains?.has(topic)) continue; const metric = tokenToMetric(token); if (seen.has(metric)) continue; seen.add(metric); @@ -676,16 +682,29 @@ function ChatBubbleImpl({ ((metricSource.metrics?.length ?? 0) > 0 || (metricSource.windows?.length ?? 0) > 0); const method = metricSource?.method ?? null; - const hasProvenance = hasChips || keyValues.length > 0 || method !== null; + const hasEvidence = hasChips || keyValues.length > 0 || method !== null; // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- // grounded `metric:` chart under a SETTLED assistant turn. Skipped // while streaming / in-flight / errored / on a refusal; a no-op when no // grounded token is present (provider-agnostic — reads the inline token). + // v1.39.4 — the result tables this answer references and the ones it + // only used; the metadata is enough to place and count them. + const resultMetas = results?.length ? results : (metricSource?.results ?? []); + const shownDomains = new Set( + resultMetas + .filter((meta) => meta.displayed) + .map((meta) => meta.source.domain), + ); + const dataUsedCount = + !inProgress && !errorCode + ? countResultsInSection(resultMetas, "dataUsed") + : 0; const chartTokens = !streaming && !inProgress && !errorCode && providerType !== "refusal" - ? selectCoachChartTokens(content, metricSource?.metrics) + ? selectCoachChartTokens(content, metricSource?.metrics, shownDomains) : []; + const hasProvenance = hasEvidence || dataUsedCount > 0; // v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure // from this reply (each rewritten to the `[…]` elision mark). Shown only on a @@ -805,6 +824,7 @@ function ChatBubbleImpl({ messageId={messageId ?? null} metas={metricSource?.results ?? []} live={results} + section="displayed" /> )} {/* v1.18.6 — a "no provider configured anywhere" turn is a @@ -916,6 +936,16 @@ function ChatBubbleImpl({ {/* v1.39.4 — how the answer was worked out, beside the key values it explains. */} + {/* v1.39.4 — the tables the answer read but did not point at. */} + {dataUsedCount > 0 && ( + + )}
)} diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index 8342434ff..6675905b8 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -6,15 +6,51 @@ * metadata, and the values are fetched when the message scrolls into view * (`useCoachMessageResults`). A withheld table is skipped here; the notice * for it belongs to the table view. + * + * The bubble renders this twice: the tables the answer referenced + * (`section="displayed"`) expanded under the prose, and the rest + * (`section="dataUsed"`) under "Data used (n)" inside the evidence + * disclosure. Both instances share one cached read. + * + * A table with a chart gets a chart/table toggle. The chart is the first + * view for a referenced table; a table under "Data used" opens as a table. + * The choice is local to this render and never stored. The chart sits in a + * figure; its plot is one `role="img"` whose name points at the table view + * for every value. */ +import { useId, useState } from "react"; +import dynamic from "next/dynamic"; +import { ChartLine, Table2 } from "lucide-react"; + +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { ViewToggle } from "@/components/ui/view-toggle"; import { useCoachMessageResults } from "@/hooks/use-coach-message-results"; +import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; import type { CoachResultMeta, CoachResultTable as CoachResultTableData, } from "@/lib/ai/coach/types"; +import { useTranslations } from "@/lib/i18n/context"; +import { importWithRetry } from "@/lib/retry-import"; import { CoachResultTable } from "./result-table"; +const CoachResultChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.CoachResultChart }), + ), + { + ssr: false, + loading: () => ( + + ), + }, +); + +export type CoachResultsSection = "displayed" | "dataUsed"; + export interface CoachResultsProps { conversationId: string | null; /** The persisted message, once it has an id. */ @@ -23,6 +59,18 @@ export interface CoachResultsProps { metas: CoachResultMeta[]; /** The tables a live turn streamed; absent on a persisted message. */ live?: CoachResultTableData[]; + /** Which of the message's tables this instance shows. */ + section: CoachResultsSection; +} + +/** How many tables sit in a section, from the metadata alone. */ +export function countResultsInSection( + metas: ReadonlyArray>, + section: CoachResultsSection, +): number { + return metas.filter((meta) => + section === "displayed" ? meta.displayed : !meta.displayed, + ).length; } export function CoachResults({ @@ -30,24 +78,122 @@ export function CoachResults({ messageId, metas, live, + section, }: CoachResultsProps) { + const { t } = useTranslations(); const hasLive = (live?.length ?? 0) > 0; + const expected = hasLive + ? countResultsInSection(live ?? [], section) + : countResultsInSection(metas, section); const { ref, results } = useCoachMessageResults({ conversationId, messageId, - enabled: !hasLive && metas.length > 0, + enabled: !hasLive && expected > 0, }); - if (!hasLive && metas.length === 0) return null; - const tables: CoachResultTableData[] = hasLive - ? (live ?? []) - : (results ?? []).filter( - (entry): entry is CoachResultTableData => !("withheld" in entry), - ); - return ( -
+ if (expected === 0) return null; + const tables: CoachResultTableData[] = ( + hasLive + ? (live ?? []) + : (results ?? []).filter( + (entry): entry is CoachResultTableData => !("withheld" in entry), + ) + ).filter((table) => + section === "displayed" ? table.displayed : !table.displayed, + ); + + const list = ( +
{tables.map((table) => ( - + ))}
); + if (section === "displayed") return list; + return ( +
+

+ {t(COACH_RESULT_UI_KEYS.dataUsed, { count: expected })} +

+ {list} +
+ ); +} + +type ResultView = "chart" | "table"; + +function CoachResultView({ + result, + chartFirst, +}: { + result: CoachResultTableData; + chartFirst: boolean; +}) { + const { t } = useTranslations(); + const titleId = useId(); + const hasChart = result.chart !== null; + const [view, setView] = useState( + hasChart && chartFirst ? "chart" : "table", + ); + if (!hasChart) return ; + + const toggle = ( + + view={view} + onChange={setView} + groupLabel={t(COACH_RESULT_UI_KEYS.viewLabel)} + dataSlotPrefix="coach-result-view" + segments={[ + { + value: "chart", + label: t(COACH_RESULT_UI_KEYS.viewChart), + icon: ChartLine, + }, + { + value: "table", + label: t(COACH_RESULT_UI_KEYS.viewTable), + icon: Table2, + }, + ]} + /> + ); + if (view === "table") { + return ; + } + return ( +
+
+ + {result.title} + + {toggle} +
+ + + +
+ ); } diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx new file mode 100644 index 000000000..c10f820f4 --- /dev/null +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -0,0 +1,563 @@ +"use client"; + +/** + * v1.39.4 — the chart of one Coach result table, drawn from the spec the + * server chose (`src/lib/ai/coach/results/chart-spec.ts`): a line over the + * periods, bars (vertical, or horizontal for many categories), or a + * histogram over bins. The component never decides the chart type itself. + * + * Recessive axes (no axis line, no tick marks, muted ticks), horizontal grid + * lines only, the shared rich tooltip, and series colours from + * `var(--chart-1..5)` in series order. Two series (blood pressure) get + * legend buttons that hide or show a series (`aria-pressed`); the last + * visible one cannot be hidden. A period without a reading stays a gap in + * the line, and each reading is a dot, so a lone reading is still visible. + * + * Exported only through `src/components/charts/chart-runtime.ts`; the call + * site loads it with `next/dynamic` so recharts stays in its one chunk. + */ +import { useMemo, useState, type ReactElement } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { RichChartTooltip } from "@/components/charts/chart-tooltip"; +import { chartCategoryRows } from "@/lib/ai/coach/results/chart-spec"; +import type { + CoachChartSpec, + CoachResultColumn, + CoachResultTable, +} from "@/lib/ai/coach/types"; +import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** The chart band's height; matches the mini chart skeleton's box. */ +export const RESULT_CHART_HEIGHT_PX = 160; +/** Row height of a horizontal bar. */ +const HORIZONTAL_BAR_ROW_PX = 26; + +const SERIES_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +const TICK = { fontSize: 11, fill: "var(--muted-foreground)" }; +const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_KEY = /^\d{4}-\d{2}$/; + +export interface CoachResultChartProps { + result: CoachResultTable; + /** Label of the folded bar in a category chart. */ + otherLabel: string; + /** The plot's accessible name; it points at the table for the values. */ + label: string; +} + +interface Point { + x: string; + [series: string]: string | number | null; +} + +function usePeriodLabel(): (key: string) => string { + const { locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); + return useMemo(() => { + const fmt = makeBucketLabelFormatters(locale, dateFormat); + const month = new Intl.DateTimeFormat(locale, { + month: "short", + year: "numeric", + timeZone: "UTC", + }); + return (key: string) => { + if (DAY_KEY.test(key)) { + return fmt.dateShortSmart(new Date(`${key}T12:00:00Z`)); + } + if (MONTH_KEY.test(key)) { + return month.format(new Date(`${key}-01T12:00:00Z`)); + } + return key; + }; + }, [locale, dateFormat]); +} + +function useValueFormat(): ( + value: number, + column: CoachResultColumn | undefined, +) => string { + const { locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); + return useMemo(() => { + const fmt = makeBucketLabelFormatters(locale, dateFormat); + return (value, column) => { + const text = + column?.kind === "count" + ? fmt.integer(value) + : fmt.number(value, column?.decimals ?? 1); + return column?.unit ? `${text} ${column.unit}` : text; + }; + }, [locale, dateFormat]); +} + +export function CoachResultChart({ + result, + otherLabel, + label, +}: CoachResultChartProps) { + const spec = result.chart; + if (!spec) return null; + if (spec.kind === "histogram") { + return ; + } + if (spec.kind === "bar" && spec.orientation === "horizontal") { + return ( + + ); + } + return ( + + ); +} + +/** + * The plot band: one `role="img"` with the summary as its name. The legend + * buttons stay outside it, and recharts' own keyboard layer is off, so the + * image holds nothing focusable. + */ +function PlotBand({ + height, + label, + children, +}: { + height: number; + label: string; + children: ReactElement; +}) { + return ( +
+ + {children} + +
+ ); +} + +// ── Line and vertical bars ───────────────────────────────────────────────── + +function SeriesChart({ + result, + spec, + otherLabel, + label, +}: { + result: CoachResultTable; + spec: Extract; + otherLabel: string; + label: string; +}) { + const periodLabel = usePeriodLabel(); + const valueFormat = useValueFormat(); + const [hidden, setHidden] = useState>(new Set()); + const animate = !prefersReducedMotion(); + const byKey = useMemo( + () => new Map(result.columns.map((column) => [column.key, column])), + [result.columns], + ); + const xColumn = byKey.get(spec.x); + const isPeriod = xColumn?.kind === "period"; + + const points = useMemo(() => { + if (spec.kind === "bar" && !isPeriod) { + return chartCategoryRows(result, spec, otherLabel).map((row) => ({ + x: row.label, + [spec.series[0]]: row.value, + })); + } + const xIndex = result.columns.findIndex((c) => c.key === spec.x); + const indexes = spec.series.map((key) => + result.columns.findIndex((c) => c.key === key), + ); + return result.rows.map((row) => { + const point: Point = { x: String(row[xIndex] ?? "") }; + spec.series.forEach((key, i) => { + const cell = row[indexes[i]]; + point[key] = typeof cell === "number" ? cell : null; + }); + return point; + }); + }, [result, spec, isPeriod, otherLabel]); + + const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x); + const colorOf = (key: string) => + SERIES_COLORS[spec.series.indexOf(key) % SERIES_COLORS.length]; + const visible = spec.series.filter((key) => !hidden.has(key)); + + const tooltip = (props: { + active?: boolean; + payload?: ReadonlyArray<{ payload?: Point }>; + }) => { + const point = props.payload?.[0]?.payload; + if (!props.active || !point) { + return ; + } + const rows = visible.flatMap((key) => { + const value = point[key]; + if (typeof value !== "number") return []; + const column = byKey.get(key); + return [ + { + name: column?.label ?? key, + value: valueFormat(value, column), + color: colorOf(key), + }, + ]; + }); + return ; + }; + + const axes = ( + <> + + + + + + ); + + return ( +
+ {spec.series.length > 1 ? ( + ({ + key, + label: byKey.get(key)?.label ?? key, + }))} + hidden={hidden} + onToggle={(key) => + setHidden((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else if (spec.series.length - next.size > 1) next.add(key); + return next; + }) + } + /> + ) : null} + + {spec.kind === "line" ? ( + + {axes} + {visible.map((key) => ( + + ))} + + ) : ( + + {axes} + {visible.map((key) => ( + + ))} + + )} + +
+ ); +} + +function SeriesLegend({ + series, + hidden, + onToggle, +}: { + series: Array<{ key: string; label: string }>; + hidden: ReadonlySet; + onToggle: (key: string) => void; +}) { + return ( +
+ {series.map(({ key, label }, index) => { + const shown = !hidden.has(key); + return ( + + ); + })} +
+ ); +} + +// ── Horizontal category bars ─────────────────────────────────────────────── + +function CategoryBarChart({ + result, + spec, + otherLabel, + label, +}: { + result: CoachResultTable; + spec: Extract; + otherLabel: string; + label: string; +}) { + const valueFormat = useValueFormat(); + const animate = !prefersReducedMotion(); + const column = result.columns.find((c) => c.key === spec.series[0]); + const rows = useMemo( + () => chartCategoryRows(result, spec, otherLabel), + [result, spec, otherLabel], + ); + const height = Math.max( + RESULT_CHART_HEIGHT_PX, + rows.length * HORIZONTAL_BAR_ROW_PX + 16, + ); + return ( + + + + + + { + const payload = props.payload as + | ReadonlyArray<{ payload?: { label: string; value: number } }> + | undefined; + const row = payload?.[0]?.payload; + if (!props.active || !row) { + return ; + } + return ( + + ); + }} + /> + + + + ); +} + +// ── Histogram ────────────────────────────────────────────────────────────── + +function HistogramChart({ + result, + spec, + label, +}: { + result: CoachResultTable; + spec: Extract; + label: string; +}) { + const valueFormat = useValueFormat(); + const animate = !prefersReducedMotion(); + const countColumn = result.columns.find((c) => c.kind === "count"); + const data = useMemo( + () => + spec.bins.map((bin, index) => { + const label = result.rows[index]?.[0]; + return { + x: + typeof label === "string" + ? label + : `${bin.from}–${bin.to}${spec.unit ? ` ${spec.unit}` : ""}`, + from: bin.from, + count: bin.count, + }; + }), + [spec, result.rows], + ); + return ( + + + + + + { + const payload = props.payload as + | ReadonlyArray<{ payload?: { x: string; count: number } }> + | undefined; + const bin = payload?.[0]?.payload; + if (!props.active || !bin) { + return ; + } + return ( + + ); + }} + /> + + + + ); +} diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx index eae730822..e8ecc92a7 100644 --- a/src/components/insights/coach-panel/result-table.tsx +++ b/src/components/insights/coach-panel/result-table.tsx @@ -43,6 +43,8 @@ export interface CoachResultTableProps { result: CoachResultTableData; /** The method line for this table, shown under the title. */ method?: ReactNode; + /** Controls shown beside the copy button (the chart/table toggle). */ + toolbar?: ReactNode; } type CellFormatter = ( @@ -98,7 +100,11 @@ function headingText(column: CoachResultColumn): string { return column.unit ? `${column.label} (${column.unit})` : column.label; } -export function CoachResultTable({ result, method }: CoachResultTableProps) { +export function CoachResultTable({ + result, + method, + toolbar, +}: CoachResultTableProps) { const { t } = useTranslations(); const formatter = useCellFormatter(); const [expanded, setExpanded] = useState(false); @@ -159,8 +165,9 @@ export function CoachResultTable({ result, method }: CoachResultTableProps) { ) : null} - + + {toolbar} diff --git a/src/lib/ai/coach/results/__tests__/chart-spec.test.ts b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts new file mode 100644 index 000000000..33c723f88 --- /dev/null +++ b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts @@ -0,0 +1,537 @@ +/** + * The chart a result table gets. Pure and deterministic: every rule of the + * heuristic, and every way a table ends up with no chart. + */ +import { describe, expect, it } from "vitest"; + +import type { + CoachResultCell, + CoachResultColumn, + CoachResultTable, + CoachStepDomain, +} from "@/lib/ai/coach/types"; + +import { + CATEGORY_TOP_N, + HISTOGRAM_MAX_BINS, + HISTOGRAM_MIN_BINS, + METRIC_AGGREGATION_KIND, + buildDistributionTable, + buildHistogram, + chartCategoryRows, + deriveChartSpec, + type DistributionLabels, +} from "../chart-spec"; + +const DAY: CoachResultColumn = { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", +}; +const READINGS: CoachResultColumn = { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", +}; + +function valueColumn( + key: string, + unit?: string, + kind: "number" | "count" = "number", +): CoachResultColumn { + return { + key, + kind, + labelKey: `coach.result.column.${key}`, + label: key, + ...(unit ? { unit } : {}), + decimals: 0, + }; +} + +function dayKey(index: number): string { + const date = new Date(Date.UTC(2026, 0, 1 + index)); + return date.toISOString().slice(0, 10); +} + +function timeSeries(args: { + domain: CoachStepDomain; + columns: CoachResultColumn[]; + rows: CoachResultCell[][]; + granularity?: "day" | "week" | "month"; +}): CoachResultTable { + return { + ref: "r1", + source: { + tool: "get_metric_table", + domain: args.domain, + window: "last90days", + period: "current", + granularity: args.granularity ?? "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "By day", + rowCount: args.rows.length, + chartKind: null, + displayed: true, + columns: args.columns, + rows: args.rows, + truncated: false, + chart: null, + }; +} + +function daySeries( + domain: CoachStepDomain, + values: Array, + unit?: string, +): CoachResultTable { + return timeSeries({ + domain, + columns: [DAY, valueColumn("value", unit), READINGS], + rows: values.map((value, index) => [ + dayKey(index), + value, + value === null ? null : 1, + ]), + }); +} + +function categories( + counts: Array, + names?: string[], +): CoachResultTable { + return { + ref: "r2", + source: { + tool: "get_workouts", + domain: "workouts", + window: "last30days", + period: "current", + }, + shape: "categoryCounts", + titleKey: "coach.result.title.workoutsBySport", + title: "Workouts by sport", + rowCount: counts.length, + chartKind: null, + displayed: true, + columns: [ + { + key: "sport", + kind: "category", + labelKey: "coach.result.column.sport", + label: "Sport", + }, + valueColumn("sessions", undefined, "count"), + valueColumn("duration", "min"), + ], + rows: counts.map((count, index) => [ + names?.[index] ?? `sport-${index + 1}`, + count, + count === null ? null : count * 30, + ]), + truncated: false, + chart: null, + }; +} + +const LABELS: DistributionLabels = { + localeTag: "en-US", + title: "Pulse: how often each range came up", + range: "Range", + count: "Times", + bin: (from, to, unit) => `${from}–${to} ${unit}`, +}; + +describe("deriveChartSpec: the row floor and ceiling", () => { + it("draws nothing for a single row", () => { + expect(deriveChartSpec(daySeries("pulse", [62]))).toBeNull(); + }); + + it("draws nothing for 401 rows", () => { + const values = Array.from({ length: 401 }, (_, i) => 60 + (i % 10)); + expect(deriveChartSpec(daySeries("pulse", values))).toBeNull(); + }); + + it("draws 400 rows", () => { + const values = Array.from({ length: 400 }, (_, i) => 60 + (i % 10)); + expect(deriveChartSpec(daySeries("pulse", values))?.kind).toBe("line"); + }); + + it("draws nothing when no column is numeric", () => { + const table = timeSeries({ + domain: "pulse", + columns: [DAY, { ...valueColumn("note"), kind: "category" }], + rows: [ + [dayKey(0), "a"], + [dayKey(1), "b"], + ], + }); + expect(deriveChartSpec(table)).toBeNull(); + }); + + it("draws nothing when every period is empty", () => { + expect(deriveChartSpec(daySeries("pulse", [null, null, null]))).toBeNull(); + }); + + it("draws nothing for a time series without a period column", () => { + const table = timeSeries({ + domain: "pulse", + columns: [valueColumn("value", "bpm")], + rows: [[60], [61]], + }); + expect(deriveChartSpec(table)).toBeNull(); + }); +}); + +describe("deriveChartSpec: time series", () => { + it("draws a level metric as a line over the period column", () => { + expect( + deriveChartSpec(daySeries("weight", [80, 80.4, 79.9], "kg")), + ).toEqual({ kind: "line", x: "day", series: ["value"] }); + }); + + it("draws a total as vertical bars", () => { + expect( + deriveChartSpec(daySeries("steps", [8000, 12000, 5400], "steps")), + ).toEqual({ + kind: "bar", + x: "day", + series: ["value"], + orientation: "vertical", + }); + }); + + it("draws blood pressure as two series in one unit", () => { + const table = timeSeries({ + domain: "bp", + columns: [ + DAY, + valueColumn("systolic", "mmHg"), + valueColumn("diastolic", "mmHg"), + READINGS, + ], + rows: [ + [dayKey(0), 128, 82, 2], + [dayKey(1), 124, 80, 1], + ], + }); + expect(deriveChartSpec(table)).toEqual({ + kind: "line", + x: "day", + series: ["systolic", "diastolic"], + }); + }); + + it("keeps mixed units off one chart: the first series alone", () => { + const table = timeSeries({ + domain: "weight", + columns: [ + DAY, + valueColumn("value", "kg"), + valueColumn("fat", "%"), + READINGS, + ], + rows: [ + [dayKey(0), 80, 22], + [dayKey(1), 79.5, 21.8], + ], + }); + expect(deriveChartSpec(table)).toEqual({ + kind: "line", + x: "day", + series: ["value"], + }); + }); + + it("draws at most two series, the first compatible pair", () => { + const table = timeSeries({ + domain: "hrv", + columns: [ + DAY, + valueColumn("sdnn", "ms"), + valueColumn("other", "%"), + valueColumn("rmssd", "ms"), + valueColumn("third", "ms"), + ], + rows: [ + [dayKey(0), 40, 1, 30, 20], + [dayKey(1), 42, 2, 31, 21], + ], + }); + expect(deriveChartSpec(table)).toEqual({ + kind: "line", + x: "day", + series: ["sdnn", "rmssd"], + }); + }); + + it("leaves the reading count off the chart", () => { + const spec = deriveChartSpec(daySeries("pulse", [60, 62], "bpm")); + expect(spec && "series" in spec ? spec.series : []).not.toContain( + "readings", + ); + }); + + it("draws a single reading as a bar, not a lone dot", () => { + expect( + deriveChartSpec(daySeries("pulse", [null, 62, null], "bpm")), + ).toEqual({ + kind: "bar", + x: "day", + series: ["value"], + orientation: "vertical", + }); + }); + + it("places every scope source as a level or a total", () => { + expect(METRIC_AGGREGATION_KIND.steps).toBe("total"); + expect(METRIC_AGGREGATION_KIND.daylight).toBe("total"); + expect(METRIC_AGGREGATION_KIND.sleep).toBe("level"); + expect(METRIC_AGGREGATION_KIND.mood).toBe("level"); + }); +}); + +describe("deriveChartSpec: category counts", () => { + it("draws up to six categories as vertical bars of the count column", () => { + expect(deriveChartSpec(categories([3, 5, 1, 2, 4, 6]))).toEqual({ + kind: "bar", + x: "sport", + series: ["sessions"], + orientation: "vertical", + }); + }); + + it("turns horizontal above six categories", () => { + const spec = deriveChartSpec(categories([3, 5, 1, 2, 4, 6, 7])); + expect(spec).toMatchObject({ kind: "bar", orientation: "horizontal" }); + }); + + it("draws nothing for a fractional count", () => { + expect(deriveChartSpec(categories([3, 1.5]))).toBeNull(); + }); + + it("draws nothing for a negative count", () => { + expect(deriveChartSpec(categories([3, -1]))).toBeNull(); + }); + + it("draws nothing without a count", () => { + expect(deriveChartSpec(categories([null, null]))).toBeNull(); + }); + + it("folds everything past the top eight into one other bar", () => { + const counts = [1, 9, 2, 8, 3, 7, 4, 6, 5, 10, 1]; + const table = categories(counts); + const spec = deriveChartSpec(table); + if (spec?.kind !== "bar") throw new Error("expected a bar chart"); + const rows = chartCategoryRows(table, spec, "Other"); + expect(rows).toHaveLength(CATEGORY_TOP_N + 1); + expect(rows.slice(0, 3).map((row) => row.value)).toEqual([10, 9, 8]); + expect(rows[CATEGORY_TOP_N]).toEqual({ + label: "Other", + value: 2 + 1 + 1, + other: true, + }); + const total = counts.reduce((sum, value) => sum + value, 0); + expect(rows.reduce((sum, row) => sum + row.value, 0)).toBe(total); + }); + + it("adds no other bar when every category fits", () => { + const table = categories([2, 1, 3]); + const spec = deriveChartSpec(table); + if (spec?.kind !== "bar") throw new Error("expected a bar chart"); + expect( + chartCategoryRows(table, spec, "Other").some((row) => row.other), + ).toBe(false); + }); +}); + +describe("deriveChartSpec: shapes without a chart of their own", () => { + it("draws nothing for a list of single values (lab results)", () => { + const table: CoachResultTable = { + ...categories([1, 2]), + shape: "single", + }; + expect(deriveChartSpec(table)).toBeNull(); + }); + + it("draws nothing for a distribution without its bins", () => { + const table: CoachResultTable = { + ...categories([1, 2]), + shape: "distribution", + chart: null, + }; + expect(deriveChartSpec(table)).toBeNull(); + }); +}); + +describe("buildHistogram", () => { + const spread = (from: number, step: number, n: number) => + Array.from({ length: n }, (_, i) => from + (i % 7) * step); + + it("needs at least ten values", () => { + expect(buildHistogram(spread(60, 3, 9), "pulse", "bpm")).toBeNull(); + expect(buildHistogram(spread(60, 3, 10), "pulse", "bpm")).not.toBeNull(); + }); + + it.each([ + ["bp", "mmHg", 110, 5], + ["pulse", "bpm", 55, 5], + ["weight", "kg", 79, 0.5], + ["sleep", "min", 360, 30], + ["glucose", "mg/dL", 85, 10], + ["steps", "steps", 4000, 1000], + ] as const)("bins %s in its fixed width", (domain, unit, base, width) => { + const values = Array.from({ length: 12 }, (_, i) => base + i * width * 0.7); + const bins = buildHistogram(values, domain, unit); + if (!bins) throw new Error("expected bins"); + for (const bin of bins) expect(bin.to - bin.from).toBeCloseTo(width, 9); + expect(bins[0].from % width).toBeCloseTo(0, 9); + }); + + it("counts every value exactly once, the highest in the last bin", () => { + const values = [120, 121, 124.9, 125, 126, 130, 131, 140, 140, 118]; + const bins = buildHistogram(values, "bp", "mmHg"); + if (!bins) throw new Error("expected bins"); + expect(bins.reduce((sum, bin) => sum + bin.count, 0)).toBe(values.length); + expect(bins[0]).toEqual({ from: 115, to: 120, count: 1 }); + expect(bins.find((bin) => bin.from === 120)?.count).toBe(3); + expect(bins.find((bin) => bin.from === 125)?.count).toBe(2); + expect(bins[bins.length - 1]).toEqual({ from: 140, to: 145, count: 2 }); + }); + + it("keeps an empty bin between readings as a zero", () => { + const values = [...Array(5).fill(60), ...Array(5).fill(80)]; + const bins = buildHistogram(values, "pulse", "bpm"); + if (!bins) throw new Error("expected bins"); + expect(bins.map((bin) => bin.count)).toEqual([5, 0, 0, 0, 5]); + }); + + it("widens a fixed width until at most twenty bins remain", () => { + const values = Array.from({ length: 30 }, (_, i) => 1000 + i * 1000); + const bins = buildHistogram(values, "steps", "steps"); + if (!bins) throw new Error("expected bins"); + expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS); + expect(bins[0].to - bins[0].from).toBe(2000); + }); + + it("ignores the glucose width when the table is not in mg/dL", () => { + const values = Array.from({ length: 20 }, (_, i) => 4.5 + i * 0.2); + const bins = buildHistogram(values, "glucose", "mmol/L"); + if (!bins) throw new Error("expected bins"); + expect(bins[0].to - bins[0].from).toBeLessThan(10); + expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS); + }); + + it("holds Freedman–Diaconis to five to twenty bins", () => { + // A tight cluster with one far outlier: raw FD would give hundreds. + const wide = [...Array.from({ length: 200 }, (_, i) => 50 + (i % 5)), 900]; + const wideBins = buildHistogram(wide, "vo2_max", undefined); + // Evenly spread values over a short range: raw FD would give two. + const narrow = Array.from({ length: 12 }, (_, i) => 30 + i); + const narrowBins = buildHistogram(narrow, "vo2_max", undefined); + for (const bins of [wideBins, narrowBins]) { + if (!bins) throw new Error("expected bins"); + expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS); + expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS); + } + }); + + it("puts identical values in one bin", () => { + const bins = buildHistogram(Array(12).fill(37.2), "body_temp", "°C"); + expect(bins).toHaveLength(1); + expect(bins?.[0].count).toBe(12); + }); + + it("is deterministic", () => { + const values = Array.from({ length: 50 }, (_, i) => 60 + ((i * 7) % 23)); + expect(buildHistogram(values, "pulse", "bpm")).toEqual( + buildHistogram([...values].reverse(), "pulse", "bpm"), + ); + }); +}); + +describe("buildDistributionTable", () => { + const values = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, null, 63]; + + it("turns a day table into counts per range with a histogram", () => { + const table = buildDistributionTable( + daySeries("pulse", values, "bpm"), + LABELS, + ); + if (!table) throw new Error("expected a distribution"); + expect(table.shape).toBe("distribution"); + expect(table.titleKey).toBe("coach.result.title.distribution"); + expect(table.columns.map((column) => column.key)).toEqual([ + "range", + "count", + ]); + expect(table.rows[0]).toEqual(["55–60 bpm", 1]); + expect(table.chart).toMatchObject({ + kind: "histogram", + column: "value", + unit: "bpm", + }); + expect(table.chartKind).toBe("histogram"); + expect(table.rowCount).toBe(table.rows.length); + const counted = table.rows.reduce( + (sum, row) => sum + (row[1] as number), + 0, + ); + expect(counted).toBe(values.filter((v) => v !== null).length); + // The stored histogram survives a second pass through the heuristic. + expect(deriveChartSpec(table)).toEqual(table.chart); + }); + + it("formats bin edges in the reply's locale", () => { + const weights = Array.from({ length: 12 }, (_, i) => 80 + i * 0.3); + const table = buildDistributionTable(daySeries("weight", weights, "kg"), { + ...LABELS, + localeTag: "de-DE", + }); + expect(table?.rows[0]?.[0]).toBe("80,0–80,5 kg"); + }); + + it("builds nothing from fewer than ten values", () => { + expect( + buildDistributionTable(daySeries("pulse", [60, 61, 62], "bpm"), LABELS), + ).toBeNull(); + }); + + it("builds nothing from a table by week", () => { + const table = timeSeries({ + domain: "pulse", + granularity: "week", + columns: [DAY, valueColumn("value", "bpm")], + rows: values.map((value, index) => [dayKey(index * 7), value]), + }); + expect(buildDistributionTable(table, LABELS)).toBeNull(); + }); + + it("builds nothing from category counts", () => { + expect( + buildDistributionTable( + categories([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), + LABELS, + ), + ).toBeNull(); + }); + + it("names the column it binned when the table has two", () => { + const table = timeSeries({ + domain: "bp", + columns: [ + DAY, + { ...valueColumn("systolic", "mmHg"), label: "Systolic" }, + { ...valueColumn("diastolic", "mmHg"), label: "Diastolic" }, + ], + rows: Array.from({ length: 12 }, (_, i) => [dayKey(i), 120 + i, 80]), + }); + const distribution = buildDistributionTable(table, LABELS); + expect(distribution?.columns[0]?.label).toBe("Range (Systolic)"); + expect(distribution?.chart).toMatchObject({ column: "systolic" }); + }); +}); diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index be0355b7b..2b6b70087 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -275,6 +275,70 @@ describe("show_result", () => { expect(readDailySeries).not.toHaveBeenCalled(); }); + it("keeps the server's chart when no view is asked for", async () => { + readMessageResults.mockResolvedValue([STORED]); + const result = await executeCoachTool({ + userId: "u1", + name: "show_result", + rawArguments: JSON.stringify({ ref: "m2.r1" }), + turn: turn(), + }); + expect(result.table).toMatchObject({ + shape: "timeSeries", + chart: { kind: "line", x: "day", series: ["value"] }, + chartKind: "line", + }); + }); + + it("shows the table without a chart for view table", async () => { + readMessageResults.mockResolvedValue([STORED]); + const result = await executeCoachTool({ + userId: "u1", + name: "show_result", + rawArguments: JSON.stringify({ ref: "m2.r1", view: "table" }), + turn: turn(), + }); + expect(result.table).toMatchObject({ + rows: STORED.rows, + chart: null, + chartKind: null, + }); + }); + + it("shows a day table as counts per range for view chart", async () => { + const days = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, 63, 65]; + readMessageResults.mockResolvedValue([ + { + ...STORED, + rowCount: days.length, + rows: days.map((value, index) => [ + `2026-09-${String(10 + index).padStart(2, "0")}`, + value, + ]), + }, + ]); + const result = await executeCoachTool({ + userId: "u1", + name: "show_result", + rawArguments: JSON.stringify({ ref: "m2.r1", view: "chart" }), + turn: turn(), + }); + expect(result.table).toMatchObject({ + ref: "r1", + shape: "distribution", + titleKey: "coach.result.title.distribution", + chartKind: "histogram", + chart: { kind: "histogram", column: "value", unit: "bpm" }, + reusedFrom: { messageId: "m-a2", ref: "r1" }, + }); + expect(result.table?.rows[0]).toEqual(["55–60 bpm", 1]); + // The model reads the counts it can talk about, not the days. + expect(result.data).toMatchObject({ + shape: "distribution", + stats: { count: { total: days.length } }, + }); + }); + it("answers unknown_result for a name this conversation does not hold", async () => { // A name that another conversation of the same account might hold. const result = await executeCoachTool({ diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts index aa3b6e67e..02151bf10 100644 --- a/src/lib/ai/coach/results/chart-spec.ts +++ b/src/lib/ai/coach/results/chart-spec.ts @@ -4,12 +4,484 @@ * category counts, a histogram for a distribution, or none (table only) * when the table is too short, too long or has no numeric column. * - * Pure. Not built yet: every table stays table-only. + * Pure and deterministic: the same table always gets the same chart, and the + * model never picks one. The model can only ask, through `show_result`'s + * `view`, for an earlier day table as a distribution (`buildDistributionTable`) + * or for the table without a chart. + * + * Imports types only, so the client can use the category view below + * (`chartCategoryRows`) without pulling anything server-side in. */ -import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types"; +import type { + CoachChartSpec, + CoachResultCell, + CoachResultColumn, + CoachResultTable, + CoachScopeSource, + CoachStepDomain, +} from "@/lib/ai/coach/types"; -export function deriveChartSpec( - _table: Omit, -): CoachChartSpec | null { - return null; +/** Fewer rows than this: table only. */ +export const CHART_MIN_ROWS = 2; +/** More rows than this: table only (the table's own row cap). */ +export const CHART_MAX_ROWS = 400; +/** Category bars turn horizontal above this many categories. */ +export const CATEGORY_VERTICAL_MAX = 6; +/** Category bars show this many categories; the rest fold into "other". */ +export const CATEGORY_TOP_N = 8; +/** A histogram needs at least this many values. */ +export const HISTOGRAM_MIN_VALUES = 10; +/** Bin-count bounds for a histogram without a fixed width. */ +export const HISTOGRAM_MIN_BINS = 5; +export const HISTOGRAM_MAX_BINS = 20; + +/** + * How a metric's periods add up: a level (a reading that stands for the + * period: blood pressure, weight, sleep) is drawn as a line; a total (a sum + * over the period: steps, energy) as bars. A record over every source, so a + * new source does not compile until it is placed. + */ +export const METRIC_AGGREGATION_KIND: Readonly< + Record +> = { + bp: "level", + weight: "level", + pulse: "level", + mood: "level", + compliance: "level", + hrv: "level", + sleep: "level", + resting_hr: "level", + steps: "total", + active_energy: "total", + flights: "total", + distance: "total", + vo2_max: "level", + body_temp: "level", + walking_hr: "level", + respiratory_rate: "level", + spo2: "level", + pulse_wave_velocity: "level", + vascular_age: "level", + body_fat: "level", + fat_mass: "level", + fat_free_mass: "level", + muscle_mass: "level", + lean_body_mass: "level", + bone_mass: "level", + total_body_water: "level", + bmi: "level", + visceral_fat: "level", + glucose: "level", + walking_steadiness: "level", + walking_asymmetry: "level", + walking_double_support: "level", + walking_step_length: "level", + walking_speed: "level", + audio_env: "level", + audio_headphone: "level", + audio_event: "total", + daylight: "total", + skin_temp: "level", + workouts: "total", +}; + +function aggregationKind(domain: CoachStepDomain): "level" | "total" { + return ( + (METRIC_AGGREGATION_KIND as Partial>)[ + domain + ] ?? "level" + ); +} + +/** + * Fixed bin widths for the metrics people ask "how often" about, in the + * unit the table carries. Glucose only when the table is in mg/dL; any + * other metric or unit falls back to the Freedman–Diaconis rule. + */ +const NICE_BIN_WIDTH: Readonly< + Partial> +> = { + bp: { width: 5, unit: "mmHg" }, + pulse: { width: 5, unit: "bpm" }, + resting_hr: { width: 5, unit: "bpm" }, + walking_hr: { width: 5, unit: "bpm" }, + weight: { width: 0.5, unit: "kg" }, + sleep: { width: 30, unit: "min" }, + glucose: { width: 10, unit: "mg/dL" }, + steps: { width: 1000 }, +}; + +type ChartInput = Omit & { + chart?: CoachChartSpec | null; +}; + +function isNumericCell(cell: CoachResultCell | undefined): cell is number { + return typeof cell === "number" && Number.isFinite(cell); +} + +function columnIndex(table: ChartInput, key: string): number { + return table.columns.findIndex((column) => column.key === key); +} + +/** + * The series a time series is drawn with: its value columns, and only the + * first one plus one partner in the same unit — two axes with different + * units on one chart read as a comparison they are not. + */ +function timeSeriesColumns(table: ChartInput): CoachResultColumn[] { + const values = table.columns.filter((column) => column.kind === "number"); + const candidates = + values.length > 0 + ? values + : table.columns.filter((column) => column.kind === "count"); + const [first, ...rest] = candidates; + if (!first) return []; + const partner = rest.find((column) => column.unit === first.unit); + return partner ? [first, partner] : [first]; +} + +function timeSeriesSpec(table: ChartInput): CoachChartSpec | null { + const x = table.columns.find((column) => column.kind === "period"); + if (!x) return null; + const series = timeSeriesColumns(table); + if (series.length === 0) return null; + const indexes = series.map((column) => columnIndex(table, column.key)); + const points = table.rows.filter((row) => + indexes.some((index) => isNumericCell(row[index])), + ).length; + if (points === 0) return null; + const keys = series.map((column) => column.key); + // A line needs two points; one reading is a single bar, not a dot. + if (aggregationKind(table.source.domain) === "total" || points < 2) { + return { kind: "bar", x: x.key, series: keys, orientation: "vertical" }; + } + return { kind: "line", x: x.key, series: keys }; +} + +function categorySpec(table: ChartInput): CoachChartSpec | null { + const x = table.columns.find((column) => column.kind === "category"); + const counts = table.columns.find((column) => column.kind === "count"); + if (!x || !counts) return null; + const index = columnIndex(table, counts.key); + let any = false; + for (const row of table.rows) { + const cell = row[index]; + if (cell === null || cell === undefined) continue; + if (!isNumericCell(cell) || cell < 0 || !Number.isInteger(cell)) { + return null; + } + any = true; + } + if (!any) return null; + return { + kind: "bar", + x: x.key, + series: [counts.key], + orientation: + table.rows.length > CATEGORY_VERTICAL_MAX ? "horizontal" : "vertical", + }; +} + +/** + * The chart for a table, or null for table only. + * + * 1. Fewer than 2 or more than 400 rows, or no numeric column: none. + * 2. A time series: a line for a level metric, vertical bars for a total; + * at most two series, and only in one unit. + * 3. Category counts: bars, horizontal above six categories (the top eight + * and "other" are drawn; `chartCategoryRows`). Counts must be + * non-negative whole numbers. + * 4. A distribution keeps the histogram it was built with + * (`buildDistributionTable`); its bins cannot be read back from the rows. + * 5. Anything else (a list of lab results): none. + */ +export function deriveChartSpec(table: ChartInput): CoachChartSpec | null { + if ( + table.rows.length < CHART_MIN_ROWS || + table.rows.length > CHART_MAX_ROWS + ) { + return null; + } + switch (table.shape) { + case "timeSeries": + return timeSeriesSpec(table); + case "categoryCounts": + return categorySpec(table); + case "distribution": + return table.chart?.kind === "histogram" ? table.chart : null; + default: + return null; + } +} + +// ── Histogram ───────────────────────────────────────────────────────────── + +export interface HistogramBin { + from: number; + to: number; + count: number; +} + +/** Strips float noise from a bin edge (0.1 + 0.2 and the like). */ +function clean(value: number): number { + return Number(value.toFixed(10)); +} + +/** Linear-interpolated quantile of a sorted list. */ +function quantile(sorted: number[], q: number): number { + const position = (sorted.length - 1) * q; + const lower = Math.floor(position); + const upper = Math.ceil(position); + const low = sorted[lower] ?? 0; + const high = sorted[upper] ?? low; + return low + (high - low) * (position - lower); +} + +/** The "nice" widths: 1, 2, 2.5 and 5 times a power of ten. */ +const NICE_STEPS = [1, 2, 2.5, 5]; + +function niceAtLeast(value: number): number { + const power = 10 ** Math.floor(Math.log10(value)); + for (const step of NICE_STEPS) { + if (step * power >= value - 1e-12) return clean(step * power); + } + return clean(10 * power); +} + +function niceBelow(width: number): number { + const power = 10 ** Math.floor(Math.log10(width)); + const mantissa = clean(width / power); + const index = NICE_STEPS.findIndex((step) => step >= mantissa); + if (index > 0) return clean(NICE_STEPS[index - 1] * power); + return clean(5 * (power / 10)); +} + +function niceAbove(width: number): number { + return niceAtLeast(clean(width * 1.000001)); +} + +function binCount(min: number, max: number, width: number): number { + const start = Math.floor(clean(min / width)) * width; + return Math.floor(clean((max - start) / width)) + 1; +} + +/** Freedman–Diaconis on a nice width, held to 5..20 bins. */ +function freedmanDiaconisWidth(sorted: number[]): number { + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + const range = max - min; + const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25); + const raw = + iqr > 0 ? (2 * iqr) / Math.cbrt(sorted.length) : range / HISTOGRAM_MIN_BINS; + let width = niceAtLeast(raw); + for (let guard = 0; guard < 64; guard += 1) { + const bins = binCount(min, max, width); + if (bins > HISTOGRAM_MAX_BINS) width = niceAbove(width); + else if (bins < HISTOGRAM_MIN_BINS && width > 1e-9) + width = niceBelow(width); + else break; + } + return width; +} + +/** + * Bins for a set of values: a fixed width for the metrics that have one + * (doubled until at most 20 bins remain), Freedman–Diaconis otherwise. + * Bins run from the one holding the lowest value to the one holding the + * highest; empty bins in between stay, with a count of 0. Each bin holds + * `from <= v < to`. Fewer than ten values: null, not a histogram of noise. + */ +export function buildHistogram( + values: readonly number[], + domain: CoachStepDomain, + unit: string | undefined, +): HistogramBin[] | null { + const finite = values.filter((value) => Number.isFinite(value)); + if (finite.length < HISTOGRAM_MIN_VALUES) return null; + const sorted = [...finite].sort((a, b) => a - b); + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + + const fixed = ( + NICE_BIN_WIDTH as Partial> + )[domain]; + let width: number; + if (fixed && (fixed.unit === undefined || fixed.unit === unit)) { + width = fixed.width; + while (binCount(min, max, width) > HISTOGRAM_MAX_BINS) width *= 2; + } else if (max === min) { + width = niceAtLeast(Math.max(Math.abs(min), 1) / 10); + } else { + width = freedmanDiaconisWidth(sorted); + } + + const start = clean(Math.floor(clean(min / width)) * width); + const count = binCount(min, max, width); + const bins: HistogramBin[] = Array.from({ length: count }, (_, i) => ({ + from: clean(start + i * width), + to: clean(start + (i + 1) * width), + count: 0, + })); + for (const value of sorted) { + const index = Math.min( + count - 1, + Math.max(0, Math.floor(clean((value - start) / width))), + ); + bins[index].count += 1; + } + return bins; +} + +/** Decimals a bin edge needs to be told apart from its neighbour. */ +export function binDecimals(bins: readonly HistogramBin[]): number { + let decimals = 0; + for (const bin of bins) { + for (const edge of [bin.from, bin.to]) { + const text = String(edge); + const dot = text.indexOf("."); + if (dot >= 0) decimals = Math.max(decimals, text.length - dot - 1); + } + } + return Math.min(decimals, 3); +} + +/** The words a distribution table is labelled with, in the reply's locale. */ +export interface DistributionLabels { + /** BCP 47 tag the bin edges are formatted in. */ + localeTag: string; + title: string; + /** Heading of the range column. */ + range: string; + /** Heading of the count column. */ + count: string; + /** One bin's label, e.g. `120–125 mmHg`. */ + bin: (from: string, to: string, unit: string) => string; +} + +/** + * A day table as a distribution: how many days fell into each range of its + * first value column. Only a day table (one value per day) qualifies, and + * only with at least ten values; otherwise null and the table keeps its + * own chart. + */ +export function buildDistributionTable( + table: CoachResultTable, + labels: DistributionLabels, +): CoachResultTable | null { + if (table.shape !== "timeSeries" || table.source.granularity !== "day") { + return null; + } + const column = table.columns.find((candidate) => candidate.kind === "number"); + if (!column) return null; + const index = columnIndex(table, column.key); + const values = table.rows + .map((row) => row[index]) + .filter((cell): cell is number => isNumericCell(cell)); + const bins = buildHistogram(values, table.source.domain, column.unit); + if (!bins) return null; + + const decimals = binDecimals(bins); + const format = new Intl.NumberFormat(labels.localeTag, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: true, + }); + const unit = column.unit ?? ""; + const valueColumns = table.columns.filter( + (candidate) => candidate.kind === "number", + ); + const rows: CoachResultCell[][] = bins.map((bin) => [ + labels.bin(format.format(bin.from), format.format(bin.to), unit).trim(), + bin.count, + ]); + const chart: CoachChartSpec = { + kind: "histogram", + column: column.key, + ...(column.unit ? { unit: column.unit } : {}), + bins, + }; + const withChart = rows.length >= CHART_MIN_ROWS ? chart : null; + return { + ref: table.ref, + source: table.source, + shape: "distribution", + titleKey: "coach.result.title.distribution", + title: labels.title, + rowCount: rows.length, + chartKind: withChart ? "histogram" : null, + displayed: table.displayed, + ...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}), + columns: [ + { + key: "range", + kind: "category", + labelKey: "coach.result.column.range", + label: + valueColumns.length > 1 + ? `${labels.range} (${column.label})` + : labels.range, + }, + { + key: "count", + kind: "count", + labelKey: "coach.result.column.count", + label: labels.count, + }, + ], + rows, + truncated: false, + chart: withChart, + }; +} + +// ── The category view (client and server) ───────────────────────────────── + +export interface ChartCategoryRow { + label: string; + value: number; + /** True for the folded "other" bar. */ + other: boolean; +} + +/** + * The bars a category chart draws: the eight largest categories, largest + * first, and the rest summed into one "other" bar. A category without a + * count is left out of the bars (the table still lists it). + */ +export function chartCategoryRows( + table: Pick, + spec: Extract, + otherLabel: string, +): ChartCategoryRow[] { + const xIndex = table.columns.findIndex((column) => column.key === spec.x); + const yIndex = table.columns.findIndex( + (column) => column.key === spec.series[0], + ); + if (xIndex < 0 || yIndex < 0) return []; + const entries = table.rows + .map((row, order) => ({ + label: String(row[xIndex] ?? ""), + value: row[yIndex], + order, + })) + .filter((entry): entry is { label: string; value: number; order: number } => + isNumericCell(entry.value), + ) + .sort((a, b) => b.value - a.value || a.order - b.order); + const top = entries.slice(0, CATEGORY_TOP_N).map((entry) => ({ + label: entry.label, + value: entry.value, + other: false, + })); + const rest = entries.slice(CATEGORY_TOP_N); + if (rest.length === 0) return top; + return [ + ...top, + { + label: otherLabel, + value: rest.reduce((sum, entry) => sum + entry.value, 0), + other: true, + }, + ]; } diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index bbe047f9c..0c21f57be 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -394,7 +394,8 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ view: { type: "string", enum: ["table", "chart"], - description: "How the person asked to see it, when they said.", + description: + "How the person asked to see it, when they said. table: without a chart. chart: with one; an earlier table by day is shown as how often its values fell into each range.", }, }, }, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index bd7f46a9f..f08c1785a 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -70,8 +70,18 @@ import { type ResultRefAllocator, } from "@/lib/ai/coach/results/refs"; import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module"; -import { COACH_RESULT_TITLE_KEYS } from "@/lib/ai/coach/dialog-keys"; +import { + COACH_RESULT_COLUMN_KEYS, + COACH_RESULT_TITLE_KEYS, + COACH_RESULT_UI_KEYS, + coachDomainLabelKey, +} from "@/lib/ai/coach/dialog-keys"; +import { + buildDistributionTable, + deriveChartSpec, +} from "@/lib/ai/coach/results/chart-spec"; import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache"; import { getMetricSeriesArgsSchema, @@ -805,6 +815,37 @@ async function getMetricTable( }; } +/** + * A table shown again, with the chart its `view` asks for. No view: the chart + * the server picks for any table. `table`: no chart. `chart`: a day table + * becomes how often each range came up (a histogram); any other table, or a + * day table with too few values, keeps the chart the server picks. The model + * then reads the summary of what is shown. + */ +function withResultView( + table: CoachResultTable, + view: "table" | "chart" | undefined, + locale: Locale, +): CoachResultTable { + if (view === "table") return { ...table, chart: null, chartKind: null }; + if (view === "chart") { + const { t } = getServerTranslator(locale); + const distribution = buildDistributionTable(table, { + localeTag: resolveIntlLocale(locale), + title: t(COACH_RESULT_TITLE_KEYS.distribution, { + metric: t(coachDomainLabelKey(table.source.domain)), + }), + range: t(COACH_RESULT_COLUMN_KEYS.range), + count: t(COACH_RESULT_COLUMN_KEYS.count), + bin: (from, to, unit) => + t(COACH_RESULT_UI_KEYS.histogramBin, { from, to, unit }), + }); + if (distribution) return distribution; + } + const chart = deriveChartSpec(table); + return { ...table, chart, chartKind: chart?.kind ?? null }; +} + /** * `show_result` — an earlier table of THIS conversation, shown again. The * name resolves only against the tables the turn's own conversation holds @@ -850,14 +891,16 @@ async function showResult( } const ref = turn.refs.next(); if (!ref) return { present: false, reason: "result_limit" }; - const table: CoachResultTable = { - ...entry, - ref, - displayed: false, - chart: null, - chartKind: null, - reusedFrom: { messageId: target.messageId, ref: target.ref }, - }; + const table = withResultView( + { + ...entry, + ref, + displayed: false, + reusedFrom: { messageId: target.messageId, ref: target.ref }, + }, + parsed.data.view, + turn.locale, + ); return { present: true, resultRef: ref, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 03b9b4ab3..af8603f07 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -141,8 +141,13 @@ function stepRecorder(emitter: TurnEmitter): { }; } -/** v1.39.4 — a projected table with the chart the server chose for it. */ +/** + * v1.39.4 — a projected table with the chart the server chose for it. A + * table shown again already carries the chart its `show_result` view asked + * for, and keeps it. + */ function withChart(table: CoachResultTable): CoachResultTable { + if (table.reusedFrom) return table; const chart = deriveChartSpec(table); return { ...table, chart, chartKind: chart?.kind ?? null }; }