Files
HealthLog/src/components/charts/chart-data-table.tsx
T
Marc-André Bombeck bf83d414e2 feat: open any past day as a layer over the page
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day
over whichever page is open: docked beside it from 1280 px, the way the
Coach's conversations panel sits, a sheet from the right below that, and a
bottom sheet on a phone that opens at a little over half the height and
grows to full height from its handle. On /coach it is always a sheet, so
there is never more than one docked panel. Back and Escape close it, the
previous and next day replace the history entry instead of adding one,
Alt with an arrow steps a day, focus moves to the day's heading on open and
returns to whatever opened it on close, and a polite region names the day.
A malformed or future date is dropped from the URL.

The day shows the value it was opened from with its comparison, what ran
through the day, the readings against the person's own usual range on a
short number line (eight curated, the rest behind "All values"), and what
happened in order of the clock, each row linking to its record. Loading,
failure, an empty day and areas a grant does not cover each read as such.
The footer offers the timeline when that module is on and capture for the
day, with the capture forms starting on that date.

Doors into it: a click on a daily point of a metric chart (a tap shows the
value and the tooltip offers the day on touch screens), the dates in the
chart's data table, a quiet row of dots under the axis for days with
entries and a ring for notable ones, the lab chart and lab readings, the
measurement, mood and intake lists, symptom and illness day headings,
episode onset and recovery, document dates, workout dates, the workout
detail, the mood and adherence heatmaps, and the days a Coach answer's
get_day calls read, shown as chips. The next planned visit carries what
happened since the last one, each date opening its day, with a doctor
report for that window.

Not here, and frozen by a guard: the dashboard's today area, mini charts
in tiles, the cycle calendar, model-written text, future dates, the
clinician share link and the doctor-report PDF.
2026-10-08 14:20:01 +02:00

240 lines
8.6 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";
import { chartPointDayKey } from "@/components/day/chart-day";
import { DayLink } from "@/components/day/day-link";
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
/**
* 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;
/**
* v1.42 — each date cell opens its day: the keyboard and screen-reader
* way to a day from a chart, which has no keyboard of its own. Daily rows
* only; a week or month row has no single day.
*/
dayLinks?: boolean;
}
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,
dayLinks = false,
}: ChartDataTableProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const regionId = useId();
if (points.length === 0 || columns.length === 0) return null;
// The row's values, carried to the top of the day it opens.
const dayFocusOf = (point: ChartTablePoint) => {
const values = columns
.map((column) => point[column.key])
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
return values.length > 0
? {
label: metricLabel,
value: values.map(formatValue).join("/"),
unit,
types: columns.map((column) => column.key),
}
: undefined;
};
// 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 })}
{/* Name the statistic where it is not the plain mean of the
readings: a day of pulse is the mean of its hours' means. */}
{columns
.filter((column) => usesHourlyMeanDay(column.key))
.map((column) => (
<span
key={column.key}
data-slot="chart-data-table-statistic"
className="mt-1 block"
>
{t("charts.dataTable.hourlyMeanNote", {
metric: column.label,
})}
</span>
))}
</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"
>
{dayLinks && bucket === "day" ? (
<DayLink
date={chartPointDayKey(point.timestamp)}
focus={dayFocusOf(point)}
>
{formatDate(new Date(point.timestamp))}
</DayLink>
) : (
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>
);
}