mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
8dc5c230da
commit
3f355fbde4
15 files changed
+1037
-453
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -123,6 +123,7 @@ export default function InsightsBmiPage() {
|
||||
valueBands={BMI_BANDS}
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
showDataTable
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="bmi" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("<120.0>");
|
||||
expect(html).toContain("<78.0>");
|
||||
});
|
||||
|
||||
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("<120.0> 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("<0.0>");
|
||||
});
|
||||
|
||||
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'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"');
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user