diff --git a/eslint.config.mjs b/eslint.config.mjs
index 50a13361d..0a1d19700 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -239,6 +239,9 @@ const eslintConfig = defineConfig([
"src/components/charts/workout-elevation-chart.tsx",
"src/components/insights/coach-panel/result-chart.tsx",
"src/components/insights/derived/score-trend-chart.tsx",
+ // Browser tests draw a chart with Recharts itself to pin what it lays
+ // out; a test file is never bundled, so it mints no chunk.
+ "src/**/*.browser.test.tsx",
],
rules: {
"no-restricted-imports": [
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index 4dcbcf91c..dcb7891fe 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 355,
- "total": 114773,
+ "total": 114755,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -70,7 +70,7 @@
"components/auth/claim-profile.tsx :: ClaimProfile": 429,
"components/auth/mfa-login-step.tsx :: MfaLoginStep": 217,
"components/charts/compliance-heatmap.tsx :: ComplianceHeatmap": 325,
- "components/charts/health-chart.tsx :: HealthChart": 488,
+ "components/charts/health-chart.tsx :: HealthChart": 470,
"components/charts/medication-compliance-chart.tsx :: MedicationComplianceChart": 543,
"components/charts/mood-chart.tsx :: MoodChart": 896,
"components/charts/mood-heatmap.tsx :: MoodHeatmap": 296,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index 32b18fa49..b7228339f 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2898,
- "total": 9297,
+ "total": 9296,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1904,7 +1904,7 @@
"lib/charts/comparison-shift.ts": 1,
"lib/charts/data-ready-latch.ts": 1,
"lib/charts/window-stats.ts": 1,
- "lib/charts/x-axis-density.ts": 6,
+ "lib/charts/x-axis-density.ts": 5,
"lib/clinician-share/__tests__/passphrase.test.ts": 1,
"lib/clinician-share/__tests__/resolve-share-gate-state.test.ts": 1,
"lib/clinician-share/__tests__/resolve-share-token.test.ts": 1,
diff --git a/src/components/charts/__tests__/health-chart-axes.browser.test.tsx b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx
new file mode 100644
index 000000000..00257dad3
--- /dev/null
+++ b/src/components/charts/__tests__/health-chart-axes.browser.test.tsx
@@ -0,0 +1,185 @@
+import { CartesianGrid, ComposedChart, Line, XAxis, YAxis } from "recharts";
+import { describe, expect, it } from "vitest";
+import { render } from "vitest-browser-react";
+
+import { computeTickPositions } from "@/lib/charts/x-axis-density";
+import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
+
+import {
+ baselineLayers,
+ rightLabelRoom,
+ targetZoneLayers,
+} from "../health-chart/chart-layers";
+
+/**
+ * The value chart's axes and its baseline label, drawn by Recharts with the
+ * props HealthChart hands it: the y ticks are round and evenly spaced and all
+ * of them drawn, the x ticks fall on the calendar grid, and the "your
+ * average" and target zone labels sit whole beside the plot, covering no
+ * reading.
+ */
+
+const DAY = 86_400_000;
+const START = Date.parse("2026-07-23T12:00:00.000Z");
+const MISSING = new Set([10, 11, 12, 40, 41, 70, 71, 72, 73, 80]);
+const POINTS = Array.from({ length: 90 }, (_, day) => day)
+ .filter((day) => !MISSING.has(day))
+ .map((day, pointIndex) => ({
+ pointIndex,
+ timestamp: START + day * DAY,
+ BLOOD_PRESSURE_SYS: 118 + 14 * Math.sin(day / 5) + (day % 7),
+ }));
+
+const t = (key: string) =>
+ key === "charts.personalBaseline" ? "Your average" : key;
+const label = "Your average";
+const zoneLabel = "Systolic target";
+
+function Chart() {
+ const padded = computePaddedYDomain(
+ POINTS.map((p) => p.BLOOD_PRESSURE_SYS),
+ ["BLOOD_PRESSURE_SYS"],
+ )!;
+ const y = niceYAxis(padded);
+ return (
+
+
+
+ new Date(POINTS[Math.round(value)]!.timestamp)
+ .toISOString()
+ .slice(5, 10)
+ }
+ padding={{ left: 10, right: 10 }}
+ />
+
+
+ {targetZoneLayers(
+ [
+ {
+ key: "sys",
+ low: 110,
+ high: 130,
+ color: "#c44",
+ opacity: 0.1,
+ lineOpacity: 0.5,
+ topPct: 0,
+ heightPct: 0,
+ label: zoneLabel,
+ textColor: undefined,
+ },
+ ],
+ POINTS.length - 1,
+ )}
+ {baselineLayers(
+ true,
+ ["BLOOD_PRESSURE_SYS"],
+ ["#888"],
+ new Map([["BLOOD_PRESSURE_SYS", 121]]),
+ t,
+ )}
+
+ );
+}
+
+async function settle() {
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+}
+
+function tickTexts(root: HTMLElement, axis: "xAxis" | "yAxis"): string[] {
+ return [
+ ...root.querySelectorAll(
+ `.recharts-${axis}-tick-labels .recharts-cartesian-axis-tick-value`,
+ ),
+ ].map((node) => node.textContent ?? "");
+}
+
+describe("value chart axes", () => {
+ it("draws every round y tick, evenly spaced", async () => {
+ const screen = await render();
+ await settle();
+ const values = tickTexts(screen.container, "yAxis").map(Number);
+ expect(values).toEqual([100, 110, 120, 130, 140, 150]);
+ });
+
+ it("puts the x ticks on the two-week grid of Mondays", async () => {
+ const screen = await render();
+ await settle();
+ expect(tickTexts(screen.container, "xAxis")).toEqual([
+ "08-01",
+ "08-17",
+ "08-31",
+ "09-14",
+ "09-28",
+ "10-12",
+ ]);
+ });
+
+ it("draws a target zone's label whole, beside the plot", async () => {
+ const screen = await render();
+ await settle();
+ const root = screen.container;
+ const plot = root
+ .querySelector(".recharts-cartesian-grid")!
+ .getBoundingClientRect();
+ const svg = root
+ .querySelector("svg.recharts-surface")!
+ .getBoundingClientRect();
+ const text = [...root.querySelectorAll(".recharts-label")].find(
+ (node) => node.textContent === zoneLabel,
+ )!;
+ expect(text).toBeDefined();
+ const box = text.getBoundingClientRect();
+ expect(box.left).toBeGreaterThanOrEqual(plot.right);
+ expect(box.right).toBeLessThanOrEqual(svg.right);
+ });
+
+ it("draws the average's label outside the plot, clear of every reading", async () => {
+ const screen = await render();
+ await settle();
+ const root = screen.container;
+ const plot = root
+ .querySelector(".recharts-cartesian-grid")!
+ .getBoundingClientRect();
+ const text = [...root.querySelectorAll(".recharts-label")].find(
+ (node) => node.textContent === label,
+ )!;
+ expect(text).toBeDefined();
+ const box = text.getBoundingClientRect();
+ const svg = root
+ .querySelector("svg.recharts-surface")!
+ .getBoundingClientRect();
+ expect(box.left).toBeGreaterThanOrEqual(plot.right);
+ expect(box.right).toBeLessThanOrEqual(svg.right);
+ for (const dot of root.querySelectorAll(".recharts-line-dot")) {
+ const d = dot.getBoundingClientRect();
+ const overlaps =
+ d.left < box.right &&
+ d.right > box.left &&
+ d.top < box.bottom &&
+ d.bottom > box.top;
+ expect(overlaps).toBe(false);
+ }
+ });
+});
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index 909ec7a4d..c7c953124 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -16,7 +16,6 @@ import {
ChartDayFooter,
useChartDayLinks,
} from "@/components/day/chart-day-links";
-import { isWholeNumberType } from "@/components/day/use-day-value-format";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
@@ -25,7 +24,6 @@ import { useViewportWidth } from "@/hooks/use-viewport-width";
import { axisTickUnitSuffix } from "@/lib/charts/axis-unit";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
-import { computeTickPositions } from "@/lib/charts/x-axis-density";
import {
useDateFormatPreference,
useFormatters,
@@ -50,12 +48,16 @@ import {
} from "./health-chart/chart-layers";
import { makeTooltipContent } from "./health-chart/chart-tooltip-content";
import {
+ axisValueFormatters,
+ dayFocusFor,
getTypeLabel,
makeDayKeyFormatter,
+ pointIndexXAxis,
resolveAnnotationPositions,
resolveVerticalMarkerPositions,
+ xTickLayout,
} from "./health-chart/series-math";
-import type { ChartDataPoint, HealthChartProps } from "./health-chart/types";
+import type { HealthChartProps } from "./health-chart/types";
import {
useHealthChartLabels,
useHealthChartOverlays,
@@ -194,6 +196,7 @@ export function HealthChart({
activeBucket,
bucketCaption,
yDomain,
+ yTicks,
} = useHealthChartView({
data,
rangePoints,
@@ -209,29 +212,25 @@ export function HealthChart({
onVisibleStats,
});
+ const overlays = useHealthChartOverlays({
+ showBands,
+ showTrend,
+ valueBands,
+ targetZones,
+ yDomain,
+ chartData,
+ types,
+ unit,
+ valueMode,
+ t,
+ fmt,
+ });
const { visibleBands, visibleTargetZones, trendInfo, personalBaselines } =
- useHealthChartOverlays({
- showBands,
- showTrend,
- valueBands,
- targetZones,
- yDomain,
- chartData,
- types,
- unit,
- valueMode,
- t,
- fmt,
- });
+ overlays;
- // One decimal on the axis while the visible domain spans under 6 units, so
- // two gridlines never round to the same integer (82.8 vs 83.5 kg).
- const yDomainSpan = yDomain ? yDomain[1] - yDomain[0] : null;
- const useDecimalAxis = yDomainSpan !== null && yDomainSpan < 6;
- const formatAxisValue = (value: number) =>
- useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value);
-
- const formatTooltipValue = (value: number) => fmt.number(value, 1);
+ const [containerWidth, setContainerWidth] = useState(null);
+ const { useDecimalAxis, formatAxisValue, formatTooltipValue } =
+ axisValueFormatters(yDomain, fmt);
const { primaryType, chartAriaLabel, dataTableColumns } =
useHealthChartLabels({
@@ -262,36 +261,21 @@ export function HealthChart({
// The days as doors: daily points only (a week or month point averages
// many days and has no one day to open).
const dayLinksActive = dayLinks && !mini && activeBucket === "day";
- // What the day shows at its top when opened from a point: the chart's name
- // and the point's value as the tooltip reads it.
- const dayFocusFor = (point: ChartDataPoint | undefined) => {
- if (!point) return null;
- const parts = types
- .map((type) => ({ type, v: point[type] }))
- .filter((p): p is { type: string; v: number } => typeof p.v === "number");
- if (parts.length === 0) return null;
- return {
- label: title,
- // A pressure or a pulse reads in whole numbers on the day, as in every
- // list; the rest keep the tooltip's one decimal.
- value: parts
- .map(({ type, v }) =>
- valueMode === "raw" && isWholeNumberType(type)
- ? fmt.number(Math.round(v), 0)
- : formatTooltipValue(v),
- )
- .join("/"),
- unit,
- types: valueMode === "raw" ? types : [],
- };
- };
const dayLinkSource = chartDataWithCompare ?? chartData;
const chartDays = useChartDayLinks({
enabled: dayLinksActive,
days: dayLinksActive
? (dayLinkSource ?? []).map((point) => chartPointDayKey(point.timestamp))
: [],
- focusFor: (index) => dayFocusFor(dayLinkSource?.[index]),
+ focusFor: (index) =>
+ dayFocusFor(dayLinkSource?.[index], {
+ types,
+ title,
+ unit,
+ valueMode,
+ fmt,
+ formatTooltipValue,
+ }),
});
const openDayIndex = chartDays.openIndex;
const openDayPoint =
@@ -405,10 +389,19 @@ export function HealthChart({
role="img"
aria-label={chartAriaLabel}
>
-
+
@@ -418,26 +411,16 @@ export function HealthChart({
opacity={0.5}
/>
- tzFmt.date(
- new Date(
- chartData?.[Math.round(value)]?.timestamp ??
- Date.now(),
- ),
- )
- }
- // Recharts ignores `interval` on a numeric axis, so the
- // day-aware density arrives as explicit tick positions.
- ticks={computeTickPositions(chartData ?? [], viewportWidth)}
- padding={{ left: 10, right: 10 }}
- tickMargin={10}
+ {...pointIndexXAxis({
+ chartData,
+ formatDate: tzFmt.date,
+ viewportWidth,
+ layout: xTickLayout(
+ containerWidth,
+ yAxisWidth,
+ overlays.rightRoom,
+ ),
+ })}
/>
typeof value === "number"
? formatAxisValue(
@@ -537,7 +521,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 + 10}
+ insetRight={8 + overlays.rightRoom + 10}
/>
) : 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 b17f2acb0..ca23ddeb3 100644
--- a/src/components/charts/health-chart/chart-layers.tsx
+++ b/src/components/charts/health-chart/chart-layers.tsx
@@ -66,8 +66,9 @@ export function targetZoneLayers(
? {
value: zone.label,
position: "right",
+ offset: BASELINE_LABEL_GAP,
fill: zone.textColor ?? zone.color,
- fontSize: 10,
+ fontSize: BASELINE_LABEL_FONT_SIZE,
}
: undefined
}
@@ -186,10 +187,51 @@ export function openDayLayers(
];
}
+/** The gap between the plot's right edge and the baseline label. */
+const BASELINE_LABEL_GAP = 6;
+/** The label's type size, and a width per character that over-reads it. */
+const BASELINE_LABEL_FONT_SIZE = 10;
+const BASELINE_LABEL_CHAR_WIDTH = 6;
+
+/**
+ * The baseline label's text when the chart draws one: the trend overlay is
+ * on and the first type has a baseline inside the visible domain. Null
+ * otherwise, and then the chart keeps no room for it.
+ */
+export function baselineLabelText(
+ showTrend: boolean,
+ types: string[],
+ personalBaselines: Map,
+ yDomain: [number, number] | undefined,
+ t: Translate,
+): string | null {
+ if (!showTrend || types.length === 0) return null;
+ const baseline = personalBaselines.get(types[0]!);
+ if (baseline == null) return null;
+ if (yDomain && (baseline < yDomain[0] || baseline > yDomain[1])) return null;
+ return t("charts.personalBaseline");
+}
+
+/**
+ * The right margin the labels drawn beside the plot need: the average's label
+ * and the target zones' labels sit outside the plot, past the lines' right
+ * ends, so they never cover a point or a line. With the margin at 8 px a
+ * zone's label was cut to its first letter, and the average's label, inside
+ * the plot at the top left, sat on the first readings.
+ */
+export function rightLabelRoom(
+ labels: ReadonlyArray,
+): number {
+ const longest = Math.max(0, ...labels.map((label) => label?.length ?? 0));
+ if (longest === 0) return 0;
+ return BASELINE_LABEL_GAP + Math.ceil(longest * BASELINE_LABEL_CHAR_WIDTH);
+}
+
/**
* The personal baseline per type, only while the trend overlay is shown (an
* always-on mean line was rejected). Only the first type carries the label,
- * so a multi-type chart does not paint it twice.
+ * so a multi-type chart does not paint it twice; it is drawn to the right of
+ * the plot, in the room `rightLabelRoom` keeps there.
*/
export function baselineLayers(
showTrend: boolean,
@@ -216,9 +258,10 @@ export function baselineLayers(
i === 0
? {
value: t("charts.personalBaseline"),
- position: "insideTopLeft",
+ position: "right",
+ offset: BASELINE_LABEL_GAP,
fill: "var(--muted-foreground)",
- fontSize: 10,
+ fontSize: BASELINE_LABEL_FONT_SIZE,
opacity: 0.7,
}
: undefined
diff --git a/src/components/charts/health-chart/series-math.ts b/src/components/charts/health-chart/series-math.ts
index b975c1ee5..6ce3eeba5 100644
--- a/src/components/charts/health-chart/series-math.ts
+++ b/src/components/charts/health-chart/series-math.ts
@@ -4,8 +4,16 @@
* land. No React and no Recharts, so each piece has direct unit coverage.
*/
import { chartPointDayKey } from "@/components/day/chart-day";
+import { isWholeNumberType } from "@/components/day/use-day-value-format";
import { translateOrNull } from "@/lib/i18n/translate-code";
+import {
+ computeTickPositions,
+ type TickLayout,
+} from "@/lib/charts/x-axis-density";
+
+import type { ChartDataPoint } from "./types";
+
const BASE_TYPE_LABEL_KEYS: Record = {
WEIGHT: "charts.weight",
BLOOD_PRESSURE_SYS: "charts.systolic",
@@ -219,3 +227,114 @@ export function resolveAnnotationPositions(
}
return out;
}
+
+/**
+ * The value axis's and the tooltip's number formats. One decimal on the axis
+ * while the visible domain spans under 6 units, so two gridlines never round
+ * to the same integer (82.8 vs 83.5 kg).
+ */
+export function axisValueFormatters(
+ yDomain: [number, number] | undefined,
+ fmt: {
+ number: (value: number, digits: number) => string;
+ integer: (value: number) => string;
+ },
+) {
+ const span = yDomain ? yDomain[1] - yDomain[0] : null;
+ const useDecimalAxis = span !== null && span < 6;
+ return {
+ useDecimalAxis,
+ formatAxisValue: (value: number) =>
+ useDecimalAxis ? fmt.number(value, 1) : fmt.integer(value),
+ formatTooltipValue: (value: number) => fmt.number(value, 1),
+ };
+}
+
+/**
+ * What the day shows at its top when opened from a point: the chart's name
+ * and the point's value as the tooltip reads it. A pressure or a pulse reads
+ * in whole numbers on the day, as in every list; the rest keep the tooltip's
+ * one decimal.
+ */
+export function dayFocusFor(
+ point: ChartDataPoint | undefined,
+ ctx: {
+ types: string[];
+ title: string;
+ unit?: string;
+ valueMode: "raw" | "bmi";
+ fmt: { number: (value: number, digits: number) => string };
+ formatTooltipValue: (value: number) => string;
+ },
+) {
+ if (!point) return null;
+ const parts = ctx.types
+ .map((type) => ({ type, v: point[type] }))
+ .filter((p): p is { type: string; v: number } => typeof p.v === "number");
+ if (parts.length === 0) return null;
+ return {
+ label: ctx.title,
+ value: parts
+ .map(({ type, v }) =>
+ ctx.valueMode === "raw" && isWholeNumberType(type)
+ ? ctx.fmt.number(Math.round(v), 0)
+ : ctx.formatTooltipValue(v),
+ )
+ .join("/"),
+ unit: ctx.unit,
+ types: ctx.valueMode === "raw" ? ctx.types : [],
+ };
+}
+
+/**
+ * Where the x labels are drawn: the plot's width (the container less the
+ * chart margins, the y axis, the right-hand label room and the axis padding)
+ * and the width a date label takes, with room between two. Null until the
+ * container has been measured; the ticks then fall back to the count cap.
+ */
+export function xTickLayout(
+ containerWidth: number | null,
+ yAxisWidth: number,
+ rightRoom: number,
+): { plotWidth: number; labelWidth: number } | undefined {
+ if (!containerWidth) return undefined;
+ const plotWidth = containerWidth - 16 - yAxisWidth - rightRoom - 20;
+ if (plotWidth <= 0) return undefined;
+ // A day-month-year date at the axis's 11 px, with a gap of its own size
+ // between two labels so the row reads as separate dates.
+ return { plotWidth, labelWidth: 64 + 16 };
+}
+
+/**
+ * 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
+ * explicit tick positions (`computeTickPositions`).
+ */
+export function pointIndexXAxis({
+ chartData,
+ formatDate,
+ viewportWidth,
+ layout,
+}: {
+ chartData: ChartDataPoint[] | undefined;
+ formatDate: (date: Date) => string;
+ viewportWidth: number;
+ layout: TickLayout | undefined;
+}) {
+ return {
+ type: "number" as const,
+ dataKey: "pointIndex",
+ domain: [0, Math.max(0, (chartData?.length ?? 1) - 1)],
+ allowDecimals: false,
+ tick: { fontSize: 11, fill: "var(--muted-foreground)" },
+ tickLine: false,
+ axisLine: false,
+ tickFormatter: (value: number) =>
+ formatDate(
+ new Date(chartData?.[Math.round(value)]?.timestamp ?? Date.now()),
+ ),
+ ticks: computeTickPositions(chartData ?? [], viewportWidth, layout),
+ padding: { left: 10, right: 10 },
+ tickMargin: 10,
+ };
+}
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 3d23fefd9..5e156965a 100644
--- a/src/components/charts/health-chart/use-health-chart-overlays.ts
+++ b/src/components/charts/health-chart/use-health-chart-overlays.ts
@@ -19,6 +19,7 @@ import type {
VisibleTargetZone,
VisibleValueBand,
} from "./types";
+import { baselineLabelText, rightLabelRoom } from "./chart-layers";
type Translate = (
key: string,
@@ -210,7 +211,26 @@ export function useHealthChartOverlays({
return map;
}, [chartData, types]);
- return { visibleBands, visibleTargetZones, trendInfo, personalBaselines };
+ // The labels drawn beside the plot (the average's, the target zones'), and
+ // so the room the plot keeps for them on its right.
+ const baselineLabel = baselineLabelText(
+ showTrend,
+ types,
+ personalBaselines,
+ yDomain,
+ t,
+ );
+
+ return {
+ visibleBands,
+ visibleTargetZones,
+ trendInfo,
+ personalBaselines,
+ rightRoom: rightLabelRoom([
+ baselineLabel,
+ ...visibleTargetZones.map((zone) => zone.label),
+ ]),
+ };
}
/**
diff --git a/src/components/charts/health-chart/use-health-chart-view.ts b/src/components/charts/health-chart/use-health-chart-view.ts
index cafb4a2cc..6f1e3efb6 100644
--- a/src/components/charts/health-chart/use-health-chart-view.ts
+++ b/src/components/charts/health-chart/use-health-chart-view.ts
@@ -15,7 +15,7 @@ import {
type MetricWindowStats,
} from "@/lib/charts/window-stats";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
-import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
+import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
import { trendLineByTime } from "@/lib/stats/linear-fit";
import { movingAverageByPoints } from "./series-math";
@@ -308,7 +308,9 @@ export function useHealthChartView({
// in the tooltip.
const bucketCaption = bucketCaptionKey(activeBucket, types);
- const yDomain = useMemo<[number, number] | undefined>(() => {
+ const yAxis = useMemo<
+ { domain: [number, number]; ticks: number[] } | undefined
+ >(() => {
if (!chartDataWithCompare?.length) return undefined;
const keys = [...types];
@@ -325,7 +327,8 @@ export function useHealthChartView({
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value));
- return computePaddedYDomain(values, types);
+ const padded = computePaddedYDomain(values, types);
+ return padded ? niceYAxis(padded) : undefined;
}, [
chartDataWithCompare,
effectiveCompareBaseline,
@@ -342,6 +345,7 @@ export function useHealthChartView({
hasComparisonData,
activeBucket,
bucketCaption,
- yDomain,
+ yDomain: yAxis?.domain,
+ yTicks: yAxis?.ticks,
};
}
diff --git a/src/lib/charts/__tests__/x-axis-density.test.ts b/src/lib/charts/__tests__/x-axis-density.test.ts
index 07febff6c..fac5dd0d6 100644
--- a/src/lib/charts/__tests__/x-axis-density.test.ts
+++ b/src/lib/charts/__tests__/x-axis-density.test.ts
@@ -112,53 +112,105 @@ describe("chooseTickInterval (v1.4.25 W3b day-aware policy)", () => {
});
});
-describe("computeTickPositions (v1.4.29 numeric-axis helper)", () => {
- // Recharts ignores `interval` on `type="number"` axes; the pulse +
- // mood charts switched to numeric axes and silently lost the
- // density policy. `computeTickPositions` translates the policy
- // into explicit indices the numeric axis can consume.
+describe("computeTickPositions — ticks on a calendar grid", () => {
+ const DAY = 86_400_000;
+ /** Daily points at noon UTC from `start`, leaving out the `missing` days. */
+ function daily(start: string, count: number, missing: number[] = []) {
+ const t0 = Date.parse(`${start}T12:00:00.000Z`);
+ return Array.from({ length: count }, (_, i) => i)
+ .filter((i) => !missing.includes(i))
+ .map((i) => ({ timestamp: t0 + i * DAY }));
+ }
+ const dates = (data: { timestamp: number }[], ticks: number[]) =>
+ ticks.map((i) => new Date(data[i]!.timestamp).toISOString().slice(0, 10));
- it("returns every index when the chart has fewer points than the bucket threshold", () => {
- const data = Array.from({ length: 5 }, (_, i) => ({ timestamp: i }));
- expect(computeTickPositions(data, 393)).toEqual([0, 1, 2, 3, 4]);
- });
-
- it("returns [0] for a single-point chart", () => {
+ it("returns [] for an empty chart and [0] for one point", () => {
+ expect(computeTickPositions([], 393)).toEqual([]);
expect(computeTickPositions([{ timestamp: 0 }], 393)).toEqual([0]);
});
- it("returns [] for an empty chart", () => {
- expect(computeTickPositions([], 393)).toEqual([]);
+ it("labels every day of a week where the width holds it, every other on a phone", () => {
+ const data = daily("2026-10-17", 7);
+ expect(computeTickPositions(data, 1440)).toEqual([0, 1, 2, 3, 4, 5, 6]);
+ const phone = dates(data, computeTickPositions(data, 393));
+ expect(phone).toHaveLength(3);
});
- it("caps ticks to ~12 for a 365-point series", () => {
- const data = Array.from({ length: 365 }, (_, i) => ({ timestamp: i }));
- const ticks = computeTickPositions(data, 393);
- expect(ticks.length).toBeLessThanOrEqual(13);
- expect(ticks.length).toBeGreaterThanOrEqual(6);
- // First + last indices always present.
- expect(ticks[0]).toBe(0);
- expect(ticks[ticks.length - 1]).toBe(364);
+ it("puts a 90-day chart with missed days on a two-week grid of Mondays", () => {
+ // The reported chart: readings with gaps, labelled every n-th point,
+ // fell 18, 14, 13, 17 and 23 days apart.
+ const data = daily(
+ "2026-07-23",
+ 90,
+ [10, 11, 12, 40, 41, 70, 71, 72, 73, 80],
+ );
+ const ticks = dates(data, computeTickPositions(data, 1440));
+ // 3 August is a grid Monday with no reading; the nearest day stands in.
+ expect(ticks).toEqual([
+ "2026-08-01",
+ "2026-08-17",
+ "2026-08-31",
+ "2026-09-14",
+ "2026-09-28",
+ "2026-10-12",
+ ]);
+ for (const tick of ticks.slice(1)) {
+ expect(new Date(`${tick}T12:00:00Z`).getUTCDay()).toBe(1);
+ }
});
- it("hits a weekly-rhythm cadence on a 30-day chart at mobile width", () => {
- const data = Array.from({ length: 30 }, (_, i) => ({ timestamp: i }));
- const ticks = computeTickPositions(data, 393);
- // 30 points / 7-day skip → 5 ticks (0, 7, 14, 21, 28) + last (29).
- expect(ticks.length).toBeGreaterThanOrEqual(4);
- expect(ticks.length).toBeLessThanOrEqual(6);
- expect(ticks[0]).toBe(0);
- expect(ticks[ticks.length - 1]).toBe(29);
+ it("takes the nearest point within half a step when the grid date has none", () => {
+ // 2026-09-14 (a grid Monday) is missing; the 13th or 15th stands in.
+ const data = daily("2026-08-03", 60, [42]);
+ const ticks = dates(data, computeTickPositions(data, 1440));
+ expect(ticks).toContain("2026-08-17");
+ expect(ticks.some((t) => t === "2026-09-13" || t === "2026-09-15")).toBe(
+ true,
+ );
});
- it("never returns duplicate or out-of-bounds indices", () => {
- for (const length of [2, 7, 14, 30, 90, 180, 365]) {
- const data = Array.from({ length }, (_, i) => ({ timestamp: i }));
- const ticks = computeTickPositions(data, 393);
- expect(new Set(ticks).size).toBe(ticks.length);
- for (const t of ticks) {
- expect(t).toBeGreaterThanOrEqual(0);
- expect(t).toBeLessThan(length);
+ it("does not force the last point in, so the final label never crowds", () => {
+ const data = daily("2026-07-23", 90);
+ const ticks = computeTickPositions(data, 1440);
+ expect(ticks[ticks.length - 1]).not.toBe(data.length - 1);
+ });
+
+ it("steps to calendar months and quarters on longer spans", () => {
+ const year = daily("2025-10-23", 365);
+ const months = dates(year, computeTickPositions(year, 1440));
+ expect(months.length).toBeGreaterThanOrEqual(4);
+ for (const tick of months) expect(tick.endsWith("-01")).toBe(true);
+
+ // Weekly buckets over a year and a bit, on a phone: quarter starts, each
+ // taken by the nearest weekly point.
+ const weeks = Array.from({ length: 58 }, (_, i) => ({
+ timestamp: Date.parse("2025-10-01T12:00:00Z") + i * 7 * DAY,
+ }));
+ for (const tick of dates(weeks, computeTickPositions(weeks, 393))) {
+ const at = Date.parse(`${tick}T12:00:00Z`);
+ const quarter = [0, 3, 6, 9].flatMap((m) =>
+ [2025, 2026, 2027].map((y) => Date.UTC(y, m, 1, 12)),
+ );
+ const off = Math.min(...quarter.map((q) => Math.abs(q - at))) / DAY;
+ expect(off).toBeLessThanOrEqual(3.5);
+ }
+ });
+
+ it("keeps under the width's cap, with no duplicate or out-of-range index", () => {
+ for (const [width, cap] of [
+ [393, 5],
+ [1440, 8],
+ ] as const) {
+ for (const length of [2, 7, 14, 30, 90, 180, 365, 730]) {
+ const data = daily("2024-01-01", length);
+ const ticks = computeTickPositions(data, width);
+ expect(ticks.length).toBeLessThanOrEqual(cap);
+ expect(new Set(ticks).size).toBe(ticks.length);
+ expect([...ticks].sort((a, b) => a - b)).toEqual(ticks);
+ for (const t of ticks) {
+ expect(t).toBeGreaterThanOrEqual(0);
+ expect(t).toBeLessThan(data.length);
+ }
}
}
});
diff --git a/src/lib/charts/x-axis-density.ts b/src/lib/charts/x-axis-density.ts
index 03d7222ef..204c2005f 100644
--- a/src/lib/charts/x-axis-density.ts
+++ b/src/lib/charts/x-axis-density.ts
@@ -162,56 +162,159 @@ export function getViewportWidth(): number {
}
/**
- * v1.4.29 — Recharts ignores the `interval` prop on a numeric
- * ``. The pulse + mood charts switched to
- * numeric axes in v1.4.25 to support multi-band rendering; the
- * legacy `interval` value silently degraded to "every tick" on
- * those charts.
+ * Tick positions for a chart whose x axis is the point index, on a calendar
+ * grid rather than every n-th point.
*
- * `computeTickPositions` returns explicit tick indices the numeric
- * axis can consume via the `ticks` prop. The skip count comes from
- * `chooseTickInterval` so the day-aware policy stays in one place.
- * The result is clamped to at least 3 ticks (when the chart has
- * enough data) and at most ~12, so the user always sees calendar
- * landmarks without overcrowding.
+ * Every n-th point read as random dates wherever the series had gaps: on a
+ * 90-day blood-pressure chart with a few missed days the labels fell 18, 14,
+ * 13, 17 and 23 days apart, and the forced last label crowded its neighbour.
+ * So the step is a calendar one (a day, two days, a week, two weeks, a month,
+ * a quarter, half a year, a year), the smallest that keeps the labels under
+ * the width's cap, and each tick is the point on that grid's date: Mondays
+ * for the week steps (an even-week grid for two), the first of the month for
+ * the month steps. A grid date with no point of its own takes the nearest
+ * point within half a step, and none if there is none, so a tick never
+ * claims a date it is not near. The last point is not forced in.
*
- * Returns an empty array for zero-length data and `[0]` for a
- * single-point chart.
+ * `timestamp` is the point's day anchor (noon UTC of the day it stands for,
+ * or the start of its week or month bucket); its UTC date is the day.
*/
export function computeTickPositions(
chartData: ReadonlyArray<{ timestamp: number } | unknown>,
viewportWidth: number,
+ layout?: TickLayout,
): number[] {
const length = chartData?.length ?? 0;
if (length <= 0) return [];
if (length === 1) return [0];
- const lastIndex = length - 1;
- const interval = chooseTickInterval(length, viewportWidth);
- // `interval === 0` means "render every tick". Cap the visible tick
- // count so dense series (e.g. 365-row pulse) never blow past ~12
- // labels — pick a step that hits ~6 ticks if the policy would
- // otherwise produce too many.
- const TARGET_MAX_TICKS = 12;
- let step = interval > 0 ? interval + 1 : 1;
- if (length / step > TARGET_MAX_TICKS) {
- step = Math.ceil(length / TARGET_MAX_TICKS);
- }
- // Floor on visible tick count — never fewer than 3 ticks unless
- // the data simply doesn't have that many points.
- const minTicks = Math.min(3, length);
- if (length / step < minTicks) {
- step = Math.max(1, Math.floor(length / minTicks));
+ const days = chartData.map((point) =>
+ Math.floor(
+ ((point as { timestamp?: number })?.timestamp ?? Number.NaN) / DAY_MS,
+ ),
+ );
+ if (days.some((day) => !Number.isFinite(day))) {
+ return Array.from({ length }, (_, index) => index);
}
+ const first = days[0]!;
+ const last = days[length - 1]!;
+ const maxTicks =
+ Number.isFinite(viewportWidth) &&
+ viewportWidth < MOBILE_DESKTOP_BREAKPOINT_PX
+ ? MAX_TICKS_MOBILE
+ : MAX_TICKS_DESKTOP;
- const ticks: number[] = [];
- for (let i = 0; i <= lastIndex; i += step) {
- ticks.push(i);
+ const ticksFor = (step: CalendarStep): number[] => {
+ const ticks: number[] = [];
+ const reach = Math.floor(step.approxDays / 2);
+ for (const anchor of calendarAnchors(first, last, step)) {
+ const index = nearestPoint(days, anchor, reach);
+ if (
+ index !== -1 &&
+ (ticks.length === 0 || index > ticks[ticks.length - 1]!)
+ ) {
+ ticks.push(index);
+ }
+ }
+ return ticks;
+ };
+
+ // With the plot's width known, the step is the smallest whose labels all
+ // fit side by side where they are drawn: on an index axis the points, not
+ // the days, are spaced evenly, so two grid dates can sit closer than their
+ // step where the series is dense. Recharts dropped such a colliding label
+ // itself, which left the grid with holes. Without a width, the count cap.
+ const fits = (ticks: number[]) => {
+ if (!layout) return ticks.length <= maxTicks;
+ const perIndex = layout.plotWidth / Math.max(1, length - 1);
+ return ticks.every(
+ (tick, i) =>
+ i === 0 || (tick - ticks[i - 1]!) * perIndex >= layout.labelWidth,
+ );
+ };
+ for (const step of CALENDAR_STEPS) {
+ const count = Math.floor((last - first) / step.approxDays) + 1;
+ if (!layout && count > maxTicks) continue;
+ const ticks = ticksFor(step);
+ if (ticks.length > 0 && fits(ticks)) return ticks;
}
- // Always include the last index so the chart's right edge keeps a
- // tick label.
- if (ticks[ticks.length - 1] !== lastIndex) {
- ticks.push(lastIndex);
+ const widest = ticksFor(CALENDAR_STEPS[CALENDAR_STEPS.length - 1]!);
+ return widest.length > 0 ? widest : [0];
+}
+
+/** Where the ticks are drawn: the plot's width and a label's width, in px. */
+export interface TickLayout {
+ plotWidth: number;
+ labelWidth: number;
+}
+
+const DAY_MS = 86_400_000;
+/** 1970-01-05 was a Monday: day numbers counted from it fall on weeks. */
+const FIRST_MONDAY = 4;
+const MAX_TICKS_MOBILE = 5;
+const MAX_TICKS_DESKTOP = 8;
+
+interface CalendarStep {
+ kind: "days" | "months";
+ /** Days per step, or months per step. */
+ size: number;
+ /** The step's length in days, for counting and the nearest-point window. */
+ approxDays: number;
+}
+
+const CALENDAR_STEPS: ReadonlyArray = [
+ { kind: "days", size: 1, approxDays: 1 },
+ { kind: "days", size: 2, approxDays: 2 },
+ { kind: "days", size: 7, approxDays: 7 },
+ { kind: "days", size: 14, approxDays: 14 },
+ { kind: "months", size: 1, approxDays: 30 },
+ { kind: "months", size: 3, approxDays: 91 },
+ { kind: "months", size: 6, approxDays: 182 },
+ { kind: "months", size: 12, approxDays: 365 },
+];
+
+/** The grid's dates (as day numbers) from `first` to `last`, both included. */
+function calendarAnchors(
+ first: number,
+ last: number,
+ step: CalendarStep,
+): number[] {
+ const anchors: number[] = [];
+ if (step.kind === "days") {
+ const offset = step.size >= 7 ? FIRST_MONDAY : 0;
+ const start =
+ first + ((((offset - first) % step.size) + step.size) % step.size);
+ for (let day = start; day <= last; day += step.size) anchors.push(day);
+ return anchors;
}
- return ticks;
+ const from = new Date(first * DAY_MS);
+ let year = from.getUTCFullYear();
+ let month = from.getUTCMonth();
+ // Start on a month the step's grid holds (Jan / Apr / Jul / Oct for a
+ // quarter), at or after the first point.
+ month = Math.ceil(month / step.size) * step.size;
+ if (Date.UTC(year, month, 1) / DAY_MS < first) month += step.size;
+ for (;;) {
+ year += Math.floor(month / 12);
+ month %= 12;
+ const day = Date.UTC(year, month, 1) / DAY_MS;
+ if (day > last) break;
+ anchors.push(day);
+ month += step.size;
+ }
+ return anchors;
+}
+
+/** The index of the point nearest `anchor` within `reach` days, or -1. */
+function nearestPoint(days: number[], anchor: number, reach: number): number {
+ let best = -1;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (let index = 0; index < days.length; index += 1) {
+ const distance = Math.abs(days[index]! - anchor);
+ if (distance <= reach && distance < bestDistance) {
+ best = index;
+ bestDistance = distance;
+ }
+ }
+ return best;
}
diff --git a/src/lib/insights/__tests__/chart-y-domain.test.ts b/src/lib/insights/__tests__/chart-y-domain.test.ts
index 51c11c51a..0c3491eef 100644
--- a/src/lib/insights/__tests__/chart-y-domain.test.ts
+++ b/src/lib/insights/__tests__/chart-y-domain.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
+import { computePaddedYDomain, niceYAxis } from "@/lib/insights/chart-y-domain";
describe("computePaddedYDomain", () => {
it("returns undefined for an empty series", () => {
@@ -55,3 +55,62 @@ describe("computePaddedYDomain", () => {
expect(domain![0]).toBeLessThan(-10);
});
});
+
+describe("niceYAxis", () => {
+ /** Every gap the same, every tick a multiple of the step. */
+ function expectEven(ticks: number[]) {
+ const gaps = ticks
+ .slice(1)
+ .map((t, i) => Number((t - ticks[i]!).toFixed(9)));
+ expect(new Set(gaps).size).toBe(1);
+ const step = gaps[0]!;
+ for (const tick of ticks) {
+ expect(Math.abs(tick / step - Math.round(tick / step))).toBeLessThan(
+ 1e-9,
+ );
+ }
+ return step;
+ }
+
+ it("turns the reported blood-pressure axis into round, even ticks", () => {
+ // Recharts drew 74, 94, 114, 134, 149 from this padded domain: the last
+ // step 15 against 20, the upper bound forced in as a tick.
+ const { domain, ticks } = niceYAxis([73.4, 149.2]);
+ expect(ticks).toEqual([60, 80, 100, 120, 140, 160]);
+ expect(domain).toEqual([60, 160]);
+ expect(expectEven(ticks)).toBe(20);
+ });
+
+ it("covers the padded domain and stays within six labels", () => {
+ for (const [low, high] of [
+ [0, 1],
+ [61.2, 63.9],
+ [0, 10234],
+ [35.9, 37.4],
+ [-3.2, 4.8],
+ [92, 99],
+ [4.1, 8.7],
+ ] as const) {
+ const { domain, ticks } = niceYAxis([low, high]);
+ expect(domain[0]).toBeLessThanOrEqual(low);
+ expect(domain[1]).toBeGreaterThanOrEqual(high);
+ expect(ticks.length).toBeGreaterThanOrEqual(2);
+ expect(ticks.length).toBeLessThanOrEqual(6);
+ expect(ticks[0]).toBe(domain[0]);
+ expect(ticks[ticks.length - 1]).toBe(domain[1]);
+ const step = expectEven(ticks);
+ const mantissa = step / 10 ** Math.floor(Math.log10(step));
+ expect([1, 2, 5]).toContain(Number(mantissa.toFixed(6)));
+ }
+ });
+
+ it("keeps a non-negative domain non-negative and decimals clean", () => {
+ expect(niceYAxis([0.2, 9.6]).domain[0]).toBe(0);
+ const { ticks } = niceYAxis([35.9, 37.4]);
+ for (const tick of ticks) expect(String(tick)).not.toMatch(/\d{4,}$/);
+ });
+
+ it("leaves a degenerate domain alone", () => {
+ expect(niceYAxis([5, 5])).toEqual({ domain: [5, 5], ticks: [5, 5] });
+ });
+});
diff --git a/src/lib/insights/chart-y-domain.ts b/src/lib/insights/chart-y-domain.ts
index 53b2ee018..fcbfce1e9 100644
--- a/src/lib/insights/chart-y-domain.ts
+++ b/src/lib/insights/chart-y-domain.ts
@@ -51,3 +51,50 @@ export function computePaddedYDomain(
const paddingTop = Math.max(span * 0.16, 1);
return [clampLower(min - paddingBottom), max + paddingTop];
}
+
+/**
+ * Evenly spaced round ticks for a value axis, and the domain padded out to
+ * them.
+ *
+ * Handed only a domain, Recharts started its ticks at the domain's lower
+ * bound and forced the upper bound in as the last tick, so a padded
+ * blood-pressure domain read 74, 94, 114, 134, 149: round-ish numbers that
+ * were not round, and a last step shorter than the others. The step here is
+ * 1, 2 or 5 times a power of ten, the smallest that keeps the axis at
+ * `maxTicks` labels or fewer, and the domain becomes the multiples of it
+ * around the padded data, so every gap is the same and every label is
+ * round. A non-negative domain stays non-negative.
+ */
+export function niceYAxis(
+ domain: readonly [number, number],
+ maxTicks = 6,
+): { domain: [number, number]; ticks: number[] } {
+ const [low, high] = domain;
+ const span = high - low;
+ if (!Number.isFinite(span) || span <= 0) {
+ return { domain: [low, high], ticks: [low, high] };
+ }
+ const intervals = Math.max(1, maxTicks - 1);
+ const raw = span / intervals;
+ const magnitude = 10 ** Math.floor(Math.log10(raw));
+ let step = magnitude * 10;
+ for (const factor of [1, 2, 5, 10]) {
+ const candidate = factor * magnitude;
+ if (
+ Math.ceil(high / candidate) - Math.floor(low / candidate) <=
+ intervals
+ ) {
+ step = candidate;
+ break;
+ }
+ }
+ const first = Math.floor(low / step) * step;
+ const last = Math.ceil(high / step) * step;
+ const ticks: number[] = [];
+ // Rounded to the step's own precision, so 0.1 + 0.2 never reads 0.30000…
+ const digits = Math.max(0, -Math.floor(Math.log10(step)));
+ for (let k = 0; first + k * step <= last + step / 2; k += 1) {
+ ticks.push(Number((first + k * step).toFixed(digits)));
+ }
+ return { domain: [ticks[0]!, ticks[ticks.length - 1]!], ticks };
+}