diff --git a/src/components/insights/coach-panel/__tests__/result-chart-compare.test.tsx b/src/components/insights/coach-panel/__tests__/result-chart-compare.test.tsx new file mode 100644 index 000000000..bbcf06796 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/result-chart-compare.test.tsx @@ -0,0 +1,167 @@ +/** + * v1.41 — the compare chart: two periods overlaid on one axis, or two + * metrics with two axes whose grid lines meet. Recharts is replaced by + * pass-through parts so the props the chart hands it can be read without a + * browser layout. + */ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const calls = vi.hoisted(() => ({ + yAxis: [] as Array>, + line: [] as Array>, +})); + +vi.mock("recharts", () => { + const Pass = ({ children }: { children?: ReactNode }) => <>{children}; + const Leaf = () => null; + return { + ResponsiveContainer: Pass, + LineChart: Pass, + BarChart: Pass, + CartesianGrid: Leaf, + Bar: Leaf, + Tooltip: Leaf, + XAxis: Leaf, + Line: (props: Record) => { + calls.line.push(props); + return null; + }, + YAxis: (props: Record) => { + calls.yAxis.push(props); + return null; + }, + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types"; + +import { CoachResultChart, alignedAxes, comparePoints } from "../result-chart"; + +function table( + chart: Extract, + rows: CoachResultTable["rows"], + labels: [string, string], +): CoachResultTable { + return { + ref: "r1", + source: { + tool: "compare_series" as never, + domain: "resting_hr", + window: "last30days", + period: "current", + }, + shape: "timeSeries", + titleKey: "k", + title: "Resting heart rate and sleep", + rowCount: rows.length, + chartKind: "compare", + displayed: true, + columns: [ + { key: "period", kind: "category", labelKey: "k", label: "Day" }, + { + key: "a", + kind: "number", + labelKey: "k", + label: labels[0], + unit: "bpm", + }, + { key: "b", kind: "number", labelKey: "k", label: labels[1], unit: "h" }, + ], + rows, + truncated: false, + chart, + }; +} + +const METRICS = table( + { kind: "compare", mode: "metrics", x: "period", a: "a", b: "b", axes: 2 }, + [ + ["1", 58, 7.5], + ["2", 61, 6.1], + ["3", null, 6.8], + ["4", 59, 8.0], + ], + ["Resting HR", "Sleep"], +); +const PERIODS = table( + { kind: "compare", mode: "periods", x: "period", a: "a", b: "b", axes: 1 }, + [ + ["1", 58, 60], + ["2", 61, 62], + ], + ["This month", "Last month"], +); + +function render(result: CoachResultTable) { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + calls.yAxis.length = 0; + calls.line.length = 0; +}); + +describe("compare chart", () => { + it("renders, where the placeholder rendered nothing", () => { + const html = render(METRICS); + expect(html).toContain('data-slot="coach-result-chart-compare"'); + expect(html).toContain('role="img"'); + // A legend button per series, both shown. + expect(html.match(/aria-pressed="true"/g)).toHaveLength(2); + expect(html).toContain("Resting HR"); + expect(html).toContain("Sleep"); + }); + + it("draws two metrics on two axes whose grid lines meet", () => { + render(METRICS); + const [left, right] = calls.yAxis; + expect(left.yAxisId).toBe("left"); + expect(right.yAxisId).toBe("right"); + expect(right.orientation).toBe("right"); + expect((left.ticks as number[]).length).toBe( + (right.ticks as number[]).length, + ); + expect(calls.line.map((l) => [l.dataKey, l.yAxisId, l.stroke])).toEqual([ + ["a", "left", "var(--chart-1)"], + ["b", "right", "var(--chart-2)"], + ]); + // A gap stays a gap. + expect(calls.line.every((l) => l.connectNulls === false)).toBe(true); + }); + + it("overlays two periods on one axis, the earlier one dashed, named a vs b", () => { + const html = render(PERIODS); + expect(calls.yAxis).toHaveLength(1); + expect(calls.line.map((l) => l.strokeDasharray)).toEqual([ + undefined, + "5 4", + ]); + expect(html).toContain('aria-label="This month vs Last month"'); + }); + + it("reads its rows by the spec's columns", () => { + expect(comparePoints(METRICS, METRICS.chart as never)[2]).toEqual({ + x: "3", + a: null, + b: 6.8, + }); + }); + + it("widens the second axis until its ticks line up with the first", () => { + const axes = alignedAxes([50, 90], [6, 7]); + expect(axes).not.toBeNull(); + expect(axes?.right.ticks.length).toBe(axes?.left.ticks.length); + const steps = axes!.right.ticks + .slice(1) + .map((v, i) => v - axes!.right.ticks[i]); + expect(new Set(steps.map((s) => s.toFixed(6))).size).toBe(1); + expect(alignedAxes([], [1])).toBeNull(); + }); +}); diff --git a/src/components/insights/coach-panel/result-chart.tsx b/src/components/insights/coach-panel/result-chart.tsx index f8ce173dc..30e842da3 100644 --- a/src/components/insights/coach-panel/result-chart.tsx +++ b/src/components/insights/coach-panel/result-chart.tsx @@ -37,7 +37,7 @@ import type { CoachResultTable, } from "@/lib/ai/coach/types"; import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; -import { niceAxis } from "@/lib/charts/nice-axis"; +import { niceAxis, type NiceAxis } from "@/lib/charts/nice-axis"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -122,9 +122,9 @@ export function CoachResultChart({ if (spec.kind === "histogram") { return ; } - // v1.41 — the contract is in place; no table carries this kind yet, and the - // table view stays available until the chart lands. - if (spec.kind === "compare") return null; + if (spec.kind === "compare") { + return ; + } if (spec.kind === "bar" && spec.orientation === "horizontal") { return ( ; hidden: ReadonlySet; onToggle: (key: string) => void; + /** The legend's accessible name, when it says more than its buttons. */ + label?: string; + /** Series drawn dashed; their swatch is a ring rather than a dot. */ + dashed?: ReadonlySet; }) { return (
{series.map(({ key, label }, index) => { @@ -385,9 +392,16 @@ function SeriesLegend({ "inline-block size-2 rounded-full", !shown && "opacity-40", )} - style={{ - backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length], - }} + style={ + dashed?.has(key) + ? { + border: `1.5px solid ${SERIES_COLORS[index % SERIES_COLORS.length]}`, + } + : { + backgroundColor: + SERIES_COLORS[index % SERIES_COLORS.length], + } + } /> {label} @@ -397,6 +411,211 @@ function SeriesLegend({ ); } +// ── Compare: two periods overlaid, or two metrics side by side ───────────── + +/** + * Two axes whose grid lines meet: the second axis is widened, a step at a + * time on its own nice step, until it has as many ticks as the first. Null + * when either side has no value. + */ +export function alignedAxes( + a: ReadonlyArray, + b: ReadonlyArray, +): { left: NiceAxis; right: NiceAxis } | null { + const left = niceAxis(a); + if (!left) return null; + const count = left.ticks.length; + const right = niceAxis(b, { maxTicks: count }); + if (!right) return null; + const ticks = [...right.ticks]; + if (ticks.length >= 2) { + const step = ticks[1] - ticks[0]; + const decimals = Math.max(0, (String(step).split(".")[1] ?? "").length); + while (ticks.length < count) { + ticks.push(Number((ticks[ticks.length - 1] + step).toFixed(decimals))); + } + } + return { + left, + right: { domain: [ticks[0], ticks[ticks.length - 1]], ticks }, + }; +} + +/** The rows of a compare chart: the shared x, then each side's value. */ +export function comparePoints( + result: CoachResultTable, + spec: Extract, +): Array<{ x: string; a: number | null; b: number | null }> { + const index = (key: string) => + result.columns.findIndex((column) => column.key === key); + const xi = index(spec.x); + const ai = index(spec.a); + const bi = index(spec.b); + return result.rows.map((row) => ({ + x: String(row[xi] ?? ""), + a: typeof row[ai] === "number" ? (row[ai] as number) : null, + b: typeof row[bi] === "number" ? (row[bi] as number) : null, + })); +} + +function CompareChart({ + result, + spec, + label, +}: { + result: CoachResultTable; + spec: Extract; + label: string; +}) { + const { t } = useTranslations(); + const periodLabel = usePeriodLabel(); + const valueFormat = useValueFormat(); + const animate = !prefersReducedMotion(); + const [hidden, setHidden] = useState>(new Set()); + const byKey = useMemo( + () => new Map(result.columns.map((column) => [column.key, column])), + [result.columns], + ); + const points = useMemo(() => comparePoints(result, spec), [result, spec]); + const isPeriod = byKey.get(spec.x)?.kind === "period"; + const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x); + const twoAxes = spec.mode === "metrics" && spec.axes === 2; + // The earlier period is the dashed line, so the two read apart without + // relying on colour alone. + const dashed = new Set(spec.mode === "periods" ? ["b"] : []); + const series = [ + { key: "a", column: byKey.get(spec.a), color: SERIES_COLORS[0] }, + { key: "b", column: byKey.get(spec.b), color: SERIES_COLORS[1] }, + ]; + const visible = series.filter((s) => !hidden.has(s.key)); + const values = (key: "a" | "b") => + hidden.has(key) ? [] : points.map((point) => point[key]); + const aligned = twoAxes ? alignedAxes(values("a"), values("b")) : null; + const shared = twoAxes ? null : niceAxis([...values("a"), ...values("b")]); + const left = aligned?.left ?? (twoAxes ? niceAxis(values("a")) : shared); + const right = aligned?.right ?? (twoAxes ? niceAxis(values("b")) : null); + const nameOf = (key: string) => { + const column = byKey.get(key === "a" ? spec.a : spec.b); + return column?.label ?? key; + }; + const legendLabel = + spec.mode === "periods" + ? // The key is spelled out rather than read from `dialog-keys.ts`: this + // file rides the shared chart runtime, and that module would pull the + // Coach's schemas into every chart on every page. + t("insights.coach.chart.compare.periods", { + a: nameOf("a"), + b: nameOf("b"), + }) + : undefined; + + const tooltip = (props: { + active?: boolean; + payload?: ReadonlyArray<{ + payload?: { x: string; a: number | null; b: number | null }; + }>; + }) => { + const point = props.payload?.[0]?.payload; + if (!props.active || !point) { + return ; + } + const rows = visible.flatMap((s) => { + const value = point[s.key as "a" | "b"]; + if (typeof value !== "number") return []; + return [ + { + name: s.column?.label ?? s.key, + value: valueFormat(value, s.column), + color: s.color, + }, + ]; + }); + return ; + }; + + const axisProps = (axis: NiceAxis | null) => + axis + ? { domain: axis.domain, ticks: axis.ticks, interval: 0 as const } + : { domain: ["auto", "auto"] as [string, string] }; + + return ( +
+ ({ key: s.key, label: nameOf(s.key) }))} + hidden={hidden} + label={legendLabel} + dashed={dashed} + onToggle={(key) => + setHidden((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else if (series.length - next.size > 1) next.add(key); + return next; + }) + } + /> + + + + + + {twoAxes ? ( + + ) : null} + + {visible.map((s) => ( + + ))} + + +
+ ); +} + // ── Horizontal category bars ─────────────────────────────────────────────── function CategoryBarChart({