From 3f355fbde4a89535f7cc73ef08aac7c60f8d5a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Jul 2026 19:13:39 +0200 Subject: [PATCH] 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 --- messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + src/app/insights/blood-pressure/page.tsx | 1 + src/app/insights/bmi/page.tsx | 1 + src/app/insights/pulse/page.tsx | 1 + src/app/insights/weight/page.tsx | 1 + .../__tests__/chart-data-table.test.tsx | 138 +++ .../health-chart-data-table.test.tsx | 132 +++ src/components/charts/chart-data-table.tsx | 190 ++++ src/components/charts/health-chart.tsx | 977 ++++++++++-------- .../insights/healthkit-metric-page.tsx | 1 + 15 files changed, 1037 insertions(+), 453 deletions(-) create mode 100644 src/components/charts/__tests__/chart-data-table.test.tsx create mode 100644 src/components/charts/__tests__/health-chart-data-table.test.tsx create mode 100644 src/components/charts/chart-data-table.tsx diff --git a/messages/de.json b/messages/de.json index 225b1a535..5e38d38b1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f57f2e8e2..8f5a8b52b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 76cf71896..7f2b2817c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index f9fbc926a..16e785bca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 756e26c52..c366d144c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 9720559da..72518273c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index b43583f34..d2ad8102e 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -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 diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index dced35190..8f0fbc906 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -123,6 +123,7 @@ export default function InsightsBmiPage() { valueBands={BMI_BANDS} compareBaseline={compareBaseline} userTimezone={user?.timezone} + showDataTable /> diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 1f63ab298..de8f3fc0d 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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 diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 92d023935..cad7f711c 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -162,6 +162,7 @@ export default function InsightsGewichtPage() { userTimezone={user?.timezone} valueScale={weightScale} onVisibleStats={onVisibleStats} + showDataTable /> diff --git a/src/components/charts/__tests__/chart-data-table.test.tsx b/src/components/charts/__tests__/chart-data-table.test.tsx new file mode 100644 index 000000000..32afaa285 --- /dev/null +++ b/src/components/charts/__tests__/chart-data-table.test.tsx @@ -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( + {node}, + ); +} + +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 { + 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("", () => { + it("renders nothing when the chart drew no points", () => { + expect(render()).toBe(""); + }); + + it("renders nothing when no series carries a column", () => { + expect(render()).toBe(""); + }); + + it("starts collapsed, with the region hidden and the trigger unexpanded", () => { + const html = render(); + 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(); + expect(html).toContain("Data points (7)"); + }); + + it("prints one row per drawn point, newest first", () => { + const html = render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(html).toContain("One row per month of Blood pressure"); + }); + + it("captions a daily bucket without claiming an average", () => { + const html = render(); + 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(); + 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(); + expect(html).toMatch( + /data-slot="chart-data-table-region"[^>]*class="[^"]*overflow-y-auto/, + ); + expect(html).toMatch( + /data-slot="table-container"[^>]*class="[^"]*overflow-x-auto/, + ); + }); +}); diff --git a/src/components/charts/__tests__/health-chart-data-table.test.tsx b/src/components/charts/__tests__/health-chart-data-table.test.tsx new file mode 100644 index 000000000..794b5b962 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-data-table.test.tsx @@ -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 `` 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 = {}, +) { + 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( + + + , + ); + vi.doUnmock("@tanstack/react-query"); + return html; +} + +function rowCount(html: string): number { + return (html.match(/data-slot="chart-data-table-row"/g) ?? []).length; +} + +describe(" — 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"'); + }); +}); diff --git a/src/components/charts/chart-data-table.tsx b/src/components/charts/chart-data-table.tsx new file mode 100644 index 000000000..5c28eee29 --- /dev/null +++ b/src/components/charts/chart-data-table.tsx @@ -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 = { + 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 ( +
+ + + +
+ ); +} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index f7d590153..563b39fba 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -54,6 +54,7 @@ import type { import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import type { MeasurementType } from "@/generated/prisma/client"; import { ChartOverlayControls } from "./chart-overlay-controls"; +import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; import { useViewportWidth } from "@/hooks/use-viewport-width"; import { computeTickPositions } from "@/lib/charts/x-axis-density"; @@ -290,6 +291,14 @@ interface HealthChartProps { * days). Omitted on self-fetching mounts. */ preloadedCoverageDays?: number; + /** + * Render the drawn points as a table beneath the chart, behind a + * collapsed disclosure. Opt-in: the metric sub-pages want it (a chart + * shows the shape and hides the numbers, and an appointment needs the + * numbers), the dashboard tiles do not — a table under every tile would + * bury the dashboard it is meant to summarise. + */ + showDataTable?: boolean; } interface ChartDataPoint { @@ -601,6 +610,7 @@ export function HealthChart({ onDataReady, preloadedSeries, preloadedCoverageDays, + showDataTable = false, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -1082,16 +1092,19 @@ export function HealthChart({ onDataReadyRef.current?.(); }, [isLoading]); - const chartData = useMemo(() => { - if (!data?.length) return data; - + // v1.4.6: aggregate to weekly / monthly when the visible range is + // long enough that drawing every daily point would clutter the + // chart. Picking the bucket from the *visible* range, not the + // total dataset, so a "30 days" toggle still shows daily even on + // a five-year-old account. + // + // Held in its own memo so the grain has ONE source. `chartData` bucket + // by it, and the data table below the chart captions its rows with it — + // a table that said "daily" over a weekly mean would be a lie, and + // recomputing the decision separately is how that lie starts. + const visibleSlice = useMemo(() => { + if (!data?.length) return null; const sliced = rangePoints > 0 ? data.slice(-rangePoints) : [...data]; - - // v1.4.6: aggregate to weekly / monthly when the visible range is - // long enough that drawing every daily point would clutter the - // chart. Picking the bucket from the *visible* range, not the - // total dataset, so a "30 days" toggle still shows daily even on - // a five-year-old account. const rangeDays = sliced.length < 2 ? 0 @@ -1099,7 +1112,13 @@ export function HealthChart({ (sliced[sliced.length - 1].timestamp - sliced[0].timestamp) / (24 * 60 * 60 * 1000), ); - const bucketType = pickBucket(rangeDays); + return { sliced, bucketType: pickBucket(rangeDays) }; + }, [data, rangePoints]); + + const chartData = useMemo(() => { + if (!data?.length || !visibleSlice) return data; + + const { sliced, bucketType } = visibleSlice; const bucketed = bucketType === "day" @@ -1180,7 +1199,7 @@ export function HealthChart({ } return enriched; - }, [data, rangePoints, showMA, showTrend, types, tzFmt]); + }, [data, visibleSlice, showMA, showTrend, types, tzFmt]); // v1.12.8 — chart-reactive metric statistics. // @@ -1467,6 +1486,34 @@ export function HealthChart({ fmt, ]); + // Columns for the data table below the chart: one per series the chart + // actually drew a value for, labelled by the SAME helper that names the + // Recharts , so a header and a legend entry can never disagree. A + // type with no value anywhere in the visible window gets no column — a + // column of dashes states nothing the chart's own empty line does not. + const dataTableColumns = useMemo(() => { + if (!showDataTable || mini) return []; + const source = chartDataWithCompare ?? chartData; + if (!source?.length) return []; + return types + .filter((type) => + source.some( + (point) => + typeof point[type] === "number" && + Number.isFinite(point[type] as number), + ), + ) + .map((type) => ({ key: type, label: getTypeLabel(type, valueMode, t) })); + }, [ + showDataTable, + mini, + chartDataWithCompare, + chartData, + types, + valueMode, + t, + ]); + const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -1791,213 +1838,215 @@ export function HealthChart({ description={t("charts.noDataInRangeDescription")} /> ) : ( -
- {visibleBands.length > 0 ? ( - // v1.4.27 R3d MB2 — band overlay positioning fix. The - // overlay used to inset `right: 18px` while the chart - // margin is `right: 8`, so the band rectangle drifted left - // of the plotted line by 10 px on every viewport. Pin the - // overlay to the same right edge the ComposedChart uses so - // the band tracks the line exactly. -
- {visibleBands.map((band) => ( -
- ))} -
- ) : null} - -
- - +
+ {visibleBands.length > 0 ? ( + // v1.4.27 R3d MB2 — band overlay positioning fix. The + // overlay used to inset `right: 18px` while the chart + // margin is `right: 8`, so the band rectangle drifted left + // of the plotted line by 10 px on every viewport. Pin the + // overlay to the same right edge the ComposedChart uses so + // the band tracks the line exactly. +
- - - tzFmt.date( - new Date( - chartData?.[Math.round(value)]?.timestamp ?? Date.now(), - ), - ) - } - // v1.4.29 — Recharts ignores `interval` on numeric - // axes (`type="number"`). Hand explicit tick - // positions through the `ticks` prop so the legacy - // day-aware density policy stays effective on the - // pulse chart. - ticks={computeTickPositions(chartData ?? [], viewportWidth)} - padding={{ left: 10, right: 10 }} - tickMargin={10} - /> - - typeof value === "number" - ? formatAxisValue( - useDecimalAxis ? value : Math.round(value), - ) - : String(value) - } - unit={ - showYAxisUnit && (yAxisUnit ?? unit) - ? ` ${yAxisUnit ?? unit}` - : undefined - } - /> - {visibleTargetZones.map((zone) => ( - ( +
))} - {visibleTargetZones.map((zone) => ( - + ) : null} + +
+ + + - ))} - {visibleTargetZones.map((zone) => ( - + tzFmt.date( + new Date( + chartData?.[Math.round(value)]?.timestamp ?? + Date.now(), + ), + ) + } + // v1.4.29 — Recharts ignores `interval` on numeric + // axes (`type="number"`). Hand explicit tick + // positions through the `ticks` prop so the legacy + // day-aware density policy stays effective on the + // pulse chart. + ticks={computeTickPositions(chartData ?? [], viewportWidth)} + padding={{ left: 10, right: 10 }} + tickMargin={10} + /> + + typeof value === "number" + ? formatAxisValue( + useDecimalAxis ? value : Math.round(value), + ) + : String(value) + } + unit={ + showYAxisUnit && (yAxisUnit ?? unit) + ? ` ${yAxisUnit ?? unit}` : undefined } /> - ))} - {/* v1.4.20 phase B4 — storyboard annotations. + {visibleTargetZones.map((zone) => ( + + ))} + {visibleTargetZones.map((zone) => ( + + ))} + {visibleTargetZones.map((zone) => ( + + ))} + {/* v1.4.20 phase B4 — storyboard annotations. Vertical reference lines pinned to a specific visible pointIndex; the label sits above with a short truncation on `=sm. Annotations off-window silently drop via `ifOverflow="discard"`. */} - {annotationPositions.map((annotation, i) => ( - - ))} - {/* v1.4.25 W6 — GLP-1 injection-day vertical markers. + {annotationPositions.map((annotation, i) => ( + + ))} + {/* v1.4.25 W6 — GLP-1 injection-day vertical markers. Thin dashed line + a small filled dot at the x-axis intersection so a row of markers reads as "injection cadence" without crowding the canvas. Optional label rendered only when the caller passed one; the dashboard tile leaves it undefined (the date is redundant — the chart's x-axis already shows it). */} - {verticalMarkerPositions.map((marker, i) => ( - - ))} - {yDomain && - verticalMarkerPositions.map((marker, i) => ( - ( + ))} - {/* v1.4.18 — personal-baseline reference line is now + {yDomain && + verticalMarkerPositions.map((marker, i) => ( + + ))} + {/* v1.4.18 — personal-baseline reference line is now opt-in via the Trend toggle. the maintainer rejected the always-on dashed mean line; it now only paints when the user actively shows the trend overlay (matching @@ -2005,177 +2054,181 @@ export function HealthChart({ 90-day rolling median per type, faint dashed line, only the first type gets the inline label so multi-type charts don't paint duplicate labels. */} - {showTrend && - types.map((type, i) => { - const baseline = personalBaselines.get(type); - if (baseline == null) return null; - return ( - { + const baseline = personalBaselines.get(type); + if (baseline == null) return null; + return ( + + ); + })} + { + const { + active, + payload, + label: rechartsLabel, + } = props as unknown as { + active?: boolean; + payload?: Array<{ + name?: string; + value?: number; + color?: string; + dataKey?: string; + payload?: ChartDataPoint; + }>; + label?: number; + }; + if (!active || !payload?.length) return null; + const ts = + payload[0]?.payload?.timestamp ?? + (typeof rechartsLabel === "number" + ? chartData?.[Math.round(rechartsLabel)]?.timestamp + : undefined); + const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; + const rows: RichTooltipRow[] = []; + // Build a quick lookup of compare values for this + // hover-day so the current-period row can attach + // a "vs. last month / year" delta inline. Same-day + // current ↔ compare values come from the SAME + // payload object. + const hoverPoint = payload[0]?.payload as + ChartDataPoint | undefined; + for (const item of payload) { + if (typeof item.value !== "number") continue; + const dataKey = String(item.dataKey ?? ""); + // Skip auxiliary lines (`*_ma`, `*_trend`, + // `*_compare`) — the comparison value is rendered + // inline as the delta on the current-period row, + // and ma / trend already appear as dashed overlays + // on the chart itself. + if ( + dataKey.endsWith("_ma") || + dataKey.endsWith("_trend") || + dataKey.endsWith("_compare") + ) + continue; + const baseline = personalBaselines.get(dataKey); + let delta: string | undefined; + // v1.4.16 phase B8 — prefer the comparison delta + // ("Δ −7 vs. last month") over the personal + // baseline delta when comparison is active and we + // have a prior value for this day. Otherwise fall + // back to the existing baseline-delta path. + const compareValue = + effectiveCompareBaseline !== "none" && hoverPoint + ? (hoverPoint[`${dataKey}_compare`] as + number | undefined) + : undefined; + if ( + effectiveCompareBaseline !== "none" && + typeof compareValue === "number" && + Number.isFinite(compareValue) + ) { + const diff = item.value - compareValue; + if (Math.abs(diff) < 0.05) { + delta = t("charts.deltaUnchanged"); + } else { + const sign = diff > 0 ? "+" : "−"; + const formatted = `${sign}${fmt.number( + Math.abs(diff), + 1, + )}${unit ? ` ${unit}` : ""}`; + delta = t( + effectiveCompareBaseline === "lastMonth" + ? "comparison.deltaVs.lastMonth" + : "comparison.deltaVs.lastYear", + ).replace("{delta}", formatted); + } + } else if (baseline != null) { + const diff = item.value - baseline; + if (Math.abs(diff) < 0.05) { + delta = t("charts.deltaUnchanged"); + } else { + const sign = diff > 0 ? "+" : "−"; + const formatted = `${sign}${fmt.number( + Math.abs(diff), + 1, + )}${unit ? ` ${unit}` : ""}`; + delta = t("charts.deltaVsBaseline").replace( + "{delta}", + formatted, + ); + } } - /> - ); - })} - { - const { - active, - payload, - label: rechartsLabel, - } = props as unknown as { - active?: boolean; - payload?: Array<{ - name?: string; - value?: number; - color?: string; - dataKey?: string; - payload?: ChartDataPoint; - }>; - label?: number; - }; - if (!active || !payload?.length) return null; - const ts = - payload[0]?.payload?.timestamp ?? - (typeof rechartsLabel === "number" - ? chartData?.[Math.round(rechartsLabel)]?.timestamp - : undefined); - const dateLabel = ts ? tzFmt.date(new Date(ts)) : ""; - const rows: RichTooltipRow[] = []; - // Build a quick lookup of compare values for this - // hover-day so the current-period row can attach - // a "vs. last month / year" delta inline. Same-day - // current ↔ compare values come from the SAME - // payload object. - const hoverPoint = payload[0]?.payload as - ChartDataPoint | undefined; - for (const item of payload) { - if (typeof item.value !== "number") continue; - const dataKey = String(item.dataKey ?? ""); - // Skip auxiliary lines (`*_ma`, `*_trend`, - // `*_compare`) — the comparison value is rendered - // inline as the delta on the current-period row, - // and ma / trend already appear as dashed overlays - // on the chart itself. - if ( - dataKey.endsWith("_ma") || - dataKey.endsWith("_trend") || - dataKey.endsWith("_compare") - ) - continue; - const baseline = personalBaselines.get(dataKey); - let delta: string | undefined; - // v1.4.16 phase B8 — prefer the comparison delta - // ("Δ −7 vs. last month") over the personal - // baseline delta when comparison is active and we - // have a prior value for this day. Otherwise fall - // back to the existing baseline-delta path. - const compareValue = - effectiveCompareBaseline !== "none" && hoverPoint - ? (hoverPoint[`${dataKey}_compare`] as - number | undefined) - : undefined; - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { - const diff = item.value - compareValue; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.deltaVs.lastMonth" - : "comparison.deltaVs.lastYear", - ).replace("{delta}", formatted); - } - } else if (baseline != null) { - const diff = item.value - baseline; - if (Math.abs(diff) < 0.05) { - delta = t("charts.deltaUnchanged"); - } else { - const sign = diff > 0 ? "+" : "−"; - const formatted = `${sign}${fmt.number( - Math.abs(diff), - 1, - )}${unit ? ` ${unit}` : ""}`; - delta = t("charts.deltaVsBaseline").replace( - "{delta}", - formatted, - ); - } - } - rows.push({ - name: item.name ?? dataKey, - value: `${formatTooltipValue(item.value)}${ - unit ? ` ${unit}` : "" - }`, - color: item.color ?? "var(--dracula-purple)", - delta, - }); - // v1.4.16 phase B8 — also surface the prior-period - // value as its own row so the user reads both - // numbers (current AND last-month / last-year) - // alongside the delta. - if ( - effectiveCompareBaseline !== "none" && - typeof compareValue === "number" && - Number.isFinite(compareValue) - ) { rows.push({ - name: `${item.name ?? dataKey} · ${t( - "comparison.tooltipPrior", - )}`, - value: `${formatTooltipValue(compareValue)}${ + name: item.name ?? dataKey, + value: `${formatTooltipValue(item.value)}${ unit ? ` ${unit}` : "" }`, color: item.color ?? "var(--dracula-purple)", + delta, }); + // v1.4.16 phase B8 — also surface the prior-period + // value as its own row so the user reads both + // numbers (current AND last-month / last-year) + // alongside the delta. + if ( + effectiveCompareBaseline !== "none" && + typeof compareValue === "number" && + Number.isFinite(compareValue) + ) { + rows.push({ + name: `${item.name ?? dataKey} · ${t( + "comparison.tooltipPrior", + )}`, + value: `${formatTooltipValue(compareValue)}${ + unit ? ` ${unit}` : "" + }`, + color: item.color ?? "var(--dracula-purple)", + }); + } } - } - if (rows.length === 0) return null; - return ( - - ); - }} - /> - {showContextDetails && ( - + ); }} /> - )} - {/* v1.8.5 — min–max range band. An Apple-Health-style + {showContextDetails && ( + + )} + {/* v1.8.5 — min–max range band. An Apple-Health-style shaded area between each day's min and max, painted behind the mean line so it reads as context, not a second series. Gated on `!mini` (the dashboard @@ -2185,69 +2238,69 @@ export function HealthChart({ weight, glucose, BP). The toggle rides the existing target-range pref so the user controls both bands from one switch. */} - {!mini && - showBands && - types.map((type, i) => ( - ( + + ))} + {types.map((type, i) => ( + ))} - {types.map((type, i) => ( - - ))} - {showMA && - types.map((type, i) => ( - - ))} - {showTrend && - types.map((type) => ( - - ))} - {/* v1.4.16 phase B8 — comparison overlay. + {showMA && + types.map((type, i) => ( + + ))} + {showTrend && + types.map((type) => ( + + ))} + {/* v1.4.16 phase B8 — comparison overlay. A dimmed dashed line per type for the prior period (lastMonth / lastYear), painted BENEATH the current series via Recharts' source-order layering. Same @@ -2255,48 +2308,66 @@ export function HealthChart({ stroke opacity (45%) and a thinner stroke (1.25) so the user reads the current line first and the overlay as orientation. */} - {effectiveCompareBaseline !== "none" && - hasComparisonData && - types.map((type, i) => ( - - ))} - - -
- {/* Sparse-data caption. With fewer than three daily points the + {effectiveCompareBaseline !== "none" && + hasComparisonData && + types.map((type, i) => ( + + ))} + + +
+ {/* Sparse-data caption. With fewer than three daily points the chart still paints every reading (a single marker for one day, a line for two), and this subtle note sets the expectation that more days fill out the trend — rather than withholding the data behind a placeholder card. */} - {!mini && (chartData?.length ?? 0) < 3 ? ( -

- {t("charts.sparseDataCaption")} -

+ {!mini && (chartData?.length ?? 0) < 3 ? ( +

+ {t("charts.sparseDataCaption")} +

+ ) : null} +
+ {/* The points the chart just drew, as a table. Reads the SAME + expression `` is handed above, so the two + cannot disagree. Sits inside this branch on purpose: an empty + window paints the empty state and no table at all, because + `` already says there is nothing here and a + disclosure opening onto nothing would say it twice. */} + {showDataTable && !mini && dataTableColumns.length > 0 ? ( + ) : null} -
+ )}
); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index d453b59fb..e4c0519b4 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -433,6 +433,7 @@ export function HealthKitMetricPage({ valueScale={resolvedScale} valueOffset={resolvedOffset} onVisibleStats={onVisibleStats} + showDataTable /> {targetSummarySlug ? (