From e788052f9602f6c4e4f0a9d3db7591ca5db4b1ff Mon Sep 17 00:00:00 2001 From: KevinBigham Date: Thu, 30 Apr 2026 13:04:17 -0500 Subject: [PATCH] Sprint 46: Polish coaching tree visuals Adds visual lineage signals to the existing coaching tree without changing engine data or save state. --- .../features/coaching/CoachingTree.test.tsx | 126 +++++++++++++ .../src/features/coaching/CoachingTree.tsx | 169 ++++++++++++------ .../coaching/coachArchetypeGlyphSvg.test.tsx | 46 +++++ .../coaching/coachArchetypeGlyphSvg.tsx | 95 ++++++++++ 4 files changed, 385 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/features/coaching/CoachingTree.test.tsx create mode 100644 apps/web/src/features/coaching/coachArchetypeGlyphSvg.test.tsx create mode 100644 apps/web/src/features/coaching/coachArchetypeGlyphSvg.tsx diff --git a/apps/web/src/features/coaching/CoachingTree.test.tsx b/apps/web/src/features/coaching/CoachingTree.test.tsx new file mode 100644 index 00000000..c000c9df --- /dev/null +++ b/apps/web/src/features/coaching/CoachingTree.test.tsx @@ -0,0 +1,126 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { CoachingTree } from './CoachingTree'; + +const rootCoach = { + id: 'root-hc', + name: 'Dana Vale', + role: 'HC', + archetype: 'strategist', + traits: ['calm_clock'], + mentorCoachId: 'mentor-direct', + disciples: ['disciple-one'], + yearsUnderMentor: 4, +}; + +const directMentor = { + id: 'mentor-direct', + name: 'Mika Stone', + role: 'HC', + archetype: 'defensive_minded', + traits: ['red_zone_wall'], + mentorCoachId: 'mentor-indirect', + disciples: ['root-hc'], + yearsUnderMentor: 5, +}; + +const indirectMentor = { + id: 'mentor-indirect', + name: 'Alex North', + role: 'HC', + archetype: 'disciplinarian', + traits: ['accountability'], + mentorCoachId: null, + disciples: ['mentor-direct'], + yearsUnderMentor: 0, +}; + +const disciple = { + id: 'disciple-one', + name: 'River Cross', + role: 'OC', + archetype: 'offensive_minded', + traits: ['motion_packages'], + mentorCoachId: 'root-hc', + disciples: [], + yearsUnderMentor: 2, +}; + +const userTeam = { + id: 'user', + abbr: 'USR', + staff: { hc: rootCoach, oc: null, dc: null }, +}; + +const mockState = { + game: { + year: 2032, + eventLog: [], + coachingHistory: [], + teams: { + user: userTeam, + north: { id: 'north', abbr: 'NTH', staff: { hc: directMentor, oc: null, dc: null } }, + summit: { id: 'summit', abbr: 'SUM', staff: { hc: indirectMentor, oc: null, dc: null } }, + metro: { id: 'metro', abbr: 'MET', staff: { hc: null, oc: disciple, dc: null } }, + }, + }, + userTeam, +}; + +vi.mock('@mfd/engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildCoachingLegacy: () => ({ + treeDepth: 3, + headCoachesProduced: 1, + coordinatorsPlaced: 1, + retiredWithEpilogue: 0, + notableProteges: [disciple], + }), + }; +}); + +vi.mock('../../app/store/game-store', () => ({ + useGameStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), + selectUserTeam: (state: typeof mockState) => state.userTeam, +})); + +describe('CoachingTree', () => { + it('renders archetype glyphs next to each coach card', () => { + const markup = renderToStaticMarkup(); + + expect(markup.match(/data-coach-archetype-glyph=/g)).toHaveLength(4); + }); + + it('renders SVG connection lines between linked coaches', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-coaching-tree-connection-lines="true"'); + expect(markup).toContain('data-coaching-tree-line="true"'); + }); + + it('uses gold stroke for direct mentor relationships', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-line-kind="direct-mentor"'); + expect(markup).toContain('stroke="var(--mfd-gold)"'); + }); + + it('uses dashed connection lines for indirect relationships', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-line-kind="indirect-mentor"'); + expect(markup).toContain('stroke-dasharray="5 5"'); + }); + + it('preserves the existing coaching-tree behavior surface', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('COACHING TREE'); + expect(markup).toContain('TREE DEPTH'); + expect(markup).toContain('DANA VALE'); + expect(markup).toContain('MIKA STONE'); + expect(markup).toContain('RIVER CROSS'); + }); +}); diff --git a/apps/web/src/features/coaching/CoachingTree.tsx b/apps/web/src/features/coaching/CoachingTree.tsx index 4daba76d..6c3cea90 100644 --- a/apps/web/src/features/coaching/CoachingTree.tsx +++ b/apps/web/src/features/coaching/CoachingTree.tsx @@ -16,6 +16,7 @@ import { screenStackStyle, teamThemeVars, } from '../shared/pixelUi'; +import { CoachArchetypeGlyphSvg } from './coachArchetypeGlyphSvg'; interface ResolvedCoach { coachId: string; @@ -209,11 +210,14 @@ function CoachCard({ ? `${labelPrefix} ${entry.roleLabel}: ${entry.name}` : `${entry.roleLabel}: ${entry.name}`; return ( -
+
-
- Team: {entry.teamAbbr} | Archetype: {entry.archetype} +
+ +
+ Team: {entry.teamAbbr} | Archetype: {entry.archetype} +
{typeof entry.yearsUnderMentor === 'number' && entry.yearsUnderMentor > 0 ? (
@@ -238,6 +242,66 @@ function CoachCard({ ); } +function lineWidth(entry: ResolvedCoach): number { + const years = entry.yearsUnderMentor ?? 1; + return Math.max(1, Math.min(3, 1 + Math.floor(years / 3))); +} + +function CoachingTreeConnectionLines({ + mentorChain, + disciples, +}: { + mentorChain: ResolvedCoach[]; + disciples: ResolvedCoach[]; +}) { + if (mentorChain.length === 0 && disciples.length === 0) return null; + return ( + + ); +} + function EmptyState() { return ( @@ -350,54 +414,57 @@ export function CoachingTree() {
-
- {/* Mentor chain upward */} -
-
MENTOR CHAIN
- {mentorChain.length === 0 ? ( - -
- Your head coach has no recorded mentor. -
-
- ) : ( - mentorChain.map((entry, idx) => ( - - )) - )} -
- - {/* User head coach centered */} -
-
YOUR HEAD COACH
- - {!hasLineage ? : null} -
- - {/* Disciples fan downward */} -
-
DISCIPLES
- {disciples.length === 0 ? ( - -
- No coaches have come up under this tree. -
-
- ) : ( - disciples.map((entry) => ( - - )) - )} +
+ +
+ {/* Mentor chain upward */} +
+
MENTOR CHAIN
+ {mentorChain.length === 0 ? ( + +
+ Your head coach has no recorded mentor. +
+
+ ) : ( + mentorChain.map((entry, idx) => ( + + )) + )} +
+ + {/* User head coach centered */} +
+
YOUR HEAD COACH
+ + {!hasLineage ? : null} +
+ + {/* Disciples fan downward */} +
+
DISCIPLES
+ {disciples.length === 0 ? ( + +
+ No coaches have come up under this tree. +
+
+ ) : ( + disciples.map((entry) => ( + + )) + )} +
diff --git a/apps/web/src/features/coaching/coachArchetypeGlyphSvg.test.tsx b/apps/web/src/features/coaching/coachArchetypeGlyphSvg.test.tsx new file mode 100644 index 00000000..dc8661c0 --- /dev/null +++ b/apps/web/src/features/coaching/coachArchetypeGlyphSvg.test.tsx @@ -0,0 +1,46 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { COACH_ARCHETYPE_GLYPHS, CoachArchetypeGlyphSvg } from './coachArchetypeGlyphSvg'; + +describe('CoachArchetypeGlyphSvg', () => { + it('renders the offensive_mind variant with route path data', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-coach-archetype-glyph="offensive_mind"'); + expect(markup).toContain('data-glyph-routes="true"'); + }); + + it('renders all 6 glyph variants without error', () => { + for (const archetype of COACH_ARCHETYPE_GLYPHS) { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(`data-coach-archetype-glyph="${archetype}"`); + } + }); + + it('renders a shield for defensive_mind', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-glyph-shield="true"'); + }); + + it('renders a handshake for players_coach', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-glyph-handshake="true"'); + }); + + it('renders a flame for fire_starter', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('data-glyph-flame="true"'); + }); + + it('renders distinct SVG path data across every glyph', () => { + const signatures = COACH_ARCHETYPE_GLYPHS.map((archetype) => + renderToStaticMarkup().match(/d="[^"]+"/g)?.join('|') ?? '', + ); + + expect(new Set(signatures)).toHaveLength(COACH_ARCHETYPE_GLYPHS.length); + }); +}); diff --git a/apps/web/src/features/coaching/coachArchetypeGlyphSvg.tsx b/apps/web/src/features/coaching/coachArchetypeGlyphSvg.tsx new file mode 100644 index 00000000..80724444 --- /dev/null +++ b/apps/web/src/features/coaching/coachArchetypeGlyphSvg.tsx @@ -0,0 +1,95 @@ +export const COACH_ARCHETYPE_GLYPHS = [ + 'offensive_mind', + 'defensive_mind', + 'players_coach', + 'gm_track', + 'cerebral', + 'fire_starter', +] as const; + +export type CoachArchetypeGlyph = typeof COACH_ARCHETYPE_GLYPHS[number]; + +function normalizeArchetype(archetype: string): CoachArchetypeGlyph { + const normalized = archetype.trim().toLowerCase(); + if (normalized.includes('offensive') || normalized.includes('air') || normalized.includes('west_coast')) return 'offensive_mind'; + if (normalized.includes('defensive') || normalized.includes('coverage') || normalized.includes('aggressive')) return 'defensive_mind'; + if (normalized.includes('player') || normalized.includes('motivator')) return 'players_coach'; + if (normalized.includes('gm') || normalized.includes('front_office')) return 'gm_track'; + if (normalized.includes('disciplinarian') || normalized.includes('fire')) return 'fire_starter'; + return 'cerebral'; +} + +export function CoachArchetypeGlyphSvg({ + archetype, + label, +}: { + archetype: CoachArchetypeGlyph | string; + label: string; +}) { + const glyph = normalizeArchetype(archetype); + + return ( + + {glyph === 'offensive_mind' ? ( + + + + + + + + + ) : null} + {glyph === 'defensive_mind' ? ( + + + + + + + ) : null} + {glyph === 'players_coach' ? ( + + + + + + + + ) : null} + {glyph === 'gm_track' ? ( + + + + + + + + ) : null} + {glyph === 'cerebral' ? ( + + + + + + + + + ) : null} + {glyph === 'fire_starter' ? ( + + + + + ) : null} + + ); +}