From d215df678e569705dc7fb04910c13eafe9883422 Mon Sep 17 00:00:00 2001 From: Chayut Orapinpatipat Date: Sat, 1 Aug 2026 18:36:50 +1000 Subject: [PATCH] Add a by-session view with dispersion ellipses and hover isolation Group dots by session instead of club to read progression over time. Sessions take a sequential colour ramp, oldest cold to newest warm. Toggling By session narrows to the busiest club and restores the club selection on the way back. Ellipses are rotated covariance ellipses at 2 sigma, fit on full swings only. A variance-minimising split drops drills from the fit while their dots stay plotted. The shot shape chart links session means with a dashed trail. Interactive date legends isolate a session on hover in both charts. The trend card gains faint per-session mean +/- 1 sigma boxes. Fix two races in the exclude flow: a StrictMode duplicate initial load could resolve late and overwrite optimistic edits, and a failed stats refetch reverted an exclusion that had already persisted. --- web/e2e/dashboard.spec.ts | 30 +++- web/src/App.tsx | 138 +++++++++++++-- web/src/components/FilterBar.tsx | 17 ++ web/src/components/RangeFan.tsx | 102 +++++++---- web/src/components/SessionLegend.tsx | 38 ++++ web/src/components/ShotShapeChart.tsx | 245 +++++++++++++++++++++----- web/src/components/TrendCard.tsx | 25 ++- web/src/index.css | 24 +++ web/src/sessionGroups.ts | 191 ++++++++++++++++++++ web/src/useDashboardData.ts | 37 ++-- 10 files changed, 731 insertions(+), 116 deletions(-) create mode 100644 web/src/components/SessionLegend.tsx create mode 100644 web/src/sessionGroups.ts diff --git a/web/e2e/dashboard.spec.ts b/web/e2e/dashboard.spec.ts index b918723..eb23c24 100644 --- a/web/e2e/dashboard.spec.ts +++ b/web/e2e/dashboard.spec.ts @@ -106,8 +106,12 @@ test('shot shape chart renders', async ({ page }) => { test('excluding a shot drops it from stats and restores on second click', async ({ page }) => { const shotsTile = page.getByTestId('stat-shots').getByTestId('stat-value') const before = Number(await shotsTile.innerText()) - const dot = page.getByTestId('fan-dot').first() - // The owner may have excluded shots of their own, so count relative. + // The owner may have excluded shots of their own, so count relative + // and pick a dot that is not already excluded. nth() keeps the same + // dot across re-renders, unlike a :not([data-excluded]) locator. + const dots = page.getByTestId('fan-dot') + const flags = await dots.evaluateAll((els) => els.map((el) => el.hasAttribute('data-excluded'))) + const dot = dots.nth(flags.indexOf(false)) const hollow = page.locator('[data-testid="fan-dot"][data-excluded]') const hollowBefore = await hollow.count() @@ -123,6 +127,28 @@ test('excluding a shot drops it from stats and restores on second click', async await expect(shotsTile).toHaveText(String(before)) }) +test('session grouping recolours the charts and adds interactive legends', async ({ page }) => { + await page.getByTestId('group-by-session').click() + await expect(page.getByTestId('group-by-session')).toHaveAttribute('aria-pressed', 'true') + + // Session legends replace or join the club legend. + await expect(page.getByTestId('fan-session-legend')).toBeVisible() + await expect(page.getByTestId('shape-session-legend')).toBeVisible() + expect(await page.getByTestId('fan-dot').count()).toBeGreaterThan(0) + + // Hovering a session date dims every other session's dots. + const entries = page.getByTestId('fan-session-legend').locator('.entry') + if ((await entries.count()) >= 2) { + await entries.first().hover() + await expect(page.locator('[data-testid="fan-dot"][opacity="0.12"]').first()).toBeVisible() + } + + // Back to club colours restores the club legend. + await page.getByTestId('group-by-club').click() + await expect(page.getByTestId('fan-legend')).toBeVisible() + await expect(page.getByTestId('fan-session-legend')).toHaveCount(0) +}) + test('theme toggle stamps an explicit theme', async ({ page }) => { const toggle = page.getByTestId('theme-toggle') await toggle.click() // auto -> light diff --git a/web/src/App.tsx b/web/src/App.tsx index d8b267c..3148a69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react' +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { currentUser } from './api/client' import type { Mode } from './theme' import { slotColor } from './theme' @@ -16,8 +16,10 @@ import { TrendCard } from './components/TrendCard' import { SessionTrendsCard } from './components/SessionTrendsCard' import { ClubTable } from './components/ClubTable' import { LoginPanel } from './components/LoginPanel' +import { SessionLegend } from './components/SessionLegend' import { toShotInput } from './api/toShotInput' import { calibrateShot, offsetsBySession } from './calibration' +import { clubGlyph, clubSymbol, sessionSlots } from './sessionGroups' import type { ShotInput } from 'golf-shot-viz' // three.js only loads when someone opens the 3D view. @@ -76,6 +78,8 @@ function Dashboard({ const [sessionId, setSessionId] = useState('all') const [activeClubs, setActiveClubs] = useState | null>(null) const [metric, setMetric] = useState<'carry' | 'total'>('carry') + const [grouping, setGrouping] = useState<'club' | 'session'>('club') + const [hoverSessionId, setHoverSessionId] = useState(null) const [viz3DOpen, setViz3DOpen] = useState(false) // Fixed slot assignment from the full club list (ordered by loft), so @@ -85,11 +89,17 @@ function Dashboard({ (a, b) => Number(a.static_loft_deg) - Number(b.static_loft_deg), ) const slots = new Map(ordered.map((c, i) => [ c.id, i ])) - const colorOf = (clubId: string | null) => - slotColor(clubId !== null ? (slots.get(clubId) ?? null) : null, mode) - return { ordered, colorOf } + const slotOf = (clubId: string | null) => + clubId !== null ? (slots.get(clubId) ?? null) : null + const colorOf = (clubId: string | null) => slotColor(slotOf(clubId), mode) + return { ordered, colorOf, slotOf } }, [data.clubs, mode]) + // Session grouping: ramp colours over every session, oldest to + // newest, so progression reads as a colour sweep in the charts. + const groupBySession = grouping === 'session' + const slots = useMemo(() => sessionSlots(data.sessions, mode), [data.sessions, mode]) + // The calibration layer: pure math over the raw shots the API served. // Nothing is refetched when the toggle flips. const shots = useMemo(() => { @@ -135,6 +145,37 @@ function Dashboard({ return entries }, [enriched, palette, sessionId]) + // Session comparison is one club at a time: mixing Driver and 7 Iron + // dots under session colours reads as noise. Switching to By session + // narrows to the busiest club; switching back restores the previous + // selection unless the user changed clubs in between. + const savedClubs = useRef<{ prev: Set | null; auto: Set | null } | null>(null) + const changeGrouping = useCallback( + (g: 'club' | 'session') => { + if (g === grouping) return + if (g === 'session') { + const busiest = chips.reduce( + (a, b) => (a === null || b.count > a.count ? b : a), + null, + ) + const auto = busiest && chips.length > 1 ? new Set([busiest.key]) : null + savedClubs.current = { prev: activeClubs, auto } + if (auto) setActiveClubs(auto) + } else if (savedClubs.current) { + const { prev, auto } = savedClubs.current + const untouched = + auto === null || + (activeClubs !== null && + activeClubs.size === auto.size && + [...auto].every((k) => activeClubs.has(k))) + if (untouched) setActiveClubs(prev) + savedClubs.current = null + } + setGrouping(g) + }, + [grouping, chips, activeClubs], + ) + const toggleClub = useCallback( (key: string) => { setActiveClubs((prev) => { @@ -168,6 +209,32 @@ function Dashboard({ [enriched, activeClubs], ) + // Sessions present in the current filter, in ramp order, for the + // interactive legends under the session-grouped charts. + const visibleSlots = useMemo(() => { + const ids = new Set(filtered.map((s) => s.training_session_id)) + return [...slots.values()].filter((s) => ids.has(s.id)).sort((a, b) => a.index - b.index) + }, [filtered, slots]) + + const clubGlyphs = useMemo( + () => + chips + .filter((c) => activeClubs === null || activeClubs.has(c.key)) + .map((c) => ({ + glyph: clubGlyph(c.key === UNCLASSIFIED_KEY ? null : palette.slotOf(c.key)), + label: c.label, + })), + [chips, activeClubs, palette], + ) + + // Club symbols matter only when two clubs share a session-coloured + // chart; a single club reads best as plain dots. + const multiClub = clubGlyphs.length > 1 + const symbolOf = useCallback( + (clubId: string | null) => (multiClub ? clubSymbol(palette.slotOf(clubId)) : 'circle'), + [multiClub, palette], + ) + // Excluded shots stay visible (hollow dots) so they can be restored, // but every stat and aggregate chart ignores them. const analyzed = useMemo(() => filtered.filter((s) => !s.excluded), [filtered]) @@ -193,6 +260,8 @@ function Dashboard({ onToggleClub={toggleClub} metric={metric} onMetricChange={setMetric} + grouping={grouping} + onGroupingChange={changeGrouping} onOpen3D={() => setViz3DOpen(true)} calibrated={calibrated} onToggleCalibrated={onToggleCalibrated} @@ -221,7 +290,8 @@ function Dashboard({

Dispersion

- Top-down view from the tee. Dashed ellipses are 1σ per club. + Top-down view from the tee. Dashed ellipses are 2σ of full swings per{' '} + {groupBySession ? 'session. Hover a dot or a date to isolate a session.' : 'club.'} {calibrated && Bay-calibrated view.} {excludedCount > 0 && ( @@ -229,17 +299,33 @@ function Dashboard({ )}

- -
- {chips - .filter((c) => activeClubs === null || activeClubs.has(c.key)) - .map((c) => ( - - - {c.label} - - ))} -
+ + {groupBySession ? ( + + ) : ( +
+ {chips + .filter((c) => activeClubs === null || activeClubs.has(c.key)) + .map((c) => ( + + + {c.label} + + ))} +
+ )}
@@ -258,11 +344,29 @@ function Dashboard({

Shot shape

Face angle vs club path at impact. Click a dot to exclude a mishit from every stat. + {groupBySession && + ' Ellipses are 2σ of full swings per session, the dashed trail links session means. Hover a dot or a date to isolate a session.'} {calibrated && ' Bay-calibrated view.'}

- +
+ {groupBySession && ( + 1 ? clubGlyphs : undefined} + /> + )}

Club averages

diff --git a/web/src/components/FilterBar.tsx b/web/src/components/FilterBar.tsx index 8d9dae6..69214bf 100644 --- a/web/src/components/FilterBar.tsx +++ b/web/src/components/FilterBar.tsx @@ -17,6 +17,8 @@ interface Props { onToggleClub: (key: string) => void metric: 'carry' | 'total' onMetricChange: (m: 'carry' | 'total') => void + grouping: 'club' | 'session' + onGroupingChange: (g: 'club' | 'session') => void onOpen3D: () => void calibrated: boolean onToggleCalibrated: () => void @@ -81,6 +83,8 @@ export function FilterBar({ onToggleClub, metric, onMetricChange, + grouping, + onGroupingChange, onOpen3D, calibrated, onToggleCalibrated, @@ -128,6 +132,19 @@ export function FilterBar({
+
+ + +
+