From 444ff66086d762c2e0768155f4b2a68a0397927e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 26 Oct 2026 07:59:58 +0100 Subject: [PATCH] refactor(charts): decide in the overlays hook where a chart's labels go The narrow-chart rule lived in HealthChart's body and grew it past its size baseline. The overlays hook, which already sizes the room beside the plot, now takes the chart's width, keeps no room on a narrow chart and marks each target zone whose label sits inside it; the x axis helper works out its own tick layout. The tiered series unit test mocks the lookup for readings older than the rollups and covers the head the read puts in front of them. --- .../health-chart-axes.browser.test.tsx | 2 +- src/components/charts/health-chart.tsx | 32 ++++-------- .../charts/health-chart/chart-layers.tsx | 3 +- .../charts/health-chart/series-math.ts | 17 ++++++- src/components/charts/health-chart/types.ts | 2 + .../health-chart/use-health-chart-overlays.ts | 25 ++++++--- .../daily-series-read-tiered.test.ts | 51 +++++++++++++++++++ 7 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx index 289218ea8..96582d693 100644 --- a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx +++ b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx @@ -102,10 +102,10 @@ function Chart({ width = 720 }: { width?: number }) { heightPct: 0, label: zoneLabel, textColor: undefined, + labelInside: !besidePlot, }, ], POINTS.length - 1, - besidePlot, )} {baselineLayers( true, diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 57b2b53ad..f90bebf1a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -51,12 +51,10 @@ import { axisValueFormatters, dayFocusFor, getTypeLabel, - labelsBesidePlot, makeDayKeyFormatter, pointIndexXAxis, resolveAnnotationPositions, resolveVerticalMarkerPositions, - xTickLayout, } from "./health-chart/series-math"; import type { HealthChartProps } from "./health-chart/types"; import { @@ -67,9 +65,6 @@ import { useHealthChartSeries } from "./health-chart/use-health-chart-series"; import { useHealthChartView } from "./health-chart/use-health-chart-view"; import { resolveMiniRangePoints } from "./mini-window"; -/** A date with a two-digit day and month: the widest an x label gets. */ -const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); - /** * A metric over time: the line, the overlays a person can switch on (the * 7-day average, the trend, the target range, the prior period), reference @@ -216,6 +211,7 @@ export function HealthChart({ onVisibleStats, }); + const [containerWidth, setContainerWidth] = useState(null); const overlays = useHealthChartOverlays({ showBands, showTrend, @@ -228,16 +224,13 @@ export function HealthChart({ valueMode, t, fmt, + plotWidth: containerWidth ?? viewportWidth, }); const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } = overlays; - const [containerWidth, setContainerWidth] = useState(null); const { useDecimalAxis, formatAxisValue, formatTooltipValue } = axisValueFormatters(yDomain, fmt, yTicks); - // Until the container is measured, the viewport stands in for its width. - const besidePlot = labelsBesidePlot(containerWidth ?? viewportWidth); - const rightRoom = besidePlot ? overlays.rightRoom : 0; const { primaryType, chartAriaLabel, dataTableColumns } = useHealthChartLabels({ @@ -405,7 +398,7 @@ export function HealthChart({ data={chartDataWithCompare ?? chartData} margin={{ top: 10, - right: 8 + rightRoom, + right: 8 + overlays.rightRoom, bottom: 8, left: 8, }} @@ -422,12 +415,9 @@ export function HealthChart({ chartData, formatDate: tzFmt.date, viewportWidth, - layout: xTickLayout( - containerWidth, - yAxisWidth, - rightRoom, - tzFmt.date(X_LABEL_SAMPLE_DATE), - ), + containerWidth, + yAxisWidth, + rightRoom: overlays.rightRoom, })} /> - {targetZoneLayers( - visibleTargetZones, - maxPointIndex, - besidePlot, - )} + {targetZoneLayers(visibleTargetZones, maxPointIndex)} {annotationLayers(annotationPositions, viewportWidth)} {verticalMarkerLayers(verticalMarkerPositions, yDomain)} {openDayLayers(openDayIndex, openDayPoint, types, colors)} @@ -465,7 +451,7 @@ export function HealthChart({ colors, personalBaselines, t, - besidePlot, + overlays.besidePlot, )} ) : null} {/* The points the chart just drew, as a table, read from the same diff --git a/src/components/charts/health-chart/chart-layers.tsx b/src/components/charts/health-chart/chart-layers.tsx index 9a8a56c59..dada67197 100644 --- a/src/components/charts/health-chart/chart-layers.tsx +++ b/src/components/charts/health-chart/chart-layers.tsx @@ -28,7 +28,6 @@ type Translate = (key: string) => string; export function targetZoneLayers( zones: VisibleTargetZone[], maxPointIndex: number, - besidePlot = true, ) { return [ zones.map((zone) => ( @@ -68,7 +67,7 @@ export function targetZoneLayers( value: zone.label, // On a narrow chart, inside the zone at its top right: the // plot keeps no room beside it there (`labelsBesidePlot`). - position: besidePlot ? "right" : "insideTopRight", + position: zone.labelInside ? "insideTopRight" : "right", offset: BASELINE_LABEL_GAP, fill: zone.textColor ?? zone.color, fontSize: BASELINE_LABEL_FONT_SIZE, diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts index 9f1a115e9..d1f1481b1 100644 --- a/src/components/charts/health-chart/series-math.ts +++ b/src/components/charts/health-chart/series-math.ts @@ -350,6 +350,9 @@ export function gridYear(timestamp: number): number { return date.getUTCMonth() >= 6 ? year + 1 : year; } +/** A date with a two-digit day and month: the widest an x label gets. */ +const X_LABEL_SAMPLE_DATE = new Date(Date.UTC(2000, 11, 31, 12)); + /** * The x axis's props: one point per index, labelled with its date. Recharts * ignores `interval` on a numeric axis, so the calendar grid arrives as @@ -360,13 +363,23 @@ export function pointIndexXAxis({ chartData, formatDate, viewportWidth, - layout, + containerWidth, + yAxisWidth, + rightRoom, }: { chartData: ChartDataPoint[] | undefined; formatDate: (date: Date) => string; viewportWidth: number; - layout: TickLayout | undefined; + containerWidth: number | null; + yAxisWidth: number; + rightRoom: number; }) { + const layout = xTickLayout( + containerWidth, + yAxisWidth, + rightRoom, + formatDate(X_LABEL_SAMPLE_DATE), + ); const grid = computeTickGrid(chartData ?? [], viewportWidth, layout); return { type: "number" as const, diff --git a/src/components/charts/health-chart/types.ts b/src/components/charts/health-chart/types.ts index db6fef62e..e35602289 100644 --- a/src/components/charts/health-chart/types.ts +++ b/src/components/charts/health-chart/types.ts @@ -183,4 +183,6 @@ export interface VisibleTargetZone { label: string | undefined; textColor: string | undefined; lineOpacity: number; + /** The label sits inside the zone: the chart is too narrow for room beside it. */ + labelInside?: boolean; } diff --git a/src/components/charts/health-chart/use-health-chart-overlays.ts b/src/components/charts/health-chart/use-health-chart-overlays.ts index 5e156965a..478cc6d7e 100644 --- a/src/components/charts/health-chart/use-health-chart-overlays.ts +++ b/src/components/charts/health-chart/use-health-chart-overlays.ts @@ -11,7 +11,11 @@ import type { useFormatters } from "@/lib/i18n/context"; import type { ChartDataTableColumn } from "../chart-data-table"; -import { computePersonalBaseline, getTypeLabel } from "./series-math"; +import { + computePersonalBaseline, + getTypeLabel, + labelsBesidePlot, +} from "./series-math"; import type { ChartDataPoint, ChartTargetZone, @@ -62,6 +66,7 @@ export function useHealthChartOverlays({ valueMode, t, fmt, + plotWidth, }: { showBands: boolean; showTrend: boolean; @@ -74,6 +79,8 @@ export function useHealthChartOverlays({ valueMode: "raw" | "bmi"; t: Translate; fmt: Formatters; + /** The chart's width, which decides whether its labels sit beside the plot. */ + plotWidth: number; }) { const visibleBands = useMemo(() => { if (!showBands || !valueBands?.length || !yDomain) return []; @@ -99,6 +106,7 @@ export function useHealthChartOverlays({ ); }, [showBands, valueBands, yDomain]); + const besidePlot = labelsBesidePlot(plotWidth); const visibleTargetZones = useMemo(() => { if (!showBands || !targetZones?.length || !yDomain) return []; const span = yDomain[1] - yDomain[0]; @@ -116,10 +124,11 @@ export function useHealthChartOverlays({ label: zone.label ?? undefined, textColor: zone.textColor ?? undefined, lineOpacity: zone.lineOpacity ?? 0.42, + labelInside: !besidePlot, }); } return zones; - }, [showBands, targetZones, yDomain]); + }, [showBands, targetZones, yDomain, besidePlot]); const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -226,10 +235,14 @@ export function useHealthChartOverlays({ visibleTargetZones, trendInfo, personalBaselines, - rightRoom: rightLabelRoom([ - baselineLabel, - ...visibleTargetZones.map((zone) => zone.label), - ]), + besidePlot, + // No room beside the plot on a narrow chart: its labels sit inside it. + rightRoom: besidePlot + ? rightLabelRoom([ + baselineLabel, + ...visibleTargetZones.map((zone) => zone.label), + ]) + : 0, }; } diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index e80ed6c9b..97250e23f 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ findMany: vi.fn(), + findFirst: vi.fn(), queryRaw: vi.fn(), readTieredRollupSeries: vi.fn(), })); @@ -19,6 +20,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/lib/db", () => ({ prisma: { measurementRollup: { findMany: mocks.findMany }, + measurement: { findFirst: mocks.findFirst }, $queryRaw: mocks.queryRaw, }, })); @@ -33,6 +35,9 @@ const DAY_MS = 86_400_000; beforeEach(() => { mocks.findMany.mockReset(); + mocks.findFirst.mockReset(); + // No reading older than the rollups unless a case says so. + mocks.findFirst.mockResolvedValue(null); mocks.queryRaw.mockReset(); mocks.readTieredRollupSeries.mockReset(); // The daily path folds live, one row per local day. @@ -75,6 +80,7 @@ describe("readDailySeries — long-range tier step-up", () => { mocks.readTieredRollupSeries.mockResolvedValueOnce({ granularity: "MONTH", rows: tieredRows, + firstDay: new Date("2017-01-01T00:00:00.000Z"), }); const result = await readDailySeries({ @@ -98,6 +104,51 @@ describe("readDailySeries — long-range tier step-up", () => { expect(mocks.queryRaw).not.toHaveBeenCalled(); }); + it("folds the readings older than the rollups live and puts them first", async () => { + const to = new Date("2026-06-03T12:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockResolvedValueOnce({ + granularity: "MONTH", + rows: [ + { + type: "WEIGHT", + value: 79, + measuredAt: "2021-07-01T12:00:00.000Z", + count: 30, + }, + ], + firstDay: new Date("2021-07-04T00:00:00.000Z"), + }); + mocks.findFirst.mockResolvedValueOnce({ id: "older" }); + mocks.queryRaw.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2018-03-01T00:00:00.000Z"), + avg: 84, + cnt: 4, + min_value: 83, + max_value: 85, + }, + ]); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + timeZone: TZ, + }); + + // The head reads before the first tier bucket (July 2021) only. + expect(mocks.findFirst.mock.calls[0][0].where.measuredAt.lt).toEqual( + new Date("2021-07-01T00:00:00.000Z"), + ); + expect(result).toHaveLength(2); + expect(result[0].value).toBe(84); + expect(result[1].measuredAt).toBe("2021-07-01T12:00:00.000Z"); + }); + it("falls through to the daily path on a tiered coverage miss (no silent empty)", async () => { const to = new Date("2026-06-21T00:00:00.000Z"); const from = new Date(to.getTime() - 3650 * DAY_MS);