From b5d33502dc828499bc771247ac4f02eeb78c4e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 12:34:07 +0200 Subject: [PATCH] feat(coach): show result tables as charts with a chart/table toggle A table the answer referenced opens under the prose as its chart; the others sit under Data used in the evidence disclosure and open as tables. The plot is one image whose name points at the table view, two series carry legend buttons, and the chart loads through the shared chart runtime. A metric chart token for a metric a shown table already covers is dropped. --- e2e/coach-results.spec.ts | 368 ++++++++++++ eslint.config.mjs | 1 + src/components/charts/chart-runtime.ts | 1 + .../__tests__/coach-results.test.tsx | 220 +++++++ .../insights/coach-panel/chat-bubble.tsx | 36 +- .../insights/coach-panel/coach-results.tsx | 166 +++++- .../insights/coach-panel/result-chart.tsx | 563 ++++++++++++++++++ .../insights/coach-panel/result-table.tsx | 11 +- 8 files changed, 1351 insertions(+), 15 deletions(-) create mode 100644 e2e/coach-results.spec.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-results.test.tsx create mode 100644 src/components/insights/coach-panel/result-chart.tsx 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}