mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(coach): show result tables as charts with a chart/table toggle
A table the answer referenced opens under the prose as its chart; the others sit under Data used in the evidence disclosure and open as tables. The plot is one image whose name points at the table view, two series carry legend buttons, and the chart loads through the shared chart runtime. A metric chart token for a metric a shown table already covers is dropped.
This commit is contained in:
1 parent
c4bf5a916d
commit
b5d33502dc
8 files changed
+1351
-15
No files matched your search
@@ -47,3 +47,4 @@ export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
|
||||
export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
|
||||
export { WorkoutHrChart } from "./workout-hr-chart";
|
||||
export { WorkoutElevationChart } from "./workout-elevation-chart";
|
||||
export { CoachResultChart } from "@/components/insights/coach-panel/result-chart";
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* v1.39.4 — where a reply's tables go and how they first show: the ones the
|
||||
* answer referenced under the prose, the rest under "Data used", a chart
|
||||
* first only for a referenced table that has one, and the chart's plot named
|
||||
* as an image that points at the table. SSR harness, like the other Coach
|
||||
* panel tests; the toggle itself is exercised in the browser suite.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { CoachResultTable as Table } from "@/lib/ai/coach/types";
|
||||
|
||||
import { CoachResults, countResultsInSection } from "../coach-results";
|
||||
import { CoachResultChart } from "../result-chart";
|
||||
import { selectCoachChartTokens } from "../chat-bubble";
|
||||
|
||||
function table(overrides: Partial<Table> = {}): Table {
|
||||
return {
|
||||
ref: "r1",
|
||||
source: {
|
||||
tool: "get_metric_table",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
period: "current",
|
||||
granularity: "day",
|
||||
},
|
||||
shape: "timeSeries",
|
||||
titleKey: "coach.result.title.byDay",
|
||||
title: "Blood pressure by day",
|
||||
rowCount: 2,
|
||||
chartKind: "line",
|
||||
displayed: true,
|
||||
columns: [
|
||||
{
|
||||
key: "day",
|
||||
kind: "period",
|
||||
labelKey: "coach.result.column.day",
|
||||
label: "Day",
|
||||
},
|
||||
{
|
||||
key: "systolic",
|
||||
kind: "number",
|
||||
labelKey: "coach.result.column.systolic",
|
||||
label: "Systolic",
|
||||
unit: "mmHg",
|
||||
decimals: 0,
|
||||
},
|
||||
{
|
||||
key: "diastolic",
|
||||
kind: "number",
|
||||
labelKey: "coach.result.column.diastolic",
|
||||
label: "Diastolic",
|
||||
unit: "mmHg",
|
||||
decimals: 0,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
["2026-09-01", 124, 81],
|
||||
["2026-09-02", 121, 79],
|
||||
],
|
||||
truncated: false,
|
||||
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function results(live: Table[], section: "displayed" | "dataUsed") {
|
||||
return render(
|
||||
<CoachResults
|
||||
conversationId="c1"
|
||||
messageId="m1"
|
||||
metas={[]}
|
||||
live={live}
|
||||
section={section}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CoachResults", () => {
|
||||
it("opens a referenced table with a chart on the chart", () => {
|
||||
const html = results([table()], "displayed");
|
||||
expect(html).toContain('data-slot="coach-result-chart"');
|
||||
expect(html).toMatch(/<figure[^>]*aria-labelledby="([^"]+)"/);
|
||||
expect(html).toMatch(/<figcaption[\s\S]*Blood pressure by day/);
|
||||
expect(html).not.toContain('data-slot="coach-result-table"');
|
||||
});
|
||||
|
||||
it("marks the chart segment as the pressed one", () => {
|
||||
const html = results([table()], "displayed");
|
||||
const chartButton = html.match(
|
||||
/<button[^>]*data-slot="coach-result-view-chart"[^>]*>/,
|
||||
)?.[0];
|
||||
const tableButton = html.match(
|
||||
/<button[^>]*data-slot="coach-result-view-table"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(chartButton).toContain('aria-pressed="true"');
|
||||
expect(tableButton).toContain('aria-pressed="false"');
|
||||
});
|
||||
|
||||
it("opens a table without a chart as a table, with no toggle", () => {
|
||||
const html = results(
|
||||
[table({ chart: null, chartKind: null })],
|
||||
"displayed",
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-result-table"');
|
||||
expect(html).not.toContain("coach-result-view-");
|
||||
});
|
||||
|
||||
it("keeps an unreferenced table out of the answer", () => {
|
||||
const html = results([table({ displayed: false })], "displayed");
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("lists an unreferenced table under Data used, as a table first", () => {
|
||||
const html = results(
|
||||
[table(), table({ ref: "r2", displayed: false })],
|
||||
"dataUsed",
|
||||
);
|
||||
expect(html).toContain("Data used (1)");
|
||||
expect(html).toContain('data-ref="r2"');
|
||||
expect(html).not.toContain('data-ref="r1"');
|
||||
expect(html).toContain('data-slot="coach-result-table"');
|
||||
// The chart stays one tap away.
|
||||
expect(html).toContain('data-slot="coach-result-view-chart"');
|
||||
});
|
||||
|
||||
it("renders no Data used block when the answer referenced every table", () => {
|
||||
expect(results([table()], "dataUsed")).toBe("");
|
||||
});
|
||||
|
||||
it("counts a section from the metadata alone", () => {
|
||||
const metas = [
|
||||
{ displayed: true },
|
||||
{ displayed: false },
|
||||
{ displayed: false },
|
||||
];
|
||||
expect(countResultsInSection(metas, "displayed")).toBe(1);
|
||||
expect(countResultsInSection(metas, "dataUsed")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CoachResultChart", () => {
|
||||
const label =
|
||||
"Blood pressure by day, shown as a chart. The table view lists every value.";
|
||||
|
||||
it("names the plot as one image that points at the table", () => {
|
||||
const html = render(
|
||||
<CoachResultChart result={table()} otherLabel="Other" label={label} />,
|
||||
);
|
||||
expect(html).toMatch(new RegExp(`<div role="img" aria-label="${label}"`));
|
||||
});
|
||||
|
||||
it("gives two series legend buttons outside the image", () => {
|
||||
const html = render(
|
||||
<CoachResultChart result={table()} otherLabel="Other" label={label} />,
|
||||
);
|
||||
const legend = html.match(
|
||||
/<div[^>]*data-slot="coach-result-chart-legend"[\s\S]*?<\/div>/,
|
||||
)?.[0];
|
||||
expect(legend?.match(/aria-pressed="true"/g)).toHaveLength(2);
|
||||
expect(legend).toContain("Systolic");
|
||||
expect(legend).toContain("Diastolic");
|
||||
expect(html.indexOf("coach-result-chart-legend")).toBeLessThan(
|
||||
html.indexOf('role="img"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("draws one series without a legend", () => {
|
||||
const html = render(
|
||||
<CoachResultChart
|
||||
result={table({
|
||||
chart: { kind: "line", x: "day", series: ["systolic"] },
|
||||
})}
|
||||
otherLabel="Other"
|
||||
label={label}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("coach-result-chart-legend");
|
||||
});
|
||||
|
||||
it("draws nothing without a spec", () => {
|
||||
const html = render(
|
||||
<CoachResultChart
|
||||
result={table({ chart: null })}
|
||||
otherLabel="Other"
|
||||
label={label}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectCoachChartTokens beside result tables", () => {
|
||||
it("drops a metric token whose metric a shown table already covers", () => {
|
||||
expect(
|
||||
selectCoachChartTokens(
|
||||
"metric:BLOOD_PRESSURE_SYS and metric:WEIGHT",
|
||||
["bp", "weight"],
|
||||
new Set(["bp"]),
|
||||
),
|
||||
).toEqual(["metric:WEIGHT"]);
|
||||
});
|
||||
|
||||
it("keeps the token when no table is shown", () => {
|
||||
expect(
|
||||
selectCoachChartTokens("metric:WEIGHT", ["weight"], new Set()),
|
||||
).toEqual(["metric:WEIGHT"]);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
import { MessageTokenFooter } from "./message-token-footer";
|
||||
import { CoachTurnSteps } from "./turn-steps";
|
||||
import { CoachResults } from "./coach-results";
|
||||
import { CoachResults, countResultsInSection } from "./coach-results";
|
||||
import { CoachMethodLine } from "./method-line";
|
||||
import {
|
||||
COACH_ICON_BUTTON,
|
||||
@@ -204,10 +204,15 @@ const MAX_COACH_CHARTS = 2;
|
||||
* allowlist-parsed, intersected with the turn's grounded provenance topics,
|
||||
* de-duplicated by metric, and capped. Exported for unit tests so the
|
||||
* grounding contract is pinned without standing up the chart component.
|
||||
*
|
||||
* v1.39.4 — `shownDomains` are the domains of the result tables the answer
|
||||
* shows under the prose; a token for one of them is dropped, since the
|
||||
* turn's own table (and its chart) already shows that metric.
|
||||
*/
|
||||
export function selectCoachChartTokens(
|
||||
content: string,
|
||||
metrics: readonly CoachProvenanceMetric[] | undefined,
|
||||
shownDomains?: ReadonlySet<string>,
|
||||
): ChartToken[] {
|
||||
const grounded = new Set(metrics ?? []);
|
||||
const out: ChartToken[] = [];
|
||||
@@ -215,6 +220,7 @@ export function selectCoachChartTokens(
|
||||
for (const token of parseChartTokens(content)) {
|
||||
const topic = CHART_TOKEN_PROVENANCE[token];
|
||||
if (!topic || !grounded.has(topic)) continue;
|
||||
if (shownDomains?.has(topic)) continue;
|
||||
const metric = tokenToMetric(token);
|
||||
if (seen.has(metric)) continue;
|
||||
seen.add(metric);
|
||||
@@ -676,16 +682,29 @@ function ChatBubbleImpl({
|
||||
((metricSource.metrics?.length ?? 0) > 0 ||
|
||||
(metricSource.windows?.length ?? 0) > 0);
|
||||
const method = metricSource?.method ?? null;
|
||||
const hasProvenance = hasChips || keyValues.length > 0 || method !== null;
|
||||
const hasEvidence = hasChips || keyValues.length > 0 || method !== null;
|
||||
|
||||
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
|
||||
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
|
||||
// while streaming / in-flight / errored / on a refusal; a no-op when no
|
||||
// grounded token is present (provider-agnostic — reads the inline token).
|
||||
// v1.39.4 — the result tables this answer references and the ones it
|
||||
// only used; the metadata is enough to place and count them.
|
||||
const resultMetas = results?.length ? results : (metricSource?.results ?? []);
|
||||
const shownDomains = new Set(
|
||||
resultMetas
|
||||
.filter((meta) => meta.displayed)
|
||||
.map((meta) => meta.source.domain),
|
||||
);
|
||||
const dataUsedCount =
|
||||
!inProgress && !errorCode
|
||||
? countResultsInSection(resultMetas, "dataUsed")
|
||||
: 0;
|
||||
const chartTokens =
|
||||
!streaming && !inProgress && !errorCode && providerType !== "refusal"
|
||||
? selectCoachChartTokens(content, metricSource?.metrics)
|
||||
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
|
||||
: [];
|
||||
const hasProvenance = hasEvidence || dataUsedCount > 0;
|
||||
|
||||
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
|
||||
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
|
||||
@@ -805,6 +824,7 @@ function ChatBubbleImpl({
|
||||
messageId={messageId ?? null}
|
||||
metas={metricSource?.results ?? []}
|
||||
live={results}
|
||||
section="displayed"
|
||||
/>
|
||||
)}
|
||||
{/* v1.18.6 — a "no provider configured anywhere" turn is a
|
||||
@@ -916,6 +936,16 @@ function ChatBubbleImpl({
|
||||
{/* v1.39.4 — how the answer was worked out, beside the key
|
||||
values it explains. */}
|
||||
<CoachMethodLine method={method} />
|
||||
{/* v1.39.4 — the tables the answer read but did not point at. */}
|
||||
{dataUsedCount > 0 && (
|
||||
<CoachResults
|
||||
conversationId={conversationId ?? null}
|
||||
messageId={messageId ?? null}
|
||||
metas={metricSource?.results ?? []}
|
||||
live={results}
|
||||
section="dataUsed"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -6,15 +6,51 @@
|
||||
* metadata, and the values are fetched when the message scrolls into view
|
||||
* (`useCoachMessageResults`). A withheld table is skipped here; the notice
|
||||
* for it belongs to the table view.
|
||||
*
|
||||
* The bubble renders this twice: the tables the answer referenced
|
||||
* (`section="displayed"`) expanded under the prose, and the rest
|
||||
* (`section="dataUsed"`) under "Data used (n)" inside the evidence
|
||||
* disclosure. Both instances share one cached read.
|
||||
*
|
||||
* A table with a chart gets a chart/table toggle. The chart is the first
|
||||
* view for a referenced table; a table under "Data used" opens as a table.
|
||||
* The choice is local to this render and never stored. The chart sits in a
|
||||
* figure; its plot is one `role="img"` whose name points at the table view
|
||||
* for every value.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ChartLine, Table2 } from "lucide-react";
|
||||
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { ViewToggle } from "@/components/ui/view-toggle";
|
||||
import { useCoachMessageResults } from "@/hooks/use-coach-message-results";
|
||||
import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
|
||||
import type {
|
||||
CoachResultMeta,
|
||||
CoachResultTable as CoachResultTableData,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
import { CoachResultTable } from "./result-table";
|
||||
|
||||
const CoachResultChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.CoachResultChart }),
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<ChartSkeleton mini className="border-0 bg-transparent p-0" />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export type CoachResultsSection = "displayed" | "dataUsed";
|
||||
|
||||
export interface CoachResultsProps {
|
||||
conversationId: string | null;
|
||||
/** The persisted message, once it has an id. */
|
||||
@@ -23,6 +59,18 @@ export interface CoachResultsProps {
|
||||
metas: CoachResultMeta[];
|
||||
/** The tables a live turn streamed; absent on a persisted message. */
|
||||
live?: CoachResultTableData[];
|
||||
/** Which of the message's tables this instance shows. */
|
||||
section: CoachResultsSection;
|
||||
}
|
||||
|
||||
/** How many tables sit in a section, from the metadata alone. */
|
||||
export function countResultsInSection(
|
||||
metas: ReadonlyArray<Pick<CoachResultMeta, "displayed">>,
|
||||
section: CoachResultsSection,
|
||||
): number {
|
||||
return metas.filter((meta) =>
|
||||
section === "displayed" ? meta.displayed : !meta.displayed,
|
||||
).length;
|
||||
}
|
||||
|
||||
export function CoachResults({
|
||||
@@ -30,24 +78,122 @@ export function CoachResults({
|
||||
messageId,
|
||||
metas,
|
||||
live,
|
||||
section,
|
||||
}: CoachResultsProps) {
|
||||
const { t } = useTranslations();
|
||||
const hasLive = (live?.length ?? 0) > 0;
|
||||
const expected = hasLive
|
||||
? countResultsInSection(live ?? [], section)
|
||||
: countResultsInSection(metas, section);
|
||||
const { ref, results } = useCoachMessageResults({
|
||||
conversationId,
|
||||
messageId,
|
||||
enabled: !hasLive && metas.length > 0,
|
||||
enabled: !hasLive && expected > 0,
|
||||
});
|
||||
if (!hasLive && metas.length === 0) return null;
|
||||
const tables: CoachResultTableData[] = hasLive
|
||||
? (live ?? [])
|
||||
: (results ?? []).filter(
|
||||
(entry): entry is CoachResultTableData => !("withheld" in entry),
|
||||
);
|
||||
return (
|
||||
<div ref={ref} data-slot="coach-results" className="flex flex-col gap-3">
|
||||
if (expected === 0) return null;
|
||||
const tables: CoachResultTableData[] = (
|
||||
hasLive
|
||||
? (live ?? [])
|
||||
: (results ?? []).filter(
|
||||
(entry): entry is CoachResultTableData => !("withheld" in entry),
|
||||
)
|
||||
).filter((table) =>
|
||||
section === "displayed" ? table.displayed : !table.displayed,
|
||||
);
|
||||
|
||||
const list = (
|
||||
<div
|
||||
ref={section === "displayed" ? ref : undefined}
|
||||
data-slot="coach-results"
|
||||
data-section={section}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{tables.map((table) => (
|
||||
<CoachResultTable key={table.ref} result={table} />
|
||||
<CoachResultView
|
||||
key={table.ref}
|
||||
result={table}
|
||||
chartFirst={section === "displayed"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
if (section === "displayed") return list;
|
||||
return (
|
||||
<div ref={ref} data-slot="coach-data-used" className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t(COACH_RESULT_UI_KEYS.dataUsed, { count: expected })}
|
||||
</p>
|
||||
{list}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ResultView = "chart" | "table";
|
||||
|
||||
function CoachResultView({
|
||||
result,
|
||||
chartFirst,
|
||||
}: {
|
||||
result: CoachResultTableData;
|
||||
chartFirst: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const titleId = useId();
|
||||
const hasChart = result.chart !== null;
|
||||
const [view, setView] = useState<ResultView>(
|
||||
hasChart && chartFirst ? "chart" : "table",
|
||||
);
|
||||
if (!hasChart) return <CoachResultTable result={result} />;
|
||||
|
||||
const toggle = (
|
||||
<ViewToggle<ResultView>
|
||||
view={view}
|
||||
onChange={setView}
|
||||
groupLabel={t(COACH_RESULT_UI_KEYS.viewLabel)}
|
||||
dataSlotPrefix="coach-result-view"
|
||||
segments={[
|
||||
{
|
||||
value: "chart",
|
||||
label: t(COACH_RESULT_UI_KEYS.viewChart),
|
||||
icon: ChartLine,
|
||||
},
|
||||
{
|
||||
value: "table",
|
||||
label: t(COACH_RESULT_UI_KEYS.viewTable),
|
||||
icon: Table2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
if (view === "table") {
|
||||
return <CoachResultTable result={result} toolbar={toggle} />;
|
||||
}
|
||||
return (
|
||||
<figure
|
||||
data-slot="coach-result-chart"
|
||||
data-ref={result.ref}
|
||||
data-chart-kind={result.chart?.kind}
|
||||
aria-labelledby={titleId}
|
||||
className="bg-card m-0 flex flex-col gap-2 rounded-lg border px-3 pt-2 pb-3"
|
||||
>
|
||||
<figcaption className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
id={titleId}
|
||||
className="text-foreground min-w-0 flex-1 pt-1.5 text-sm font-medium"
|
||||
>
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="shrink-0">{toggle}</span>
|
||||
</figcaption>
|
||||
<ChartErrorBoundary>
|
||||
<CoachResultChartLazy
|
||||
result={result}
|
||||
otherLabel={t(COACH_RESULT_UI_KEYS.other)}
|
||||
label={t(COACH_RESULT_UI_KEYS.chartSummary, {
|
||||
title: result.title,
|
||||
})}
|
||||
/>
|
||||
</ChartErrorBoundary>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.39.4 — the chart of one Coach result table, drawn from the spec the
|
||||
* server chose (`src/lib/ai/coach/results/chart-spec.ts`): a line over the
|
||||
* periods, bars (vertical, or horizontal for many categories), or a
|
||||
* histogram over bins. The component never decides the chart type itself.
|
||||
*
|
||||
* Recessive axes (no axis line, no tick marks, muted ticks), horizontal grid
|
||||
* lines only, the shared rich tooltip, and series colours from
|
||||
* `var(--chart-1..5)` in series order. Two series (blood pressure) get
|
||||
* legend buttons that hide or show a series (`aria-pressed`); the last
|
||||
* visible one cannot be hidden. A period without a reading stays a gap in
|
||||
* the line, and each reading is a dot, so a lone reading is still visible.
|
||||
*
|
||||
* Exported only through `src/components/charts/chart-runtime.ts`; the call
|
||||
* site loads it with `next/dynamic` so recharts stays in its one chunk.
|
||||
*/
|
||||
import { useMemo, useState, type ReactElement } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { RichChartTooltip } from "@/components/charts/chart-tooltip";
|
||||
import { chartCategoryRows } from "@/lib/ai/coach/results/chart-spec";
|
||||
import type {
|
||||
CoachChartSpec,
|
||||
CoachResultColumn,
|
||||
CoachResultTable,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** The chart band's height; matches the mini chart skeleton's box. */
|
||||
export const RESULT_CHART_HEIGHT_PX = 160;
|
||||
/** Row height of a horizontal bar. */
|
||||
const HORIZONTAL_BAR_ROW_PX = 26;
|
||||
|
||||
const SERIES_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
const TICK = { fontSize: 11, fill: "var(--muted-foreground)" };
|
||||
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MONTH_KEY = /^\d{4}-\d{2}$/;
|
||||
|
||||
export interface CoachResultChartProps {
|
||||
result: CoachResultTable;
|
||||
/** Label of the folded bar in a category chart. */
|
||||
otherLabel: string;
|
||||
/** The plot's accessible name; it points at the table for the values. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: string;
|
||||
[series: string]: string | number | null;
|
||||
}
|
||||
|
||||
function usePeriodLabel(): (key: string) => string {
|
||||
const { locale } = useTranslations();
|
||||
const dateFormat = useDateFormatPreference();
|
||||
return useMemo(() => {
|
||||
const fmt = makeBucketLabelFormatters(locale, dateFormat);
|
||||
const month = new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return (key: string) => {
|
||||
if (DAY_KEY.test(key)) {
|
||||
return fmt.dateShortSmart(new Date(`${key}T12:00:00Z`));
|
||||
}
|
||||
if (MONTH_KEY.test(key)) {
|
||||
return month.format(new Date(`${key}-01T12:00:00Z`));
|
||||
}
|
||||
return key;
|
||||
};
|
||||
}, [locale, dateFormat]);
|
||||
}
|
||||
|
||||
function useValueFormat(): (
|
||||
value: number,
|
||||
column: CoachResultColumn | undefined,
|
||||
) => string {
|
||||
const { locale } = useTranslations();
|
||||
const dateFormat = useDateFormatPreference();
|
||||
return useMemo(() => {
|
||||
const fmt = makeBucketLabelFormatters(locale, dateFormat);
|
||||
return (value, column) => {
|
||||
const text =
|
||||
column?.kind === "count"
|
||||
? fmt.integer(value)
|
||||
: fmt.number(value, column?.decimals ?? 1);
|
||||
return column?.unit ? `${text} ${column.unit}` : text;
|
||||
};
|
||||
}, [locale, dateFormat]);
|
||||
}
|
||||
|
||||
export function CoachResultChart({
|
||||
result,
|
||||
otherLabel,
|
||||
label,
|
||||
}: CoachResultChartProps) {
|
||||
const spec = result.chart;
|
||||
if (!spec) return null;
|
||||
if (spec.kind === "histogram") {
|
||||
return <HistogramChart result={result} spec={spec} label={label} />;
|
||||
}
|
||||
if (spec.kind === "bar" && spec.orientation === "horizontal") {
|
||||
return (
|
||||
<CategoryBarChart
|
||||
result={result}
|
||||
spec={spec}
|
||||
otherLabel={otherLabel}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SeriesChart
|
||||
result={result}
|
||||
spec={spec}
|
||||
otherLabel={otherLabel}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plot band: one `role="img"` with the summary as its name. The legend
|
||||
* buttons stay outside it, and recharts' own keyboard layer is off, so the
|
||||
* image holds nothing focusable.
|
||||
*/
|
||||
function PlotBand({
|
||||
height,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
height: number;
|
||||
label: string;
|
||||
children: ReactElement;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={label}
|
||||
style={{ height }}
|
||||
className="w-full"
|
||||
data-slot="coach-result-chart-plot"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{children}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Line and vertical bars ─────────────────────────────────────────────────
|
||||
|
||||
function SeriesChart({
|
||||
result,
|
||||
spec,
|
||||
otherLabel,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "line" | "bar" }>;
|
||||
otherLabel: string;
|
||||
label: string;
|
||||
}) {
|
||||
const periodLabel = usePeriodLabel();
|
||||
const valueFormat = useValueFormat();
|
||||
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
|
||||
const animate = !prefersReducedMotion();
|
||||
const byKey = useMemo(
|
||||
() => new Map(result.columns.map((column) => [column.key, column])),
|
||||
[result.columns],
|
||||
);
|
||||
const xColumn = byKey.get(spec.x);
|
||||
const isPeriod = xColumn?.kind === "period";
|
||||
|
||||
const points = useMemo<Point[]>(() => {
|
||||
if (spec.kind === "bar" && !isPeriod) {
|
||||
return chartCategoryRows(result, spec, otherLabel).map((row) => ({
|
||||
x: row.label,
|
||||
[spec.series[0]]: row.value,
|
||||
}));
|
||||
}
|
||||
const xIndex = result.columns.findIndex((c) => c.key === spec.x);
|
||||
const indexes = spec.series.map((key) =>
|
||||
result.columns.findIndex((c) => c.key === key),
|
||||
);
|
||||
return result.rows.map((row) => {
|
||||
const point: Point = { x: String(row[xIndex] ?? "") };
|
||||
spec.series.forEach((key, i) => {
|
||||
const cell = row[indexes[i]];
|
||||
point[key] = typeof cell === "number" ? cell : null;
|
||||
});
|
||||
return point;
|
||||
});
|
||||
}, [result, spec, isPeriod, otherLabel]);
|
||||
|
||||
const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x);
|
||||
const colorOf = (key: string) =>
|
||||
SERIES_COLORS[spec.series.indexOf(key) % SERIES_COLORS.length];
|
||||
const visible = spec.series.filter((key) => !hidden.has(key));
|
||||
|
||||
const tooltip = (props: {
|
||||
active?: boolean;
|
||||
payload?: ReadonlyArray<{ payload?: Point }>;
|
||||
}) => {
|
||||
const point = props.payload?.[0]?.payload;
|
||||
if (!props.active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rows = visible.flatMap((key) => {
|
||||
const value = point[key];
|
||||
if (typeof value !== "number") return [];
|
||||
const column = byKey.get(key);
|
||||
return [
|
||||
{
|
||||
name: column?.label ?? key,
|
||||
value: valueFormat(value, column),
|
||||
color: colorOf(key),
|
||||
},
|
||||
];
|
||||
});
|
||||
return <RichChartTooltip active label={tickLabel(point.x)} rows={rows} />;
|
||||
};
|
||||
|
||||
const axes = (
|
||||
<>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="x"
|
||||
tickFormatter={tickLabel}
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={40}
|
||||
domain={spec.kind === "bar" ? [0, "auto"] : ["auto", "auto"]}
|
||||
/>
|
||||
<Tooltip content={tooltip} cursor={{ fill: "var(--muted)" }} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{spec.series.length > 1 ? (
|
||||
<SeriesLegend
|
||||
series={spec.series.map((key) => ({
|
||||
key,
|
||||
label: byKey.get(key)?.label ?? key,
|
||||
}))}
|
||||
hidden={hidden}
|
||||
onToggle={(key) =>
|
||||
setHidden((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else if (spec.series.length - next.size > 1) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
|
||||
{spec.kind === "line" ? (
|
||||
<LineChart
|
||||
accessibilityLayer={false}
|
||||
data={points}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
{axes}
|
||||
{visible.map((key) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={byKey.get(key)?.label ?? key}
|
||||
stroke={colorOf(key)}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2.5, strokeWidth: 0, fill: colorOf(key) }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={points}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
{axes}
|
||||
{visible.map((key) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
name={byKey.get(key)?.label ?? key}
|
||||
fill={colorOf(key)}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</PlotBand>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesLegend({
|
||||
series,
|
||||
hidden,
|
||||
onToggle,
|
||||
}: {
|
||||
series: Array<{ key: string; label: string }>;
|
||||
hidden: ReadonlySet<string>;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-result-chart-legend"
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
{series.map(({ key, label }, index) => {
|
||||
const shown = !hidden.has(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
onClick={() => onToggle(key)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 text-xs transition-colors outline-none focus-visible:ring-2 motion-reduce:transition-none sm:min-h-8",
|
||||
shown
|
||||
? "border-border text-foreground"
|
||||
: "border-border/60 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
!shown && "opacity-40",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Horizontal category bars ───────────────────────────────────────────────
|
||||
|
||||
function CategoryBarChart({
|
||||
result,
|
||||
spec,
|
||||
otherLabel,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "bar" }>;
|
||||
otherLabel: string;
|
||||
label: string;
|
||||
}) {
|
||||
const valueFormat = useValueFormat();
|
||||
const animate = !prefersReducedMotion();
|
||||
const column = result.columns.find((c) => c.key === spec.series[0]);
|
||||
const rows = useMemo(
|
||||
() => chartCategoryRows(result, spec, otherLabel),
|
||||
[result, spec, otherLabel],
|
||||
);
|
||||
const height = Math.max(
|
||||
RESULT_CHART_HEIGHT_PX,
|
||||
rows.length * HORIZONTAL_BAR_ROW_PX + 16,
|
||||
);
|
||||
return (
|
||||
<PlotBand height={height} label={label}>
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={rows}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={96}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={(props) => {
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { label: string; value: number } }>
|
||||
| undefined;
|
||||
const row = payload?.[0]?.payload;
|
||||
if (!props.active || !row) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={row.label}
|
||||
rows={[
|
||||
{
|
||||
name: column?.label ?? spec.series[0],
|
||||
value: valueFormat(row.value, column),
|
||||
color: SERIES_COLORS[0],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill={SERIES_COLORS[0]}
|
||||
radius={[0, 2, 2, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</BarChart>
|
||||
</PlotBand>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Histogram ──────────────────────────────────────────────────────────────
|
||||
|
||||
function HistogramChart({
|
||||
result,
|
||||
spec,
|
||||
label,
|
||||
}: {
|
||||
result: CoachResultTable;
|
||||
spec: Extract<CoachChartSpec, { kind: "histogram" }>;
|
||||
label: string;
|
||||
}) {
|
||||
const valueFormat = useValueFormat();
|
||||
const animate = !prefersReducedMotion();
|
||||
const countColumn = result.columns.find((c) => c.kind === "count");
|
||||
const data = useMemo(
|
||||
() =>
|
||||
spec.bins.map((bin, index) => {
|
||||
const label = result.rows[index]?.[0];
|
||||
return {
|
||||
x:
|
||||
typeof label === "string"
|
||||
? label
|
||||
: `${bin.from}–${bin.to}${spec.unit ? ` ${spec.unit}` : ""}`,
|
||||
from: bin.from,
|
||||
count: bin.count,
|
||||
};
|
||||
}),
|
||||
[spec, result.rows],
|
||||
);
|
||||
return (
|
||||
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
|
||||
<BarChart
|
||||
accessibilityLayer={false}
|
||||
data={data}
|
||||
barCategoryGap={1}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="from"
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={16}
|
||||
/>
|
||||
<YAxis
|
||||
tick={TICK}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--muted)" }}
|
||||
content={(props) => {
|
||||
const payload = props.payload as
|
||||
| ReadonlyArray<{ payload?: { x: string; count: number } }>
|
||||
| undefined;
|
||||
const bin = payload?.[0]?.payload;
|
||||
if (!props.active || !bin) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={bin.x}
|
||||
rows={[
|
||||
{
|
||||
name: countColumn?.label ?? "",
|
||||
value: valueFormat(bin.count, countColumn),
|
||||
color: SERIES_COLORS[0],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill={SERIES_COLORS[0]}
|
||||
radius={[2, 2, 0, 0]}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</BarChart>
|
||||
</PlotBand>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export interface CoachResultTableProps {
|
||||
result: CoachResultTableData;
|
||||
/** The method line for this table, shown under the title. */
|
||||
method?: ReactNode;
|
||||
/** Controls shown beside the copy button (the chart/table toggle). */
|
||||
toolbar?: ReactNode;
|
||||
}
|
||||
|
||||
type CellFormatter = (
|
||||
@@ -98,7 +100,11 @@ function headingText(column: CoachResultColumn): string {
|
||||
return column.unit ? `${column.label} (${column.unit})` : column.label;
|
||||
}
|
||||
|
||||
export function CoachResultTable({ result, method }: CoachResultTableProps) {
|
||||
export function CoachResultTable({
|
||||
result,
|
||||
method,
|
||||
toolbar,
|
||||
}: CoachResultTableProps) {
|
||||
const { t } = useTranslations();
|
||||
const formatter = useCellFormatter();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -159,8 +165,9 @@ export function CoachResultTable({ result, method }: CoachResultTableProps) {
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<CopyTableButton grid={grid} caption={result.title} />
|
||||
{toolbar}
|
||||
</span>
|
||||
</span>
|
||||
</caption>
|
||||
|
||||
Reference in new issue
Block a user