Merge branch 'coach/wp2-tables' into release/v1.39.4

# Conflicts:
#	src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:55:05 +02:00
commit 914fcbcbf1
29 files changed
+4122 -72

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>
);
}