Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions web/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down
138 changes: 121 additions & 17 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
Expand Down Expand Up @@ -76,6 +78,8 @@ function Dashboard({
const [sessionId, setSessionId] = useState('all')
const [activeClubs, setActiveClubs] = useState<Set<string> | null>(null)
const [metric, setMetric] = useState<'carry' | 'total'>('carry')
const [grouping, setGrouping] = useState<'club' | 'session'>('club')
const [hoverSessionId, setHoverSessionId] = useState<string | null>(null)
const [viz3DOpen, setViz3DOpen] = useState(false)

// Fixed slot assignment from the full club list (ordered by loft), so
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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<string> | null; auto: Set<string> | null } | null>(null)
const changeGrouping = useCallback(
(g: 'club' | 'session') => {
if (g === grouping) return
if (g === 'session') {
const busiest = chips.reduce<ClubChip | null>(
(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) => {
Expand Down Expand Up @@ -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])
Expand All @@ -193,6 +260,8 @@ function Dashboard({
onToggleClub={toggleClub}
metric={metric}
onMetricChange={setMetric}
grouping={grouping}
onGroupingChange={changeGrouping}
onOpen3D={() => setViz3DOpen(true)}
calibrated={calibrated}
onToggleCalibrated={onToggleCalibrated}
Expand Down Expand Up @@ -221,25 +290,42 @@ function Dashboard({
<section className="card" aria-label="Shot dispersion">
<h2>Dispersion</h2>
<p className="subtitle">
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 && <span data-testid="calibrated-note"> Bay-calibrated view.</span>}
{excludedCount > 0 && (
<span className="excluded-note" data-testid="excluded-note">
{' '}{excludedCount} excluded (hollow dots). Click one to restore it.
</span>
)}
</p>
<RangeFan shots={filtered} metric={metric} mode={mode} onToggle={onToggleShot} />
<div className="fan-legend" data-testid="fan-legend">
{chips
.filter((c) => activeClubs === null || activeClubs.has(c.key))
.map((c) => (
<span className="entry" key={c.key}>
<span className="dot" style={{ background: c.color }} />
{c.label}
</span>
))}
</div>
<RangeFan
shots={filtered}
metric={metric}
mode={mode}
onToggle={onToggleShot}
sessionSlots={groupBySession ? slots : null}
hoverSessionId={hoverSessionId}
/>
{groupBySession ? (
<SessionLegend
slots={visibleSlots}
hovered={hoverSessionId}
onHover={setHoverSessionId}
testId="fan-session-legend"
/>
) : (
<div className="fan-legend" data-testid="fan-legend">
{chips
.filter((c) => activeClubs === null || activeClubs.has(c.key))
.map((c) => (
<span className="entry" key={c.key}>
<span className="dot" style={{ background: c.color }} />
{c.label}
</span>
))}
</div>
)}
</section>
<div className="right-col">
<section className="card" aria-label="Ball flight">
Expand All @@ -258,11 +344,29 @@ function Dashboard({
<h2>Shot shape</h2>
<p className="subtitle">
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.'}
</p>
<div className="shape-chart">
<ShotShapeChart shots={filtered} mode={mode} onToggle={onToggleShot} />
<ShotShapeChart
shots={filtered}
mode={mode}
onToggle={onToggleShot}
sessionSlots={groupBySession ? slots : null}
hoverSessionId={hoverSessionId}
symbolOf={symbolOf}
/>
</div>
{groupBySession && (
<SessionLegend
slots={visibleSlots}
hovered={hoverSessionId}
onHover={setHoverSessionId}
testId="shape-session-legend"
extras={clubGlyphs.length > 1 ? clubGlyphs : undefined}
/>
)}
</section>
<section className="card table-card" aria-label="Club averages">
<h2>Club averages</h2>
Expand Down
17 changes: 17 additions & 0 deletions web/src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +83,8 @@ export function FilterBar({
onToggleClub,
metric,
onMetricChange,
grouping,
onGroupingChange,
onOpen3D,
calibrated,
onToggleCalibrated,
Expand Down Expand Up @@ -128,6 +132,19 @@ export function FilterBar({
</button>
</div>

<div className="segmented" role="group" aria-label="Colour dots by" title="Colour dots by club or by session date">
<button data-testid="group-by-club" aria-pressed={grouping === 'club'} onClick={() => onGroupingChange('club')}>
By club
</button>
<button
data-testid="group-by-session"
aria-pressed={grouping === 'session'}
onClick={() => onGroupingChange('session')}
>
By session
</button>
</div>

<button
className="ghost-btn"
data-testid="calibration-toggle"
Expand Down
Loading