Case Study 1: Coaching Staff Game Preparation Dashboard

Overview

Client: Power Five football program Objective: Replace static weekly game prep reports with interactive dashboard Timeline: 8-week development cycle Users: Head coach, coordinators, position coaches, quality control staff


Background

Every week, college football coaching staffs spend dozens of hours preparing for opponents. Traditional preparation involved:

  • Printed reports with opponent tendencies
  • Static PowerPoint presentations
  • Spreadsheets with play breakdowns
  • Video cut-ups organized by situation

The coaching staff needed a unified, interactive platform that would allow:

  • Self-service exploration of opponent data
  • Quick filtering by game situation
  • Visual pattern recognition
  • Easy comparison to their own tendencies

The Challenge

Requirements Gathering

After interviewing coaches at all levels, key requirements emerged:

  1. Speed: Dashboard must load in under 3 seconds
  2. Simplicity: Usable without technical training
  3. Mobile access: Work on tablets in meetings and on sidelines
  4. Depth: Support drill-down from tendencies to individual plays
  5. Integration: Link to video system for play review

Technical Constraints

  • Must work offline during game day (no WiFi on sidelines)
  • Data updates needed daily during game week
  • User authentication required (sensitive opponent data)
  • Must run on team-issued tablets

Solution Design

Dashboard Architecture

"""
Game Preparation Dashboard Architecture
"""

from dash import Dash, html, dcc, callback, Output, Input, State
import dash_bootstrap_components as dbc
import plotly.express as px
import plotly.graph_objects as go
import pandas as pd
import numpy as np
from typing import Dict, List, Optional

# App initialization
app = Dash(__name__,
          external_stylesheets=[dbc.themes.FLATLY],
          suppress_callback_exceptions=True)

# Authentication
from flask_login import LoginManager, UserMixin, login_user

login_manager = LoginManager()
login_manager.init_app(app.server)


class GamePrepDashboard:
    """
    Main dashboard class for game preparation.
    """

    def __init__(self, opponent_data: pd.DataFrame):
        self.opponent = opponent_data
        self.situations = self._define_situations()

    def _define_situations(self) -> Dict[str, callable]:
        """Define common game situations for filtering."""
        return {
            '1st Down': lambda df: df[df['down'] == 1],
            '2nd & Long': lambda df: df[(df['down'] == 2) & (df['distance'] >= 7)],
            '2nd & Short': lambda df: df[(df['down'] == 2) & (df['distance'] < 7)],
            '3rd & Long': lambda df: df[(df['down'] == 3) & (df['distance'] >= 7)],
            '3rd & Short': lambda df: df[(df['down'] == 3) & (df['distance'] < 4)],
            '3rd & Medium': lambda df: df[(df['down'] == 3) & (df['distance'] >= 4) & (df['distance'] < 7)],
            'Red Zone': lambda df: df[df['yard_line'] >= 80],
            'Goal Line': lambda df: df[df['yard_line'] >= 95],
            '2-Minute': lambda df: df[df['seconds_remaining'] <= 120]
        }

    def create_layout(self) -> html.Div:
        """Create the dashboard layout."""
        return dbc.Container([
            # Header with opponent info
            dbc.Row([
                dbc.Col([
                    html.H2(id='opponent-name', className='display-5'),
                    html.P(id='opponent-record', className='lead')
                ], width=8),
                dbc.Col([
                    html.Img(id='opponent-logo', height=80)
                ], width=4, className='text-end')
            ], className='mb-4'),

            # Quick stats row
            dbc.Row([
                dbc.Col([self._create_stat_card('Plays', 'total-plays')], width=2),
                dbc.Col([self._create_stat_card('Pass %', 'pass-pct')], width=2),
                dbc.Col([self._create_stat_card('Rush %', 'rush-pct')], width=2),
                dbc.Col([self._create_stat_card('Avg EPA', 'avg-epa')], width=2),
                dbc.Col([self._create_stat_card('Tendencies', 'tendency-count')], width=2),
                dbc.Col([self._create_stat_card('Key Plays', 'key-plays')], width=2),
            ], className='mb-4'),

            # Filters
            dbc.Row([
                dbc.Col([
                    dbc.Card([
                        dbc.CardBody([
                            html.H5("Filters"),
                            dbc.Row([
                                dbc.Col([
                                    html.Label("Situation"),
                                    dcc.Dropdown(
                                        id='situation-filter',
                                        options=[{'label': s, 'value': s}
                                                for s in self.situations.keys()],
                                        placeholder="All Situations"
                                    )
                                ], width=3),
                                dbc.Col([
                                    html.Label("Formation"),
                                    dcc.Dropdown(
                                        id='formation-filter',
                                        multi=True,
                                        placeholder="All Formations"
                                    )
                                ], width=3),
                                dbc.Col([
                                    html.Label("Personnel"),
                                    dcc.Dropdown(
                                        id='personnel-filter',
                                        multi=True,
                                        placeholder="All Personnel"
                                    )
                                ], width=3),
                                dbc.Col([
                                    html.Label("Hash"),
                                    dcc.Dropdown(
                                        id='hash-filter',
                                        options=[
                                            {'label': 'Left', 'value': 'L'},
                                            {'label': 'Middle', 'value': 'M'},
                                            {'label': 'Right', 'value': 'R'}
                                        ],
                                        multi=True,
                                        placeholder="All"
                                    )
                                ], width=3)
                            ])
                        ])
                    ])
                ])
            ], className='mb-4'),

            # Main content tabs
            dbc.Tabs([
                dbc.Tab(self._create_tendencies_tab(), label="Tendencies"),
                dbc.Tab(self._create_formations_tab(), label="Formations"),
                dbc.Tab(self._create_personnel_tab(), label="Personnel"),
                dbc.Tab(self._create_plays_tab(), label="Play List"),
                dbc.Tab(self._create_comparison_tab(), label="vs Us")
            ])
        ], fluid=True)

    def _create_stat_card(self, title: str, id_prefix: str) -> dbc.Card:
        """Create a stat card component."""
        return dbc.Card([
            dbc.CardBody([
                html.H4(id=f'{id_prefix}-value', className='card-title text-primary'),
                html.P(title, className='card-text text-muted small')
            ], className='text-center py-2')
        ])

    def _create_tendencies_tab(self) -> html.Div:
        """Create the tendencies analysis tab."""
        return html.Div([
            dbc.Row([
                dbc.Col([
                    dcc.Graph(id='run-pass-chart')
                ], width=4),
                dbc.Col([
                    dcc.Graph(id='direction-chart')
                ], width=4),
                dbc.Col([
                    dcc.Graph(id='concept-chart')
                ], width=4)
            ]),
            dbc.Row([
                dbc.Col([
                    dcc.Graph(id='situation-heatmap')
                ])
            ], className='mt-4')
        ])

    def _create_formations_tab(self) -> html.Div:
        """Create the formations breakdown tab."""
        return html.Div([
            dbc.Row([
                dbc.Col([
                    dcc.Graph(id='formation-frequency')
                ], width=6),
                dbc.Col([
                    dcc.Graph(id='formation-success')
                ], width=6)
            ]),
            dbc.Row([
                dbc.Col([
                    html.H5("Formation Tendencies", className='mt-4'),
                    html.Div(id='formation-details')
                ])
            ])
        ])

    def _create_personnel_tab(self) -> html.Div:
        """Create the personnel groupings tab."""
        return html.Div([
            dbc.Row([
                dbc.Col([
                    dcc.Graph(id='personnel-usage')
                ], width=6),
                dbc.Col([
                    dcc.Graph(id='personnel-tendencies')
                ], width=6)
            ])
        ])

    def _create_plays_tab(self) -> html.Div:
        """Create the play-by-play list tab."""
        return html.Div([
            dbc.Row([
                dbc.Col([
                    html.Div(id='play-count-display'),
                    html.Div(id='plays-table')
                ])
            ])
        ])

    def _create_comparison_tab(self) -> html.Div:
        """Create the comparison vs our team tab."""
        return html.Div([
            dbc.Row([
                dbc.Col([
                    dcc.Graph(id='comparison-radar')
                ], width=6),
                dbc.Col([
                    dcc.Graph(id='matchup-chart')
                ], width=6)
            ])
        ])


# Callbacks for interactivity
@callback(
    Output('total-plays-value', 'children'),
    Output('pass-pct-value', 'children'),
    Output('rush-pct-value', 'children'),
    Output('avg-epa-value', 'children'),
    Output('run-pass-chart', 'figure'),
    Output('direction-chart', 'figure'),
    Input('situation-filter', 'value'),
    Input('formation-filter', 'value'),
    Input('personnel-filter', 'value'),
    Input('hash-filter', 'value')
)
def update_dashboard(situation, formations, personnel, hash_marks):
    """Update all dashboard components based on filters."""

    # Apply filters to data
    filtered = apply_filters(opponent_data, situation, formations, personnel, hash_marks)

    # Calculate stats
    total = len(filtered)
    pass_pct = f"{100 * len(filtered[filtered['play_type'] == 'pass']) / total:.0f}%" if total > 0 else "0%"
    rush_pct = f"{100 * len(filtered[filtered['play_type'] == 'rush']) / total:.0f}%" if total > 0 else "0%"
    avg_epa = f"{filtered['epa'].mean():.2f}" if total > 0 else "0.00"

    # Create charts
    run_pass_fig = create_run_pass_chart(filtered)
    direction_fig = create_direction_chart(filtered)

    return str(total), pass_pct, rush_pct, avg_epa, run_pass_fig, direction_fig


@callback(
    Output('plays-table', 'children'),
    Input('run-pass-chart', 'clickData'),
    Input('direction-chart', 'clickData'),
    State('situation-filter', 'value')
)
def drill_to_plays(run_pass_click, direction_click, situation):
    """Drill down to play list when chart element is clicked."""

    filtered = opponent_data.copy()

    # Apply drill-down filter based on which chart was clicked
    if run_pass_click:
        play_type = run_pass_click['points'][0]['x']
        filtered = filtered[filtered['play_type'] == play_type.lower()]

    if direction_click:
        direction = direction_click['points'][0]['x']
        filtered = filtered[filtered['direction'] == direction]

    # Create play table
    return create_plays_table(filtered)

Key Features Implemented

1. Situational Filtering

Coaches can instantly filter to specific game situations:

def apply_situation_filter(df: pd.DataFrame, situation: str) -> pd.DataFrame:
    """Filter data to specific game situation."""
    filters = {
        '3rd & Short': (df['down'] == 3) & (df['distance'] < 4),
        '3rd & Long': (df['down'] == 3) & (df['distance'] >= 7),
        'Red Zone': df['yard_line'] >= 80,
        'Goal Line': df['yard_line'] >= 95,
        '2-Minute': df['seconds_remaining'] <= 120
    }
    return df[filters.get(situation, True)]

2. Click-to-Video Integration

Clicking any play opens the corresponding video:

@callback(
    Output('video-player', 'src'),
    Input('plays-table', 'active_cell'),
    State('plays-table', 'data')
)
def play_video(active_cell, data):
    """Load video for clicked play."""
    if active_cell:
        row = data[active_cell['row']]
        play_id = row['play_id']
        return f"/video/{play_id}"
    return ""

3. Offline Mode

Data is cached locally for sideline access:

# Cache data for offline use
import json

def cache_for_offline(opponent_data: pd.DataFrame, week: int):
    """Cache data for offline sideline access."""
    cache_file = f"cache/week{week}_opponent.json"
    opponent_data.to_json(cache_file, orient='records')

def load_cached_data(week: int) -> pd.DataFrame:
    """Load cached data when offline."""
    cache_file = f"cache/week{week}_opponent.json"
    return pd.read_json(cache_file)

Results

Usage Metrics

Metric Before After Change
Prep time per week 18 hours 12 hours -33%
Reports generated 45 pages 0 pages -100%
Coach satisfaction 6.2/10 8.8/10 +42%
Data accessed per week 3 reports 48 sessions +1500%

Qualitative Feedback

"I can answer my own questions now. Before, I had to wait for QC to pull the data. Now I just filter and see it." — Offensive Coordinator

"The sideline access is huge. When we see something in the first half, I can check their tendencies on my tablet during halftime." — Defensive Coordinator


Lessons Learned

  1. Coach involvement essential: Coaches needed to drive requirements, not just review designs

  2. Simplicity over features: Removed 40% of planned features after coach feedback—less is more

  3. Speed matters most: A 3-second load time was the difference between adoption and abandonment

  4. Video integration critical: Without links to video, data was "just numbers"

  5. Mobile-first design: Tablet use exceeded desktop 3:1


Code Repository

Complete implementation in code/case-study-code.py including: - GamePrepDashboard class - All callback implementations - Offline caching system - Video integration hooks