"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; /** * The column's own formatter, where its values are not the chart's (a * count beside a rate). Defaults to the table's `formatValue`. */ format?: (value: number) => 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 = { 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 (
); }