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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-26 07:59:58 +01:00
1 parent f4cb15d823
commit 444ff66086
7 files changed
+98 -34

No files matched your search

@@ -102,10 +102,10 @@ function Chart({ width = 720 }: { width?: number }) {
heightPct: 0,
label: zoneLabel,
textColor: undefined,
labelInside: !besidePlot,
},
],
POINTS.length - 1,
besidePlot,
)}
{baselineLayers(
true,
+9 -23
View File
@@ -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<number | null>(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<number | null>(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,
})}
/>
<YAxis
@@ -451,11 +441,7 @@ export function HealthChart({
: undefined
}
/>
{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,
)}
<Tooltip
filterNull={false}
@@ -534,7 +520,7 @@ export function HealthChart({
// The plot's margin (8) + the y axis + the x axis padding (10)
// on the left, margin + padding on the right.
insetLeft={8 + yAxisWidth + 10}
insetRight={8 + rightRoom + 10}
insetRight={8 + overlays.rightRoom + 10}
/>
) : null}
{/* The points the chart just drew, as a table, read from the same
@@ -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,
@@ -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,
@@ -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;
}
@@ -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<VisibleValueBand[]>(() => {
if (!showBands || !valueBands?.length || !yDomain) return [];
@@ -99,6 +106,7 @@ export function useHealthChartOverlays({
);
}, [showBands, valueBands, yDomain]);
const besidePlot = labelsBesidePlot(plotWidth);
const visibleTargetZones = useMemo<VisibleTargetZone[]>(() => {
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,
};
}
@@ -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);