Braves Booth v1.0.0: Player Drill-Down, Lineup Cache Bug, and Shipping 1.0
Braves Booth Intelligence is a real-time broadcast operations tool for Atlanta Braves radio. It pulls live game data from MLB’s GUMBO API, runs it through narrative generation, and surfaces radio-ready intel — matchup histories, milestone watches, pitching trends, bio nuggets — on a dashboard the broadcast team uses during games.
Today was v1.0.0 release day. Six commits. Twenty-seven files changed. One bug found during live testing that would have silently broken the app five minutes into every game.
The Player Drill-Down
The core problem: player data was scattered across a dozen components. Batter cards, pitcher cards, on-deck panels, lineup cards, bullpen availability, milestone watches, matchup previews — each showed a slice of a player’s information, but none connected to a full picture. The broadcast team would see a name and have to mentally cross-reference other panels to build context.
The solution: make every player name a clickable link that opens a slide-over panel with everything about that player in one place.
Architecture: Three Phases
Phase 1 was the UI shell. Three new components:
PlayerDrillDownContext manages a navigation stack. The drill-down supports nested navigation — click a player, then click another player mentioned in their matchup history, then hit back. Stack-based state makes this trivial:
interface DrillDownState {
stack: PlayerEntry[];
isOpen: boolean;
}
function pushPlayer(player: PlayerEntry) {
setState(prev => ({
stack: [...prev.stack, player],
isOpen: true
}));
}
function popPlayer() {
setState(prev => ({
stack: prev.stack.slice(0, -1),
isOpen: prev.stack.length > 1
}));
}
SlideOverPanel is 420px on desktop, full-screen on mobile. Escape key closes it. Clicking the backdrop closes it. Back button pops the stack. Standard patterns, but they matter — this panel will be opened dozens of times per game, and any friction kills adoption.
PlayerLink is the component that replaced hardcoded player name text across the app. It inherits the parent element’s typography — font size, weight, color — so dropping it into a <h3> or a <td> or a <span> doesn’t break the visual design. It just adds the click handler and a subtle gold underline as a resting-state affordance.
const PlayerLink: React.FC<PlayerLinkProps> = ({ player, children }) => {
const { pushPlayer } = usePlayerDrillDown();
return (
<span
onClick={() => pushPlayer(player)}
className="player-link"
style={{ textDecorationColor: 'var(--braves-gold)' }}
>
{children || player.name}
</span>
);
};
That component got added to 11 existing components: BatterCard, PitcherCard, OnDeckPanel, LineupCard, BullpenAvailability, MilestoneWatch, MatchupPreview, MatchupHeader, PreviousMatchups, and both roster views in LineupSidebar.
Phase 2 was the data layer. Two additions:
The GUMBO poller already fetched boxscore data on every tick to update the game state. It now extracts per-player batting and pitching lines from that same payload and caches them. When you open a player’s drill-down, “Tonight’s Game Line” shows their current stats for this game — at-bats, hits, RBIs, or innings pitched, strikeouts, earned runs — pulled from that cache. No additional API calls.
Bio storytelling nuggets take existing player data — birthplace, draft position, years of service, career milestones — and format them into radio-ready one-liners. “Drafted 15th overall by Atlanta in 2018.” “Born in Curaçao, one of 14 active MLB players from the island.” These aren’t generated by an LLM. They’re templated from structured data, which means they’re always accurate and always available, even when the narrative service is under load.
A new bio_json column was added to the player_stats schema with a migration to store these structured bio fragments.
Phase 3 was the lineup integration. LineupSidebar now shows both team rosters with every name clickable. On desktop, both lineups are always visible in the sidebar — no toggle needed. On mobile, a new “Lineup” tab was added to MobileTabs.
Prefetch Changes
The narrative service was already prefetching data for the on-deck batter. This release extended that to the in-hole batter (two batters ahead). The concurrency limit went from 5 to 8 simultaneous requests. When the broadcast team clicks a player name, the data is already warm.
The Lineup Cache Bug
This one surfaced during live testing. Five minutes into a simulated game, the lineup cards went blank. Both teams. Just gone.
The Symptom
Lineups rendered correctly on initial load. After exactly five minutes, they disappeared. No errors in the console. No failed network requests. The components were still mounted — they just had no data to render.
The Root Cause
The lineup data was cached with a 5-minute TTL:
// Before: getLineup() returned null after cache expired
function getLineup(teamId: string): Lineup | null {
const cached = cache.get(`lineup:${teamId}`);
if (!cached || cached.expiry < Date.now()) {
return null;
}
return cached.data;
}
When the cache expired, getLineup() returned null. The component received null and rendered nothing. The cache was only populated during the initial game load — there was no mechanism to re-fetch.
This is a classic cache invalidation mistake. The TTL was set to 5 minutes because that seemed reasonable for “fresh enough” data. But lineups don’t change mid-game. A batter might get substituted, but the lineup order is locked in. A 5-minute TTL made no sense for data that’s effectively immutable for the duration of the game.
The Fix
Replace the passive getter with an active fetch-on-miss:
// After: getOrFetchLineup() re-fetches from MLB API on cache miss
async function getOrFetchLineup(teamId: string): Promise<Lineup> {
const cached = cache.get(`lineup:${teamId}`);
if (cached && cached.expiry > Date.now()) {
return cached.data;
}
const boxscore = await fetchBoxscore(gameId);
const lineup = extractLineup(boxscore, teamId);
cache.set(`lineup:${teamId}`, lineup, ONE_HOUR);
return lineup;
}
Three changes: the function is now async, it fetches from the MLB boxscore API on cache miss instead of returning null, and the TTL is 1 hour instead of 5 minutes. The boxscore endpoint always has the current lineup, including any substitutions.
Forty-six insertions, nine deletions. The kind of fix that takes 15 minutes to write and would have been a production incident without live testing.
Shipping 1.0.0
The version bump from 0.1.0 to 1.0.0 happened across both the backend and frontend packages. The release included:
- CHANGELOG.md documenting every feature since the project started
- MIT license
- SECURITY.md, CONTRIBUTING.md, CODE_OF_CONDUCT.md
- An After-Action Report documenting the first production release — what worked, what didn’t, what to change for v1.1
The npm audit turned up vulnerabilities in brace-expansion, fastify, flatted, and picomatch. All resolved before the version bump.
Career Card Fix
One more fix that landed in the final PR: the career baseball card in the drill-down panel wasn’t rendering because normalizePlayerStats() didn’t map the bio field. The raw API data had the field, the component expected it, but the normalization layer between them dropped it. A one-line fix in the mapper.
Click Affordance
During PR review, the PlayerLink component got a resting-state gold underline so users know names are clickable without having to hover. The hover state was also changed from direct DOM manipulation to React state — a PR review catch that prevented a class of potential bugs with stale DOM references.
// PR review fix: React state instead of direct DOM manipulation
const [isHovered, setIsHovered] = useState(false);
<span
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
textDecorationColor: isHovered ? 'var(--braves-red)' : 'var(--braves-gold)',
cursor: 'pointer'
}}
>
What 1,684 Lines Bought
The drill-down feature alone was 1,684 insertions and 108 deletions across 27 files. That’s a big changeset for a single feature, but the integration surface was genuinely wide — 11 components needed PlayerLink wiring, the data layer needed per-player boxscore extraction, the schema needed a migration, and the prefetch system needed expansion.
The lineup cache bug was 46 lines. The career card fix was effectively one line. The release scaffolding was boilerplate. But the drill-down — that was the feature that turns the dashboard from a collection of panels into a connected intelligence tool. Every name is a doorway into a player’s full context. That’s what a broadcast team needs when they have three seconds to decide what to say on air.
v1.0.0 is live.
Related Posts:
- Building Production Multi-Agent AI with BrightStream on Vertex AI — Multi-agent architecture patterns that informed the narrative prefetch design
- Building Production-Grade Testing Infrastructure: A Playwright Case Study — The testing methodology that caught the lineup cache bug before production