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({
+
+ + +
+