Case Study 2: Public-Facing Fan Analytics Portal
Overview
Client: College athletics media department Objective: Create engaging analytics experience for fans and media Timeline: 12-week development, launch before season Users: Fans, media members, recruits, general public
Background
Most college football analytics remain locked away from fans. Advanced metrics like EPA, success rate, and win probability are common in analytics departments but rarely shared publicly.
The athletics department saw an opportunity to: - Differentiate their brand through data transparency - Engage fans with deeper insights - Attract data-savvy recruits - Support media coverage with accessible statistics
The Challenge
Audience Complexity
Unlike internal dashboards with trained users, this portal needed to serve:
- Casual fans: Want highlights and simple comparisons
- Analytics enthusiasts: Want detailed metrics and exploration
- Media members: Want ready-to-use graphics and data
- Recruits and families: Want performance context
Technical Requirements
- Handle 10,000+ concurrent users on game day
- Fast initial load (under 2 seconds)
- SEO-friendly for search visibility
- Shareable charts for social media
- Accessible (WCAG 2.1 AA compliance)
Solution Design
Technology Stack
"""
Fan Analytics Portal - Streamlit Implementation
Chosen for rapid development and easy hosting.
"""
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from typing import Dict, List
# Configuration
st.set_page_config(
page_title="State Football Analytics",
page_icon="🏈",
layout="wide",
initial_sidebar_state="collapsed"
)
# Custom CSS for branding
st.markdown("""
<style>
.main-header {
font-family: 'Georgia', serif;
color: #CC0033;
}
.metric-card {
background-color: #f8f9fa;
border-radius: 10px;
padding: 20px;
text-align: center;
}
.highlight-stat {
font-size: 48px;
font-weight: bold;
color: #CC0033;
}
</style>
""", unsafe_allow_html=True)
class FanAnalyticsPortal:
"""
Public-facing analytics portal for fans and media.
"""
def __init__(self):
self.team_color = '#CC0033'
self.secondary_color = '#003366'
def render_home_page(self):
"""Render the main landing page."""
st.markdown('<h1 class="main-header">State Football Analytics</h1>',
unsafe_allow_html=True)
# Season summary
self._render_season_summary()
# Featured insights
st.subheader("This Week's Insights")
col1, col2, col3 = st.columns(3)
with col1:
self._render_insight_card(
"Offensive Efficiency",
"2nd in Conference",
"Based on EPA per play, State ranks among the elite offenses."
)
with col2:
self._render_insight_card(
"Third Down Conversion",
"52% Success Rate",
"Converting over half of third downs, well above the 40% average."
)
with col3:
self._render_insight_card(
"Win Probability",
"78% Playoff Odds",
"Based on remaining schedule and current performance."
)
# Quick links to sections
st.subheader("Explore")
explore_col1, explore_col2, explore_col3, explore_col4 = st.columns(4)
with explore_col1:
if st.button("📊 Team Stats", use_container_width=True):
st.session_state.page = 'team_stats'
with explore_col2:
if st.button("👤 Player Stats", use_container_width=True):
st.session_state.page = 'player_stats'
with explore_col3:
if st.button("🏟️ Game Recaps", use_container_width=True):
st.session_state.page = 'games'
with explore_col4:
if st.button("📈 Predictions", use_container_width=True):
st.session_state.page = 'predictions'
def _render_season_summary(self):
"""Render season summary with key metrics."""
# Sample data
season_data = {
'Record': '10-2',
'Conference': '7-1',
'Off EPA/Play': '+0.22',
'Def EPA/Play': '-0.18',
'Net EPA': '+0.40'
}
cols = st.columns(5)
for i, (metric, value) in enumerate(season_data.items()):
with cols[i]:
st.markdown(f"""
<div class="metric-card">
<div class="highlight-stat">{value}</div>
<div>{metric}</div>
</div>
""", unsafe_allow_html=True)
def _render_insight_card(self, title: str, value: str, description: str):
"""Render an insight card."""
st.markdown(f"""
<div style="background: linear-gradient(135deg, #CC0033, #990000);
color: white; padding: 20px; border-radius: 10px;
margin-bottom: 10px;">
<h4>{title}</h4>
<div style="font-size: 24px; font-weight: bold;">{value}</div>
<p style="margin: 0; font-size: 14px; opacity: 0.9;">{description}</p>
</div>
""", unsafe_allow_html=True)
def render_game_recap(self, game_id: str):
"""Render interactive game recap page."""
st.title("Game Recap: State vs. Rival")
st.markdown("*Championship Game - December 2*")
# Score box
col1, col2, col3 = st.columns([2, 1, 2])
with col1:
st.markdown('<h2 style="text-align: right;">STATE</h2>',
unsafe_allow_html=True)
st.markdown('<h1 style="text-align: right; color: #CC0033;">31</h1>',
unsafe_allow_html=True)
with col2:
st.markdown('<h2 style="text-align: center;">FINAL</h2>',
unsafe_allow_html=True)
with col3:
st.markdown('<h2>RIVAL</h2>', unsafe_allow_html=True)
st.markdown('<h1 style="color: #003366;">28</h1>',
unsafe_allow_html=True)
# Win probability chart
st.subheader("Win Probability")
wp_fig = self._create_win_probability_chart()
st.plotly_chart(wp_fig, use_container_width=True)
# Key plays
st.subheader("Key Plays")
self._render_key_plays()
# Stats comparison
st.subheader("Team Stats Comparison")
self._render_stats_comparison()
def _create_win_probability_chart(self) -> go.Figure:
"""Create interactive win probability chart."""
# Sample data
import numpy as np
np.random.seed(42)
plays = 140
time = np.linspace(0, 60, plays)
wp = 0.5 + np.cumsum(np.random.uniform(-0.02, 0.02, plays))
wp = np.clip(wp, 0.1, 0.9)
wp[-10:] = np.linspace(wp[-11], 0.82, 10) # End with win
fig = go.Figure()
# Fill areas
fig.add_trace(go.Scatter(
x=time, y=[0.5] * len(time),
fill=None, mode='lines',
line=dict(width=0), showlegend=False
))
fig.add_trace(go.Scatter(
x=time, y=wp,
fill='tonexty',
mode='lines',
name='Win Probability',
line=dict(color=self.team_color, width=2),
fillcolor='rgba(204, 0, 51, 0.3)'
))
# Key moment annotations
fig.add_annotation(
x=35, y=0.72,
text="Go-ahead TD",
showarrow=True,
arrowhead=2
)
fig.update_layout(
xaxis_title="Game Time (minutes)",
yaxis_title="State Win Probability",
yaxis=dict(tickformat='.0%', range=[0, 1]),
hovermode='x unified',
template='plotly_white',
height=400
)
return fig
def _render_key_plays(self):
"""Render key plays section."""
key_plays = [
{"time": "Q2 5:23", "play": "75-yard TD pass",
"wpa": "+18%", "description": "Smith to Johnson, deep left"},
{"time": "Q3 12:01", "play": "Fumble recovery",
"wpa": "+12%", "description": "Defense forces turnover at midfield"},
{"time": "Q4 2:15", "play": "4th down conversion",
"wpa": "+15%", "description": "QB keeper for first down"}
]
for play in key_plays:
col1, col2, col3 = st.columns([1, 3, 1])
with col1:
st.write(f"**{play['time']}**")
with col2:
st.write(f"{play['play']}")
st.caption(play['description'])
with col3:
color = "green" if '+' in play['wpa'] else "red"
st.markdown(f"<span style='color: {color}; font-weight: bold;'>{play['wpa']}</span>",
unsafe_allow_html=True)
st.divider()
def _render_stats_comparison(self):
"""Render team stats comparison bars."""
stats = {
'Total Yards': (425, 380),
'Passing Yards': (285, 220),
'Rushing Yards': (140, 160),
'First Downs': (24, 21),
'Turnovers': (1, 2)
}
for stat, (our, their) in stats.items():
col1, col2, col3 = st.columns([2, 3, 2])
with col1:
st.markdown(f"<div style='text-align: right; font-size: 20px;'>{our}</div>",
unsafe_allow_html=True)
with col2:
total = our + their
our_pct = our / total * 100
st.markdown(f"""
<div style="text-align: center; font-size: 12px; margin-bottom: 5px;">{stat}</div>
<div style="display: flex; height: 20px; border-radius: 10px; overflow: hidden;">
<div style="width: {our_pct}%; background-color: {self.team_color};"></div>
<div style="width: {100-our_pct}%; background-color: {self.secondary_color};"></div>
</div>
""", unsafe_allow_html=True)
with col3:
st.markdown(f"<div style='font-size: 20px;'>{their}</div>",
unsafe_allow_html=True)
def render_player_page(self, player_id: str):
"""Render individual player stats page."""
st.title("John Smith - Quarterback")
# Player card
col1, col2 = st.columns([1, 3])
with col1:
st.image("https://via.placeholder.com/200", width=150)
with col2:
st.markdown("""
**#12 | Senior | 6'2" 215 lbs**
2024 Stats: 3,245 yards | 32 TD | 6 INT | 68.5% Comp
""")
# Season progression
st.subheader("Season Performance")
self._render_player_progression()
# Situational stats
st.subheader("Situational Breakdown")
self._render_situational_stats()
def _render_player_progression(self):
"""Render player performance over the season."""
games = ['vs A', 'vs B', 'vs C', 'vs D', 'vs E', 'vs F',
'vs G', 'vs H', 'vs I', 'vs J', 'vs K', 'vs L']
qbr = [72, 68, 85, 78, 92, 88, 75, 82, 90, 85, 88, 94]
fig = px.line(x=games, y=qbr, markers=True)
fig.update_traces(line_color=self.team_color)
fig.update_layout(
xaxis_title="Game",
yaxis_title="QBR",
template='plotly_white',
height=300
)
st.plotly_chart(fig, use_container_width=True)
def _render_situational_stats(self):
"""Render situational performance breakdown."""
situations = ['1st Down', '2nd Down', '3rd Down', 'Red Zone']
comp_pct = [72, 65, 58, 70]
ypa = [8.2, 7.5, 6.8, 5.5]
col1, col2 = st.columns(2)
with col1:
fig1 = px.bar(x=situations, y=comp_pct, title="Completion %")
fig1.update_traces(marker_color=self.team_color)
fig1.update_layout(template='plotly_white', height=300)
st.plotly_chart(fig1, use_container_width=True)
with col2:
fig2 = px.bar(x=situations, y=ypa, title="Yards per Attempt")
fig2.update_traces(marker_color=self.team_color)
fig2.update_layout(template='plotly_white', height=300)
st.plotly_chart(fig2, use_container_width=True)
def create_share_button(chart_type: str, data: dict) -> str:
"""Generate shareable link for a chart."""
import base64
import json
encoded_data = base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
return f"https://analytics.statefootball.com/share/{chart_type}?d={encoded_data}"
# Main app routing
if __name__ == "__main__":
portal = FanAnalyticsPortal()
page = st.session_state.get('page', 'home')
if page == 'home':
portal.render_home_page()
elif page == 'games':
portal.render_game_recap('latest')
elif page == 'player_stats':
portal.render_player_page('smith_12')
Key Features
1. Progressive Complexity
Casual fans see simple summaries; analytics enthusiasts can dig deeper:
# Default view (simple)
st.metric("Offense Rank", "2nd", "in Conference")
# Expandable detail (for enthusiasts)
with st.expander("See Advanced Metrics"):
st.write("EPA/Play: +0.22 (85th percentile)")
st.write("Success Rate: 48%")
st.write("Explosiveness: 52% of yards on explosive plays")
2. Social Sharing
Charts can be shared directly to social media:
def export_chart_for_social(fig: go.Figure) -> bytes:
"""Export chart as image for social sharing."""
return fig.to_image(format="png", width=1200, height=630)
if st.button("Share to Twitter"):
image = export_chart_for_social(fig)
# Generate shareable link
st.markdown(f"[Share this chart](https://twitter.com/intent/tweet?...)")
3. Metric Explanations
Every advanced metric includes accessible explanations:
def explain_metric(metric: str) -> str:
"""Return plain-English explanation of metric."""
explanations = {
'EPA': "Expected Points Added measures how much each play "
"increases (or decreases) a team's expected score.",
'Success Rate': "The percentage of plays that gain enough yards "
"to keep the offense 'on schedule'.",
'WPA': "Win Probability Added shows how much each play changed "
"the team's chances of winning."
}
return explanations.get(metric, "")
# Usage
st.metric("EPA/Play", "+0.22")
st.caption(explain_metric('EPA'))
Results
Engagement Metrics (First Season)
| Metric | Value |
|---|---|
| Unique visitors | 125,000 |
| Page views | 2.1 million |
| Average session | 4.2 minutes |
| Social shares | 8,500 |
| Media citations | 45 articles |
Peak Performance
On game days, the portal handled: - 12,000 concurrent users - 50 requests/second sustained - 99.8% uptime through season
Lessons Learned
-
Explain everything: Fans appreciated metric explanations more than expected features
-
Mobile is 70% of traffic: Mobile-first design was essential
-
Shareable = viral: Social sharing drove 40% of new traffic
-
Speed matters for SEO: Faster pages ranked higher in search
-
Accessibility broadens audience: Compliance helped reach wider audience
Code Repository
Complete implementation in code/case-study-code.py including:
- FanAnalyticsPortal class
- All page rendering methods
- Social sharing utilities
- Caching and performance optimizations