From 47efdd6606615458119b01dca32a22dbd1c6ad07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 11:54:31 +0200 Subject: [PATCH] feat(coach): result table with copy for a spreadsheet or as text The table shows its caption, scoped headers, right-aligned tabular numbers and a dash for a missing reading, twelve rows before Show all. Copy writes TSV and an HTML table together, falling back to TSV, with formula starts neutralised and separators flattened inside cells. --- .../success-affordance-guard.test.ts | 6 + .../__tests__/result-table.test.tsx | 114 ++++++++ .../coach-panel/copy-table-button.tsx | 111 ++++++++ .../insights/coach-panel/result-table.tsx | 249 +++++++++++++++++- .../__tests__/coach-result-clipboard.test.ts | 152 +++++++++++ src/lib/insights/coach-result-clipboard.ts | 138 ++++++++++ 6 files changed, 763 insertions(+), 7 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/result-table.test.tsx create mode 100644 src/components/insights/coach-panel/copy-table-button.tsx create mode 100644 src/lib/insights/__tests__/coach-result-clipboard.test.ts create mode 100644 src/lib/insights/coach-result-clipboard.ts diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index d8f561058..383bf15d3 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -174,6 +174,12 @@ const PINNED_AFFORDANCES: Record< "toast.success": 1, }, "src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 }, + // The copy tick on a result table: shown only after the clipboard write + // resolved, so it reports a copy that happened; nothing is written to the + // record. + "src/components/insights/coach-panel/copy-table-button.tsx": { + "text-success": 1, + }, "src/components/insights/coach-panel/history-rail.tsx": { "toast.success": 1, }, diff --git a/src/components/insights/coach-panel/__tests__/result-table.test.tsx b/src/components/insights/coach-panel/__tests__/result-table.test.tsx new file mode 100644 index 000000000..b2c34e0c4 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/result-table.test.tsx @@ -0,0 +1,114 @@ +/** + * v1.39.4 — the result table's anatomy: caption with title and method, + * column headers scoped, numbers right-aligned in tabular figures, absence + * shown as a dash and announced, and the first twelve rows before + * "Show all". SSR harness, like the other Coach panel tests; the toggle + * itself is exercised in the browser suite. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { CoachResultTable as Table } from "@/lib/ai/coach/types"; + +import { CoachResultTable, RESULT_TABLE_PREVIEW_ROWS } from "../result-table"; + +function table(rowCount: number): 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, + chartKind: null, + 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: "readings", + kind: "count", + labelKey: "coach.result.column.readings", + label: "Readings", + }, + ], + rows: Array.from({ length: rowCount }, (_, i) => [ + `2026-09-${String(i + 1).padStart(2, "0")}`, + i === 1 ? null : 120.4 + i, + i === 1 ? null : 1, + ]), + truncated: false, + chart: null, + }; +} + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("CoachResultTable", () => { + it("captions the table with its title and the method line", () => { + const html = render( + , + ); + expect(html).toMatch( + /]*>.*Blood pressure by day.*30 readings, daily means.*<\/caption>/s, + ); + }); + + it("scopes headers to columns and right-aligns numbers in tabular figures", () => { + const html = render(); + expect(html.match(/]*scope="col"/g)).toHaveLength(3); + expect(html).toContain("(mmHg)"); + expect(html).toMatch(/]*text-right tabular-nums[^>]*>120<\/td>/); + }); + + it("shows a day without a reading as a dash and says so to a screen reader", () => { + const html = render(); + expect(html).toContain( + '', + ); + expect(html).toContain('no reading'); + expect(html).not.toMatch(/>0<\/td>/); + }); + + it("shows the first twelve rows and offers the rest", () => { + const html = render(); + expect(html.match(/]*data-slot="table-row"/g)).toHaveLength( + RESULT_TABLE_PREVIEW_ROWS + 1, + ); + const button = html.match( + /]*aria-expanded="false"[^>]*>[^<]*<\/button>/, + )?.[0]; + expect(button).toContain("Show all (20)"); + const controls = button?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controls).toBeTruthy(); + expect(html).toContain(`id="${controls}"`); + }); + + it("offers nothing to expand for a short table", () => { + const html = render(); + expect(html).not.toContain("aria-expanded"); + }); +}); diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx new file mode 100644 index 000000000..29758f854 --- /dev/null +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -0,0 +1,111 @@ +"use client"; + +/** + * v1.39.4 — copy a Coach result table: for a spreadsheet (tab-separated + * values plus an HTML table, so a document gets a real table too) or as + * aligned plain text. + * + * A check mark and a polite live "Copied" confirm for two seconds; a failure + * says so in a toast. Absent where the Clipboard API is (plain-HTTP + * self-hosts), rather than failing on every tap. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { toast } from "sonner"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useTranslations } from "@/lib/i18n/context"; +import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; +import { + copyResultTable, + copyResultText, + type ClipboardGrid, +} from "@/lib/insights/coach-result-clipboard"; + +import { COACH_ICON_BUTTON, useClipboardSupported } from "./read-aloud"; + +/** How long the confirmation stays up. */ +export const COPY_CONFIRM_MS = 2_000; + +export interface CopyTableButtonProps { + /** Built on demand, so a table nobody copies formats nothing extra. */ + grid: () => ClipboardGrid; + caption: string; +} + +export function CopyTableButton({ grid, caption }: CopyTableButtonProps) { + const { t } = useTranslations(); + const supported = useClipboardSupported(); + const [copied, setCopied] = useState(false); + const timer = useRef(null); + + useEffect( + () => () => { + if (timer.current !== null) window.clearTimeout(timer.current); + }, + [], + ); + + const run = useCallback( + async (copy: (g: ClipboardGrid, c: string) => Promise) => { + try { + await copy(grid(), caption); + setCopied(true); + if (timer.current !== null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => { + setCopied(false); + timer.current = null; + }, COPY_CONFIRM_MS); + } catch { + toast.error(t(COACH_RESULT_UI_KEYS.copyFailed)); + } + }, + [grid, caption, t], + ); + + if (!supported) return null; + const label = t(COACH_RESULT_UI_KEYS.copyMenu); + return ( + <> + + + + + + void run(copyResultTable)} + > + {t(COACH_RESULT_UI_KEYS.copyForSpreadsheet)} + + void run(copyResultText)} + > + {t(COACH_RESULT_UI_KEYS.copyAsText)} + + + + + {copied ? t(COACH_RESULT_UI_KEYS.copied) : ""} + + + ); +} diff --git a/src/components/insights/coach-panel/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx index 591d5b378..eae730822 100644 --- a/src/components/insights/coach-panel/result-table.tsx +++ b/src/components/insights/coach-panel/result-table.tsx @@ -1,18 +1,253 @@ "use client"; /** - * v1.39.4 — one table of values a Coach turn read: a captioned table with - * the first rows shown, the rest behind "Show all", and copy for a - * spreadsheet or as text. + * v1.39.4 — one table of values a Coach turn read. * - * Not built yet: renders nothing. + * A captioned table (title, then the method line when the caller passes + * one), numbers right-aligned in tabular figures, a period without a reading + * shown as "—" and announced as "no reading". The first twelve rows show; a + * "Show all (n)" button opens the rest in a scrolling region whose header + * row stays put. Copy for a spreadsheet or as text sits beside the title. + * + * Periods are calendar days (or the Monday of a week, or a month), so they + * are spelled by the UTC-pinned bucket formatters: the label names the day + * the server keyed, in the reader's date order, whatever their zone. */ -import type { CoachResultTable as CoachResultTableData } from "@/lib/ai/coach/types"; +import { useCallback, useId, useMemo, useState, type ReactNode } from "react"; + +import { Button } from "@/components/ui/button"; +import { + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context"; +import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label"; +import { COACH_RESULT_UI_KEYS } from "@/lib/ai/coach/dialog-keys"; +import type { + CoachResultCell, + CoachResultColumn, + CoachResultTable as CoachResultTableData, +} from "@/lib/ai/coach/types"; +import type { ClipboardGrid } from "@/lib/insights/coach-result-clipboard"; +import { cn } from "@/lib/utils"; + +import { CopyTableButton } from "./copy-table-button"; + +/** Rows shown before "Show all". */ +export const RESULT_TABLE_PREVIEW_ROWS = 12; export interface CoachResultTableProps { result: CoachResultTableData; + /** The method line for this table, shown under the title. */ + method?: ReactNode; } -export function CoachResultTable(_props: CoachResultTableProps) { - return null; +type CellFormatter = ( + cell: CoachResultCell, + column: CoachResultColumn, +) => string | null; + +const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_KEY = /^\d{4}-\d{2}$/; + +function isNumeric(column: CoachResultColumn): boolean { + return column.kind === "number" || column.kind === "count"; +} + +/** + * The formatter a table's cells go through, for the screen and the + * clipboard alike. `grouped` adds thousands separators to counts on screen; + * the clipboard leaves them out so a spreadsheet reads a number. + */ +function useCellFormatter(): (grouped: boolean) => CellFormatter { + 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 (grouped: boolean): CellFormatter => + (cell, column) => { + if (cell === null) return null; + if (typeof cell === "number") { + if (column.kind === "count") { + return grouped ? fmt.integer(cell) : fmt.number(cell, 0); + } + return fmt.number(cell, column.decimals ?? 1); + } + if (column.kind === "period") { + if (DAY_KEY.test(cell)) { + return fmt.date(new Date(`${cell}T12:00:00Z`)); + } + if (MONTH_KEY.test(cell)) { + return month.format(new Date(`${cell}-01T12:00:00Z`)); + } + } + return cell; + }; + }, [locale, dateFormat]); +} + +function headingText(column: CoachResultColumn): string { + return column.unit ? `${column.label} (${column.unit})` : column.label; +} + +export function CoachResultTable({ result, method }: CoachResultTableProps) { + const { t } = useTranslations(); + const formatter = useCellFormatter(); + const [expanded, setExpanded] = useState(false); + const titleId = useId(); + const regionId = useId(); + + const format = useMemo(() => formatter(true), [formatter]); + const hasMore = result.rows.length > RESULT_TABLE_PREVIEW_ROWS; + const rows = expanded + ? result.rows + : result.rows.slice(0, RESULT_TABLE_PREVIEW_ROWS); + + const grid = useCallback((): ClipboardGrid => { + const plain = formatter(false); + return { + header: result.columns.map(headingText), + rows: result.rows.map((row) => + result.columns.map((column, index) => + plain(row[index] ?? null, column), + ), + ), + alignRight: result.columns.map(isNumeric), + }; + }, [formatter, result.columns, result.rows]); + + const noReading = t(COACH_RESULT_UI_KEYS.noReading); + + return ( +
+
+ + + + + {result.columns.map((column) => ( + + {column.label} + {column.unit ? ( + + {" "} + ({column.unit}) + + ) : null} + + ))} + + + + {rows.map((row, rowIndex) => ( + + {result.columns.map((column, index) => { + const text = format(row[index] ?? null, column); + return ( + + {text === null ? ( + <> + + {noReading} + + ) : ( + text + )} + + ); + })} + + ))} + +
+ + + + {result.title} + + {method ? ( + + {method} + + ) : null} + + + + + +
+
+ {hasMore || result.truncated ? ( +
+ {result.truncated ? ( +

+ {t(COACH_RESULT_UI_KEYS.truncated, { + shown: result.rows.length, + total: result.rowCount, + })} +

+ ) : ( + + )} + {hasMore ? ( + + ) : null} +
+ ) : null} +
+ ); } diff --git a/src/lib/insights/__tests__/coach-result-clipboard.test.ts b/src/lib/insights/__tests__/coach-result-clipboard.test.ts new file mode 100644 index 000000000..cf5d99d81 --- /dev/null +++ b/src/lib/insights/__tests__/coach-result-clipboard.test.ts @@ -0,0 +1,152 @@ +/** + * v1.39.4 — copying a result table: a grid a spreadsheet parses back, cells + * that cannot start a formula, escaped HTML, aligned text, and the fallback + * when the rich clipboard is missing. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + copyResultTable, + copyResultText, + sanitiseCell, + toHtmlTable, + toPlainText, + toTsv, + type ClipboardGrid, +} from "../coach-result-clipboard"; + +const GRID: ClipboardGrid = { + header: ["Day", "Systolic (mmHg)", "Readings"], + rows: [ + ["Sep 21, 2026", "131", "2"], + ["Sep 22, 2026", null, null], + ["Sep 23, 2026", "-4.5", "1"], + ], + alignRight: [false, true, true], +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("sanitiseCell", () => { + it.each([ + ['=HYPERLINK("http://x")', '\'=HYPERLINK("http://x")'], + ["+1+2", "'+1+2"], + ["-2+3", "'-2+3"], + ["@SUM(A1)", "'@SUM(A1)"], + ])("neutralises a formula start: %s", (input, expected) => { + expect(sanitiseCell(input)).toBe(expected); + }); + + it("turns a leading tab into a space, which no spreadsheet reads as a formula", () => { + expect(sanitiseCell("\t=1")).toBe(" =1"); + }); + + it("leaves plain negatives and ordinary text alone", () => { + expect(sanitiseCell("-4.5")).toBe("-4.5"); + expect(sanitiseCell("-1 234,5")).toBe("-1 234,5"); + expect(sanitiseCell("LDL")).toBe("LDL"); + expect(sanitiseCell(null)).toBe(""); + }); + + it("flattens tabs and line breaks inside a cell", () => { + expect(sanitiseCell("two\tparts\nhere")).toBe("two parts here"); + }); +}); + +describe("toTsv", () => { + it("round-trips into the same grid a spreadsheet would read", () => { + const tsv = toTsv({ + header: GRID.header, + rows: [...GRID.rows, ["line\nbreak", "=cmd|' /C calc'!A0", "3"]], + }); + const parsed = tsv.split("\n").map((line) => line.split("\t")); + expect(parsed).toEqual([ + ["Day", "Systolic (mmHg)", "Readings"], + ["Sep 21, 2026", "131", "2"], + ["Sep 22, 2026", "", ""], + ["Sep 23, 2026", "-4.5", "1"], + ["line break", "'=cmd|' /C calc'!A0", "3"], + ]); + // Every row has the same width: no cell split or swallowed a column. + expect(new Set(parsed.map((row) => row.length)).size).toBe(1); + }); +}); + +describe("toHtmlTable", () => { + it("escapes every cell and the caption", () => { + const html = toHtmlTable( + { + header: ["Test"], + rows: [[""], ["a & 'b'"]], + }, + 'Labs "latest"', + ); + expect(html).not.toContain(""); + expect(html).toContain("<img src=x onerror=alert(1)>"); + expect(html).toContain("a & 'b'"); + expect(html).toContain("Labs "latest""); + }); +}); + +describe("toPlainText", () => { + it("lines the columns up and shows a missing reading as a dash", () => { + const text = toPlainText(GRID, "Blood pressure by day"); + expect(text.split("\n")).toEqual([ + "Blood pressure by day", + "Day Systolic (mmHg) Readings", + "Sep 21, 2026 131 2", + "Sep 22, 2026 — —", + "Sep 23, 2026 -4.5 1", + ]); + }); +}); + +describe("copyResultTable", () => { + it("writes TSV and HTML as one clipboard item", async () => { + const write = vi.fn(async () => undefined); + const writeText = vi.fn(async () => undefined); + class FakeItem { + constructor(public items: Record) {} + } + vi.stubGlobal("ClipboardItem", FakeItem); + vi.stubGlobal("navigator", { clipboard: { write, writeText } }); + await copyResultTable(GRID, "Blood pressure by day"); + expect(write).toHaveBeenCalledTimes(1); + const [[items]] = write.mock.calls as unknown as [[FakeItem[]]]; + expect(Object.keys(items[0].items).sort()).toEqual([ + "text/html", + "text/plain", + ]); + expect(await items[0].items["text/plain"].text()).toBe(toTsv(GRID)); + expect(writeText).not.toHaveBeenCalled(); + }); + + it("falls back to the TSV alone without ClipboardItem", async () => { + const writeText = vi.fn(async () => undefined); + vi.stubGlobal("ClipboardItem", undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + await copyResultTable(GRID); + expect(writeText).toHaveBeenCalledWith(toTsv(GRID)); + }); + + it("falls back when the rich write is refused", async () => { + const write = vi.fn(async () => { + throw new Error("NotAllowedError"); + }); + const writeText = vi.fn(async () => undefined); + vi.stubGlobal("ClipboardItem", class {}); + vi.stubGlobal("navigator", { clipboard: { write, writeText } }); + await copyResultTable(GRID); + expect(writeText).toHaveBeenCalledWith(toTsv(GRID)); + }); + + it("copies the aligned text on request", async () => { + const writeText = vi.fn(async () => undefined); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + await copyResultText(GRID, "Title"); + expect(writeText).toHaveBeenCalledWith(toPlainText(GRID, "Title")); + }); +}); diff --git a/src/lib/insights/coach-result-clipboard.ts b/src/lib/insights/coach-result-clipboard.ts new file mode 100644 index 000000000..1a86f2b9d --- /dev/null +++ b/src/lib/insights/coach-result-clipboard.ts @@ -0,0 +1,138 @@ +/** + * Copying a Coach result table: as tab-separated values and an HTML table + * together (a spreadsheet takes the grid, a document takes the table), or + * as plain text with aligned columns. + * + * The cells arrive already formatted the way the table shows them. Two + * things are done to them on the way out: + * + * - Formula injection. A cell that starts with `=`, `+`, `-` or `@` (or a + * tab / carriage return) is read by a spreadsheet as a formula, and a + * table can carry text the person did not type — a lab analyte read off + * an uploaded report. Such a cell gets a leading `'`, which spreadsheets + * treat as "this is text". A plain negative number is left alone. + * - Separators. A tab or line break inside a cell would split the grid; + * both become a space. + * + * Client-safe: no server import. + */ + +/** One row of formatted cells; `null` is a period without a reading. */ +export type ClipboardRow = ReadonlyArray; + +export interface ClipboardGrid { + /** Column headings, units included. */ + header: ReadonlyArray; + rows: ReadonlyArray; + /** Right-align these columns in the plain-text form (the numbers). */ + alignRight?: ReadonlyArray; +} + +const FORMULA_START = /^[=+\-@\t\r]/; +const NEGATIVE_NUMBER = /^-\s?\d[\d\s.,  ]*$/; + +/** A cell with its separators flattened and a formula start neutralised. */ +export function sanitiseCell(value: string | null): string { + if (value === null) return ""; + const flat = value.replace(/[\t\r\n]+/g, " "); + if (FORMULA_START.test(flat) && !NEGATIVE_NUMBER.test(flat)) { + return `'${flat}`; + } + return flat; +} + +/** Tab-separated values, one line per row, the header first. */ +export function toTsv(grid: ClipboardGrid): string { + return [grid.header, ...grid.rows] + .map((row) => row.map(sanitiseCell).join("\t")) + .join("\n"); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** An HTML table, every cell escaped, with an optional caption. */ +export function toHtmlTable(grid: ClipboardGrid, caption?: string): string { + const cell = (tag: "th" | "td", value: string | null) => + `<${tag}>${escapeHtml(sanitiseCell(value))}`; + const head = `${grid.header.map((h) => cell("th", h)).join("")}`; + const body = `${grid.rows + .map((row) => `${row.map((v) => cell("td", v)).join("")}`) + .join("")}`; + const captionHtml = caption + ? `${escapeHtml(caption)}` + : ""; + return `${captionHtml}${head}${body}
`; +} + +/** + * Plain text with the columns padded to line up in a monospaced font; a + * period without a reading reads as "—". Separators are flattened, but no + * formula guard: this form is for reading, not for a spreadsheet. + */ +export function toPlainText(grid: ClipboardGrid, caption?: string): string { + const lines = [grid.header, ...grid.rows].map((row) => + row.map((value) => + value === null ? "—" : value.replace(/[\t\r\n]+/g, " "), + ), + ); + const widths = grid.header.map((_, index) => + Math.max(...lines.map((row) => [...(row[index] ?? "")].length)), + ); + const body = lines.map((row) => + row + .map((value, index) => { + const pad = " ".repeat(widths[index] - [...value].length); + return grid.alignRight?.[index] ? pad + value : value + pad; + }) + .join(" ") + .trimEnd(), + ); + return [...(caption ? [caption] : []), ...body].join("\n"); +} + +/** + * Put the table on the clipboard as TSV and HTML in one item. Falls back to + * the TSV alone where `ClipboardItem` or `clipboard.write` is missing. + * Rejects when neither works; the caller says so. + */ +export async function copyResultTable( + grid: ClipboardGrid, + caption?: string, +): Promise { + const tsv = toTsv(grid); + const clipboard = navigator.clipboard; + if ( + typeof ClipboardItem !== "undefined" && + typeof clipboard.write === "function" + ) { + try { + await clipboard.write([ + new ClipboardItem({ + "text/plain": new Blob([tsv], { type: "text/plain" }), + "text/html": new Blob([toHtmlTable(grid, caption)], { + type: "text/html", + }), + }), + ]); + return; + } catch { + // Some browsers refuse the HTML flavour; the grid alone still helps. + } + } + await clipboard.writeText(tsv); +} + +/** Put the plain-text form on the clipboard. */ +export async function copyResultText( + grid: ClipboardGrid, + caption?: string, +): Promise { + await navigator.clipboard.writeText(toPlainText(grid, caption)); +}