mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user