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:
- Speed: Dashboard must load in under 3 seconds
- Simplicity: Usable without technical training
- Mobile access: Work on tablets in meetings and on sidelines
- Depth: Support drill-down from tendencies to individual plays
- 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
-
Coach involvement essential: Coaches needed to drive requirements, not just review designs
-
Simplicity over features: Removed 40% of planned features after coach feedback—less is more
-
Speed matters most: A 3-second load time was the difference between adoption and abandonment
-
Video integration critical: Without links to video, data was "just numbers"
-
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