mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'coach/wp3-charts' into release/v1.39.4
This commit is contained in:
commit
e7f3385374
14 files changed
+2490
-32
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>
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* The chart a result table gets. Pure and deterministic: every rule of the
|
||||
* heuristic, and every way a table ends up with no chart.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
CoachResultCell,
|
||||
CoachResultColumn,
|
||||
CoachResultTable,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
import {
|
||||
CATEGORY_TOP_N,
|
||||
HISTOGRAM_MAX_BINS,
|
||||
HISTOGRAM_MIN_BINS,
|
||||
METRIC_AGGREGATION_KIND,
|
||||
buildDistributionTable,
|
||||
buildHistogram,
|
||||
chartCategoryRows,
|
||||
deriveChartSpec,
|
||||
type DistributionLabels,
|
||||
} from "../chart-spec";
|
||||
|
||||
const DAY: CoachResultColumn = {
|
||||
key: "day",
|
||||
kind: "period",
|
||||
labelKey: "coach.result.column.day",
|
||||
label: "Day",
|
||||
};
|
||||
const READINGS: CoachResultColumn = {
|
||||
key: "readings",
|
||||
kind: "count",
|
||||
labelKey: "coach.result.column.readings",
|
||||
label: "Readings",
|
||||
};
|
||||
|
||||
function valueColumn(
|
||||
key: string,
|
||||
unit?: string,
|
||||
kind: "number" | "count" = "number",
|
||||
): CoachResultColumn {
|
||||
return {
|
||||
key,
|
||||
kind,
|
||||
labelKey: `coach.result.column.${key}`,
|
||||
label: key,
|
||||
...(unit ? { unit } : {}),
|
||||
decimals: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function dayKey(index: number): string {
|
||||
const date = new Date(Date.UTC(2026, 0, 1 + index));
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function timeSeries(args: {
|
||||
domain: CoachStepDomain;
|
||||
columns: CoachResultColumn[];
|
||||
rows: CoachResultCell[][];
|
||||
granularity?: "day" | "week" | "month";
|
||||
}): CoachResultTable {
|
||||
return {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: args.domain,
|
||||
window: "last90days",
|
||||
period: "current",
|
||||
granularity: args.granularity ?? "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byDay",
|
||||
title: "By day",
|
||||
rowCount: args.rows.length,
|
||||
chartKind: null,
|
||||
displayed: true,
|
||||
columns: args.columns,
|
||||
rows: args.rows,
|
||||
truncated: false,
|
||||
chart: null,
|
||||
};
|
||||
}
|
||||
|
||||
function daySeries(
|
||||
domain: CoachStepDomain,
|
||||
values: Array<number | null>,
|
||||
unit?: string,
|
||||
): CoachResultTable {
|
||||
return timeSeries({
|
||||
domain,
|
||||
columns: [DAY, valueColumn("value", unit), READINGS],
|
||||
rows: values.map((value, index) => [
|
||||
dayKey(index),
|
||||
value,
|
||||
value === null ? null : 1,
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
function categories(
|
||||
counts: Array<number | null>,
|
||||
names?: string[],
|
||||
): CoachResultTable {
|
||||
return {
|
||||
ref: "r2",
|
||||
source: {
|
||||
tool: "get_workouts",
|
||||
domain: "workouts",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
},
|
||||
shape: "categoryCounts",
|
||||
titleKey: "coach.result.title.workoutsBySport",
|
||||
title: "Workouts by sport",
|
||||
rowCount: counts.length,
|
||||
chartKind: null,
|
||||
displayed: true,
|
||||
columns: [
|
||||
{
|
||||
key: "sport",
|
||||
kind: "category",
|
||||
labelKey: "coach.result.column.sport",
|
||||
label: "Sport",
|
||||
},
|
||||
valueColumn("sessions", undefined, "count"),
|
||||
valueColumn("duration", "min"),
|
||||
],
|
||||
rows: counts.map((count, index) => [
|
||||
names?.[index] ?? `sport-${index + 1}`,
|
||||
count,
|
||||
count === null ? null : count * 30,
|
||||
]),
|
||||
truncated: false,
|
||||
chart: null,
|
||||
};
|
||||
}
|
||||
|
||||
const LABELS: DistributionLabels = {
|
||||
localeTag: "en-US",
|
||||
title: "Pulse: how often each range came up",
|
||||
range: "Range",
|
||||
count: "Times",
|
||||
bin: (from, to, unit) => `${from}–${to} ${unit}`,
|
||||
};
|
||||
|
||||
describe("deriveChartSpec: the row floor and ceiling", () => {
|
||||
it("draws nothing for a single row", () => {
|
||||
expect(deriveChartSpec(daySeries("pulse", [62]))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for 401 rows", () => {
|
||||
const values = Array.from({ length: 401 }, (_, i) => 60 + (i % 10));
|
||||
expect(deriveChartSpec(daySeries("pulse", values))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws 400 rows", () => {
|
||||
const values = Array.from({ length: 400 }, (_, i) => 60 + (i % 10));
|
||||
expect(deriveChartSpec(daySeries("pulse", values))?.kind).toBe("line");
|
||||
});
|
||||
|
||||
it("draws nothing when no column is numeric", () => {
|
||||
const table = timeSeries({
|
||||
domain: "pulse",
|
||||
columns: [DAY, { ...valueColumn("note"), kind: "category" }],
|
||||
rows: [
|
||||
[dayKey(0), "a"],
|
||||
[dayKey(1), "b"],
|
||||
],
|
||||
});
|
||||
expect(deriveChartSpec(table)).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing when every period is empty", () => {
|
||||
expect(deriveChartSpec(daySeries("pulse", [null, null, null]))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for a time series without a period column", () => {
|
||||
const table = timeSeries({
|
||||
domain: "pulse",
|
||||
columns: [valueColumn("value", "bpm")],
|
||||
rows: [[60], [61]],
|
||||
});
|
||||
expect(deriveChartSpec(table)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveChartSpec: time series", () => {
|
||||
it("draws a level metric as a line over the period column", () => {
|
||||
expect(
|
||||
deriveChartSpec(daySeries("weight", [80, 80.4, 79.9], "kg")),
|
||||
).toEqual({ kind: "line", x: "day", series: ["value"] });
|
||||
});
|
||||
|
||||
it("draws a total as vertical bars", () => {
|
||||
expect(
|
||||
deriveChartSpec(daySeries("steps", [8000, 12000, 5400], "steps")),
|
||||
).toEqual({
|
||||
kind: "bar",
|
||||
x: "day",
|
||||
series: ["value"],
|
||||
orientation: "vertical",
|
||||
});
|
||||
});
|
||||
|
||||
it("draws blood pressure as two series in one unit", () => {
|
||||
const table = timeSeries({
|
||||
domain: "bp",
|
||||
columns: [
|
||||
DAY,
|
||||
valueColumn("systolic", "mmHg"),
|
||||
valueColumn("diastolic", "mmHg"),
|
||||
READINGS,
|
||||
],
|
||||
rows: [
|
||||
[dayKey(0), 128, 82, 2],
|
||||
[dayKey(1), 124, 80, 1],
|
||||
],
|
||||
});
|
||||
expect(deriveChartSpec(table)).toEqual({
|
||||
kind: "line",
|
||||
x: "day",
|
||||
series: ["systolic", "diastolic"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mixed units off one chart: the first series alone", () => {
|
||||
const table = timeSeries({
|
||||
domain: "weight",
|
||||
columns: [
|
||||
DAY,
|
||||
valueColumn("value", "kg"),
|
||||
valueColumn("fat", "%"),
|
||||
READINGS,
|
||||
],
|
||||
rows: [
|
||||
[dayKey(0), 80, 22],
|
||||
[dayKey(1), 79.5, 21.8],
|
||||
],
|
||||
});
|
||||
expect(deriveChartSpec(table)).toEqual({
|
||||
kind: "line",
|
||||
x: "day",
|
||||
series: ["value"],
|
||||
});
|
||||
});
|
||||
|
||||
it("draws at most two series, the first compatible pair", () => {
|
||||
const table = timeSeries({
|
||||
domain: "hrv",
|
||||
columns: [
|
||||
DAY,
|
||||
valueColumn("sdnn", "ms"),
|
||||
valueColumn("other", "%"),
|
||||
valueColumn("rmssd", "ms"),
|
||||
valueColumn("third", "ms"),
|
||||
],
|
||||
rows: [
|
||||
[dayKey(0), 40, 1, 30, 20],
|
||||
[dayKey(1), 42, 2, 31, 21],
|
||||
],
|
||||
});
|
||||
expect(deriveChartSpec(table)).toEqual({
|
||||
kind: "line",
|
||||
x: "day",
|
||||
series: ["sdnn", "rmssd"],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the reading count off the chart", () => {
|
||||
const spec = deriveChartSpec(daySeries("pulse", [60, 62], "bpm"));
|
||||
expect(spec && "series" in spec ? spec.series : []).not.toContain(
|
||||
"readings",
|
||||
);
|
||||
});
|
||||
|
||||
it("draws a single reading as a bar, not a lone dot", () => {
|
||||
expect(
|
||||
deriveChartSpec(daySeries("pulse", [null, 62, null], "bpm")),
|
||||
).toEqual({
|
||||
kind: "bar",
|
||||
x: "day",
|
||||
series: ["value"],
|
||||
orientation: "vertical",
|
||||
});
|
||||
});
|
||||
|
||||
it("places every scope source as a level or a total", () => {
|
||||
expect(METRIC_AGGREGATION_KIND.steps).toBe("total");
|
||||
expect(METRIC_AGGREGATION_KIND.daylight).toBe("total");
|
||||
expect(METRIC_AGGREGATION_KIND.sleep).toBe("level");
|
||||
expect(METRIC_AGGREGATION_KIND.mood).toBe("level");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveChartSpec: category counts", () => {
|
||||
it("draws up to six categories as vertical bars of the count column", () => {
|
||||
expect(deriveChartSpec(categories([3, 5, 1, 2, 4, 6]))).toEqual({
|
||||
kind: "bar",
|
||||
x: "sport",
|
||||
series: ["sessions"],
|
||||
orientation: "vertical",
|
||||
});
|
||||
});
|
||||
|
||||
it("turns horizontal above six categories", () => {
|
||||
const spec = deriveChartSpec(categories([3, 5, 1, 2, 4, 6, 7]));
|
||||
expect(spec).toMatchObject({ kind: "bar", orientation: "horizontal" });
|
||||
});
|
||||
|
||||
it("draws nothing for a fractional count", () => {
|
||||
expect(deriveChartSpec(categories([3, 1.5]))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for a negative count", () => {
|
||||
expect(deriveChartSpec(categories([3, -1]))).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing without a count", () => {
|
||||
expect(deriveChartSpec(categories([null, null]))).toBeNull();
|
||||
});
|
||||
|
||||
it("folds everything past the top eight into one other bar", () => {
|
||||
const counts = [1, 9, 2, 8, 3, 7, 4, 6, 5, 10, 1];
|
||||
const table = categories(counts);
|
||||
const spec = deriveChartSpec(table);
|
||||
if (spec?.kind !== "bar") throw new Error("expected a bar chart");
|
||||
const rows = chartCategoryRows(table, spec, "Other");
|
||||
expect(rows).toHaveLength(CATEGORY_TOP_N + 1);
|
||||
expect(rows.slice(0, 3).map((row) => row.value)).toEqual([10, 9, 8]);
|
||||
expect(rows[CATEGORY_TOP_N]).toEqual({
|
||||
label: "Other",
|
||||
value: 2 + 1 + 1,
|
||||
other: true,
|
||||
});
|
||||
const total = counts.reduce((sum, value) => sum + value, 0);
|
||||
expect(rows.reduce((sum, row) => sum + row.value, 0)).toBe(total);
|
||||
});
|
||||
|
||||
it("adds no other bar when every category fits", () => {
|
||||
const table = categories([2, 1, 3]);
|
||||
const spec = deriveChartSpec(table);
|
||||
if (spec?.kind !== "bar") throw new Error("expected a bar chart");
|
||||
expect(
|
||||
chartCategoryRows(table, spec, "Other").some((row) => row.other),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveChartSpec: shapes without a chart of their own", () => {
|
||||
it("draws nothing for a list of single values (lab results)", () => {
|
||||
const table: CoachResultTable = {
|
||||
...categories([1, 2]),
|
||||
shape: "single",
|
||||
};
|
||||
expect(deriveChartSpec(table)).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing for a distribution without its bins", () => {
|
||||
const table: CoachResultTable = {
|
||||
...categories([1, 2]),
|
||||
shape: "distribution",
|
||||
chart: null,
|
||||
};
|
||||
expect(deriveChartSpec(table)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHistogram", () => {
|
||||
const spread = (from: number, step: number, n: number) =>
|
||||
Array.from({ length: n }, (_, i) => from + (i % 7) * step);
|
||||
|
||||
it("needs at least ten values", () => {
|
||||
expect(buildHistogram(spread(60, 3, 9), "pulse", "bpm")).toBeNull();
|
||||
expect(buildHistogram(spread(60, 3, 10), "pulse", "bpm")).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bp", "mmHg", 110, 5],
|
||||
["pulse", "bpm", 55, 5],
|
||||
["weight", "kg", 79, 0.5],
|
||||
["sleep", "min", 360, 30],
|
||||
["glucose", "mg/dL", 85, 10],
|
||||
["steps", "steps", 4000, 1000],
|
||||
] as const)("bins %s in its fixed width", (domain, unit, base, width) => {
|
||||
const values = Array.from({ length: 12 }, (_, i) => base + i * width * 0.7);
|
||||
const bins = buildHistogram(values, domain, unit);
|
||||
if (!bins) throw new Error("expected bins");
|
||||
for (const bin of bins) expect(bin.to - bin.from).toBeCloseTo(width, 9);
|
||||
expect(bins[0].from % width).toBeCloseTo(0, 9);
|
||||
});
|
||||
|
||||
it("counts every value exactly once, the highest in the last bin", () => {
|
||||
const values = [120, 121, 124.9, 125, 126, 130, 131, 140, 140, 118];
|
||||
const bins = buildHistogram(values, "bp", "mmHg");
|
||||
if (!bins) throw new Error("expected bins");
|
||||
expect(bins.reduce((sum, bin) => sum + bin.count, 0)).toBe(values.length);
|
||||
expect(bins[0]).toEqual({ from: 115, to: 120, count: 1 });
|
||||
expect(bins.find((bin) => bin.from === 120)?.count).toBe(3);
|
||||
expect(bins.find((bin) => bin.from === 125)?.count).toBe(2);
|
||||
expect(bins[bins.length - 1]).toEqual({ from: 140, to: 145, count: 2 });
|
||||
});
|
||||
|
||||
it("keeps an empty bin between readings as a zero", () => {
|
||||
const values = [...Array(5).fill(60), ...Array(5).fill(80)];
|
||||
const bins = buildHistogram(values, "pulse", "bpm");
|
||||
if (!bins) throw new Error("expected bins");
|
||||
expect(bins.map((bin) => bin.count)).toEqual([5, 0, 0, 0, 5]);
|
||||
});
|
||||
|
||||
it("widens a fixed width until at most twenty bins remain", () => {
|
||||
const values = Array.from({ length: 30 }, (_, i) => 1000 + i * 1000);
|
||||
const bins = buildHistogram(values, "steps", "steps");
|
||||
if (!bins) throw new Error("expected bins");
|
||||
expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS);
|
||||
expect(bins[0].to - bins[0].from).toBe(2000);
|
||||
});
|
||||
|
||||
it("ignores the glucose width when the table is not in mg/dL", () => {
|
||||
const values = Array.from({ length: 20 }, (_, i) => 4.5 + i * 0.2);
|
||||
const bins = buildHistogram(values, "glucose", "mmol/L");
|
||||
if (!bins) throw new Error("expected bins");
|
||||
expect(bins[0].to - bins[0].from).toBeLessThan(10);
|
||||
expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS);
|
||||
});
|
||||
|
||||
it("holds Freedman–Diaconis to five to twenty bins", () => {
|
||||
// A tight cluster with one far outlier: raw FD would give hundreds.
|
||||
const wide = [...Array.from({ length: 200 }, (_, i) => 50 + (i % 5)), 900];
|
||||
const wideBins = buildHistogram(wide, "vo2_max", undefined);
|
||||
// Evenly spread values over a short range: raw FD would give two.
|
||||
const narrow = Array.from({ length: 12 }, (_, i) => 30 + i);
|
||||
const narrowBins = buildHistogram(narrow, "vo2_max", undefined);
|
||||
for (const bins of [wideBins, narrowBins]) {
|
||||
if (!bins) throw new Error("expected bins");
|
||||
expect(bins.length).toBeGreaterThanOrEqual(HISTOGRAM_MIN_BINS);
|
||||
expect(bins.length).toBeLessThanOrEqual(HISTOGRAM_MAX_BINS);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts identical values in one bin", () => {
|
||||
const bins = buildHistogram(Array(12).fill(37.2), "body_temp", "°C");
|
||||
expect(bins).toHaveLength(1);
|
||||
expect(bins?.[0].count).toBe(12);
|
||||
});
|
||||
|
||||
it("is deterministic", () => {
|
||||
const values = Array.from({ length: 50 }, (_, i) => 60 + ((i * 7) % 23));
|
||||
expect(buildHistogram(values, "pulse", "bpm")).toEqual(
|
||||
buildHistogram([...values].reverse(), "pulse", "bpm"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDistributionTable", () => {
|
||||
const values = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, null, 63];
|
||||
|
||||
it("turns a day table into counts per range with a histogram", () => {
|
||||
const table = buildDistributionTable(
|
||||
daySeries("pulse", values, "bpm"),
|
||||
LABELS,
|
||||
);
|
||||
if (!table) throw new Error("expected a distribution");
|
||||
expect(table.shape).toBe("distribution");
|
||||
expect(table.titleKey).toBe("coach.result.title.distribution");
|
||||
expect(table.columns.map((column) => column.key)).toEqual([
|
||||
"range",
|
||||
"count",
|
||||
]);
|
||||
expect(table.rows[0]).toEqual(["55–60 bpm", 1]);
|
||||
expect(table.chart).toMatchObject({
|
||||
kind: "histogram",
|
||||
column: "value",
|
||||
unit: "bpm",
|
||||
});
|
||||
expect(table.chartKind).toBe("histogram");
|
||||
expect(table.rowCount).toBe(table.rows.length);
|
||||
const counted = table.rows.reduce(
|
||||
(sum, row) => sum + (row[1] as number),
|
||||
0,
|
||||
);
|
||||
expect(counted).toBe(values.filter((v) => v !== null).length);
|
||||
// The stored histogram survives a second pass through the heuristic.
|
||||
expect(deriveChartSpec(table)).toEqual(table.chart);
|
||||
});
|
||||
|
||||
it("formats bin edges in the reply's locale", () => {
|
||||
const weights = Array.from({ length: 12 }, (_, i) => 80 + i * 0.3);
|
||||
const table = buildDistributionTable(daySeries("weight", weights, "kg"), {
|
||||
...LABELS,
|
||||
localeTag: "de-DE",
|
||||
});
|
||||
expect(table?.rows[0]?.[0]).toBe("80,0–80,5 kg");
|
||||
});
|
||||
|
||||
it("builds nothing from fewer than ten values", () => {
|
||||
expect(
|
||||
buildDistributionTable(daySeries("pulse", [60, 61, 62], "bpm"), LABELS),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("builds nothing from a table by week", () => {
|
||||
const table = timeSeries({
|
||||
domain: "pulse",
|
||||
granularity: "week",
|
||||
columns: [DAY, valueColumn("value", "bpm")],
|
||||
rows: values.map((value, index) => [dayKey(index * 7), value]),
|
||||
});
|
||||
expect(buildDistributionTable(table, LABELS)).toBeNull();
|
||||
});
|
||||
|
||||
it("builds nothing from category counts", () => {
|
||||
expect(
|
||||
buildDistributionTable(
|
||||
categories([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
|
||||
LABELS,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("names the column it binned when the table has two", () => {
|
||||
const table = timeSeries({
|
||||
domain: "bp",
|
||||
columns: [
|
||||
DAY,
|
||||
{ ...valueColumn("systolic", "mmHg"), label: "Systolic" },
|
||||
{ ...valueColumn("diastolic", "mmHg"), label: "Diastolic" },
|
||||
],
|
||||
rows: Array.from({ length: 12 }, (_, i) => [dayKey(i), 120 + i, 80]),
|
||||
});
|
||||
const distribution = buildDistributionTable(table, LABELS);
|
||||
expect(distribution?.columns[0]?.label).toBe("Range (Systolic)");
|
||||
expect(distribution?.chart).toMatchObject({ column: "systolic" });
|
||||
});
|
||||
});
|
||||
@@ -275,6 +275,70 @@ describe("show_result", () => {
|
||||
expect(readDailySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the server's chart when no view is asked for", async () => {
|
||||
readMessageResults.mockResolvedValue([STORED]);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "show_result",
|
||||
rawArguments: JSON.stringify({ ref: "m2.r1" }),
|
||||
turn: turn(),
|
||||
});
|
||||
expect(result.table).toMatchObject({
|
||||
shape: "timeSeries",
|
||||
chart: { kind: "line", x: "day", series: ["value"] },
|
||||
chartKind: "line",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the table without a chart for view table", async () => {
|
||||
readMessageResults.mockResolvedValue([STORED]);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "show_result",
|
||||
rawArguments: JSON.stringify({ ref: "m2.r1", view: "table" }),
|
||||
turn: turn(),
|
||||
});
|
||||
expect(result.table).toMatchObject({
|
||||
rows: STORED.rows,
|
||||
chart: null,
|
||||
chartKind: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a day table as counts per range for view chart", async () => {
|
||||
const days = [58, 61, 62, 64, 66, 66, 67, 70, 71, 74, 63, 65];
|
||||
readMessageResults.mockResolvedValue([
|
||||
{
|
||||
...STORED,
|
||||
rowCount: days.length,
|
||||
rows: days.map((value, index) => [
|
||||
`2026-09-${String(10 + index).padStart(2, "0")}`,
|
||||
value,
|
||||
]),
|
||||
},
|
||||
]);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "show_result",
|
||||
rawArguments: JSON.stringify({ ref: "m2.r1", view: "chart" }),
|
||||
turn: turn(),
|
||||
});
|
||||
expect(result.table).toMatchObject({
|
||||
ref: "r1",
|
||||
shape: "distribution",
|
||||
titleKey: "coach.result.title.distribution",
|
||||
chartKind: "histogram",
|
||||
chart: { kind: "histogram", column: "value", unit: "bpm" },
|
||||
reusedFrom: { messageId: "m-a2", ref: "r1" },
|
||||
});
|
||||
expect(result.table?.rows[0]).toEqual(["55–60 bpm", 1]);
|
||||
// The model reads the counts it can talk about, not the days.
|
||||
expect(result.data).toMatchObject({
|
||||
shape: "distribution",
|
||||
stats: { count: { total: days.length } },
|
||||
});
|
||||
});
|
||||
|
||||
it("answers unknown_result for a name this conversation does not hold", async () => {
|
||||
// A name that another conversation of the same account might hold.
|
||||
const result = await executeCoachTool({
|
||||
|
||||
@@ -4,12 +4,484 @@
|
||||
* category counts, a histogram for a distribution, or none (table only)
|
||||
* when the table is too short, too long or has no numeric column.
|
||||
*
|
||||
* Pure. Not built yet: every table stays table-only.
|
||||
* Pure and deterministic: the same table always gets the same chart, and the
|
||||
* model never picks one. The model can only ask, through `show_result`'s
|
||||
* `view`, for an earlier day table as a distribution (`buildDistributionTable`)
|
||||
* or for the table without a chart.
|
||||
*
|
||||
* Imports types only, so the client can use the category view below
|
||||
* (`chartCategoryRows`) without pulling anything server-side in.
|
||||
*/
|
||||
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
|
||||
import type {
|
||||
CoachChartSpec,
|
||||
CoachResultCell,
|
||||
CoachResultColumn,
|
||||
CoachResultTable,
|
||||
CoachScopeSource,
|
||||
CoachStepDomain,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
export function deriveChartSpec(
|
||||
_table: Omit<CoachResultTable, "chart" | "chartKind">,
|
||||
): CoachChartSpec | null {
|
||||
return null;
|
||||
/** Fewer rows than this: table only. */
|
||||
export const CHART_MIN_ROWS = 2;
|
||||
/** More rows than this: table only (the table's own row cap). */
|
||||
export const CHART_MAX_ROWS = 400;
|
||||
/** Category bars turn horizontal above this many categories. */
|
||||
export const CATEGORY_VERTICAL_MAX = 6;
|
||||
/** Category bars show this many categories; the rest fold into "other". */
|
||||
export const CATEGORY_TOP_N = 8;
|
||||
/** A histogram needs at least this many values. */
|
||||
export const HISTOGRAM_MIN_VALUES = 10;
|
||||
/** Bin-count bounds for a histogram without a fixed width. */
|
||||
export const HISTOGRAM_MIN_BINS = 5;
|
||||
export const HISTOGRAM_MAX_BINS = 20;
|
||||
|
||||
/**
|
||||
* How a metric's periods add up: a level (a reading that stands for the
|
||||
* period: blood pressure, weight, sleep) is drawn as a line; a total (a sum
|
||||
* over the period: steps, energy) as bars. A record over every source, so a
|
||||
* new source does not compile until it is placed.
|
||||
*/
|
||||
export const METRIC_AGGREGATION_KIND: Readonly<
|
||||
Record<CoachScopeSource, "level" | "total">
|
||||
> = {
|
||||
bp: "level",
|
||||
weight: "level",
|
||||
pulse: "level",
|
||||
mood: "level",
|
||||
compliance: "level",
|
||||
hrv: "level",
|
||||
sleep: "level",
|
||||
resting_hr: "level",
|
||||
steps: "total",
|
||||
active_energy: "total",
|
||||
flights: "total",
|
||||
distance: "total",
|
||||
vo2_max: "level",
|
||||
body_temp: "level",
|
||||
walking_hr: "level",
|
||||
respiratory_rate: "level",
|
||||
spo2: "level",
|
||||
pulse_wave_velocity: "level",
|
||||
vascular_age: "level",
|
||||
body_fat: "level",
|
||||
fat_mass: "level",
|
||||
fat_free_mass: "level",
|
||||
muscle_mass: "level",
|
||||
lean_body_mass: "level",
|
||||
bone_mass: "level",
|
||||
total_body_water: "level",
|
||||
bmi: "level",
|
||||
visceral_fat: "level",
|
||||
glucose: "level",
|
||||
walking_steadiness: "level",
|
||||
walking_asymmetry: "level",
|
||||
walking_double_support: "level",
|
||||
walking_step_length: "level",
|
||||
walking_speed: "level",
|
||||
audio_env: "level",
|
||||
audio_headphone: "level",
|
||||
audio_event: "total",
|
||||
daylight: "total",
|
||||
skin_temp: "level",
|
||||
workouts: "total",
|
||||
};
|
||||
|
||||
function aggregationKind(domain: CoachStepDomain): "level" | "total" {
|
||||
return (
|
||||
(METRIC_AGGREGATION_KIND as Partial<Record<string, "level" | "total">>)[
|
||||
domain
|
||||
] ?? "level"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed bin widths for the metrics people ask "how often" about, in the
|
||||
* unit the table carries. Glucose only when the table is in mg/dL; any
|
||||
* other metric or unit falls back to the Freedman–Diaconis rule.
|
||||
*/
|
||||
const NICE_BIN_WIDTH: Readonly<
|
||||
Partial<Record<CoachScopeSource, { width: number; unit?: string }>>
|
||||
> = {
|
||||
bp: { width: 5, unit: "mmHg" },
|
||||
pulse: { width: 5, unit: "bpm" },
|
||||
resting_hr: { width: 5, unit: "bpm" },
|
||||
walking_hr: { width: 5, unit: "bpm" },
|
||||
weight: { width: 0.5, unit: "kg" },
|
||||
sleep: { width: 30, unit: "min" },
|
||||
glucose: { width: 10, unit: "mg/dL" },
|
||||
steps: { width: 1000 },
|
||||
};
|
||||
|
||||
type ChartInput = Omit<CoachResultTable, "chart" | "chartKind"> & {
|
||||
chart?: CoachChartSpec | null;
|
||||
};
|
||||
|
||||
function isNumericCell(cell: CoachResultCell | undefined): cell is number {
|
||||
return typeof cell === "number" && Number.isFinite(cell);
|
||||
}
|
||||
|
||||
function columnIndex(table: ChartInput, key: string): number {
|
||||
return table.columns.findIndex((column) => column.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The series a time series is drawn with: its value columns, and only the
|
||||
* first one plus one partner in the same unit — two axes with different
|
||||
* units on one chart read as a comparison they are not.
|
||||
*/
|
||||
function timeSeriesColumns(table: ChartInput): CoachResultColumn[] {
|
||||
const values = table.columns.filter((column) => column.kind === "number");
|
||||
const candidates =
|
||||
values.length > 0
|
||||
? values
|
||||
: table.columns.filter((column) => column.kind === "count");
|
||||
const [first, ...rest] = candidates;
|
||||
if (!first) return [];
|
||||
const partner = rest.find((column) => column.unit === first.unit);
|
||||
return partner ? [first, partner] : [first];
|
||||
}
|
||||
|
||||
function timeSeriesSpec(table: ChartInput): CoachChartSpec | null {
|
||||
const x = table.columns.find((column) => column.kind === "period");
|
||||
if (!x) return null;
|
||||
const series = timeSeriesColumns(table);
|
||||
if (series.length === 0) return null;
|
||||
const indexes = series.map((column) => columnIndex(table, column.key));
|
||||
const points = table.rows.filter((row) =>
|
||||
indexes.some((index) => isNumericCell(row[index])),
|
||||
).length;
|
||||
if (points === 0) return null;
|
||||
const keys = series.map((column) => column.key);
|
||||
// A line needs two points; one reading is a single bar, not a dot.
|
||||
if (aggregationKind(table.source.domain) === "total" || points < 2) {
|
||||
return { kind: "bar", x: x.key, series: keys, orientation: "vertical" };
|
||||
}
|
||||
return { kind: "line", x: x.key, series: keys };
|
||||
}
|
||||
|
||||
function categorySpec(table: ChartInput): CoachChartSpec | null {
|
||||
const x = table.columns.find((column) => column.kind === "category");
|
||||
const counts = table.columns.find((column) => column.kind === "count");
|
||||
if (!x || !counts) return null;
|
||||
const index = columnIndex(table, counts.key);
|
||||
let any = false;
|
||||
for (const row of table.rows) {
|
||||
const cell = row[index];
|
||||
if (cell === null || cell === undefined) continue;
|
||||
if (!isNumericCell(cell) || cell < 0 || !Number.isInteger(cell)) {
|
||||
return null;
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
if (!any) return null;
|
||||
return {
|
||||
kind: "bar",
|
||||
x: x.key,
|
||||
series: [counts.key],
|
||||
orientation:
|
||||
table.rows.length > CATEGORY_VERTICAL_MAX ? "horizontal" : "vertical",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart for a table, or null for table only.
|
||||
*
|
||||
* 1. Fewer than 2 or more than 400 rows, or no numeric column: none.
|
||||
* 2. A time series: a line for a level metric, vertical bars for a total;
|
||||
* at most two series, and only in one unit.
|
||||
* 3. Category counts: bars, horizontal above six categories (the top eight
|
||||
* and "other" are drawn; `chartCategoryRows`). Counts must be
|
||||
* non-negative whole numbers.
|
||||
* 4. A distribution keeps the histogram it was built with
|
||||
* (`buildDistributionTable`); its bins cannot be read back from the rows.
|
||||
* 5. Anything else (a list of lab results): none.
|
||||
*/
|
||||
export function deriveChartSpec(table: ChartInput): CoachChartSpec | null {
|
||||
if (
|
||||
table.rows.length < CHART_MIN_ROWS ||
|
||||
table.rows.length > CHART_MAX_ROWS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
switch (table.shape) {
|
||||
case "timeSeries":
|
||||
return timeSeriesSpec(table);
|
||||
case "categoryCounts":
|
||||
return categorySpec(table);
|
||||
case "distribution":
|
||||
return table.chart?.kind === "histogram" ? table.chart : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Histogram ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HistogramBin {
|
||||
from: number;
|
||||
to: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Strips float noise from a bin edge (0.1 + 0.2 and the like). */
|
||||
function clean(value: number): number {
|
||||
return Number(value.toFixed(10));
|
||||
}
|
||||
|
||||
/** Linear-interpolated quantile of a sorted list. */
|
||||
function quantile(sorted: number[], q: number): number {
|
||||
const position = (sorted.length - 1) * q;
|
||||
const lower = Math.floor(position);
|
||||
const upper = Math.ceil(position);
|
||||
const low = sorted[lower] ?? 0;
|
||||
const high = sorted[upper] ?? low;
|
||||
return low + (high - low) * (position - lower);
|
||||
}
|
||||
|
||||
/** The "nice" widths: 1, 2, 2.5 and 5 times a power of ten. */
|
||||
const NICE_STEPS = [1, 2, 2.5, 5];
|
||||
|
||||
function niceAtLeast(value: number): number {
|
||||
const power = 10 ** Math.floor(Math.log10(value));
|
||||
for (const step of NICE_STEPS) {
|
||||
if (step * power >= value - 1e-12) return clean(step * power);
|
||||
}
|
||||
return clean(10 * power);
|
||||
}
|
||||
|
||||
function niceBelow(width: number): number {
|
||||
const power = 10 ** Math.floor(Math.log10(width));
|
||||
const mantissa = clean(width / power);
|
||||
const index = NICE_STEPS.findIndex((step) => step >= mantissa);
|
||||
if (index > 0) return clean(NICE_STEPS[index - 1] * power);
|
||||
return clean(5 * (power / 10));
|
||||
}
|
||||
|
||||
function niceAbove(width: number): number {
|
||||
return niceAtLeast(clean(width * 1.000001));
|
||||
}
|
||||
|
||||
function binCount(min: number, max: number, width: number): number {
|
||||
const start = Math.floor(clean(min / width)) * width;
|
||||
return Math.floor(clean((max - start) / width)) + 1;
|
||||
}
|
||||
|
||||
/** Freedman–Diaconis on a nice width, held to 5..20 bins. */
|
||||
function freedmanDiaconisWidth(sorted: number[]): number {
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
const range = max - min;
|
||||
const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25);
|
||||
const raw =
|
||||
iqr > 0 ? (2 * iqr) / Math.cbrt(sorted.length) : range / HISTOGRAM_MIN_BINS;
|
||||
let width = niceAtLeast(raw);
|
||||
for (let guard = 0; guard < 64; guard += 1) {
|
||||
const bins = binCount(min, max, width);
|
||||
if (bins > HISTOGRAM_MAX_BINS) width = niceAbove(width);
|
||||
else if (bins < HISTOGRAM_MIN_BINS && width > 1e-9)
|
||||
width = niceBelow(width);
|
||||
else break;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bins for a set of values: a fixed width for the metrics that have one
|
||||
* (doubled until at most 20 bins remain), Freedman–Diaconis otherwise.
|
||||
* Bins run from the one holding the lowest value to the one holding the
|
||||
* highest; empty bins in between stay, with a count of 0. Each bin holds
|
||||
* `from <= v < to`. Fewer than ten values: null, not a histogram of noise.
|
||||
*/
|
||||
export function buildHistogram(
|
||||
values: readonly number[],
|
||||
domain: CoachStepDomain,
|
||||
unit: string | undefined,
|
||||
): HistogramBin[] | null {
|
||||
const finite = values.filter((value) => Number.isFinite(value));
|
||||
if (finite.length < HISTOGRAM_MIN_VALUES) return null;
|
||||
const sorted = [...finite].sort((a, b) => a - b);
|
||||
const min = sorted[0];
|
||||
const max = sorted[sorted.length - 1];
|
||||
|
||||
const fixed = (
|
||||
NICE_BIN_WIDTH as Partial<Record<string, { width: number; unit?: string }>>
|
||||
)[domain];
|
||||
let width: number;
|
||||
if (fixed && (fixed.unit === undefined || fixed.unit === unit)) {
|
||||
width = fixed.width;
|
||||
while (binCount(min, max, width) > HISTOGRAM_MAX_BINS) width *= 2;
|
||||
} else if (max === min) {
|
||||
width = niceAtLeast(Math.max(Math.abs(min), 1) / 10);
|
||||
} else {
|
||||
width = freedmanDiaconisWidth(sorted);
|
||||
}
|
||||
|
||||
const start = clean(Math.floor(clean(min / width)) * width);
|
||||
const count = binCount(min, max, width);
|
||||
const bins: HistogramBin[] = Array.from({ length: count }, (_, i) => ({
|
||||
from: clean(start + i * width),
|
||||
to: clean(start + (i + 1) * width),
|
||||
count: 0,
|
||||
}));
|
||||
for (const value of sorted) {
|
||||
const index = Math.min(
|
||||
count - 1,
|
||||
Math.max(0, Math.floor(clean((value - start) / width))),
|
||||
);
|
||||
bins[index].count += 1;
|
||||
}
|
||||
return bins;
|
||||
}
|
||||
|
||||
/** Decimals a bin edge needs to be told apart from its neighbour. */
|
||||
export function binDecimals(bins: readonly HistogramBin[]): number {
|
||||
let decimals = 0;
|
||||
for (const bin of bins) {
|
||||
for (const edge of [bin.from, bin.to]) {
|
||||
const text = String(edge);
|
||||
const dot = text.indexOf(".");
|
||||
if (dot >= 0) decimals = Math.max(decimals, text.length - dot - 1);
|
||||
}
|
||||
}
|
||||
return Math.min(decimals, 3);
|
||||
}
|
||||
|
||||
/** The words a distribution table is labelled with, in the reply's locale. */
|
||||
export interface DistributionLabels {
|
||||
/** BCP 47 tag the bin edges are formatted in. */
|
||||
localeTag: string;
|
||||
title: string;
|
||||
/** Heading of the range column. */
|
||||
range: string;
|
||||
/** Heading of the count column. */
|
||||
count: string;
|
||||
/** One bin's label, e.g. `120–125 mmHg`. */
|
||||
bin: (from: string, to: string, unit: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A day table as a distribution: how many days fell into each range of its
|
||||
* first value column. Only a day table (one value per day) qualifies, and
|
||||
* only with at least ten values; otherwise null and the table keeps its
|
||||
* own chart.
|
||||
*/
|
||||
export function buildDistributionTable(
|
||||
table: CoachResultTable,
|
||||
labels: DistributionLabels,
|
||||
): CoachResultTable | null {
|
||||
if (table.shape !== "timeSeries" || table.source.granularity !== "day") {
|
||||
return null;
|
||||
}
|
||||
const column = table.columns.find((candidate) => candidate.kind === "number");
|
||||
if (!column) return null;
|
||||
const index = columnIndex(table, column.key);
|
||||
const values = table.rows
|
||||
.map((row) => row[index])
|
||||
.filter((cell): cell is number => isNumericCell(cell));
|
||||
const bins = buildHistogram(values, table.source.domain, column.unit);
|
||||
if (!bins) return null;
|
||||
|
||||
const decimals = binDecimals(bins);
|
||||
const format = new Intl.NumberFormat(labels.localeTag, {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: true,
|
||||
});
|
||||
const unit = column.unit ?? "";
|
||||
const valueColumns = table.columns.filter(
|
||||
(candidate) => candidate.kind === "number",
|
||||
);
|
||||
const rows: CoachResultCell[][] = bins.map((bin) => [
|
||||
labels.bin(format.format(bin.from), format.format(bin.to), unit).trim(),
|
||||
bin.count,
|
||||
]);
|
||||
const chart: CoachChartSpec = {
|
||||
kind: "histogram",
|
||||
column: column.key,
|
||||
...(column.unit ? { unit: column.unit } : {}),
|
||||
bins,
|
||||
};
|
||||
const withChart = rows.length >= CHART_MIN_ROWS ? chart : null;
|
||||
return {
|
||||
ref: table.ref,
|
||||
source: table.source,
|
||||
shape: "distribution",
|
||||
titleKey: "coach.result.title.distribution",
|
||||
title: labels.title,
|
||||
rowCount: rows.length,
|
||||
chartKind: withChart ? "histogram" : null,
|
||||
displayed: table.displayed,
|
||||
...(table.reusedFrom ? { reusedFrom: table.reusedFrom } : {}),
|
||||
columns: [
|
||||
{
|
||||
key: "range",
|
||||
kind: "category",
|
||||
labelKey: "coach.result.column.range",
|
||||
label:
|
||||
valueColumns.length > 1
|
||||
? `${labels.range} (${column.label})`
|
||||
: labels.range,
|
||||
},
|
||||
{
|
||||
key: "count",
|
||||
kind: "count",
|
||||
labelKey: "coach.result.column.count",
|
||||
label: labels.count,
|
||||
},
|
||||
],
|
||||
rows,
|
||||
truncated: false,
|
||||
chart: withChart,
|
||||
};
|
||||
}
|
||||
|
||||
// ── The category view (client and server) ─────────────────────────────────
|
||||
|
||||
export interface ChartCategoryRow {
|
||||
label: string;
|
||||
value: number;
|
||||
/** True for the folded "other" bar. */
|
||||
other: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bars a category chart draws: the eight largest categories, largest
|
||||
* first, and the rest summed into one "other" bar. A category without a
|
||||
* count is left out of the bars (the table still lists it).
|
||||
*/
|
||||
export function chartCategoryRows(
|
||||
table: Pick<CoachResultTable, "columns" | "rows">,
|
||||
spec: Extract<CoachChartSpec, { kind: "bar" }>,
|
||||
otherLabel: string,
|
||||
): ChartCategoryRow[] {
|
||||
const xIndex = table.columns.findIndex((column) => column.key === spec.x);
|
||||
const yIndex = table.columns.findIndex(
|
||||
(column) => column.key === spec.series[0],
|
||||
);
|
||||
if (xIndex < 0 || yIndex < 0) return [];
|
||||
const entries = table.rows
|
||||
.map((row, order) => ({
|
||||
label: String(row[xIndex] ?? ""),
|
||||
value: row[yIndex],
|
||||
order,
|
||||
}))
|
||||
.filter((entry): entry is { label: string; value: number; order: number } =>
|
||||
isNumericCell(entry.value),
|
||||
)
|
||||
.sort((a, b) => b.value - a.value || a.order - b.order);
|
||||
const top = entries.slice(0, CATEGORY_TOP_N).map((entry) => ({
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
other: false,
|
||||
}));
|
||||
const rest = entries.slice(CATEGORY_TOP_N);
|
||||
if (rest.length === 0) return top;
|
||||
return [
|
||||
...top,
|
||||
{
|
||||
label: otherLabel,
|
||||
value: rest.reduce((sum, entry) => sum + entry.value, 0),
|
||||
other: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -394,7 +394,8 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [
|
||||
view: {
|
||||
type: "string",
|
||||
enum: ["table", "chart"],
|
||||
description: "How the person asked to see it, when they said.",
|
||||
description:
|
||||
"How the person asked to see it, when they said. table: without a chart. chart: with one; an earlier table by day is shown as how often its values fell into each range.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -70,8 +70,18 @@ import {
|
||||
type ResultRefAllocator,
|
||||
} from "@/lib/ai/coach/results/refs";
|
||||
import { isCoachDomainWithheld } from "@/lib/ai/coach/results/domain-module";
|
||||
import { COACH_RESULT_TITLE_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
COACH_RESULT_COLUMN_KEYS,
|
||||
COACH_RESULT_TITLE_KEYS,
|
||||
COACH_RESULT_UI_KEYS,
|
||||
coachDomainLabelKey,
|
||||
} from "@/lib/ai/coach/dialog-keys";
|
||||
import {
|
||||
buildDistributionTable,
|
||||
deriveChartSpec,
|
||||
} from "@/lib/ai/coach/results/chart-spec";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { DEFAULT_WINDOW } from "@/lib/ai/coach/snapshot-cache";
|
||||
import {
|
||||
getMetricSeriesArgsSchema,
|
||||
@@ -805,6 +815,37 @@ async function getMetricTable(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A table shown again, with the chart its `view` asks for. No view: the chart
|
||||
* the server picks for any table. `table`: no chart. `chart`: a day table
|
||||
* becomes how often each range came up (a histogram); any other table, or a
|
||||
* day table with too few values, keeps the chart the server picks. The model
|
||||
* then reads the summary of what is shown.
|
||||
*/
|
||||
function withResultView(
|
||||
table: CoachResultTable,
|
||||
view: "table" | "chart" | undefined,
|
||||
locale: Locale,
|
||||
): CoachResultTable {
|
||||
if (view === "table") return { ...table, chart: null, chartKind: null };
|
||||
if (view === "chart") {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const distribution = buildDistributionTable(table, {
|
||||
localeTag: resolveIntlLocale(locale),
|
||||
title: t(COACH_RESULT_TITLE_KEYS.distribution, {
|
||||
metric: t(coachDomainLabelKey(table.source.domain)),
|
||||
}),
|
||||
range: t(COACH_RESULT_COLUMN_KEYS.range),
|
||||
count: t(COACH_RESULT_COLUMN_KEYS.count),
|
||||
bin: (from, to, unit) =>
|
||||
t(COACH_RESULT_UI_KEYS.histogramBin, { from, to, unit }),
|
||||
});
|
||||
if (distribution) return distribution;
|
||||
}
|
||||
const chart = deriveChartSpec(table);
|
||||
return { ...table, chart, chartKind: chart?.kind ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* `show_result` — an earlier table of THIS conversation, shown again. The
|
||||
* name resolves only against the tables the turn's own conversation holds
|
||||
@@ -850,14 +891,16 @@ async function showResult(
|
||||
}
|
||||
const ref = turn.refs.next();
|
||||
if (!ref) return { present: false, reason: "result_limit" };
|
||||
const table: CoachResultTable = {
|
||||
...entry,
|
||||
ref,
|
||||
displayed: false,
|
||||
chart: null,
|
||||
chartKind: null,
|
||||
reusedFrom: { messageId: target.messageId, ref: target.ref },
|
||||
};
|
||||
const table = withResultView(
|
||||
{
|
||||
...entry,
|
||||
ref,
|
||||
displayed: false,
|
||||
reusedFrom: { messageId: target.messageId, ref: target.ref },
|
||||
},
|
||||
parsed.data.view,
|
||||
turn.locale,
|
||||
);
|
||||
return {
|
||||
present: true,
|
||||
resultRef: ref,
|
||||
|
||||
@@ -141,8 +141,13 @@ function stepRecorder(emitter: TurnEmitter): {
|
||||
};
|
||||
}
|
||||
|
||||
/** v1.39.4 — a projected table with the chart the server chose for it. */
|
||||
/**
|
||||
* v1.39.4 — a projected table with the chart the server chose for it. A
|
||||
* table shown again already carries the chart its `show_result` view asked
|
||||
* for, and keeps it.
|
||||
*/
|
||||
function withChart(table: CoachResultTable): CoachResultTable {
|
||||
if (table.reusedFrom) return table;
|
||||
const chart = deriveChartSpec(table);
|
||||
return { ...table, chart, chartKind: chart?.kind ?? null };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user