diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index fd33024b5..dbf6e2674 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -41,7 +41,7 @@ import type { import { ChartOverlayControls } from "./chart-overlay-controls"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { chooseTickInterval } from "@/lib/charts/x-axis-density"; +import { computeTickPositions } from "@/lib/charts/x-axis-density"; const TIME_RANGES_KEYS = [ { @@ -1281,8 +1281,13 @@ export function HealthChart({ ), ) } - interval={chooseTickInterval( - chartData?.length ?? 0, + // v1.4.29 — Recharts ignores `interval` on numeric + // axes (`type="number"`). Hand explicit tick + // positions through the `ticks` prop so the legacy + // day-aware density policy stays effective on the + // pulse chart. + ticks={computeTickPositions( + chartData ?? [], viewportWidth, )} padding={{ left: 10, right: 10 }} diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx index 4d5b478d9..de9174a50 100644 --- a/src/components/charts/mood-chart.tsx +++ b/src/components/charts/mood-chart.tsx @@ -36,7 +36,7 @@ import type { import { ChartOverlayControls } from "./chart-overlay-controls"; import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs"; import { useViewportWidth } from "@/hooks/use-viewport-width"; -import { chooseTickInterval } from "@/lib/charts/x-axis-density"; +import { computeTickPositions } from "@/lib/charts/x-axis-density"; import { CHART_HEIGHT_PX } from "@/lib/charts/constants"; import { moodLabelKeyForScore } from "@/lib/mood/labels"; @@ -748,8 +748,10 @@ export function MoodChart({ ), ) } - interval={chooseTickInterval( - chartData?.length ?? 0, + // v1.4.29 — Recharts ignores `interval` on numeric + // axes; pass explicit tick positions instead. + ticks={computeTickPositions( + chartData ?? [], viewportWidth, )} padding={{ left: 10, right: 10 }} diff --git a/src/lib/charts/__tests__/x-axis-density.test.ts b/src/lib/charts/__tests__/x-axis-density.test.ts index c4c8faf1d..07febff6c 100644 --- a/src/lib/charts/__tests__/x-axis-density.test.ts +++ b/src/lib/charts/__tests__/x-axis-density.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { chooseTickInterval, resolveTargetTickCount } from "../x-axis-density"; +import { + chooseTickInterval, + computeTickPositions, + resolveTargetTickCount, +} from "../x-axis-density"; describe("resolveTargetTickCount (v1.4.19 legacy cap helper)", () => { it("caps Galaxy Fold compact (≤360px) at 4 ticks", () => { @@ -107,3 +111,55 @@ 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. + + 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", () => { + expect(computeTickPositions([{ timestamp: 0 }], 393)).toEqual([0]); + }); + + it("returns [] for an empty chart", () => { + expect(computeTickPositions([], 393)).toEqual([]); + }); + + 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("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("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); + } + } + }); +}); diff --git a/src/lib/charts/x-axis-density.ts b/src/lib/charts/x-axis-density.ts index a76d22f25..03d7222ef 100644 --- a/src/lib/charts/x-axis-density.ts +++ b/src/lib/charts/x-axis-density.ts @@ -160,3 +160,58 @@ export function getViewportWidth(): number { // viewport, not the visual one. Fall back to innerWidth. return window.innerWidth ?? 1280; } + +/** + * 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. + * + * `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. + * + * Returns an empty array for zero-length data and `[0]` for a + * single-point chart. + */ +export function computeTickPositions( + chartData: ReadonlyArray<{ timestamp: number } | unknown>, + viewportWidth: number, +): 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 ticks: number[] = []; + for (let i = 0; i <= lastIndex; i += step) { + ticks.push(i); + } + // Always include the last index so the chart's right edge keeps a + // tick label. + if (ticks[ticks.length - 1] !== lastIndex) { + ticks.push(lastIndex); + } + return ticks; +}