mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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
191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|