Add loading states and performance optimizations
Performance Improvements: - Added loading template with skeleton UI and progress animations - Split routes: show loading state immediately, then fetch data - Added memory cache for API responses (5-minute TTL) - Cached expensive calls: user lookup, NFL state, user leagues - Meta refresh redirect instead of JavaScript for no-JS requirement User Experience: - Immediate page load with branded loading screen - Skeleton UI shows expected layout structure - Progress bar and spinner animations during load - Users see something instantly instead of blank page Technical Details: - /<username> and /<username>/<week> now show loading.html first - /<username>/<week>/data route handles actual API calls - Simple memory cache with fallback to stale data on errors - CSS animations for loading states and skeleton placeholders 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2109bac65c
commit
1837590a79
3 changed files with 302 additions and 15 deletions
66
app.py
66
app.py
|
|
@ -1,5 +1,5 @@
|
||||||
from flask import Flask, render_template, jsonify, request, session, redirect, url_for
|
from flask import Flask, render_template, jsonify, request, session, redirect, url_for
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import traceback
|
import traceback
|
||||||
|
|
@ -20,6 +20,34 @@ app.config.from_object(Config)
|
||||||
sleeper_api = SleeperAPI()
|
sleeper_api = SleeperAPI()
|
||||||
espn_api = ESPNAPI()
|
espn_api = ESPNAPI()
|
||||||
|
|
||||||
|
# Simple memory cache for API responses
|
||||||
|
api_cache = {}
|
||||||
|
CACHE_DURATION = 300 # 5 minutes in seconds
|
||||||
|
|
||||||
|
def get_cached_or_fetch(cache_key, fetch_function, *args):
|
||||||
|
"""Get data from cache or fetch it and cache the result"""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if cache_key in api_cache:
|
||||||
|
cached_data, cached_time = api_cache[cache_key]
|
||||||
|
if (now - cached_time).total_seconds() < CACHE_DURATION:
|
||||||
|
print(f"DEBUG: Using cached data for {cache_key}", flush=True)
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
print(f"DEBUG: Fetching fresh data for {cache_key}", flush=True)
|
||||||
|
try:
|
||||||
|
data = fetch_function(*args)
|
||||||
|
api_cache[cache_key] = (data, now)
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to fetch {cache_key}: {str(e)}", flush=True)
|
||||||
|
# Return cached data if available, even if stale
|
||||||
|
if cache_key in api_cache:
|
||||||
|
cached_data, _ = api_cache[cache_key]
|
||||||
|
print(f"DEBUG: Using stale cached data for {cache_key}", flush=True)
|
||||||
|
return cached_data
|
||||||
|
raise
|
||||||
|
|
||||||
def get_league_color(league_index):
|
def get_league_color(league_index):
|
||||||
"""Assign colors to leagues in order"""
|
"""Assign colors to leagues in order"""
|
||||||
colors = app.config['LEAGUE_COLORS']
|
colors = app.config['LEAGUE_COLORS']
|
||||||
|
|
@ -76,13 +104,14 @@ def set_timezone():
|
||||||
|
|
||||||
@app.route('/<username>')
|
@app.route('/<username>')
|
||||||
def dashboard_current(username):
|
def dashboard_current(username):
|
||||||
"""Dashboard for current NFL week"""
|
"""Dashboard for current NFL week - shows loading state first"""
|
||||||
print(f"DEBUG: Dashboard current route - username: '{username}'", flush=True)
|
print(f"DEBUG: Dashboard current route - username: '{username}'", flush=True)
|
||||||
try:
|
try:
|
||||||
nfl_state = sleeper_api.get_nfl_state()
|
nfl_state = sleeper_api.get_nfl_state()
|
||||||
current_week = nfl_state.get('display_week', 1)
|
current_week = nfl_state.get('display_week', 1)
|
||||||
print(f"DEBUG: Current week: {current_week}", flush=True)
|
print(f"DEBUG: Current week: {current_week}", flush=True)
|
||||||
return dashboard(username, current_week)
|
# Show loading state first for better UX
|
||||||
|
return render_template('loading.html', username=username, week=current_week)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: dashboard_current exception - {str(e)}", flush=True)
|
print(f"ERROR: dashboard_current exception - {str(e)}", flush=True)
|
||||||
print(f"ERROR: Full traceback: {traceback.format_exc()}", flush=True)
|
print(f"ERROR: Full traceback: {traceback.format_exc()}", flush=True)
|
||||||
|
|
@ -91,23 +120,30 @@ def dashboard_current(username):
|
||||||
|
|
||||||
@app.route('/<username>/<int:week>')
|
@app.route('/<username>/<int:week>')
|
||||||
def dashboard_week(username, week):
|
def dashboard_week(username, week):
|
||||||
"""Dashboard for specific week"""
|
"""Dashboard for specific week - shows loading state first"""
|
||||||
print(f"DEBUG: Dashboard week - username: '{username}', week: {week}", flush=True)
|
print(f"DEBUG: Dashboard week - username: '{username}', week: {week}", flush=True)
|
||||||
|
# Show loading state first for better UX
|
||||||
|
return render_template('loading.html', username=username, week=week)
|
||||||
|
|
||||||
|
@app.route('/<username>/<int:week>/data')
|
||||||
|
def dashboard_data(username, week):
|
||||||
|
"""Dashboard data route - actual API calls and data loading"""
|
||||||
|
print(f"DEBUG: Dashboard data route - username: '{username}', week: {week}", flush=True)
|
||||||
return dashboard(username, week)
|
return dashboard(username, week)
|
||||||
|
|
||||||
@app.route('/<username>/<int:week>/refresh', methods=['POST'])
|
@app.route('/<username>/<int:week>/refresh', methods=['POST'])
|
||||||
def refresh_scores(username, week):
|
def refresh_scores(username, week):
|
||||||
"""Server-side refresh scores - redirect back to dashboard"""
|
"""Server-side refresh scores - redirect back to dashboard data"""
|
||||||
print(f"DEBUG: Refresh scores POST - username: '{username}', week: {week}", flush=True)
|
print(f"DEBUG: Refresh scores POST - username: '{username}', week: {week}", flush=True)
|
||||||
return redirect(url_for('dashboard_week', username=username, week=week))
|
return redirect(url_for('dashboard_data', username=username, week=week))
|
||||||
|
|
||||||
def dashboard(username, week):
|
def dashboard(username, week):
|
||||||
"""Main dashboard logic - fetch and display user's fantasy data"""
|
"""Main dashboard logic - fetch and display user's fantasy data"""
|
||||||
print(f"DEBUG: Dashboard function START - username: '{username}', week: {week}", flush=True)
|
print(f"DEBUG: Dashboard function START - username: '{username}', week: {week}", flush=True)
|
||||||
try:
|
try:
|
||||||
# Get user info from Sleeper
|
# Get user info from Sleeper (cached)
|
||||||
print(f"DEBUG: Calling get_user() with username: '{username}'", flush=True)
|
user_cache_key = f"user_{username}"
|
||||||
user = sleeper_api.get_user(username)
|
user = get_cached_or_fetch(user_cache_key, sleeper_api.get_user, username)
|
||||||
print(f"DEBUG: get_user() returned: {user}", flush=True)
|
print(f"DEBUG: get_user() returned: {user}", flush=True)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -117,16 +153,16 @@ def dashboard(username, week):
|
||||||
|
|
||||||
print(f"DEBUG: User found - ID: {user.get('user_id')}, Display: {user.get('display_name', 'Unknown')}", flush=True)
|
print(f"DEBUG: User found - ID: {user.get('user_id')}, Display: {user.get('display_name', 'Unknown')}", flush=True)
|
||||||
|
|
||||||
# Get current NFL season
|
# Get current NFL season (cached)
|
||||||
print("DEBUG: Calling get_nfl_state()", flush=True)
|
nfl_cache_key = "nfl_state"
|
||||||
nfl_state = sleeper_api.get_nfl_state()
|
nfl_state = get_cached_or_fetch(nfl_cache_key, sleeper_api.get_nfl_state)
|
||||||
season = nfl_state.get('season', str(datetime.now().year))
|
season = nfl_state.get('season', str(datetime.now().year))
|
||||||
print(f"DEBUG: Using season: {season}", flush=True)
|
print(f"DEBUG: Using season: {season}", flush=True)
|
||||||
|
|
||||||
# Get user's fantasy leagues
|
# Get user's fantasy leagues (cached)
|
||||||
user_id = user['user_id']
|
user_id = user['user_id']
|
||||||
print(f"DEBUG: Calling get_user_leagues() with user_id: '{user_id}', season: '{season}'", flush=True)
|
leagues_cache_key = f"leagues_{user_id}_{season}"
|
||||||
leagues = sleeper_api.get_user_leagues(user_id, season)
|
leagues = get_cached_or_fetch(leagues_cache_key, sleeper_api.get_user_leagues, user_id, season)
|
||||||
print(f"DEBUG: Found {len(leagues) if leagues else 0} leagues", flush=True)
|
print(f"DEBUG: Found {len(leagues) if leagues else 0} leagues", flush=True)
|
||||||
|
|
||||||
if leagues:
|
if leagues:
|
||||||
|
|
|
||||||
156
static/style.css
156
static/style.css
|
|
@ -1137,4 +1137,160 @@ body {
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading States and Animations */
|
||||||
|
.loading-section {
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
margin: 20px 0;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top: 4px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-progress {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--border-light);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(45deg, var(--accent), var(--accent-hover));
|
||||||
|
animation: progress 2s ease-in-out infinite;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress {
|
||||||
|
0% { width: 0%; }
|
||||||
|
50% { width: 70%; }
|
||||||
|
100% { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton Loading Styles */
|
||||||
|
.skeleton-score-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-league-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--border-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: skeleton 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 16px;
|
||||||
|
background: var(--border-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
animation: skeleton 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-league-name {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-score {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-timezone {
|
||||||
|
width: 150px;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-day-row {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-day-header {
|
||||||
|
background: var(--border-secondary);
|
||||||
|
padding: 15px 20px;
|
||||||
|
animation: skeleton 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-day-name {
|
||||||
|
width: 150px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled button states */
|
||||||
|
.week-btn.disabled,
|
||||||
|
.refresh-btn.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
95
templates/loading.html
Normal file
95
templates/loading.html
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Loading {{ username }} - Week {{ week }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<!-- Auto-redirect to data route after 2 seconds -->
|
||||||
|
<meta http-equiv="refresh" content="2;url=/{{ username }}/{{ week }}/data">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Header with user name and week navigation -->
|
||||||
|
<header class="dashboard-header">
|
||||||
|
<h1>{{ username }} <span class="loading-indicator">⏳</span></h1>
|
||||||
|
<div class="week-nav">
|
||||||
|
<span class="week-btn disabled">← Week {{ week - 1 }}</span>
|
||||||
|
<span class="current-week">Week {{ week }}</span>
|
||||||
|
<span class="week-btn disabled">Week {{ week + 1 }} →</span>
|
||||||
|
</div>
|
||||||
|
<!-- Loading refresh button -->
|
||||||
|
<div class="refresh-nav">
|
||||||
|
<button class="refresh-btn disabled" disabled>🔄 Loading...</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Loading message -->
|
||||||
|
<section class="loading-section">
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<h2>Loading your fantasy data...</h2>
|
||||||
|
<p>Fetching leagues and player information</p>
|
||||||
|
<div class="loading-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Skeleton loading for leagues -->
|
||||||
|
<section class="scores-summary">
|
||||||
|
<div class="skeleton-score-row">
|
||||||
|
<div class="skeleton-league-info">
|
||||||
|
<div class="skeleton-dot"></div>
|
||||||
|
<div class="skeleton-text skeleton-league-name"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-score-compact">
|
||||||
|
<div class="skeleton-text skeleton-score"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-score-row">
|
||||||
|
<div class="skeleton-league-info">
|
||||||
|
<div class="skeleton-dot"></div>
|
||||||
|
<div class="skeleton-text skeleton-league-name"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-score-compact">
|
||||||
|
<div class="skeleton-text skeleton-score"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-score-row">
|
||||||
|
<div class="skeleton-league-info">
|
||||||
|
<div class="skeleton-dot"></div>
|
||||||
|
<div class="skeleton-text skeleton-league-name"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-score-compact">
|
||||||
|
<div class="skeleton-text skeleton-score"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skeleton loading for games -->
|
||||||
|
<section class="schedule-section">
|
||||||
|
<h2>Week {{ week }} Games <br><span class="skeleton-text skeleton-timezone"></span></h2>
|
||||||
|
|
||||||
|
<div class="calendar-rows">
|
||||||
|
<div class="skeleton-day-row">
|
||||||
|
<div class="skeleton-day-header">
|
||||||
|
<div class="skeleton-text skeleton-day-name"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-day-row">
|
||||||
|
<div class="skeleton-day-header">
|
||||||
|
<div class="skeleton-text skeleton-day-name"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-day-row">
|
||||||
|
<div class="skeleton-day-header">
|
||||||
|
<div class="skeleton-text skeleton-day-name"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue