Merge branch 'coach/wp3-charts' into release/v1.39.4

This commit is contained in:
Marc-André Bombeck committed 2026-09-27 12:47:28 +02:00
commit e7f3385374
14 files changed
+2490 -32

No files matched your search

+368
View File
@@ -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`);
});
}
});
+1
View File
@@ -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": [
+1
View File
@@ -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({
+478 -6
View File
@@ -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,
},
];
}
+2 -1
View File
@@ -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.",
},
},
},
+52 -9
View File
@@ -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,
+6 -1
View File
@@ -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 };
}