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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:54:31 +02:00
1 parent a967ccac44
commit 47efdd6606
6 files changed
+763 -7

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("CoachResultTable", () => {
it("captions the table with its title and the method line", () => {
const html = render(
<CoachResultTable result={table(3)} method="30 readings, daily means" />,
);
expect(html).toMatch(
/<caption[^>]*>.*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(<CoachResultTable result={table(3)} />);
expect(html.match(/<th[^>]*scope="col"/g)).toHaveLength(3);
expect(html).toContain("(mmHg)");
expect(html).toMatch(/<td[^>]*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(<CoachResultTable result={table(3)} />);
expect(html).toContain(
'<span class="text-muted-foreground" aria-hidden="true">—</span>',
);
expect(html).toContain('<span class="sr-only">no reading</span>');
expect(html).not.toMatch(/>0<\/td>/);
});
it("shows the first twelve rows and offers the rest", () => {
const html = render(<CoachResultTable result={table(20)} />);
expect(html.match(/<tr[^>]*data-slot="table-row"/g)).toHaveLength(
RESULT_TABLE_PREVIEW_ROWS + 1,
);
const button = html.match(
/<button[^>]*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(<CoachResultTable result={table(5)} />);
expect(html).not.toContain("aria-expanded");
});
});
@@ -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<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const run = useCallback(
async (copy: (g: ClipboardGrid, c: string) => Promise<void>) => {
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
data-slot="coach-copy-table"
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-slot="coach-copy-table-spreadsheet"
onSelect={() => void run(copyResultTable)}
>
{t(COACH_RESULT_UI_KEYS.copyForSpreadsheet)}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-copy-table-text"
onSelect={() => void run(copyResultText)}
>
{t(COACH_RESULT_UI_KEYS.copyAsText)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="sr-only" role="status" aria-live="polite">
{copied ? t(COACH_RESULT_UI_KEYS.copied) : ""}
</span>
</>
);
}
@@ -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 (
<div
data-slot="coach-result-table"
data-ref={result.ref}
className="bg-card rounded-lg border"
>
<div
id={regionId}
{...(expanded
? { role: "region", tabIndex: 0, "aria-labelledby": titleId }
: {})}
className={cn(
"overflow-x-auto rounded-lg",
expanded &&
"focus-visible:ring-ring/50 max-h-96 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2",
)}
>
<table className="w-full caption-top text-sm">
<caption className="px-3 pt-2 pb-1 text-left">
<span className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 pt-1.5">
<span
id={titleId}
className="text-foreground block font-medium"
>
{result.title}
</span>
{method ? (
<span className="text-muted-foreground block text-xs">
{method}
</span>
) : null}
</span>
<span className="shrink-0">
<CopyTableButton grid={grid} caption={result.title} />
</span>
</span>
</caption>
<TableHeader className="bg-card sticky top-0 z-10">
<TableRow className="hover:bg-transparent">
{result.columns.map((column) => (
<TableHead
key={column.key}
scope="col"
className={cn("h-9 px-3", isNumeric(column) && "text-right")}
>
{column.label}
{column.unit ? (
<span className="text-muted-foreground font-normal">
{" "}
({column.unit})
</span>
) : null}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, rowIndex) => (
<TableRow key={`${String(row[0])}-${rowIndex}`}>
{result.columns.map((column, index) => {
const text = format(row[index] ?? null, column);
return (
<TableCell
key={column.key}
className={cn(
"px-3 py-1.5",
isNumeric(column) && "text-right tabular-nums",
)}
>
{text === null ? (
<>
<span
className="text-muted-foreground"
aria-hidden="true"
>
—
</span>
<span className="sr-only">{noReading}</span>
</>
) : (
text
)}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</table>
</div>
{hasMore || result.truncated ? (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-3 py-1.5">
{result.truncated ? (
<p className="text-muted-foreground text-xs">
{t(COACH_RESULT_UI_KEYS.truncated, {
shown: result.rows.length,
total: result.rowCount,
})}
</p>
) : (
<span />
)}
{hasMore ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
aria-expanded={expanded}
aria-controls={regionId}
onClick={() => setExpanded((open) => !open)}
>
{expanded
? t(COACH_RESULT_UI_KEYS.showFewer)
: t(COACH_RESULT_UI_KEYS.showAll, {
count: result.rows.length,
})}
</Button>
) : null}
</div>
) : null}
</div>
);
}