feat(charts): put the drawn data points under the chart as a table

A chart shows the shape and hides the numbers. An appointment needs the
numbers, and so does anyone moving readings into a spreadsheet.

The table reads the same expression the chart hands Recharts, after the range
slice, the week/month bucketing and the display scale, and it formats through
the chart's own value and date formatters. Nothing is re-derived, so a cell and
the tooltip cannot print two numbers for one point.

Collapsed by default, with the point count in the trigger so the size reads
while it is closed; the region scrolls in its own box rather than growing the
page, and the table primitive already scrolls a wide row set sideways. The
caption names the grain, because a weekly mean labelled as a daily reading
would be a lie, and the grain now has one source shared with the bucketing.
Derived overlays get no column: a moving average is drawn from the points, not
measured. An empty window renders no table at all, since the chart's own empty
state already says there is nothing there.

Opt-in per mount. The metric sub-pages take it; the dashboard tiles do not,
because a table under every tile buries the dashboard it summarises.

Refs #636
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 19:23:52 +02:00
1 parent 8dc5c230da
commit 3f355fbde4
15 files changed
+1037 -453

No files matched your search

+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "Diagramm konnte nicht geladen werden",
"loadErrorReload": "Neu laden",
"sparseDataCaption": "Mehr Messtage füllen den Trend.",
"dataTable": {
"toggle": "Datenpunkte ({count})",
"columnDate": "Datum",
"noValue": "Kein Wert",
"captionDay": "Eine Zeile pro Tag für {metric} — genau die Punkte, die das Diagramm zeichnet.",
"captionWeek": "Eine Zeile pro Woche für {metric}, der Mittelwert der Tage dieser Woche — genau die Punkte, die das Diagramm zeichnet.",
"captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats — genau die Punkte, die das Diagramm zeichnet."
},
"personalBaseline": "Dein Mittel",
"deltaVsBaseline": "{delta} vs. dein Mittel",
"deltaUnchanged": "entspricht deinem Mittel",
+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "Chart could not be loaded",
"loadErrorReload": "Reload",
"sparseDataCaption": "More days will fill out the trend.",
"dataTable": {
"toggle": "Data points ({count})",
"columnDate": "Date",
"noValue": "No value",
"captionDay": "One row per day of {metric}, the same points the chart draws.",
"captionWeek": "One row per week of {metric}, the average of that week's days — the same points the chart draws.",
"captionMonth": "One row per month of {metric}, the average of that month's days — the same points the chart draws."
},
"personalBaseline": "Your normal",
"deltaVsBaseline": "{delta} vs. your normal",
"deltaUnchanged": "matches your normal",
+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "No se pudo cargar el gráfico",
"loadErrorReload": "Recargar",
"sparseDataCaption": "Más días completarán la tendencia.",
"dataTable": {
"toggle": "Puntos de datos ({count})",
"columnDate": "Fecha",
"noValue": "Sin valor",
"captionDay": "Una fila por día de {metric}: los mismos puntos que dibuja el gráfico.",
"captionWeek": "Una fila por semana de {metric}, la media de los días de esa semana: los mismos puntos que dibuja el gráfico.",
"captionMonth": "Una fila por mes de {metric}, la media de los días de ese mes: los mismos puntos que dibuja el gráfico."
},
"personalBaseline": "Tu media",
"deltaVsBaseline": "{delta} vs. tu media",
"deltaUnchanged": "coincide con tu media",
+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "Impossible de charger le graphique",
"loadErrorReload": "Recharger",
"sparseDataCaption": "Davantage de jours étofferont la tendance.",
"dataTable": {
"toggle": "Points de données ({count})",
"columnDate": "Date",
"noValue": "Aucune valeur",
"captionDay": "Une ligne par jour pour {metric} : les mêmes points que ceux tracés par le graphique.",
"captionWeek": "Une ligne par semaine pour {metric}, la moyenne des jours de cette semaine : les mêmes points que ceux tracés par le graphique.",
"captionMonth": "Une ligne par mois pour {metric}, la moyenne des jours de ce mois : les mêmes points que ceux tracés par le graphique."
},
"personalBaseline": "Ta moyenne",
"deltaVsBaseline": "{delta} vs. ta moyenne",
"deltaUnchanged": "correspond à ta moyenne",
+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "Impossibile caricare il grafico",
"loadErrorReload": "Ricarica",
"sparseDataCaption": "Più giorni completeranno il trend.",
"dataTable": {
"toggle": "Punti dati ({count})",
"columnDate": "Data",
"noValue": "Nessun valore",
"captionDay": "Una riga per giorno di {metric}: gli stessi punti disegnati dal grafico.",
"captionWeek": "Una riga per settimana di {metric}, la media dei giorni di quella settimana: gli stessi punti disegnati dal grafico.",
"captionMonth": "Una riga per mese di {metric}, la media dei giorni di quel mese: gli stessi punti disegnati dal grafico."
},
"personalBaseline": "La tua media",
"deltaVsBaseline": "{delta} vs. la tua media",
"deltaUnchanged": "corrisponde alla tua media",
+8
View File
@@ -2026,6 +2026,14 @@
"loadErrorTitle": "Nie udało się załadować wykresu",
"loadErrorReload": "Załaduj ponownie",
"sparseDataCaption": "Więcej dni dopełni trend.",
"dataTable": {
"toggle": "Punkty danych ({count})",
"columnDate": "Data",
"noValue": "Brak wartości",
"captionDay": "Jeden wiersz na dzień dla {metric} — dokładnie te punkty, które rysuje wykres.",
"captionWeek": "Jeden wiersz na tydzień dla {metric}, średnia z dni tego tygodnia — dokładnie te punkty, które rysuje wykres.",
"captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca — dokładnie te punkty, które rysuje wykres."
},
"personalBaseline": "Twoja średnia",
"deltaVsBaseline": "{delta} vs. twoja średnia",
"deltaUnchanged": "odpowiada twojej średniej",
+1
View File
@@ -173,6 +173,7 @@ export default function InsightsBlutdruckPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
showDataTable
/>
{/* v1.12.4 — target card sits between the chart and the assessment on
+1
View File
@@ -123,6 +123,7 @@ export default function InsightsBmiPage() {
valueBands={BMI_BANDS}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
showDataTable
/>
<MetricTargetSummary slug="bmi" />
+1
View File
@@ -158,6 +158,7 @@ export default function InsightsPulsPage() {
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
onVisibleStats={onVisibleStats}
showDataTable
/>
{/* S11 — the intraday "shape of the day" layer: 10-minute mean heart
+1
View File
@@ -162,6 +162,7 @@ export default function InsightsGewichtPage() {
userTimezone={user?.timezone}
valueScale={weightScale}
onVisibleStats={onVisibleStats}
showDataTable
/>
<MetricTargetSummary slug="weight" />
@@ -0,0 +1,138 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
ChartDataTable,
type ChartDataTableProps,
type ChartTablePoint,
} from "../chart-data-table";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const BASE_TS = Date.UTC(2026, 5, 1, 12, 0, 0);
function points(count: number): ChartTablePoint[] {
return Array.from({ length: count }, (_, i) => ({
date: `d${i}`,
timestamp: BASE_TS + i * 86_400_000,
BLOOD_PRESSURE_SYS: 120 + i,
BLOOD_PRESSURE_DIA: 78 + i,
}));
}
function props(over: Partial<ChartDataTableProps> = {}): ChartDataTableProps {
return {
points: points(3),
columns: [
{ key: "BLOOD_PRESSURE_SYS", label: "Systolic" },
{ key: "BLOOD_PRESSURE_DIA", label: "Diastolic" },
],
unit: "mmHg",
formatValue: (v) => `<${v.toFixed(1)}>`,
formatDate: (d) => `day:${d.getUTCDate()}`,
bucket: "day",
metricLabel: "Blood pressure",
...over,
};
}
describe("<ChartDataTable>", () => {
it("renders nothing when the chart drew no points", () => {
expect(render(<ChartDataTable {...props({ points: [] })} />)).toBe("");
});
it("renders nothing when no series carries a column", () => {
expect(render(<ChartDataTable {...props({ columns: [] })} />)).toBe("");
});
it("starts collapsed, with the region hidden and the trigger unexpanded", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).toContain('aria-expanded="false"');
expect(html).toMatch(/hidden=""\s+data-slot="chart-data-table-region"/);
});
it("names the point count in the trigger so the size reads while closed", () => {
const html = render(<ChartDataTable {...props({ points: points(7) })} />);
expect(html).toContain("Data points (7)");
});
it("prints one row per drawn point, newest first", () => {
const html = render(<ChartDataTable {...props()} />);
const rows = html.match(/data-slot="chart-data-table-row"/g) ?? [];
expect(rows).toHaveLength(3);
// The fixture runs 1 → 3 June; the table must lead with the 3rd.
expect(html.indexOf("day:3")).toBeLessThan(html.indexOf("day:1"));
});
// The whole point of the feature: the table prints the chart's own
// formatter output, so a cell and the tooltip cannot show two numbers for
// one point.
it("prints values through the injected formatter verbatim", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).toContain("&lt;120.0&gt;");
expect(html).toContain("&lt;78.0&gt;");
});
it("puts the unit in the column header and leaves the cells bare", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).toContain("Systolic (mmHg)");
expect(html).toContain("Diastolic (mmHg)");
expect(html).not.toContain("&lt;120.0&gt; mmHg");
});
it("omits the unit suffix when the chart has none", () => {
const html = render(<ChartDataTable {...props({ unit: undefined })} />);
expect(html).toContain(">Systolic<");
expect(html).not.toContain("Systolic (");
});
it("marks a gap as absent rather than inventing a number", () => {
const gapped: ChartTablePoint[] = [
{ date: "d0", timestamp: BASE_TS, BLOOD_PRESSURE_SYS: 120 },
];
const html = render(<ChartDataTable {...props({ points: gapped })} />);
expect(html).toContain("—");
expect(html).toContain("No value");
expect(html).not.toContain("&lt;0.0&gt;");
});
it("captions a weekly bucket as an average, never as a daily reading", () => {
const html = render(<ChartDataTable {...props({ bucket: "week" })} />);
expect(html).toContain("One row per week of Blood pressure");
expect(html).toContain("the average of that week&#x27;s days");
});
it("captions a monthly bucket as an average", () => {
const html = render(<ChartDataTable {...props({ bucket: "month" })} />);
expect(html).toContain("One row per month of Blood pressure");
});
it("captions a daily bucket without claiming an average", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).toContain("One row per day of Blood pressure");
expect(html).not.toContain("the average of");
});
it("makes the date the row header so a row reads as a labelled record", () => {
const html = render(<ChartDataTable {...props()} />);
expect(html).toContain('scope="row"');
expect(html).toContain('scope="col"');
});
// The release just fixed a horizontal-overflow defect; wide content has to
// scroll inside its own box, and a long series must not grow the page.
it("scrolls inside its own box in both axes", () => {
const html = render(<ChartDataTable {...props({ points: points(200) })} />);
expect(html).toMatch(
/data-slot="chart-data-table-region"[^>]*class="[^"]*overflow-y-auto/,
);
expect(html).toMatch(
/data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/,
);
});
});
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import enMessages from "../../../../messages/en.json";
/**
* The table under the chart must show what the chart drew.
*
* These render the real `<HealthChart>` so the table is fed by the component's
* own resolution path — the range slice, the bucketing, the row order — rather
* than by a fixture handed straight to the table. If the chart's points and the
* table's rows can ever diverge, that is the failure this catches.
*/
/** `spacingDays` apart, so a fixed point count can span a chosen range. */
function series(count: number, spacingDays = 1) {
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
return Array.from({ length: count }, (_, i) => ({
date: `p${i}`,
timestamp: base + i * spacingDays * 86_400_000,
PULSE: 60 + i,
}));
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null, isLoading: false }),
}));
async function renderChart(
data: unknown[],
extraProps: Record<string, unknown> = {},
) {
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en" initialMessages={enMessages}>
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" {...extraProps} />
</I18nProvider>,
);
vi.doUnmock("@tanstack/react-query");
return html;
}
function rowCount(html: string): number {
return (html.match(/data-slot="chart-data-table-row"/g) ?? []).length;
}
describe("<HealthChart> — data table", () => {
beforeEach(() => {
vi.resetModules();
});
it("does not render a table unless the mount asks for one", async () => {
const html = await renderChart(series(10));
expect(html).not.toContain('data-slot="chart-data-table"');
});
it("renders one row per drawn point when the mount opts in", async () => {
const html = await renderChart(series(10), { showDataTable: true });
expect(html).toContain('data-slot="chart-data-table"');
expect(rowCount(html)).toBe(10);
expect(html).toContain("Data points (10)");
});
it("prints the drawn values through the chart's own tooltip formatter", async () => {
const html = await renderChart(series(3), { showDataTable: true });
// One decimal, the same `formatTooltipValue` the hover read-out uses.
expect(html).toContain(">60.0<");
expect(html).toContain(">61.0<");
expect(html).toContain(">62.0<");
});
// The overlays are drawn FROM the points, not measured. A table of "the data
// points behind the chart" that carried them would claim more than the chart
// recorded.
it("carries no column for the derived overlay series", async () => {
const html = await renderChart(series(10), {
showDataTable: true,
chartKey: "pulse",
});
expect(html).not.toContain("PULSE_ma");
expect(html).not.toContain("PULSE_trend");
expect(html).not.toContain("PULSE_compare");
// Exactly two column headers: the date and the one series.
const headers = html.match(/scope="col"/g) ?? [];
expect(headers).toHaveLength(2);
});
it("honours the visible range rather than the whole fetched series", async () => {
// 40 points fetched, the default range tab draws the newest 30.
const html = await renderChart(series(40), { showDataTable: true });
expect(rowCount(html)).toBe(30);
expect(html).toContain("Data points (30)");
});
it("captions a bucketed range as an average instead of implying daily readings", async () => {
// 30 points four days apart span 116 days, so the chart buckets to ISO
// weeks and several points share a bucket. The rows are weekly means and
// the caption has to say so.
const html = await renderChart(series(30, 4), { showDataTable: true });
expect(html).toContain("One row per week of");
expect(html).toContain("the average of that week");
expect(html).not.toContain("One row per day of");
// Weekly buckets collapse the 30 daily points into fewer rows.
expect(rowCount(html)).toBeLessThan(30);
expect(rowCount(html)).toBeGreaterThan(0);
});
it("shows no table at all when the window holds nothing", async () => {
const html = await renderChart([], { showDataTable: true });
expect(html).not.toContain('data-slot="chart-data-table"');
});
it("stays off a mini chart even when the prop is set", async () => {
const html = await renderChart(series(10), {
showDataTable: true,
mini: true,
});
expect(html).not.toContain('data-slot="chart-data-table"');
});
});
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { ChartBucketType } from "@/lib/charts/bucket-time-series";
/**
* The data points behind the chart, as a table.
*
* A chart shows the shape and hides the numbers. A doctor's appointment needs
* the numbers, and so does anyone moving readings into a spreadsheet. This
* renders the series the chart just drew, row per point, column per metric.
*
* It takes the FINALISED array — the same expression Recharts is handed, after
* the range slice, the week/month bucketing and the display scale — and it
* takes the chart's own value and date formatters as props. Nothing is
* re-derived here, so the table cannot disagree with the line above it or with
* the tooltip: they are the same numbers put through the same formatter.
*
* Derived overlays (moving average, trend line, comparison period) are NOT
* columns. They are drawn from the points rather than measured, and a table of
* "the data points behind the chart" that carried them would be claiming more
* than the chart recorded.
*
* Collapsed by default: the chart is the primary read, and a hundred rows
* expanded under every metric page would bury everything below it. The point
* count rides in the trigger so the size is legible without opening. Once open
* the region scrolls in its own box rather than growing the page.
*
* No Collapsible primitive exists in the UI kit, so this follows the same
* button + region disclosure the glucose panel uses: `aria-expanded` on the
* trigger, `aria-controls` / `hidden` on the region.
*/
/**
* Structurally the chart's own `ChartDataPoint`. Declared here rather than
* imported so the table stays independent of the chart module (which is behind
* a lazy boundary and would otherwise import in a cycle).
*/
export interface ChartTablePoint {
date: string;
timestamp: number;
[key: string]: string | number | undefined | [number, number];
}
export interface ChartDataTableColumn {
/** The point key to read — a `MeasurementType` string. */
key: string;
/** Resolved column label, from the chart's own series-label helper. */
label: string;
}
export interface ChartDataTableProps {
/** The finalised points, in the chart's own order (oldest first). */
points: readonly ChartTablePoint[];
columns: readonly ChartDataTableColumn[];
/** Display unit for every column; appended to each header when present. */
unit?: string | undefined;
/** The chart's value formatter — the one its tooltip uses. */
formatValue: (value: number) => string;
/** The chart's date formatter — the one its axis and tooltip use. */
formatDate: (date: Date) => string;
/** The grain of one row, so the caption never implies a daily reading. */
bucket: ChartBucketType;
/** Metric name for the table caption. */
metricLabel: string;
}
const BUCKET_CAPTION_KEY: Record<ChartBucketType, string> = {
day: "charts.dataTable.captionDay",
week: "charts.dataTable.captionWeek",
month: "charts.dataTable.captionMonth",
};
export function ChartDataTable({
points,
columns,
unit,
formatValue,
formatDate,
bucket,
metricLabel,
}: ChartDataTableProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const regionId = useId();
if (points.length === 0 || columns.length === 0) return null;
// Newest first, matching the measurement list — the two places the app
// prints the same readings must not disagree on reading order.
const rows = [...points].reverse();
return (
<div
data-slot="chart-data-table"
className="border-border mt-3 border-t pt-3"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="chart-data-table-toggle"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<span>{t("charts.dataTable.toggle", { count: points.length })}</span>
<ChevronDown
aria-hidden="true"
className={cn("size-4 transition-transform", open && "rotate-180")}
/>
</button>
<div
id={regionId}
hidden={!open}
data-slot="chart-data-table-region"
className="max-h-96 overflow-y-auto overscroll-contain pt-2"
>
<Table>
<TableCaption className="mt-2 text-left text-xs">
{t(BUCKET_CAPTION_KEY[bucket], { metric: metricLabel })}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">
{t("charts.dataTable.columnDate")}
</TableHead>
{columns.map((column) => (
<TableHead key={column.key} scope="col" className="text-right">
{unit ? `${column.label} (${unit})` : column.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((point) => (
<TableRow key={point.timestamp} data-slot="chart-data-table-row">
{/* The date is the row's header, so a screen reader reads
"12 July, systolic 128" rather than a bare number. */}
<TableHead
scope="row"
className="text-foreground h-auto p-2 font-normal"
>
{formatDate(new Date(point.timestamp))}
</TableHead>
{columns.map((column) => {
const value = point[column.key];
const numeric =
typeof value === "number" && Number.isFinite(value)
? value
: null;
return (
<TableCell
key={column.key}
className="text-foreground text-right tabular-nums"
>
{numeric === null ? (
<>
<span aria-hidden="true">—</span>
<span className="sr-only">
{t("charts.dataTable.noValue")}
</span>
</>
) : (
formatValue(numeric)
)}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -433,6 +433,7 @@ export function HealthKitMetricPage({
valueScale={resolvedScale}
valueOffset={resolvedOffset}
onVisibleStats={onVisibleStats}
showDataTable
/>
{targetSummarySlug ? (
<MetricTargetSummary slug={targetSummarySlug} />