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:
efigueroa 2025-09-06 23:20:09 -07:00
parent 2109bac65c
commit 1837590a79
3 changed files with 302 additions and 15 deletions

66
app.py
View file

@ -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:

View file

@ -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
View 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 %}