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}