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 }; +}