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:

  1. Casual fans: Want highlights and simple comparisons
  2. Analytics enthusiasts: Want detailed metrics and exploration
  3. Media members: Want ready-to-use graphics and data
  4. 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

  1. Explain everything: Fans appreciated metric explanations more than expected features

  2. Mobile is 70% of traffic: Mobile-first design was essential

  3. Shareable = viral: Social sharing drove 40% of new traffic

  4. Speed matters for SEO: Faster pages ranked higher in search

  5. 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