From 90be451b28f7ef773816043d448a9d36c14d3a12 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Fri, 17 Jul 2026 18:31:14 -0400 Subject: [PATCH] =?UTF-8?q?fix(fairway):=20stats=20cockpit=20readability?= =?UTF-8?q?=20=E2=80=94=20stretch,=20tone,=20consolidate,=20slim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #921. Four findings on FairwayStatsCockpit.tsx and its card primitives: 1. DetailGrid didn't stretch in grid rows, causing uneven card bottoms. DetailGridShell now applies h-full + flex-1 on the bordered Surface so cards in the same row equalize. The Approach tab's "By lie" 2-row vs 8-row Efficiency pair gets an internal max-height + scroll instead of blowing the row out. 2. Values rendered colorless. Added a tone system (good/warn/neutral, text-fw-success/text-fw-warning, never red) to DetailRow, DetailGrid, TeeMissByClub, and GirByDistanceBoard. Tone is derived from a real PGA Tour baseline (via the already-fetched player standing rows) wherever the v3 metric registry has a matching metric — scrambling by lie, putt make% bands, putt miss bias — and falls back to an honest relative read across a board's own values (GIR by distance) or a self-referential symmetry threshold (tee miss L/R) where no external baseline exists. Never fabricated; stays neutral when no comparison is available. 3. Consolidated triplicated primitives: RoundsReadout/FairwaysReadout/ GirReadout/PuttsReadout folded into the existing HeadlineReadout (now accepts an optional delta). TeeMissByClub and GirByDistanceBoard's hand-rolled title+Surface wrappers folded into a shared DetailGridShell alongside DetailGrid itself. 4. The Analysis tab's "Full shot detail" disclosure re-rendered ~17 DetailGrids the metric tabs already substantially owned. Exact duplicates (GIR by hole type/distance, putting headline/make-bands) were dropped; grids with no other home moved onto their owning tab (GIR-by-lie + approach proximity onto Approach, miss-direction + by-break overview onto Putting, scoring detail onto the Scoring tab). What remains in Analysis is a genuine summary (personal bests, 30-day trend) plus quick-nav buttons to the tabs that now own the rest. Gates: tsc --noEmit clean, eslint clean, vitest (24 files / 352 tests in src/components/fairway/pages/coachhelm/) all green. Added FairwayStatsCockpit.test.ts covering the three new tone helpers (toneVsBenchmark, relativeTones, skewTone). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MMdviLDsAg2YYJ8adsM6fg --- .../coachhelm/FairwayStatsCockpit.test.ts | 94 ++ .../pages/coachhelm/FairwayStatsCockpit.tsx | 998 ++++++++++-------- 2 files changed, 671 insertions(+), 421 deletions(-) create mode 100644 src/components/fairway/pages/coachhelm/FairwayStatsCockpit.test.ts diff --git a/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.test.ts b/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.test.ts new file mode 100644 index 000000000..53691a821 --- /dev/null +++ b/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { toneVsBenchmark, relativeTones, skewTone } from './FairwayStatsCockpit'; + +// ── toneVsBenchmark (P921 #2 — DetailGrid tone vs a real PGA/team baseline) ─── + +describe('toneVsBenchmark', () => { + it('reads good when meaningfully ahead of the benchmark (higher_better)', () => { + expect(toneVsBenchmark(70, 60, 'higher_better')).toBe('good'); + }); + + it('reads warn when meaningfully behind the benchmark (higher_better)', () => { + expect(toneVsBenchmark(50, 60, 'higher_better')).toBe('warn'); + }); + + it('reads neutral inside the dead zone (higher_better)', () => { + expect(toneVsBenchmark(61, 60, 'higher_better')).toBe('neutral'); + expect(toneVsBenchmark(58, 60, 'higher_better')).toBe('neutral'); + }); + + it('flips ahead/behind for lower_better metrics (e.g. proximity, miss bias)', () => { + // Lower is better: a smaller value than the benchmark is 'good'. + expect(toneVsBenchmark(10, 20, 'lower_better')).toBe('good'); + expect(toneVsBenchmark(30, 20, 'lower_better')).toBe('warn'); + expect(toneVsBenchmark(21, 20, 'lower_better')).toBe('neutral'); + }); + + it('never fabricates a comparison — neutral when either input is missing', () => { + expect(toneVsBenchmark(null, 60, 'higher_better')).toBe('neutral'); + expect(toneVsBenchmark(70, null, 'higher_better')).toBe('neutral'); + expect(toneVsBenchmark(null, null, 'higher_better')).toBe('neutral'); + }); + + it('respects a custom deadzone', () => { + expect(toneVsBenchmark(65, 60, 'higher_better', 10)).toBe('neutral'); + expect(toneVsBenchmark(71, 60, 'higher_better', 10)).toBe('good'); + }); +}); + +// ── relativeTones (P921 #2 — GirByDistanceBoard self-referential read) ──────── + +describe('relativeTones', () => { + it('flags the strongest band good and the weakest band warn when the spread is real', () => { + const tones = relativeTones([40, 70, 55]); + expect(tones).toEqual(['warn', 'good', 'neutral']); + }); + + it('stays all-neutral with fewer than 3 real values', () => { + expect(relativeTones([40, 70])).toEqual(['neutral', 'neutral']); + expect(relativeTones([40])).toEqual(['neutral']); + expect(relativeTones([])).toEqual([]); + }); + + it('stays all-neutral when the spread is inside minGap (noise, not a trend)', () => { + expect(relativeTones([50, 52, 54])).toEqual(['neutral', 'neutral', 'neutral']); + }); + + it('treats nulls as honest gaps, never fabricating a tone for missing data', () => { + const tones = relativeTones([40, null, 70, 55]); + expect(tones).toEqual(['warn', 'neutral', 'good', 'neutral']); + }); + + it('respects a custom minGap', () => { + expect(relativeTones([50, 55, 60], 20)).toEqual(['neutral', 'neutral', 'neutral']); + expect(relativeTones([50, 55, 60], 5)).toEqual(['warn', 'neutral', 'good']); + }); +}); + +// ── skewTone (P921 #2 — TeeMissByClub L/R symmetry read, no external baseline) ─ + +describe('skewTone', () => { + it('warns when a share is meaningfully lopsided', () => { + expect(skewTone(70)).toBe('warn'); + expect(skewTone(62)).toBe('warn'); + }); + + it('reads neutral for a roughly even split', () => { + expect(skewTone(50)).toBe('neutral'); + expect(skewTone(55)).toBe('neutral'); + }); + + it('never reads good — a missed fairway is never a positive outcome', () => { + expect(skewTone(0)).toBe('neutral'); + expect(skewTone(100)).toBe('warn'); + }); + + it('is neutral (never fabricated) when the value is missing', () => { + expect(skewTone(null)).toBe('neutral'); + }); + + it('respects a custom threshold', () => { + expect(skewTone(55, 70)).toBe('neutral'); + expect(skewTone(75, 70)).toBe('warn'); + }); +}); diff --git a/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.tsx b/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.tsx index eb90f88ff..87bc70c85 100644 --- a/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.tsx +++ b/src/components/fairway/pages/coachhelm/FairwayStatsCockpit.tsx @@ -308,11 +308,33 @@ function fmtInt(value: number | null | undefined): string { return n === null ? '—' : String(Math.round(n)); } +/** + * Semantic value tone — 'good' (ahead of the benchmark), 'warn' (behind it — + * amber, NEVER red; a stat lagging Tour/team is not an error), or 'neutral' + * (the default: no comparison point exists yet, or the value sits inside the + * dead zone). Mirrors the "ONE behind-benchmark hue" rule StandingStrip + * already uses (`text-fw-warning` for behind, never destructive red). + */ +export type DetailTone = 'good' | 'warn' | 'neutral'; + +const TONE_VALUE_CLASS: Record = { + good: 'text-fw-success', + warn: 'text-fw-warning', + neutral: 'text-text-primary', +}; + /** A single label → honest value row inside a detail grid. */ interface DetailRow { label: string; /** Preformatted, already null-guarded display string ("—" when no data). */ value: string; + /** + * Optional semantic tone for the VALUE text only (never the whole card). + * Omit — or leave 'neutral' — when no real comparison exists for this row; + * tone is only ever derived from a genuine PGA/team baseline or an honest + * relative read across the row's own peers, never fabricated. + */ + tone?: DetailTone; } /** True when every row in a detail block is the honest em-dash (no real data). */ @@ -320,22 +342,120 @@ function allDash(rows: DetailRow[]): boolean { return rows.every((r) => r.value === '—'); } +/** + * Compare a raw value to a real benchmark (a PGA Tour standard or a team + * average, both already fetched elsewhere on this page) and return a calm + * tone. `direction` flips which side of the benchmark reads as ahead; + * `deadzone` (in the metric's own units — percentage points, feet, etc.) + * keeps noise-level differences neutral instead of flickering good/warn. + * Returns 'neutral' whenever either input is missing — no comparison is + * ever invented. + */ +export function toneVsBenchmark( + value: number | null, + benchmark: number | null, + direction: 'higher_better' | 'lower_better', + deadzone = 3, +): DetailTone { + if (value === null || benchmark === null) return 'neutral'; + const diff = direction === 'higher_better' ? value - benchmark : benchmark - value; + if (diff > deadzone) return 'good'; + if (diff < -deadzone) return 'warn'; + return 'neutral'; +} + +/** + * Self-referential tone across a player's OWN band values, for boards with no + * external baseline available (e.g. GIR% by approach distance — the engine + * has no per-band PGA/team standard). The same "biggest gain / biggest leak" + * read the SG tab already draws across categories, applied within one board: + * the strongest band reads 'good', the weakest 'warn'. Needs >= 3 real values + * AND a meaningful spread (>= minGap) or every row stays neutral — a 2-point + * or barely-different set is noise, not a trend. + */ +export function relativeTones(values: ReadonlyArray, minGap = 8): DetailTone[] { + const real = values.filter((v): v is number => v !== null); + if (real.length < 3) return values.map(() => 'neutral'); + const max = Math.max(...real); + const min = Math.min(...real); + if (max - min < minGap) return values.map(() => 'neutral'); + return values.map((v) => (v === null ? 'neutral' : v === max ? 'good' : v === min ? 'warn' : 'neutral')); +} + +/** + * Miss-direction symmetry tone. There is no external benchmark for which way + * a miss "should" lean, so this reads the row's own share: a meaningfully + * lopsided split (>= thresholdPct of that row's own misses) warns; a roughly + * even split reads neutral. Never 'good' — a missed fairway is never a + * positive outcome, only more or less directionally biased. + */ +export function skewTone(sharePct: number | null, thresholdPct = 62): DetailTone { + if (sharePct === null) return 'neutral'; + return sharePct >= thresholdPct ? 'warn' : 'neutral'; +} + +/** + * Shared chrome for the DetailGrid family — a title/hint header over a + * matte bordered Surface that STRETCHES to fill its grid row (`h-full` + + * `flex-1` on the Surface) so bordered cards in the same row equalize their + * bottoms instead of trailing off at their own content height. DetailGrid, + * TeeMissByClub, and GirByDistanceBoard all mount their row content into + * this ONE shell instead of each hand-rolling the title-block + Surface + * wrapper (previously three copies that could quietly drift out of sync). + */ +function DetailGridShell({ + title, + hint, + surfaceClassName, + children, +}: { + title: string; + hint?: string; + surfaceClassName?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {hint ? ( + {hint} + ) : null} +
+ + {children} + +
+ ); +} + /** * Compact honest readout grid on a sunken Surface — the Fairway-native * replacement for the legacy StatRow list. Each row is a label + tabular value; - * a row with no data reads em-dash, never a fabricated 0. The whole block is - * only rendered by callers when at least one row has data (allDash guard). + * a row with no data reads em-dash, never a fabricated 0. A row's `tone` + * (good/warn/neutral) tints ONLY the value text, never the card. The whole + * block is only rendered by callers when at least one row has data (allDash + * guard). */ function DetailGrid({ title, hint, rows, columns = 2, + scrollable = false, }: { title: string; hint?: string; rows: DetailRow[]; columns?: 2 | 3 | 4; + /** + * Caps the row list at a fixed internal height with its own scroll — for a + * grid paired in the same row against a much shorter sibling (e.g. the + * Approach tab's "By lie" 2-row grid next to the 8-row Efficiency grid) so + * the taller card doesn't blow the row out of proportion. Every value stays + * reachable by scrolling; nothing is hidden. + */ + scrollable?: boolean; }) { const colClass = columns === 4 @@ -344,34 +464,32 @@ function DetailGrid({ ? 'sm:grid-cols-3' : 'sm:grid-cols-2'; return ( -
-
-

{title}

- {hint ? ( - {hint} - ) : null} -
- -
- {rows.map((r) => ( -
+
+ {rows.map((r) => ( +
+
{r.label}
+
-
{r.label}
-
- {r.value} -
-
- ))} -
- -
+ {r.value} + +
+ ))} + + ); } @@ -384,6 +502,7 @@ function HeadlineReadout({ hasSample, awaitingLabel, haveSamples, + delta, }: { value: number | null; format?: { maximumFractionDigits?: number; minimumFractionDigits?: number }; @@ -392,6 +511,8 @@ function HeadlineReadout({ hasSample: boolean; awaitingLabel: string; haveSamples?: number; + /** Optional signed delta line (e.g. "vs prev 30d") — rendered only while live. */ + delta?: ReadoutDelta; }) { const live = value != null && hasSample; return ( @@ -405,6 +526,7 @@ function HeadlineReadout({ state={live ? 'live' : 'awaiting'} samples={live ? undefined : { have: haveSamples ?? 0, need: 1 }} awaitingLabel={awaitingLabel} + delta={live ? delta : undefined} /> ); @@ -457,7 +579,6 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }: const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [showDetailed, setShowDetailed] = useState(false); - const [showComprehensive, setShowComprehensive] = useState(false); // ── P355 · Tab persistence — sync the active tab to the `?tab=` search param ─ // so refresh / browser-back / deep-link all restore (and share) the user's @@ -754,6 +875,17 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }: ); } + // ── Vitals readouts — folded RoundsReadout/FairwaysReadout/GirReadout/ + // PuttsReadout (four near-identical hand-rolled components) into ONE shared + // HeadlineReadout below; these are just their per-metric inputs. ────────── + const roundsVal = detailedStats?.roundsPlayed ?? 0; + const fairwaysPct = finite(detailedStats?.fairwayPercentage); + const fairwaysLive = fairwaysPct != null && (detailedStats?.fairwayOpportunities ?? 0) > 0; + const girPct = finite(detailedStats?.girPercentage); + const girLive = girPct != null && (detailedStats?.girOpportunities ?? 0) > 0; + const puttsPerRoundVal = finite(detailedStats?.puttsPerRound); + const puttsLive = puttsPerRoundVal != null && (detailedStats?.totalPutts ?? 0) > 0; + return (
{/* ════════════════ 1 · VERDICT — SG hero + synthesized read ════════════ */} @@ -790,10 +922,39 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }:
The fundamentals
- - - - + 0} + awaitingLabel="None yet" + /> + + +
@@ -834,6 +995,7 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }: valueFormatter={(v) => v.toFixed(1)} /> +
@@ -866,7 +1028,7 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }:
- + {leakError ? ( void loadAll(playerId)} retrying={loading} /> ) : ( @@ -884,7 +1046,7 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }: - + @@ -998,11 +1160,10 @@ export function FairwayStatsCockpit({ playerId, className, isOwnStats = false }: onToggle={() => setShowDetailed((v) => !v)} /> - setShowComprehensive((v) => !v)} + onNavigateTab={handleTabChange} />
@@ -1133,103 +1294,11 @@ function SgVerdict({ /* ════════════════════════════════════════════════════════════════════════════ * 2 · VITALS — micro-readouts off the REUSED getDetailedStats GolfStats. + * Rounds/Fairways/GIR/Putts all folded into the shared HeadlineReadout above + * (was four near-identical hand-rolled InstrumentPanel+Readout components — + * see the `roundsVal`/`fairwaysPct`/`girPct`/`puttsPerRoundVal` locals and the + * "The fundamentals" section in the main render). * ══════════════════════════════════════════════════════════════════════════ */ -function RoundsReadout({ detailedStats }: { detailedStats: GolfStats | null }) { - const rounds = detailedStats?.roundsPlayed ?? 0; - return ( - - 0 ? 'live' : 'awaiting'} - samples={rounds === 0 ? { have: 0, need: 1 } : undefined} - awaitingLabel="None yet" - /> - - ); -} - -function FairwaysReadout({ - detailedStats, - delta, -}: { - detailedStats: GolfStats | null; - delta?: ReadoutDelta; -}) { - const pct = finite(detailedStats?.fairwayPercentage); - const opps = detailedStats?.fairwayOpportunities ?? 0; - const live = pct != null && opps > 0; - return ( - - - - ); -} - -function GirReadout({ - detailedStats, - delta, -}: { - detailedStats: GolfStats | null; - delta?: ReadoutDelta; -}) { - const pct = finite(detailedStats?.girPercentage); - const opps = detailedStats?.girOpportunities ?? 0; - const live = pct != null && opps > 0; - return ( - - - - ); -} - -function PuttsReadout({ - detailedStats, - delta, -}: { - detailedStats: GolfStats | null; - delta?: ReadoutDelta; -}) { - const perRound = finite(detailedStats?.puttsPerRound); - const putts = detailedStats?.totalPutts ?? 0; - const live = perRound != null && putts > 0; - return ( - - - - ); -} /* ════════════════════════════════════════════════════════════════════════════ * 3b · SCORING BY PAR — the outcome mix on par 3s / 4s / 5s as a stacked @@ -1320,7 +1389,17 @@ function ScoringByPar({ data }: { data: GolfStats['scoringByPar'] }) { * ══════════════════════════════════════════════════════════════════════════ */ type ScrambleCut = 'lie' | 'distance'; -function ShortGameSection({ detailedStats }: { detailedStats: GolfStats | null }) { +function ShortGameSection({ + detailedStats, + standing, +}: { + detailedStats: GolfStats | null; + /** Player standing rows keyed by metric_id — carries the real PGA-Tour + * baseline for the by-lie scrambling percentages (see `toneVsBenchmark` + * calls below). Optional so this section still renders honestly (all + * neutral) if the standing fetch hasn't landed yet. */ + standing?: Map; +}) { const [scrambleCut, setScrambleCut] = useState('lie'); if (!detailedStats) return null; const scramblePct = finite(detailedStats.scramblingPercentage); @@ -1329,12 +1408,28 @@ function ShortGameSection({ detailedStats }: { detailedStats: GolfStats | null } const sandAtt = detailedStats.sandSaveAttempts ?? 0; const penPerRound = finite(detailedStats.penaltiesPerRound); const rounds = detailedStats.roundsPlayed ?? 0; + const pgaFor = (metricId: string) => finite(standing?.get(metricId)?.pga_value ?? null); // By-lie + by-distance scrambling drill-in (only rows with data shown honest). + // Tone compares each lie's scrambling % to the matching v3 registry metric's + // real PGA-Tour standard (scrambling_pct_fairway/rough/sand) — the exact + // same quantity, so this is a genuine baseline, not an invented one. const byLie: DetailRow[] = [ - { label: 'From fairway', value: fmtPct(detailedStats.scramblingPctFairway) }, - { label: 'From rough', value: fmtPct(detailedStats.scramblingPctRough) }, - { label: 'From sand', value: fmtPct(detailedStats.scramblingPctSand) }, + { + label: 'From fairway', + value: fmtPct(detailedStats.scramblingPctFairway), + tone: toneVsBenchmark(finite(detailedStats.scramblingPctFairway), pgaFor('scrambling_pct_fairway'), 'higher_better'), + }, + { + label: 'From rough', + value: fmtPct(detailedStats.scramblingPctRough), + tone: toneVsBenchmark(finite(detailedStats.scramblingPctRough), pgaFor('scrambling_pct_rough'), 'higher_better'), + }, + { + label: 'From sand', + value: fmtPct(detailedStats.scramblingPctSand), + tone: toneVsBenchmark(finite(detailedStats.scramblingPctSand), pgaFor('scrambling_pct_sand'), 'higher_better'), + }, ]; const byDistance: DetailRow[] = [ { label: '0–10 yds', value: fmtPct(detailedStats.scramblingPct0_10) }, @@ -1568,62 +1663,60 @@ function DrivingSection({ detailedStats }: { detailedStats: GolfStats | null }) /* ── Tee-miss L/R split, by club — a small 2×2 matrix (Driver / Non-driver × * Miss left % / Miss right %). Sits beside the overall miss-direction grid so - * the per-club bias reads at a glance. Each cell em-dashes independently. ──── */ + * the per-club bias reads at a glance. Each cell em-dashes independently. + * Folded into the shared DetailGridShell (was a hand-rolled title+Surface + * clone of DetailGrid's own wrapper). No external L/R benchmark exists, so + * tone reads the row's OWN symmetry — see `skewTone`. ─────────────────────── */ function TeeMissByClub({ rows, }: { rows: Array<{ club: string; left: number | null; right: number | null }>; }) { return ( -
-
-

- Tee miss by club -

- - Left / right miss split among missed fairways, by club. + +
+ + + Miss left + + + Miss right + {rows.map((r, i) => { + const edge = i < rows.length - 1 ? 'border-b border-border-subtle/60 pb-2' : ''; + const leftTone = skewTone(r.left); + const rightTone = skewTone(r.right); + return ( +
+ + {r.club} + + + {fmtPct(r.left)} + + + {fmtPct(r.right)} + +
+ ); + })}
- -
- - - Miss left - - - Miss right - - {rows.map((r, i) => { - const edge = i < rows.length - 1 ? 'border-b border-border-subtle/60 pb-2' : ''; - return ( -
- - {r.club} - - - {fmtPct(r.left)} - - - {fmtPct(r.right)} - -
- ); - })} -
-
-
+ ); } @@ -1743,6 +1836,14 @@ function dominantMiss(dir: ApproachMissDir | undefined): { label: string; pct: n return best; } +/** + * Folded into the shared DetailGridShell (was a hand-rolled title+Surface + * clone of DetailGrid's own wrapper). No per-band GIR% baseline is fetched + * anywhere on this page (the engine has no PGA/team standard per approach + * distance), so tone is a relative read across the player's OWN bands — + * `relativeTones` — the same "biggest gain / biggest leak" idea the SG tab + * already uses across categories, applied within this one board. + */ function GirByDistanceBoard({ bands, missByBand, @@ -1752,52 +1853,52 @@ function GirByDistanceBoard({ }) { const rows = bands.filter((b) => b.gir != null); if (rows.length === 0) return null; + const tones = relativeTones(rows.map((b) => b.gir)); return ( -
-
-

- GIR by approach distance -

- - Greens hit by distance, with where the misses tend to leak. - -
- -
    - {rows.map((b) => { - const pct = b.gir ?? 0; - const miss = dominantMiss(missByBand[b.missKey]); - return ( -
  • -
    - {b.label} - - {miss ? ( - - miss: {miss.label} {Math.round(miss.pct)}% - - ) : null} - - {fmtPct(b.gir)} + +
      + {rows.map((b, i) => { + const pct = b.gir ?? 0; + const miss = dominantMiss(missByBand[b.missKey]); + const tone = tones[i] ?? 'neutral'; + return ( +
    • +
      + {b.label} + + {miss ? ( + + miss: {miss.label} {Math.round(miss.pct)}% + ) : null} + + {fmtPct(b.gir)} -
      + +
    +
    -
    -
    -
  • - ); - })} -
-
-
+ className="h-full rounded-full bg-accent-500" + style={{ width: `${Math.max(0, Math.min(100, pct))}%` }} + /> +
+ + ); + })} + + ); } @@ -1826,6 +1927,38 @@ function ApproachLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nu { label: 'Par 4s', value: fmtPct(s.girPctPar4) }, { label: 'Par 5s', value: fmtPct(s.girPctPar5) }, ]; + // Full breakdown — every lie/hole-type/distance band at once (moved here + // from the old Analysis-tab "Full shot detail" disclosure, which re-rendered + // these on top of what this tab already owns — P921). No PGA/team baseline + // exists for these bucket schemes on this page, so tone stays neutral — + // honest, not fabricated. + const girByLie: DetailRow[] = [ + { label: 'From fairway', value: fmtPct(s.girPctFromFairway) }, + { label: 'From rough', value: fmtPct(s.girPctFromRough) }, + { label: 'From sand', value: fmtPct(s.girPctFromSand) }, + ]; + const approachByPar: DetailRow[] = [ + { label: 'Par 3s', value: fmtFeet(s.approachProximityPar3) }, + { label: 'Par 4s', value: fmtFeet(s.approachProximityPar4) }, + { label: 'Par 5s', value: fmtFeet(s.approachProximityPar5) }, + ]; + const approachByLie: DetailRow[] = [ + { label: 'From fairway', value: fmtFeet(s.approachProximityFairway) }, + { label: 'From rough', value: fmtFeet(s.approachProximityRough) }, + { label: 'From sand', value: fmtFeet(s.approachProximitySand) }, + ]; + const approachByDistance: DetailRow[] = [ + { label: '30–75 yds', value: fmtFeet(s.approachProx30_75) }, + { label: '75–100 yds', value: fmtFeet(s.approachProx75_100) }, + { label: '100–125 yds', value: fmtFeet(s.approachProx100_125) }, + { label: '125–150 yds', value: fmtFeet(s.approachProx125_150) }, + { label: '150–175 yds', value: fmtFeet(s.approachProx150_175) }, + { label: '175–200 yds', value: fmtFeet(s.approachProx175_200) }, + { label: '200–225 yds', value: fmtFeet(s.approachProx200_225) }, + { label: '225+ yds', value: fmtFeet(s.approachProx225Plus) }, + ]; + const hasFullBreakdown = + !allDash(girByLie) || !allDash(approachByPar) || !allDash(approachByLie) || !allDash(approachByDistance); const lieRows: Record = { fairway: [ { label: 'GIR from fairway', value: fmtPct(s.girPctFromFairway) }, @@ -1857,7 +1990,7 @@ function ApproachLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nu // across every lie and must NOT sit under the lie filter. const lieLabel = `${selectedLie.charAt(0).toUpperCase()}${selectedLie.slice(1)}`; const lieHasData = !allDash(lieRows[selectedLie]) || !allDash(approachEfficiencyRows); - const hasAny = hasGirBands || !allDash(girByPar) || lieHasData; + const hasAny = hasGirBands || !allDash(girByPar) || lieHasData || hasFullBreakdown; if (!hasAny) return null; return ( @@ -1911,6 +2044,10 @@ function ApproachLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nu hint="Average strokes to hole out" rows={approachEfficiencyRows} columns={4} + // The 8-row Efficiency grid paired against the 2-row lie grid — + // cap it to an internal scroll instead of stretching the whole + // row to 8 rows tall (P921 #1). + scrollable /> ) : null} @@ -1922,11 +2059,47 @@ function ApproachLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nu )} + + {hasFullBreakdown ? ( +
+
+

+ Full proximity & GIR breakdown +

+ + Every lie and distance band at once, alongside the interactive filter above. + +
+
+ {!allDash(girByLie) ? : null} + {!allDash(approachByPar) ? ( + + ) : null} + {!allDash(approachByLie) ? ( + + ) : null} + {!allDash(approachByDistance) ? ( + + ) : null} +
+
+ ) : null} ); } -function PuttingLegacyDetail({ detailedStats }: { detailedStats: GolfStats | null }) { +function PuttingLegacyDetail({ + detailedStats, + standing, +}: { + detailedStats: GolfStats | null; + /** Player standing rows keyed by metric_id — carries the real PGA-Tour + * baseline for the 3-5/5-10/10-15/15-25/25+ ft make-rate bands and the + * high/low/left/right miss-bias metrics (see `toneVsBenchmark` below). + * Optional so this section still renders honestly (all neutral) if the + * standing fetch hasn't landed yet. */ + standing?: Map; +}) { const [selectedBreak, setSelectedBreak] = useState('left_to_right'); if (!detailedStats) return null; const s = detailedStats; @@ -1939,6 +2112,7 @@ function PuttingLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nul : selectedBreak === 'straight' ? 'Straight' : 'Multiple breaks'; + const pgaFor = (metricId: string) => finite(standing?.get(metricId)?.pga_value ?? null); const headline: DetailRow[] = [ { label: 'Putts / round', value: fmtNum(s.puttsPerRound, 1) }, @@ -1949,16 +2123,23 @@ function PuttingLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nul // Avg distance left after every putt (0 for makes) — the "approach putting" stat. { label: 'Approach putting avg', value: s.approachPuttAvgLeave !== null ? `${s.approachPuttAvgLeave.toFixed(1)} ft avg` : '—' }, ]; + // Tone compares each band's make% to the matching v3 registry PGA standard. + // The registry's bands (3-5/5-10/10-15/15-25/25+) are coarser than the + // engine's 9-way split, so 15-20 & 20-25 both read against the SAME + // 15-25ft standard, and 25-30/30-35/35+ all read against the SAME 25+ft + // standard — a real PGA number, just not sub-divided as finely. 0-3ft has + // no registry standard at all (same as the leak-map's own 0-3ft band) and + // stays neutral. const makeBands: DetailRow[] = [ { label: '0-3 ft', value: fmtPct(s.puttMakePct0_3) }, - { label: '3-5 ft', value: fmtPct(s.puttMakePct3_5) }, - { label: '5-10 ft', value: fmtPct(s.puttMakePct5_10) }, - { label: '10-15 ft', value: fmtPct(s.puttMakePct10_15) }, - { label: '15-20 ft', value: fmtPct(s.puttMakePct15_20) }, - { label: '20-25 ft', value: fmtPct(s.puttMakePct20_25) }, - { label: '25-30 ft', value: fmtPct(s.puttMakePct25_30) }, - { label: '30-35 ft', value: fmtPct(s.puttMakePct30_35) }, - { label: '35+ ft', value: fmtPct(s.puttMakePct35Plus) }, + { label: '3-5 ft', value: fmtPct(s.puttMakePct3_5), tone: toneVsBenchmark(finite(s.puttMakePct3_5), pgaFor('putts_made_3_5ft_pct'), 'higher_better') }, + { label: '5-10 ft', value: fmtPct(s.puttMakePct5_10), tone: toneVsBenchmark(finite(s.puttMakePct5_10), pgaFor('putts_made_5_10ft_pct'), 'higher_better') }, + { label: '10-15 ft', value: fmtPct(s.puttMakePct10_15), tone: toneVsBenchmark(finite(s.puttMakePct10_15), pgaFor('putts_made_10_15ft_pct'), 'higher_better') }, + { label: '15-20 ft', value: fmtPct(s.puttMakePct15_20), tone: toneVsBenchmark(finite(s.puttMakePct15_20), pgaFor('putts_made_15_25ft_pct'), 'higher_better') }, + { label: '20-25 ft', value: fmtPct(s.puttMakePct20_25), tone: toneVsBenchmark(finite(s.puttMakePct20_25), pgaFor('putts_made_15_25ft_pct'), 'higher_better') }, + { label: '25-30 ft', value: fmtPct(s.puttMakePct25_30), tone: toneVsBenchmark(finite(s.puttMakePct25_30), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, + { label: '30-35 ft', value: fmtPct(s.puttMakePct30_35), tone: toneVsBenchmark(finite(s.puttMakePct30_35), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, + { label: '35+ ft', value: fmtPct(s.puttMakePct35Plus), tone: toneVsBenchmark(finite(s.puttMakePct35Plus), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, ]; // Approach putting: avg distance left after putts that STARTED in each band. // 0 for made putts; missed putts with unknown leave are excluded (null-honest). @@ -1969,23 +2150,48 @@ function PuttingLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nul const v = s.approachPuttAvgLeaveByBand[key]; return { label, value: v !== undefined ? `${v.toFixed(1)} ft avg` : '—' }; }); + // Same PGA-band reuse as `makeBands` above, applied to the break-filtered + // figure — the baseline number is real, just not scoped to one break type. const breakMakeBands: DetailRow[] = [ { label: '0-3 ft', value: fmtPct(breakStats.makePct0_3) }, - { label: '3-5 ft', value: fmtPct(breakStats.makePct3_5) }, - { label: '5-10 ft', value: fmtPct(breakStats.makePct5_10) }, - { label: '10-15 ft', value: fmtPct(breakStats.makePct10_15) }, - { label: '15-20 ft', value: fmtPct(breakStats.makePct15_20) }, - { label: '20-25 ft', value: fmtPct(breakStats.makePct20_25) }, - { label: '25-30 ft', value: fmtPct(breakStats.makePct25_30) }, - { label: '30-35 ft', value: fmtPct(breakStats.makePct30_35) }, - { label: '35+ ft', value: fmtPct(breakStats.makePct35Plus) }, + { label: '3-5 ft', value: fmtPct(breakStats.makePct3_5), tone: toneVsBenchmark(finite(breakStats.makePct3_5), pgaFor('putts_made_3_5ft_pct'), 'higher_better') }, + { label: '5-10 ft', value: fmtPct(breakStats.makePct5_10), tone: toneVsBenchmark(finite(breakStats.makePct5_10), pgaFor('putts_made_5_10ft_pct'), 'higher_better') }, + { label: '10-15 ft', value: fmtPct(breakStats.makePct10_15), tone: toneVsBenchmark(finite(breakStats.makePct10_15), pgaFor('putts_made_10_15ft_pct'), 'higher_better') }, + { label: '15-20 ft', value: fmtPct(breakStats.makePct15_20), tone: toneVsBenchmark(finite(breakStats.makePct15_20), pgaFor('putts_made_15_25ft_pct'), 'higher_better') }, + { label: '20-25 ft', value: fmtPct(breakStats.makePct20_25), tone: toneVsBenchmark(finite(breakStats.makePct20_25), pgaFor('putts_made_15_25ft_pct'), 'higher_better') }, + { label: '25-30 ft', value: fmtPct(breakStats.makePct25_30), tone: toneVsBenchmark(finite(breakStats.makePct25_30), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, + { label: '30-35 ft', value: fmtPct(breakStats.makePct30_35), tone: toneVsBenchmark(finite(breakStats.makePct30_35), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, + { label: '35+ ft', value: fmtPct(breakStats.makePct35Plus), tone: toneVsBenchmark(finite(breakStats.makePct35Plus), pgaFor('putts_made_25_plus_ft_pct'), 'higher_better') }, { label: 'Overall make %', value: fmtPct(breakStats.overallMakePct) }, ]; const breakMissRows: DetailRow[] = [ { label: 'Miss short', value: fmtPct(breakStats.missShortPct) }, - { label: 'Low side', value: fmtPct(breakStats.missLowPct) }, - { label: 'High side', value: fmtPct(breakStats.missHighPct) }, + { label: 'Low side', value: fmtPct(breakStats.missLowPct), tone: toneVsBenchmark(finite(breakStats.missLowPct), pgaFor('putt_miss_bias_low_pct'), 'lower_better') }, + { label: 'High side', value: fmtPct(breakStats.missHighPct), tone: toneVsBenchmark(finite(breakStats.missHighPct), pgaFor('putt_miss_bias_high_pct'), 'lower_better') }, + ]; + // Overall miss direction + all-4-breaks-at-once overview — moved here from + // the old Analysis-tab "Full shot detail" disclosure (P921). Left/right/ + // high/low have a real PGA standard (putt_miss_bias_*); short/long and the + // by-break overview don't, so those stay neutral. + const puttMissDir: DetailRow[] = [ + { label: 'Miss left', value: fmtPct(s.puttMissLeftPct), tone: toneVsBenchmark(finite(s.puttMissLeftPct), pgaFor('putt_miss_bias_left_pct'), 'lower_better') }, + { label: 'Miss right', value: fmtPct(s.puttMissRightPct), tone: toneVsBenchmark(finite(s.puttMissRightPct), pgaFor('putt_miss_bias_right_pct'), 'lower_better') }, + { label: 'Miss short', value: fmtPct(s.puttMissShortPct) }, + { label: 'Miss long', value: fmtPct(s.puttMissLongPct) }, + { label: 'Under-read (low)', value: fmtPct(s.puttMissLowPct), tone: toneVsBenchmark(finite(s.puttMissLowPct), pgaFor('putt_miss_bias_low_pct'), 'lower_better') }, + { label: 'Over-read (high)', value: fmtPct(s.puttMissHighPct), tone: toneVsBenchmark(finite(s.puttMissHighPct), pgaFor('putt_miss_bias_high_pct'), 'lower_better') }, + ]; + const overviewMakeRow = (label: string, b: GolfStats['puttingByBreak'][keyof GolfStats['puttingByBreak']]): DetailRow => ({ + label, + value: fmtPct(b.overallMakePct), + }); + const puttByBreak: DetailRow[] = [ + overviewMakeRow('Left-to-right', s.puttingByBreak.left_to_right), + overviewMakeRow('Straight', s.puttingByBreak.straight), + overviewMakeRow('Right-to-left', s.puttingByBreak.right_to_left), + overviewMakeRow('Multiple breaks', s.puttingByBreak.multiple), ]; + const hasOverview = !allDash(puttMissDir) || !allDash(puttByBreak); return (
@@ -2029,6 +2235,25 @@ function PuttingLegacyDetail({ detailedStats }: { detailedStats: GolfStats | nul ) : null} + + {hasOverview ? ( +
+
+

+ Overall miss direction & by-break overview +

+ + Every break at a glance, alongside the interactive filter above. + +
+
+ {!allDash(puttMissDir) ? ( + + ) : null} + {!allDash(puttByBreak) ? : null} +
+
+ ) : null}
); } @@ -2114,112 +2339,17 @@ function DetailedStandingsSection({ } /* ════════════════════════════════════════════════════════════════════════════ - * 6b · FULL SHOT DETAIL — one collapsed disclosure holding the heavy drill-in - * grids that don't belong in the always-on flow: GIR by par / distance / lie, - * approach proximity by par / lie / distance, putting make-% bands + miss - * direction + by-break, scoring per-round / career / by-type + streaks, and the - * personal-bests + 30-day comparison analysis readouts. Mirrors the existing - * "Detailed standings" disclosure chrome exactly. Every grid is null-honest and - * self-hides when its block has no data (allDash); the whole disclosure hides - * when there is nothing to show. + * 3a2 · SCORING DETAIL — bests/worsts, per-round + career totals, by round + * type, and streaks/records. This tab is literally named Scoring, so it now + * owns its own breakdowns directly instead of them being buried in the old + * Analysis-tab "Full shot detail" disclosure (P921 #4). Every grid is + * null-honest and self-hides (allDash); the whole section hides when there + * is nothing to show. * ══════════════════════════════════════════════════════════════════════════ */ -function ComprehensiveDetail({ - detailedStats, - trendData, - open, - onToggle, -}: { - detailedStats: GolfStats | null; - trendData: TrendAnalysisResponse | null; - open: boolean; - onToggle: () => void; -}) { +function ScoringDetail({ detailedStats }: { detailedStats: GolfStats | null }) { if (!detailedStats) return null; const s = detailedStats; - // ── GIR detail ───────────────────────────────────────────────────────────── - const girByPar: DetailRow[] = [ - { label: 'Par 3s', value: fmtPct(s.girPctPar3) }, - { label: 'Par 4s', value: fmtPct(s.girPctPar4) }, - { label: 'Par 5s', value: fmtPct(s.girPctPar5) }, - ]; - const girByDistance: DetailRow[] = [ - { label: '50–75 yds', value: fmtPct(s.girPct50_75) }, - { label: '75–100 yds', value: fmtPct(s.girPct75_100) }, - { label: '100–125 yds', value: fmtPct(s.girPct100_125) }, - { label: '125–150 yds', value: fmtPct(s.girPct125_150) }, - { label: '150–175 yds', value: fmtPct(s.girPct150_175) }, - { label: '175–200 yds', value: fmtPct(s.girPct175_200) }, - { label: '200–225 yds', value: fmtPct(s.girPct200_225) }, - { label: '225+ yds', value: fmtPct(s.girPct225Plus) }, - ]; - const girByLie: DetailRow[] = [ - { label: 'From fairway', value: fmtPct(s.girPctFromFairway) }, - { label: 'From rough', value: fmtPct(s.girPctFromRough) }, - { label: 'From sand', value: fmtPct(s.girPctFromSand) }, - ]; - - // ── Approach proximity detail (feet) ─────────────────────────────────────-- - const approachByPar: DetailRow[] = [ - { label: 'Par 3s', value: fmtFeet(s.approachProximityPar3) }, - { label: 'Par 4s', value: fmtFeet(s.approachProximityPar4) }, - { label: 'Par 5s', value: fmtFeet(s.approachProximityPar5) }, - ]; - const approachByLie: DetailRow[] = [ - { label: 'From fairway', value: fmtFeet(s.approachProximityFairway) }, - { label: 'From rough', value: fmtFeet(s.approachProximityRough) }, - { label: 'From sand', value: fmtFeet(s.approachProximitySand) }, - ]; - const approachByDistance: DetailRow[] = [ - { label: '30–75 yds', value: fmtFeet(s.approachProx30_75) }, - { label: '75–100 yds', value: fmtFeet(s.approachProx75_100) }, - { label: '100–125 yds', value: fmtFeet(s.approachProx100_125) }, - { label: '125–150 yds', value: fmtFeet(s.approachProx125_150) }, - { label: '150–175 yds', value: fmtFeet(s.approachProx150_175) }, - { label: '175–200 yds', value: fmtFeet(s.approachProx175_200) }, - { label: '200–225 yds', value: fmtFeet(s.approachProx200_225) }, - { label: '225+ yds', value: fmtFeet(s.approachProx225Plus) }, - ]; - - // ── Putting detail ───────────────────────────────────────────────────────── - const puttHeadline: DetailRow[] = [ - { label: 'Putts / round', value: fmtNum(s.puttsPerRound, 1) }, - { label: 'Putts / hole', value: fmtNum(s.puttsPerHole, 2) }, - { label: 'Putts / GIR', value: fmtNum(s.puttsPerGir, 2) }, - { label: '3-putts / round', value: fmtNum(s.threePuttsPerRound, 2) }, - { label: '1-putts (total)', value: s.totalPutts > 0 ? fmtInt(s.onePuttsTotal) : '—' }, - ]; - const puttMakeBands: DetailRow[] = [ - { label: '0–3 ft', value: fmtPct(s.puttMakePct0_3) }, - { label: '3–5 ft', value: fmtPct(s.puttMakePct3_5) }, - { label: '5–10 ft', value: fmtPct(s.puttMakePct5_10) }, - { label: '10–15 ft', value: fmtPct(s.puttMakePct10_15) }, - { label: '15–20 ft', value: fmtPct(s.puttMakePct15_20) }, - { label: '20–25 ft', value: fmtPct(s.puttMakePct20_25) }, - { label: '25–30 ft', value: fmtPct(s.puttMakePct25_30) }, - { label: '30–35 ft', value: fmtPct(s.puttMakePct30_35) }, - { label: '35+ ft', value: fmtPct(s.puttMakePct35Plus) }, - ]; - const puttMissDir: DetailRow[] = [ - { label: 'Miss left', value: fmtPct(s.puttMissLeftPct) }, - { label: 'Miss right', value: fmtPct(s.puttMissRightPct) }, - { label: 'Miss short', value: fmtPct(s.puttMissShortPct) }, - { label: 'Miss long', value: fmtPct(s.puttMissLongPct) }, - { label: 'Under-read (low)', value: fmtPct(s.puttMissLowPct) }, - { label: 'Over-read (high)', value: fmtPct(s.puttMissHighPct) }, - ]; - const breakMakeRows = (label: string, b: GolfStats['puttingByBreak'][keyof GolfStats['puttingByBreak']]): DetailRow => ({ - label, - value: fmtPct(b.overallMakePct), - }); - const puttByBreak: DetailRow[] = [ - breakMakeRows('Left-to-right', s.puttingByBreak.left_to_right), - breakMakeRows('Straight', s.puttingByBreak.straight), - breakMakeRows('Right-to-left', s.puttingByBreak.right_to_left), - breakMakeRows('Multiple breaks', s.puttingByBreak.multiple), - ]; - - // ── Scoring detail ───────────────────────────────────────────────────────── const scoringBestWorst: DetailRow[] = [ { label: 'Best round', value: fmtInt(s.bestRound) }, { label: 'Worst round', value: fmtInt(s.worstRound) }, @@ -2265,7 +2395,58 @@ function ComprehensiveDetail({ { label: 'Longest hole-out', value: s.longestHoleOut != null ? fmtYds(s.longestHoleOut) : '—' }, ]; - // ── Analysis (from the already-fetched trendData) ────────────────────────-- + const hasAny = [scoringBestWorst, perRound, careerTotals, byRoundType, streaks].some((rows) => !allDash(rows)); + if (!hasAny) return null; + + return ( +
+
+ Scoring detail + + Bests, per-round and career totals, round type, and streaks. + +
+
+ {!allDash(scoringBestWorst) ? : null} + {!allDash(perRound) ? : null} + {!allDash(careerTotals) ? : null} + {!allDash(byRoundType) ? ( + + ) : null} + {!allDash(streaks) ? : null} +
+
+ ); +} + +/* ════════════════════════════════════════════════════════════════════════════ + * 6b · ANALYSIS SUMMARY — trends + personal bests, plus quick links to the + * tabs that now OWN the full breakdowns (GIR/approach on Approach, putting on + * Putting, scoring detail on Scoring). Replaces the old "Full shot detail" + * disclosure, which re-rendered ~17 DetailGrids the metric tabs already + * (mostly) owned — the genuinely-duplicated ones were dropped, the rest moved + * onto their owning tab (P921 #4). What's left here is the honest summary: + * data with no other home (career bests, 30-day trend) plus navigation. + * ══════════════════════════════════════════════════════════════════════════ */ +const ANALYSIS_NAV_TABS: ReadonlyArray<{ id: (typeof STATS_TABS)[number]['id']; label: string }> = [ + { id: 'scoring', label: 'Scoring' }, + { id: 'driving', label: 'Driving' }, + { id: 'approach', label: 'Approach' }, + { id: 'putting', label: 'Putting' }, + { id: 'scrambling', label: 'Scrambling' }, +]; + +function AnalysisSummary({ + detailedStats, + trendData, + onNavigateTab, +}: { + detailedStats: GolfStats | null; + trendData: TrendAnalysisResponse | null; + onNavigateTab: (tab: string) => void; +}) { + if (!detailedStats) return null; + const pb = trendData?.personalBests ?? null; const personalBests: DetailRow[] = [ { label: 'Best score', value: pb?.bestScore ? fmtInt(pb.bestScore.value) : '—' }, @@ -2310,82 +2491,57 @@ function ComprehensiveDetail({ ] : []; - // Assemble the blocks that actually carry data (allDash → omit). - const blocks: Array<{ heading: string; grids: React.ReactNode[] }> = []; - - const girGrids: React.ReactNode[] = []; - if (!allDash(girByPar)) girGrids.push(); - if (!allDash(girByLie)) girGrids.push(); - if (!allDash(girByDistance)) girGrids.push(); - if (girGrids.length) blocks.push({ heading: 'Greens in regulation', grids: girGrids }); - - const approachGrids: React.ReactNode[] = []; - if (!allDash(approachByPar)) approachGrids.push(); - if (!allDash(approachByLie)) approachGrids.push(); - if (!allDash(approachByDistance)) approachGrids.push(); - if (approachGrids.length) blocks.push({ heading: 'Approach proximity', grids: approachGrids }); - - const puttGrids: React.ReactNode[] = []; - if (!allDash(puttHeadline)) puttGrids.push(); - if (!allDash(puttMakeBands)) puttGrids.push(); - if (!allDash(puttMissDir)) puttGrids.push(); - if (!allDash(puttByBreak)) puttGrids.push(); - if (puttGrids.length) blocks.push({ heading: 'Putting', grids: puttGrids }); - - const scoringGrids: React.ReactNode[] = []; - if (!allDash(scoringBestWorst)) scoringGrids.push(); - if (!allDash(perRound)) scoringGrids.push(); - if (!allDash(careerTotals)) scoringGrids.push(); - if (!allDash(byRoundType)) scoringGrids.push(); - if (!allDash(streaks)) scoringGrids.push(); - if (scoringGrids.length) blocks.push({ heading: 'Scoring', grids: scoringGrids }); - - const analysisGrids: React.ReactNode[] = []; - if (!allDash(personalBests)) analysisGrids.push(); - if (periodCompare.length && !allDash(periodCompare)) analysisGrids.push(); - if (analysisGrids.length) blocks.push({ heading: 'Trends & bests', grids: analysisGrids }); - - if (blocks.length === 0) return null; + const hasPersonalBests = !allDash(personalBests); + const hasPeriodCompare = periodCompare.length > 0 && !allDash(periodCompare); return ( -
- + - {open ? ( -
- {blocks.map((block) => ( -
-

- {block.heading} -

-
{block.grids}
-
+ {hasPersonalBests || hasPeriodCompare ? ( +
+ {hasPersonalBests ? : null} + {hasPeriodCompare ? ( + + ) : null} +
+ ) : ( + +

+ Personal bests and 30-day trends fill in as more rounds are logged. +

+
+ )} + + +
+

+ Full shot detail lives on each tab +

+

+ GIR, proximity, putting bands, and scoring breakdowns are on their own tab now — jump straight there. +

+
+
+ {ANALYSIS_NAV_TABS.map((t) => ( + ))}
- ) : null} +
); }