From a4ab152fc71f63f174691b34b6e2203842e32c73 Mon Sep 17 00:00:00 2001 From: Oliver Le Date: Wed, 15 Apr 2026 00:37:04 -0700 Subject: [PATCH] =?UTF-8?q?refactor(dashboard):=20true=20visual=20graduati?= =?UTF-8?q?on=20markers=20=E2=80=94=20drop=20hidden-span=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move data-graduation-marker hook onto an SVG rendered via the ReferenceLine label prop. Markers now live on the actual chart, not a sibling hidden div. - Add role='img' + aria-label + SVG for native hover tooltip showing the graduated rule's description (no extra JS needed). - Add invisible hit-target <rect> so hovering near the dashed line triggers the SVG title tooltip. - Drop the separate <div aria-hidden><span data-graduation-marker> fallback block now that the hook lives on the real marker. - Mock Recharts ResponsiveContainer in tests/setup.ts so jsdom actually renders chart SVG (fixed-800x400). Required for in-chart test hooks going forward. - Add test: marker renders at correct x position for a lesson graduated 5 days ago in the 7d range (left third of plot area). - Add test: marker carries aria-label + SVG <title> with rule description. - Fix dashboard-page.test.tsx false collision on 'Never use em dashes' (now also appears in the marker <title>, so use getAllByText). All 137 dashboard tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Oliver Le <oliver@gradata.ai> --- .../components/brain/CorrectionDecayCurve.tsx | 48 ++++++++++++------- .../tests/CorrectionDecayCurve.test.tsx | 31 ++++++++++++ cloud/dashboard/tests/dashboard-page.test.tsx | 6 ++- cloud/dashboard/tests/setup.ts | 23 +++++++++ 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx b/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx index 3f81a8cc..b78d3ceb 100644 --- a/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx +++ b/cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx @@ -105,10 +105,14 @@ export function CorrectionDecayCurve({ /> {/* Visual graduation markers: dashed vertical lines mapped to graduation timestamps. Numeric XAxis above is what makes - ReferenceLine x={ms} actually align with the curve. */} + ReferenceLine x={ms} actually align with the curve. The + custom `label` renders an invisible SVG <g> carrying the + test hook attribute + accessible <title> tooltip shown on + hover (native SVG — no extra JS). */} {visibleMarkers.map((l) => { const gMs = l.graduated_at ? new Date(l.graduated_at).getTime() : null if (gMs === null) return null + const ariaLabel = `Rule graduated: ${l.description ?? l.id}` return ( <ReferenceLine key={`refline-${l.id}`} @@ -117,6 +121,33 @@ export function CorrectionDecayCurve({ strokeOpacity={0.4} strokeDasharray="4 4" ifOverflow="extendDomain" + label={(props: { viewBox?: { x?: number; y?: number; height?: number } }) => { + const vb = props?.viewBox ?? {} + const cx = typeof vb.x === 'number' ? vb.x : 0 + const yTop = typeof vb.y === 'number' ? vb.y : 0 + const h = typeof vb.height === 'number' ? vb.height : 0 + return ( + <g + data-graduation-marker="" + data-lesson-id={l.id} + data-graduated-at={l.graduated_at ?? ''} + data-x={cx} + role="img" + aria-label={ariaLabel} + > + <title>{ariaLabel} + {/* transparent hit-target for hover tooltip */} + + + ) + }} /> ) })} @@ -161,21 +192,6 @@ export function CorrectionDecayCurve({ - {/* Hidden marker list — a11y fallback + test hook. The visible - dashed vertical lines are rendered above via , - but Recharts emits SVG that screen readers don't surface well, - so we keep this list as the accessible representation. Tests - also count [data-graduation-marker] from this list. */} -
- {visibleMarkers.map((l) => ( - - ))} -
{visibleMarkers.length > 0 && (
{visibleMarkers.length} rule graduation{visibleMarkers.length === 1 ? '' : 's'} in range diff --git a/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx b/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx index 56e9ce67..d4a3366f 100644 --- a/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx +++ b/cloud/dashboard/tests/CorrectionDecayCurve.test.tsx @@ -42,6 +42,37 @@ describe('CorrectionDecayCurve graduation markers', () => { expect(markers.length).toBe(2) }) + it('positions marker at correct x for lesson graduated 5 days ago (7d range)', () => { + const corrections = Array.from({ length: 10 }, (_, i) => mkCorr(`c${i}`, i + 1)) + const lessons = [mkLesson('five-days', daysAgo(5))] + const { container } = render( + , + ) + const marker = container.querySelector('[data-graduation-marker][data-lesson-id="five-days"]') + expect(marker).not.toBeNull() + // 5 days ago in a 7d range is ~2/7 of the way from left; with the mocked + // 800px wide container and Recharts' default Y-axis width, the x pixel + // should land in the left third of the plot area (i.e. < 400, > 0). + const x = Number(marker!.getAttribute('data-x')) + expect(Number.isFinite(x)).toBe(true) + expect(x).toBeGreaterThan(0) + expect(x).toBeLessThan(400) + }) + + it('marker carries accessible aria-label + SVG for tooltip', () => { + const corrections = [mkCorr('c0', 1)] + const lessons: Lesson[] = [ + { ...mkLesson('a', daysAgo(3)), description: 'Avoid em dashes' } as Lesson, + ] + const { container } = render( + <CorrectionDecayCurve corrections={corrections} lessons={lessons} range="7d" />, + ) + const marker = container.querySelector('[data-graduation-marker][data-lesson-id="a"]') + expect(marker).not.toBeNull() + expect(marker!.getAttribute('aria-label')).toMatch(/Avoid em dashes/) + expect(marker!.querySelector('title')?.textContent).toMatch(/Avoid em dashes/) + }) + it('caps markers at 12 and renders "+N more" note', () => { const corrections = Array.from({ length: 30 }, (_, i) => mkCorr(`c${i}`, i + 1)) const lessons = Array.from({ length: 15 }, (_, i) => mkLesson(`r${i}`, daysAgo(i + 1))) diff --git a/cloud/dashboard/tests/dashboard-page.test.tsx b/cloud/dashboard/tests/dashboard-page.test.tsx index e0f880ec..1b596407 100644 --- a/cloud/dashboard/tests/dashboard-page.test.tsx +++ b/cloud/dashboard/tests/dashboard-page.test.tsx @@ -78,8 +78,10 @@ describe('/dashboard preview-with-sample-data flow', () => { // Fixture-backed panels render expect(screen.getByText('Time Saved')).toBeInTheDocument() expect(screen.getByText('Your Rules')).toBeInTheDocument() - // Demo lessons appear (from demo-dashboard fixture) - expect(screen.getByText(/Never use em dashes/i)).toBeInTheDocument() + // Demo lessons appear (from demo-dashboard fixture). Use getAllByText + // because a graduated lesson's description also surfaces in the decay + // curve's SVG <title> tooltip on its graduation marker. + expect(screen.getAllByText(/Never use em dashes/i).length).toBeGreaterThan(0) // Exit demo await user.click(screen.getByRole('button', { name: /Exit demo/i })) diff --git a/cloud/dashboard/tests/setup.ts b/cloud/dashboard/tests/setup.ts index c44951a6..0ae13351 100644 --- a/cloud/dashboard/tests/setup.ts +++ b/cloud/dashboard/tests/setup.ts @@ -1 +1,24 @@ import '@testing-library/jest-dom' +import { vi } from 'vitest' +import * as React from 'react' + +// Recharts' ResponsiveContainer uses getBoundingClientRect which returns 0 in +// jsdom, collapsing charts to width/height -1 and skipping render. Mock it with +// a fixed-size wrapper so Recharts actually renders SVG (required for tests +// that assert on chart-level custom SVG attributes like [data-graduation-marker]). +vi.mock('recharts', async (importOriginal) => { + const actual = await importOriginal<typeof import('recharts')>() + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => + React.createElement( + 'div', + { style: { width: 800, height: 400 } }, + React.createElement( + actual.ResponsiveContainer, + { width: 800, height: 400 }, + children, + ), + ), + } +})