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 datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
|
|
@ -20,6 +20,34 @@ app.config.from_object(Config)
|
|||
sleeper_api = SleeperAPI()
|
||||
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):
|
||||
"""Assign colors to leagues in order"""
|
||||
colors = app.config['LEAGUE_COLORS']
|
||||
|
|
@ -76,13 +104,14 @@ def set_timezone():
|
|||
|
||||
@app.route('/<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)
|
||||
try:
|
||||
nfl_state = sleeper_api.get_nfl_state()
|
||||
current_week = nfl_state.get('display_week', 1)
|
||||
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:
|
||||
print(f"ERROR: dashboard_current exception - {str(e)}", 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>')
|
||||
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)
|
||||
# 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)
|
||||
|
||||
@app.route('/<username>/<int:week>/refresh', methods=['POST'])
|
||||
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)
|
||||
return redirect(url_for('dashboard_week', username=username, week=week))
|
||||
return redirect(url_for('dashboard_data', username=username, week=week))
|
||||
|
||||
def dashboard(username, week):
|
||||
"""Main dashboard logic - fetch and display user's fantasy data"""
|
||||
print(f"DEBUG: Dashboard function START - username: '{username}', week: {week}", flush=True)
|
||||
try:
|
||||
# Get user info from Sleeper
|
||||
print(f"DEBUG: Calling get_user() with username: '{username}'", flush=True)
|
||||
user = sleeper_api.get_user(username)
|
||||
# Get user info from Sleeper (cached)
|
||||
user_cache_key = f"user_{username}"
|
||||
user = get_cached_or_fetch(user_cache_key, sleeper_api.get_user, username)
|
||||
print(f"DEBUG: get_user() returned: {user}", flush=True)
|
||||
|
||||
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)
|
||||
|
||||
# Get current NFL season
|
||||
print("DEBUG: Calling get_nfl_state()", flush=True)
|
||||
nfl_state = sleeper_api.get_nfl_state()
|
||||
# Get current NFL season (cached)
|
||||
nfl_cache_key = "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))
|
||||
print(f"DEBUG: Using season: {season}", flush=True)
|
||||
|
||||
# Get user's fantasy leagues
|
||||
# Get user's fantasy leagues (cached)
|
||||
user_id = user['user_id']
|
||||
print(f"DEBUG: Calling get_user_leagues() with user_id: '{user_id}', season: '{season}'", flush=True)
|
||||
leagues = sleeper_api.get_user_leagues(user_id, season)
|
||||
leagues_cache_key = f"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)
|
||||
|
||||
if leagues:
|
||||
|
|
|
|||
156
static/style.css
156
static/style.css
|
|
@ -1137,4 +1137,160 @@ body {
|
|||
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