From c4bf5a916d95d3cf370a76bfdac329c0cf87a56e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 12:34:06 +0200 Subject: [PATCH 1/2] feat(coach): choose a chart for each result table on the server A table over time is drawn as a line for a level metric and as bars for a total, with at most two series in one unit. Category counts get bars, horizontal above six categories, the top eight and one other. Fewer than two or more than 400 rows, or no numeric column, stay table only. show_result's view now counts: table drops the chart, chart turns an earlier day table into counts per range with a histogram (fixed widths for the common metrics, Freedman-Diaconis otherwise, none below ten values). --- .../results/__tests__/chart-spec.test.ts | 537 ++++++++++++++++++ .../__tests__/executor-results.test.ts | 64 +++ src/lib/ai/coach/results/chart-spec.ts | 484 +++++++++++++++- src/lib/ai/coach/tools/definitions.ts | 3 +- src/lib/ai/coach/tools/executor.ts | 61 +- src/lib/ai/coach/turn/model.ts | 7 +- 6 files changed, 1139 insertions(+), 17 deletions(-) create mode 100644 src/lib/ai/coach/results/__tests__/chart-spec.test.ts diff --git a/src/lib/ai/coach/results/__tests__/chart-spec.test.ts b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts new file mode 100644 index 000000000..33c723f88 --- /dev/null +++ b/src/lib/ai/coach/results/__tests__/chart-spec.test.ts @@ -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, + 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, + 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" }); + }); +}); diff --git a/src/lib/ai/coach/results/__tests__/executor-results.test.ts b/src/lib/ai/coach/results/__tests__/executor-results.test.ts index be0355b7b..2b6b70087 100644 --- a/src/lib/ai/coach/results/__tests__/executor-results.test.ts +++ b/src/lib/ai/coach/results/__tests__/executor-results.test.ts @@ -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({ diff --git a/src/lib/ai/coach/results/chart-spec.ts b/src/lib/ai/coach/results/chart-spec.ts index aa3b6e67e..02151bf10 100644 --- a/src/lib/ai/coach/results/chart-spec.ts +++ b/src/lib/ai/coach/results/chart-spec.ts @@ -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, -): 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 +> = { + 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>)[ + 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> +> = { + 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 & { + 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> + )[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, + spec: Extract, + 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, + }, + ]; } diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index bbe047f9c..0c21f57be 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -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.", }, }, }, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index bd7f46a9f..f08c1785a 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -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, diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 347d75af8..77828d80a 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -132,8 +132,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 }; } From b5d33502dc828499bc771247ac4f02eeb78c4e23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 12:34:07 +0200 Subject: [PATCH 2/2] 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. --- e2e/coach-results.spec.ts | 368 ++++++++++++ eslint.config.mjs | 1 + src/components/charts/chart-runtime.ts | 1 + .../__tests__/coach-results.test.tsx | 220 +++++++ .../insights/coach-panel/chat-bubble.tsx | 36 +- .../insights/coach-panel/coach-results.tsx | 166 +++++- .../insights/coach-panel/result-chart.tsx | 563 ++++++++++++++++++ .../insights/coach-panel/result-table.tsx | 11 +- 8 files changed, 1351 insertions(+), 15 deletions(-) create mode 100644 e2e/coach-results.spec.ts create mode 100644 src/components/insights/coach-panel/__tests__/coach-results.test.tsx create mode 100644 src/components/insights/coach-panel/result-chart.tsx diff --git a/e2e/coach-results.spec.ts b/e2e/coach-results.spec.ts new file mode 100644 index 000000000..a0f4d67af --- /dev/null +++ b/e2e/coach-results.spec.ts @@ -0,0 +1,368 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * The tables and charts under a Coach answer, on a persisted conversation. + * + * Contracts under test: + * + * 1. A table the answer referenced opens under the prose as its chart: a + * figure whose plot is one image named after the table, pointing at + * the table view for every value. + * 2. The chart/table toggle switches to the captioned table and back; the + * pressed segment follows. + * 3. Two series (blood pressure) carry legend buttons that hide and show + * a series; the last visible one stays. + * 4. A table the answer only used sits in the evidence disclosure under + * "Data used (1)", opening as a table, with its chart one tap away. + * 5. axe finds nothing on either view, in the light and the dark theme. + * + * The conversation, its stored tables and the Coach side surfaces are + * mocked; the charts are drawn by the real chart runtime. + */ + +const CONVERSATION_ID = "coach-results-e2e"; +const MESSAGE_ID = "coach-results-e2e-assistant"; + +const BP_VALUES: Array<[number, number] | null> = [ + [128, 84], + [125, 82], + null, + [131, 85], + [122, 80], + [124, 81], + null, + [127, 83], + [121, 79], + [126, 82], + [123, 80], + [129, 84], + [120, 78], + [124, 81], +]; + +function dayKey(index: number): string { + return new Date(Date.UTC(2026, 8, 10 + index)).toISOString().slice(0, 10); +} + +const BP_TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: BP_VALUES.length, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: BP_VALUES.map((pair, index) => + pair + ? [dayKey(index), pair[0], pair[1], 1] + : [dayKey(index), null, null, null], + ), + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, +}; + +const SPORTS: Array<[string, number]> = [ + ["Walking", 12], + ["Cycling", 7], + ["Running", 5], + ["Swimming", 3], + ["Yoga", 3], + ["Hiking", 2], + ["Rowing", 1], +]; + +const WORKOUT_TABLE = { + ref: "r2", + source: { + tool: "get_workouts", + domain: "workouts", + window: "last30days", + period: "current", + }, + shape: "categoryCounts", + titleKey: "coach.result.title.workoutsBySport", + title: "Workouts by sport", + rowCount: SPORTS.length, + chartKind: "bar", + displayed: false, + columns: [ + { + key: "sport", + kind: "category", + labelKey: "coach.result.column.sport", + label: "Sport", + }, + { + key: "sessions", + kind: "count", + labelKey: "coach.result.column.sessions", + label: "Sessions", + }, + { + key: "duration", + kind: "number", + labelKey: "coach.result.column.duration", + label: "Duration", + unit: "min", + decimals: 0, + }, + ], + rows: SPORTS.map(([sport, count]) => [sport, count, count * 35]), + truncated: false, + chart: { + kind: "bar", + x: "sport", + series: ["sessions"], + orientation: "horizontal", + }, +}; + +function meta(table: typeof BP_TABLE | typeof WORKOUT_TABLE) { + return { + ref: table.ref, + source: table.source, + shape: table.shape, + titleKey: table.titleKey, + title: table.title, + rowCount: table.rowCount, + chartKind: table.chartKind, + displayed: table.displayed, + }; +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname === + `/api/insights/chat/${CONVERSATION_ID}/messages/${MESSAGE_ID}/results` + ) { + return fulfilJson(route, { results: [BP_TABLE, WORKOUT_TABLE] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + id: CONVERSATION_ID, + title: "Blood pressure and workouts", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + attachmentCount: 0, + summary: null, + messages: [ + { + id: "coach-results-e2e-user", + role: "user", + content: "How was my blood pressure this month?", + createdAt: "2026-09-24T09:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: MESSAGE_ID, + role: "assistant", + content: + "Your readings stayed in a narrow band this month; the table below lists each day.", + createdAt: "2026-09-24T09:01:00.000Z", + metricSource: { + windows: ["last30days"], + metrics: ["bp", "workouts"], + results: [meta(BP_TABLE), meta(WORKOUT_TABLE)], + }, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 42, + model: "mock", + }, + ], + }); + } + return fulfilJson(route, { + conversations: [ + { + id: CONVERSATION_ID, + title: "Blood pressure and workouts", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:01:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }, + ], + nextCursor: null, + }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +async function useTheme(page: Page, theme: "light" | "dark") { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((value: string) => { + window.localStorage.setItem("healthlog-theme", value); + }, theme); +} + +async function expectNoAxeViolations(page: Page, label: string) { + const result = await new AxeBuilder({ page }) + .include('[data-slot="coach-bubble-assistant"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + result.violations.map((violation) => ({ + id: violation.id, + nodes: violation.nodes.map((node) => node.target.join(" ")), + })), + `${label} accessibility violations`, + ).toEqual([]); +} + +test.describe("Coach result tables and charts", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + }); + + for (const theme of ["light", "dark"] as const) { + test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({ + page, + }) => { + await useTheme(page, theme); + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("html")).toHaveClass( + new RegExp(`(^|\\s)${theme}(\\s|$)`), + ); + + const bubble = page.locator('[data-slot="coach-bubble-assistant"]'); + const figure = bubble.locator( + '[data-slot="coach-result-chart"][data-ref="r1"]', + ); + await expect(figure).toBeVisible({ timeout: 15_000 }); + await expect(figure).toHaveAttribute("data-chart-kind", "line"); + const plot = figure.getByRole("img", { + name: "Blood pressure by day, shown as a chart. The table view lists every value.", + }); + await expect(plot).toBeVisible(); + await expect(plot.locator(".recharts-line")).toHaveCount(2); + + // Legend buttons hide one series; the last visible one stays. + const systolic = figure.getByRole("button", { name: "Systolic" }); + const diastolic = figure.getByRole("button", { name: "Diastolic" }); + await expect(systolic).toHaveAttribute("aria-pressed", "true"); + await diastolic.click(); + await expect(diastolic).toHaveAttribute("aria-pressed", "false"); + await expect(plot.locator(".recharts-line")).toHaveCount(1); + await systolic.click(); + await expect(systolic).toHaveAttribute("aria-pressed", "true"); + await diastolic.click(); + await expect(plot.locator(".recharts-line")).toHaveCount(2); + + await expectNoAxeViolations(page, `${theme} chart view`); + + // The toggle opens the captioned table and comes back. + await figure.locator('[data-slot="coach-result-view-table"]').click(); + const table = bubble.locator( + '[data-slot="coach-result-table"][data-ref="r1"]', + ); + await expect(table).toBeVisible(); + await expect(table.locator("caption")).toContainText( + "Blood pressure by day", + ); + await expect( + table.locator('[data-slot="coach-result-view-table"]'), + ).toHaveAttribute("aria-pressed", "true"); + await expectNoAxeViolations(page, `${theme} table view`); + await table.locator('[data-slot="coach-result-view-chart"]').click(); + await expect(figure).toBeVisible(); + + // The table the answer only used waits in the evidence disclosure. + await expect( + bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'), + ).toBeHidden(); + await bubble.locator('[data-slot="coach-evidence-summary"]').click(); + const dataUsed = bubble.locator('[data-slot="coach-data-used"]'); + await expect(dataUsed).toContainText("Data used (1)"); + const workouts = dataUsed.locator( + '[data-slot="coach-result-table"][data-ref="r2"]', + ); + await expect(workouts).toBeVisible(); + await workouts.locator('[data-slot="coach-result-view-chart"]').click(); + const workoutChart = dataUsed.locator( + '[data-slot="coach-result-chart"][data-ref="r2"]', + ); + await expect(workoutChart).toHaveAttribute("data-chart-kind", "bar"); + await expect(workoutChart.locator(".recharts-bar-rectangle")).toHaveCount( + SPORTS.length, + ); + + await expectNoAxeViolations(page, `${theme} data used`); + }); + } +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 10070eaa6..13102ce22 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -145,6 +145,7 @@ const eslintConfig = defineConfig([ "src/components/mental-health/assessment-history-chart.tsx", "src/components/charts/workout-hr-chart.tsx", "src/components/charts/workout-elevation-chart.tsx", + "src/components/insights/coach-panel/result-chart.tsx", ], rules: { "no-restricted-imports": [ diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 788235dc0..56dd4ab60 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -47,3 +47,4 @@ export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart"; export { WorkoutHrChart } from "./workout-hr-chart"; export { WorkoutElevationChart } from "./workout-elevation-chart"; +export { CoachResultChart } from "@/components/insights/coach-panel/result-chart"; diff --git a/src/components/insights/coach-panel/__tests__/coach-results.test.tsx b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx new file mode 100644 index 000000000..a20b8e685 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-results.test.tsx @@ -0,0 +1,220 @@ +/** + * v1.39.4 — where a reply's tables go and how they first show: the ones the + * answer referenced under the prose, the rest under "Data used", a chart + * first only for a referenced table that has one, and the chart's plot named + * as an image that points at the table. SSR harness, like the other Coach + * panel tests; the toggle itself is exercised in the browser suite. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachResultTable as Table } from "@/lib/ai/coach/types"; + +import { CoachResults, countResultsInSection } from "../coach-results"; +import { CoachResultChart } from "../result-chart"; +import { selectCoachChartTokens } from "../chat-bubble"; + +function table(overrides: Partial = {}): Table { + return { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: 2, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + { + key: "diastolic", + kind: "number", + labelKey: "coach.result.column.diastolic", + label: "Diastolic", + unit: "mmHg", + decimals: 0, + }, + ], + rows: [ + ["2026-09-01", 124, 81], + ["2026-09-02", 121, 79], + ], + truncated: false, + chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] }, + ...overrides, + }; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function results(live: Table[], section: "displayed" | "dataUsed") { + return render( + , + ); +} + +describe("CoachResults", () => { + it("opens a referenced table with a chart on the chart", () => { + const html = results([table()], "displayed"); + expect(html).toContain('data-slot="coach-result-chart"'); + expect(html).toMatch(/]*aria-labelledby="([^"]+)"/); + expect(html).toMatch(/ { + const html = results([table()], "displayed"); + const chartButton = html.match( + /]*data-slot="coach-result-view-chart"[^>]*>/, + )?.[0]; + const tableButton = html.match( + /]*data-slot="coach-result-view-table"[^>]*>/, + )?.[0]; + expect(chartButton).toContain('aria-pressed="true"'); + expect(tableButton).toContain('aria-pressed="false"'); + }); + + it("opens a table without a chart as a table, with no toggle", () => { + const html = results( + [table({ chart: null, chartKind: null })], + "displayed", + ); + expect(html).toContain('data-slot="coach-result-table"'); + expect(html).not.toContain("coach-result-view-"); + }); + + it("keeps an unreferenced table out of the answer", () => { + const html = results([table({ displayed: false })], "displayed"); + expect(html).toBe(""); + }); + + it("lists an unreferenced table under Data used, as a table first", () => { + const html = results( + [table(), table({ ref: "r2", displayed: false })], + "dataUsed", + ); + expect(html).toContain("Data used (1)"); + expect(html).toContain('data-ref="r2"'); + expect(html).not.toContain('data-ref="r1"'); + expect(html).toContain('data-slot="coach-result-table"'); + // The chart stays one tap away. + expect(html).toContain('data-slot="coach-result-view-chart"'); + }); + + it("renders no Data used block when the answer referenced every table", () => { + expect(results([table()], "dataUsed")).toBe(""); + }); + + it("counts a section from the metadata alone", () => { + const metas = [ + { displayed: true }, + { displayed: false }, + { displayed: false }, + ]; + expect(countResultsInSection(metas, "displayed")).toBe(1); + expect(countResultsInSection(metas, "dataUsed")).toBe(2); + }); +}); + +describe("CoachResultChart", () => { + const label = + "Blood pressure by day, shown as a chart. The table view lists every value."; + + it("names the plot as one image that points at the table", () => { + const html = render( + , + ); + expect(html).toMatch(new RegExp(`
{ + const html = render( + , + ); + const legend = html.match( + /]*data-slot="coach-result-chart-legend"[\s\S]*?<\/div>/, + )?.[0]; + expect(legend?.match(/aria-pressed="true"/g)).toHaveLength(2); + expect(legend).toContain("Systolic"); + expect(legend).toContain("Diastolic"); + expect(html.indexOf("coach-result-chart-legend")).toBeLessThan( + html.indexOf('role="img"'), + ); + }); + + it("draws one series without a legend", () => { + const html = render( + , + ); + expect(html).not.toContain("coach-result-chart-legend"); + }); + + it("draws nothing without a spec", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); + +describe("selectCoachChartTokens beside result tables", () => { + it("drops a metric token whose metric a shown table already covers", () => { + expect( + selectCoachChartTokens( + "metric:BLOOD_PRESSURE_SYS and metric:WEIGHT", + ["bp", "weight"], + new Set(["bp"]), + ), + ).toEqual(["metric:WEIGHT"]); + }); + + it("keeps the token when no table is shown", () => { + expect( + selectCoachChartTokens("metric:WEIGHT", ["weight"], new Set()), + ).toEqual(["metric:WEIGHT"]); + }); +}); diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 7cd012496..24c5bce53 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -42,7 +42,7 @@ import { SuggestedActionCard } from "./suggested-action-card"; import { StreamedProse } from "./streamed-prose"; import { MessageTokenFooter } from "./message-token-footer"; import { CoachTurnSteps } from "./turn-steps"; -import { CoachResults } from "./coach-results"; +import { CoachResults, countResultsInSection } from "./coach-results"; import { CoachMethodLine } from "./method-line"; import { COACH_ICON_BUTTON, @@ -204,10 +204,15 @@ const MAX_COACH_CHARTS = 2; * allowlist-parsed, intersected with the turn's grounded provenance topics, * de-duplicated by metric, and capped. Exported for unit tests so the * grounding contract is pinned without standing up the chart component. + * + * v1.39.4 — `shownDomains` are the domains of the result tables the answer + * shows under the prose; a token for one of them is dropped, since the + * turn's own table (and its chart) already shows that metric. */ export function selectCoachChartTokens( content: string, metrics: readonly CoachProvenanceMetric[] | undefined, + shownDomains?: ReadonlySet, ): ChartToken[] { const grounded = new Set(metrics ?? []); const out: ChartToken[] = []; @@ -215,6 +220,7 @@ export function selectCoachChartTokens( for (const token of parseChartTokens(content)) { const topic = CHART_TOKEN_PROVENANCE[token]; if (!topic || !grounded.has(topic)) continue; + if (shownDomains?.has(topic)) continue; const metric = tokenToMetric(token); if (seen.has(metric)) continue; seen.add(metric); @@ -676,16 +682,29 @@ function ChatBubbleImpl({ ((metricSource.metrics?.length ?? 0) > 0 || (metricSource.windows?.length ?? 0) > 0); const method = metricSource?.method ?? null; - const hasProvenance = hasChips || keyValues.length > 0 || method !== null; + const hasEvidence = hasChips || keyValues.length > 0 || method !== null; // v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance- // grounded `metric:` chart under a SETTLED assistant turn. Skipped // while streaming / in-flight / errored / on a refusal; a no-op when no // grounded token is present (provider-agnostic — reads the inline token). + // v1.39.4 — the result tables this answer references and the ones it + // only used; the metadata is enough to place and count them. + const resultMetas = results?.length ? results : (metricSource?.results ?? []); + const shownDomains = new Set( + resultMetas + .filter((meta) => meta.displayed) + .map((meta) => meta.source.domain), + ); + const dataUsedCount = + !inProgress && !errorCode + ? countResultsInSection(resultMetas, "dataUsed") + : 0; const chartTokens = !streaming && !inProgress && !errorCode && providerType !== "refusal" - ? selectCoachChartTokens(content, metricSource?.metrics) + ? selectCoachChartTokens(content, metricSource?.metrics, shownDomains) : []; + const hasProvenance = hasEvidence || dataUsedCount > 0; // v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure // from this reply (each rewritten to the `[…]` elision mark). Shown only on a @@ -805,6 +824,7 @@ function ChatBubbleImpl({ messageId={messageId ?? null} metas={metricSource?.results ?? []} live={results} + section="displayed" /> )} {/* v1.18.6 — a "no provider configured anywhere" turn is a @@ -916,6 +936,16 @@ function ChatBubbleImpl({ {/* v1.39.4 — how the answer was worked out, beside the key values it explains. */} + {/* v1.39.4 — the tables the answer read but did not point at. */} + {dataUsedCount > 0 && ( + + )}
)} diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index 8342434ff..6675905b8 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -6,15 +6,51 @@ * metadata, and the values are fetched when the message scrolls into view * (`useCoachMessageResults`). A withheld table is skipped here; the notice * for it belongs to the table view. + * + * The bubble renders this twice: the tables the answer referenced + * (`section="displayed"`) expanded under the prose, and the rest + * (`section="dataUsed"`) under "Data used (n)" inside the evidence + * disclosure. Both instances share one cached read. + * + * A table with a chart gets a chart/table toggle. The chart is the first + * view for a referenced table; a table under "Data used" opens as a table. + * The choice is local to this render and never stored. The chart sits in a + * figure; its plot is one `role="img"` whose name points at the table view + * for every value. */ +import { useId, useState } from "react"; +import dynamic from "next/dynamic"; +import { ChartLine, Table2 } from "lucide-react"; + +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { ViewToggle } from "@/components/ui/view-toggle"; import { useCoachMessageResults } from "@/hooks/use-coach-message-results"; +import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; import type { CoachResultMeta, CoachResultTable as CoachResultTableData, } from "@/lib/ai/coach/types"; +import { useTranslations } from "@/lib/i18n/context"; +import { importWithRetry } from "@/lib/retry-import"; import { CoachResultTable } from "./result-table"; +const CoachResultChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.CoachResultChart }), + ), + { + ssr: false, + loading: () => ( + + ), + }, +); + +export type CoachResultsSection = "displayed" | "dataUsed"; + export interface CoachResultsProps { conversationId: string | null; /** The persisted message, once it has an id. */ @@ -23,6 +59,18 @@ export interface CoachResultsProps { metas: CoachResultMeta[]; /** The tables a live turn streamed; absent on a persisted message. */ live?: CoachResultTableData[]; + /** Which of the message's tables this instance shows. */ + section: CoachResultsSection; +} + +/** How many tables sit in a section, from the metadata alone. */ +export function countResultsInSection( + metas: ReadonlyArray>, + section: CoachResultsSection, +): number { + return metas.filter((meta) => + section === "displayed" ? meta.displayed : !meta.displayed, + ).length; } export function CoachResults({ @@ -30,24 +78,122 @@ export function CoachResults({ messageId, metas, live, + section, }: CoachResultsProps) { + const { t } = useTranslations(); const hasLive = (live?.length ?? 0) > 0; + const expected = hasLive + ? countResultsInSection(live ?? [], section) + : countResultsInSection(metas, section); const { ref, results } = useCoachMessageResults({ conversationId, messageId, - enabled: !hasLive && metas.length > 0, + enabled: !hasLive && expected > 0, }); - if (!hasLive && metas.length === 0) return null; - const tables: CoachResultTableData[] = hasLive - ? (live ?? []) - : (results ?? []).filter( - (entry): entry is CoachResultTableData => !("withheld" in entry), - ); - return ( -
+ if (expected === 0) return null; + const tables: CoachResultTableData[] = ( + hasLive + ? (live ?? []) + : (results ?? []).filter( + (entry): entry is CoachResultTableData => !("withheld" in entry), + ) + ).filter((table) => + section === "displayed" ? table.displayed : !table.displayed, + ); + + const list = ( +
{tables.map((table) => ( - + ))}
); + if (section === "displayed") return list; + return ( +
+

+ {t(COACH_RESULT_UI_KEYS.dataUsed, { count: expected })} +

+ {list} +
+ ); +} + +type ResultView = "chart" | "table"; + +function CoachResultView({ + result, + chartFirst, +}: { + result: CoachResultTableData; + chartFirst: boolean; +}) { + const { t } = useTranslations(); + const titleId = useId(); + const hasChart = result.chart !== null; + const [view, setView] = useState( + hasChart && chartFirst ? "chart" : "table", + ); + if (!hasChart) return ; + + const toggle = ( + + view={view} + onChange={setView} + groupLabel={t(COACH_RESULT_UI_KEYS.viewLabel)} + dataSlotPrefix="coach-result-view" + segments={[ + { + value: "chart", + label: t(COACH_RESULT_UI_KEYS.viewChart), + icon: ChartLine, + }, + { + value: "table", + label: t(COACH_RESULT_UI_KEYS.viewTable), + icon: Table2, + }, + ]} + /> + ); + if (view === "table") { + return ; + } + return ( +
+
+ + {result.title} + + {toggle} +
+ + + +
+ ); } diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx new file mode 100644 index 000000000..c10f820f4 --- /dev/null +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -0,0 +1,563 @@ +"use client"; + +/** + * v1.39.4 — the chart of one Coach result table, drawn from the spec the + * server chose (`src/lib/ai/coach/results/chart-spec.ts`): a line over the + * periods, bars (vertical, or horizontal for many categories), or a + * histogram over bins. The component never decides the chart type itself. + * + * Recessive axes (no axis line, no tick marks, muted ticks), horizontal grid + * lines only, the shared rich tooltip, and series colours from + * `var(--chart-1..5)` in series order. Two series (blood pressure) get + * legend buttons that hide or show a series (`aria-pressed`); the last + * visible one cannot be hidden. A period without a reading stays a gap in + * the line, and each reading is a dot, so a lone reading is still visible. + * + * Exported only through `src/components/charts/chart-runtime.ts`; the call + * site loads it with `next/dynamic` so recharts stays in its one chunk. + */ +import { useMemo, useState, type ReactElement } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { RichChartTooltip } from "@/components/charts/chart-tooltip"; +import { chartCategoryRows } from "@/lib/ai/coach/results/chart-spec"; +import type { + CoachChartSpec, + CoachResultColumn, + CoachResultTable, +} from "@/lib/ai/coach/types"; +import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** The chart band's height; matches the mini chart skeleton's box. */ +export const RESULT_CHART_HEIGHT_PX = 160; +/** Row height of a horizontal bar. */ +const HORIZONTAL_BAR_ROW_PX = 26; + +const SERIES_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +const TICK = { fontSize: 11, fill: "var(--muted-foreground)" }; +const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_KEY = /^\d{4}-\d{2}$/; + +export interface CoachResultChartProps { + result: CoachResultTable; + /** Label of the folded bar in a category chart. */ + otherLabel: string; + /** The plot's accessible name; it points at the table for the values. */ + label: string; +} + +interface Point { + x: string; + [series: string]: string | number | null; +} + +function usePeriodLabel(): (key: string) => string { + const { locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); + return useMemo(() => { + const fmt = makeBucketLabelFormatters(locale, dateFormat); + const month = new Intl.DateTimeFormat(locale, { + month: "short", + year: "numeric", + timeZone: "UTC", + }); + return (key: string) => { + if (DAY_KEY.test(key)) { + return fmt.dateShortSmart(new Date(`${key}T12:00:00Z`)); + } + if (MONTH_KEY.test(key)) { + return month.format(new Date(`${key}-01T12:00:00Z`)); + } + return key; + }; + }, [locale, dateFormat]); +} + +function useValueFormat(): ( + value: number, + column: CoachResultColumn | undefined, +) => string { + const { locale } = useTranslations(); + const dateFormat = useDateFormatPreference(); + return useMemo(() => { + const fmt = makeBucketLabelFormatters(locale, dateFormat); + return (value, column) => { + const text = + column?.kind === "count" + ? fmt.integer(value) + : fmt.number(value, column?.decimals ?? 1); + return column?.unit ? `${text} ${column.unit}` : text; + }; + }, [locale, dateFormat]); +} + +export function CoachResultChart({ + result, + otherLabel, + label, +}: CoachResultChartProps) { + const spec = result.chart; + if (!spec) return null; + if (spec.kind === "histogram") { + return ; + } + if (spec.kind === "bar" && spec.orientation === "horizontal") { + return ( + + ); + } + return ( + + ); +} + +/** + * The plot band: one `role="img"` with the summary as its name. The legend + * buttons stay outside it, and recharts' own keyboard layer is off, so the + * image holds nothing focusable. + */ +function PlotBand({ + height, + label, + children, +}: { + height: number; + label: string; + children: ReactElement; +}) { + return ( +
+ + {children} + +
+ ); +} + +// ── Line and vertical bars ───────────────────────────────────────────────── + +function SeriesChart({ + result, + spec, + otherLabel, + label, +}: { + result: CoachResultTable; + spec: Extract; + otherLabel: string; + label: string; +}) { + const periodLabel = usePeriodLabel(); + const valueFormat = useValueFormat(); + const [hidden, setHidden] = useState>(new Set()); + const animate = !prefersReducedMotion(); + const byKey = useMemo( + () => new Map(result.columns.map((column) => [column.key, column])), + [result.columns], + ); + const xColumn = byKey.get(spec.x); + const isPeriod = xColumn?.kind === "period"; + + const points = useMemo(() => { + if (spec.kind === "bar" && !isPeriod) { + return chartCategoryRows(result, spec, otherLabel).map((row) => ({ + x: row.label, + [spec.series[0]]: row.value, + })); + } + const xIndex = result.columns.findIndex((c) => c.key === spec.x); + const indexes = spec.series.map((key) => + result.columns.findIndex((c) => c.key === key), + ); + return result.rows.map((row) => { + const point: Point = { x: String(row[xIndex] ?? "") }; + spec.series.forEach((key, i) => { + const cell = row[indexes[i]]; + point[key] = typeof cell === "number" ? cell : null; + }); + return point; + }); + }, [result, spec, isPeriod, otherLabel]); + + const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x); + const colorOf = (key: string) => + SERIES_COLORS[spec.series.indexOf(key) % SERIES_COLORS.length]; + const visible = spec.series.filter((key) => !hidden.has(key)); + + const tooltip = (props: { + active?: boolean; + payload?: ReadonlyArray<{ payload?: Point }>; + }) => { + const point = props.payload?.[0]?.payload; + if (!props.active || !point) { + return ; + } + const rows = visible.flatMap((key) => { + const value = point[key]; + if (typeof value !== "number") return []; + const column = byKey.get(key); + return [ + { + name: column?.label ?? key, + value: valueFormat(value, column), + color: colorOf(key), + }, + ]; + }); + return ; + }; + + const axes = ( + <> + + + + + + ); + + return ( +
+ {spec.series.length > 1 ? ( + ({ + key, + label: byKey.get(key)?.label ?? key, + }))} + hidden={hidden} + onToggle={(key) => + setHidden((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else if (spec.series.length - next.size > 1) next.add(key); + return next; + }) + } + /> + ) : null} + + {spec.kind === "line" ? ( + + {axes} + {visible.map((key) => ( + + ))} + + ) : ( + + {axes} + {visible.map((key) => ( + + ))} + + )} + +
+ ); +} + +function SeriesLegend({ + series, + hidden, + onToggle, +}: { + series: Array<{ key: string; label: string }>; + hidden: ReadonlySet; + onToggle: (key: string) => void; +}) { + return ( +
+ {series.map(({ key, label }, index) => { + const shown = !hidden.has(key); + return ( + + ); + })} +
+ ); +} + +// ── Horizontal category bars ─────────────────────────────────────────────── + +function CategoryBarChart({ + result, + spec, + otherLabel, + label, +}: { + result: CoachResultTable; + spec: Extract; + otherLabel: string; + label: string; +}) { + const valueFormat = useValueFormat(); + const animate = !prefersReducedMotion(); + const column = result.columns.find((c) => c.key === spec.series[0]); + const rows = useMemo( + () => chartCategoryRows(result, spec, otherLabel), + [result, spec, otherLabel], + ); + const height = Math.max( + RESULT_CHART_HEIGHT_PX, + rows.length * HORIZONTAL_BAR_ROW_PX + 16, + ); + return ( + + + + + + { + const payload = props.payload as + | ReadonlyArray<{ payload?: { label: string; value: number } }> + | undefined; + const row = payload?.[0]?.payload; + if (!props.active || !row) { + return ; + } + return ( + + ); + }} + /> + + + + ); +} + +// ── Histogram ────────────────────────────────────────────────────────────── + +function HistogramChart({ + result, + spec, + label, +}: { + result: CoachResultTable; + spec: Extract; + label: string; +}) { + const valueFormat = useValueFormat(); + const animate = !prefersReducedMotion(); + const countColumn = result.columns.find((c) => c.kind === "count"); + const data = useMemo( + () => + spec.bins.map((bin, index) => { + const label = result.rows[index]?.[0]; + return { + x: + typeof label === "string" + ? label + : `${bin.from}–${bin.to}${spec.unit ? ` ${spec.unit}` : ""}`, + from: bin.from, + count: bin.count, + }; + }), + [spec, result.rows], + ); + return ( + + + + + + { + const payload = props.payload as + | ReadonlyArray<{ payload?: { x: string; count: number } }> + | undefined; + const bin = payload?.[0]?.payload; + if (!props.active || !bin) { + return ; + } + return ( + + ); + }} + /> + + + + ); +} diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx index eae730822..e8ecc92a7 100644 --- a/src/components/insights/coach-panel/result-table.tsx +++ b/src/components/insights/coach-panel/result-table.tsx @@ -43,6 +43,8 @@ export interface CoachResultTableProps { result: CoachResultTableData; /** The method line for this table, shown under the title. */ method?: ReactNode; + /** Controls shown beside the copy button (the chart/table toggle). */ + toolbar?: ReactNode; } type CellFormatter = ( @@ -98,7 +100,11 @@ function headingText(column: CoachResultColumn): string { return column.unit ? `${column.label} (${column.unit})` : column.label; } -export function CoachResultTable({ result, method }: CoachResultTableProps) { +export function CoachResultTable({ + result, + method, + toolbar, +}: CoachResultTableProps) { const { t } = useTranslations(); const formatter = useCellFormatter(); const [expanded, setExpanded] = useState(false); @@ -159,8 +165,9 @@ export function CoachResultTable({ result, method }: CoachResultTableProps) { ) : null} - + + {toolbar}