mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
This commit is contained in:
1 parent
c4bf5a916d
commit
b5d33502dc
8 files changed
+1351
-15
No files matched your search
@@ -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`);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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": [
|
||||
|
||||
@@ -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";
|
||||
@@ -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> = {}): 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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function results(live: Table[], section: "displayed" | "dataUsed") {
|
||||
return render(
|
||||
<CoachResults
|
||||
conversationId="c1"
|
||||
messageId="m1"
|
||||
metas={[]}
|
||||
live={live}
|
||||
section={section}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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(/<figure[^>]*aria-labelledby="([^"]+)"/);
|
||||
expect(html).toMatch(/<figcaption[\s\S]*Blood pressure by day/);
|
||||
expect(html).not.toContain('data-slot="coach-result-table"');
|
||||
});
|
||||
|
||||
it("marks the chart segment as the pressed one", () => {
|
||||
const html = results([table()], "displayed");
|
||||
const chartButton = html.match(
|
||||
/<button[^>]*data-slot="coach-result-view-chart"[^>]*>/,
|
||||
)?.[0];
|
||||
const tableButton = html.match(
|
||||
/<button[^>]*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(
|
||||
<CoachResultChart result={table()} otherLabel="Other" label={label} />,
|
||||
);
|
||||
expect(html).toMatch(new RegExp(`<div role="img" aria-label="${label}"`));
|
||||
});
|
||||
|
||||
it("gives two series legend buttons outside the image", () => {
|
||||
const html = render(
|
||||
<CoachResultChart result={table()} otherLabel="Other" label={label} />,
|
||||
);
|
||||
const legend = html.match(
|
||||
/<div[^>]*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(
|
||||
<CoachResultChart
|
||||
result={table({
|
||||
chart: { kind: "line", x: "day", series: ["systolic"] },
|
||||
})}
|
||||
otherLabel="Other"
|
||||
label={label}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("coach-result-chart-legend");
|
||||
});
|
||||
|
||||
it("draws nothing without a spec", () => {
|
||||
const html = render(
|
||||
<CoachResultChart
|
||||
result={table({ chart: null })}
|
||||
otherLabel="Other"
|
||||
label={label}
|
||||
/>,
|
||||
);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -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<string>,
|
||||
): 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:<TYPE>` 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. */}
|
||||
<CoachMethodLine method={method} />
|
||||
{/* v1.39.4 — the tables the answer read but did not point at. */}
|
||||
{dataUsedCount > 0 && (
|
||||
<CoachResults
|
||||
conversationId={conversationId ?? null}
|
||||
messageId={messageId ?? null}
|
||||
metas={metricSource?.results ?? []}
|
||||
live={results}
|
||||
section="dataUsed"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -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: () => (
|
||||
<ChartSkeleton mini className="border-0 bg-transparent p-0" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
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<Pick<CoachResultMeta, "displayed">>,
|
||||
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 (
|
||||
<div ref={ref} data-slot="coach-results" className="flex flex-col gap-3">
|
||||
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 = (
|
||||
<div
|
||||
ref={section === "displayed" ? ref : undefined}
|
||||
data-slot="coach-results"
|
||||
data-section={section}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{tables.map((table) => (
|
||||
<CoachResultTable key={table.ref} result={table} />
|
||||
<CoachResultView
|
||||
key={table.ref}
|
||||
result={table}
|
||||
chartFirst={section === "displayed"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (section === "displayed") return list;
|
||||
return (
|
||||
<div ref={ref} data-slot="coach-data-used" className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t(COACH_RESULT_UI_KEYS.dataUsed, { count: expected })}
|
||||
</p>
|
||||
{list}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<ResultView>(
|
||||
hasChart && chartFirst ? "chart" : "table",
|
||||
);
|
||||
if (!hasChart) return <CoachResultTable result={result} />;
|
||||
|
||||
const toggle = (
|
||||
<ViewToggle<ResultView>
|
||||
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 <CoachResultTable result={result} toolbar={toggle} />;
|
||||
}
|
||||
return (
|
||||
<figure
|
||||
data-slot="coach-result-chart"
|
||||
data-ref={result.ref}
|
||||
data-chart-kind={result.chart?.kind}
|
||||
aria-labelledby={titleId}
|
||||
className="bg-card m-0 flex flex-col gap-2 rounded-lg border px-3 pt-2 pb-3"
|
||||
>
|
||||
<figcaption className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
id={titleId}
|
||||
className="text-foreground min-w-0 flex-1 pt-1.5 text-sm font-medium"
|
||||
>
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="shrink-0">{toggle}</span>
|
||||
</figcaption>
|
||||
<ChartErrorBoundary>
|
||||
<CoachResultChartLazy
|
||||
result={result}
|
||||
otherLabel={t(COACH_RESULT_UI_KEYS.other)}
|
||||
label={t(COACH_RESULT_UI_KEYS.chartSummary, {
|
||||
title: result.title,
|
||||
})}
|
||||
/>
|
||||
</ChartErrorBoundary>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -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 <HistogramChart result={result} spec={spec} label={label} />;
|
||||
}
|
||||
if (spec.kind === "bar" && spec.orientation === "horizontal") {
|
||||
return (
|
||||
<CategoryBarChart
|
||||
result={result}
|
||||
spec={spec}
|
||||
otherLabel={otherLabel}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesChart
|
||||
result={result}
|
||||
spec={spec}
|
||||
otherLabel={otherLabel}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ height }}
|
||||
className="w-full"
|
||||
data-slot="coach-result-chart-plot"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Line and vertical bars ─────────────────────────────────────────────────
|
||||
|
||||
function SeriesChart({
|
||||
result,
|
||||
spec,
|
||||
otherLabel,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "line" | "bar" }>;
|
||||
otherLabel: string;
|
||||
label: string;
|
||||
}) {
|
||||
const periodLabel = usePeriodLabel();
|
||||
const valueFormat = useValueFormat();
|
||||
const [hidden, setHidden] = useState<ReadonlySet<string>>(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<Point[]>(() => {
|
||||
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 <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
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 <RichChartTooltip active label={tickLabel(point.x)} rows={rows} />;
|
||||
};
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
tickFormatter={tickLabel}
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
domain={spec.kind === "bar" ? [0, "auto"] : ["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip content={tooltip} cursor={{ fill: "var(--muted)" }} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{spec.series.length > 1 ? (
|
||||
<SeriesLegend
|
||||
series={spec.series.map((key) => ({
|
||||
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}
|
||||
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
|
||||
{spec.kind === "line" ? (
|
||||
<LineChart
|
||||
accessibilityLayer={false}
|
||||
data={points}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
{axes}
|
||||
{visible.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={byKey.get(key)?.label ?? key}
|
||||
stroke={colorOf(key)}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2.5, strokeWidth: 0, fill: colorOf(key) }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={points}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
{axes}
|
||||
{visible.map((key) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={byKey.get(key)?.label ?? key}
|
||||
fill={colorOf(key)}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</PlotBand>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesLegend({
|
||||
series,
|
||||
hidden,
|
||||
onToggle,
|
||||
}: {
|
||||
series: Array<{ key: string; label: string }>;
|
||||
hidden: ReadonlySet<string>;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-result-chart-legend"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
{series.map(({ key, label }, index) => {
|
||||
const shown = !hidden.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
onClick={() => onToggle(key)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 text-xs transition-colors outline-none focus-visible:ring-2 motion-reduce:transition-none sm:min-h-8",
|
||||
shown
|
||||
? "border-border text-foreground"
|
||||
: "border-border/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
!shown && "opacity-40",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal category bars ───────────────────────────────────────────────
|
||||
|
||||
function CategoryBarChart({
|
||||
result,
|
||||
spec,
|
||||
otherLabel,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "bar" }>;
|
||||
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 (
|
||||
<PlotBand height={height} label={label}>
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={rows}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={96}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={(props) => {
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { label: string; value: number } }>
|
||||
| undefined;
|
||||
const row = payload?.[0]?.payload;
|
||||
if (!props.active || !row) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={row.label}
|
||||
rows={[
|
||||
{
|
||||
name: column?.label ?? spec.series[0],
|
||||
value: valueFormat(row.value, column),
|
||||
color: SERIES_COLORS[0],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={SERIES_COLORS[0]}
|
||||
radius={[0, 2, 2, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</BarChart>
|
||||
</PlotBand>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Histogram ──────────────────────────────────────────────────────────────
|
||||
|
||||
function HistogramChart({
|
||||
result,
|
||||
spec,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "histogram" }>;
|
||||
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 (
|
||||
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={data}
|
||||
barCategoryGap={1}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="from"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={(props) => {
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { x: string; count: number } }>
|
||||
| undefined;
|
||||
const bin = payload?.[0]?.payload;
|
||||
if (!props.active || !bin) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={bin.x}
|
||||
rows={[
|
||||
{
|
||||
name: countColumn?.label ?? "",
|
||||
value: valueFormat(bin.count, countColumn),
|
||||
color: SERIES_COLORS[0],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill={SERIES_COLORS[0]}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</BarChart>
|
||||
</PlotBand>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<CopyTableButton grid={grid} caption={result.title} />
|
||||
{toolbar}
|
||||
</span>
|
||||
</span>
|
||||
</caption>
|
||||
|
||||
Reference in new issue
Block a user