diff --git a/app/routes/main.py b/app/routes/main.py
index 7109451..8b85347 100644
--- a/app/routes/main.py
+++ b/app/routes/main.py
@@ -116,14 +116,27 @@ def dashboard():
elif isinstance(user, Coach):
stats['my_evaluations'] = Evaluation.query.filter_by(evaluator_id=user.id).count()
- registrations = TryoutRegistration.query.filter(
- TryoutRegistration.status.in_(['registered', 'attended'])
- ).all()
- registered_player_ids = [r.player_id for r in registrations]
- evaluated_player_ids = [
- e.player_id for e in Evaluation.query.filter_by(evaluator_id=user.id).all()
- ]
- stats['pending_evaluations'] = len(set(registered_player_ids) - set(evaluated_player_ids))
+
+ # A count, computed as a count. This used to load every registration
+ # row in the club and every evaluation this coach had written, build
+ # two Python sets and subtract them — two full table reads to produce
+ # one integer (PERF-004).
+ already_evaluated = (
+ db.session.query(Evaluation.player_id)
+ .filter(
+ Evaluation.evaluator_id == user.id,
+ Evaluation.player_id == TryoutRegistration.player_id,
+ )
+ .exists()
+ )
+ stats['pending_evaluations'] = (
+ db.session.query(func.count(func.distinct(TryoutRegistration.player_id)))
+ .filter(
+ TryoutRegistration.status.in_(['registered', 'attended']),
+ ~already_evaluated,
+ )
+ .scalar()
+ )
stats['my_recent_evaluations'] = (
Evaluation.query.filter_by(evaluator_id=user.id)
.order_by(Evaluation.created_at.desc())
diff --git a/app/routes/matches.py b/app/routes/matches.py
index 9d7c8fa..1488f5a 100644
--- a/app/routes/matches.py
+++ b/app/routes/matches.py
@@ -25,6 +25,8 @@ from app.models import (
PersonalNote,
)
from datetime import datetime, timedelta
+
+from sqlalchemy.orm import joinedload
from app.services.scheduling import notify_participants, zip_participants
matches_bp = Blueprint('matches', __name__, url_prefix='/matches')
@@ -50,15 +52,76 @@ def calendar():
return render_template('pages/calendar.html')
+def calendar_window(args):
+ """The date range FullCalendar is asking about, if it said.
+
+ A URL event source appends `start` and `end` automatically, in ISO 8601
+ with an offset (`2026-08-01T00:00:00-04:00`). Only the date part is
+ needed here, and a value that does not parse is treated as absent
+ rather than as an error: a calendar that shows too much is a
+ performance problem, one that 400s is a broken page.
+
+ Args:
+ args: request.args.
+
+ Returns:
+ tuple[date | None, date | None]: Inclusive bounds.
+ """
+
+ def _parse(value):
+ if not value:
+ return None
+ try:
+ return datetime.strptime(value[:10], '%Y-%m-%d').date()
+ except (ValueError, TypeError):
+ return None
+
+ return _parse(args.get('start')), _parse(args.get('end'))
+
+
@matches_bp.route('/api/events')
@login_required
def api_events():
- """API endpoint returning calendar events for FullCalendar."""
+ """Calendar events for FullCalendar.
+
+ Bounded and batched (PERF-002). This used to walk `tryout.matches` for
+ every visible tryout — every tryout the club has ever run, for a
+ president — and then issue one MatchParticipant query per match to find
+ out whether the viewer was in it. The calendar's cost grew with the
+ whole history, on every navigation.
+ """
events = []
tryouts = get_visible_tryouts_for_user()
+ tryouts_by_id = {tryout.id: tryout for tryout in tryouts}
- for tryout in tryouts:
- for match in tryout.matches:
+ if tryouts_by_id:
+ window_start, window_end = calendar_window(request.args)
+ query = Match.query.filter(Match.tryout_id.in_(tryouts_by_id))
+ if window_start:
+ query = query.filter(Match.date >= window_start)
+ if window_end:
+ query = query.filter(Match.date <= window_end)
+ matches = query.all()
+
+ # Participants for every match in the window, in one query rather
+ # than one per match. `participants` is a dynamic relationship, so
+ # eager loading options do not apply to it.
+ match_ids = [match.id for match in matches]
+ participants_by_match = {}
+ mine_by_match = {}
+ if match_ids:
+ rows = (
+ MatchParticipant.query.filter(MatchParticipant.match_id.in_(match_ids))
+ .options(joinedload(MatchParticipant.player))
+ .all()
+ )
+ for row in rows:
+ participants_by_match.setdefault(row.match_id, []).append(row)
+ if row.player_id == current_user.id:
+ mine_by_match[row.match_id] = row
+
+ for match in matches:
+ tryout = tryouts_by_id[match.tryout_id]
match_color = '#10b981' if match.match_type == 'team_vs_team' else '#f59e0b'
# 'description' used to be participants_str + ' ' + description.
# Building presentation markup inside a JSON field is what carried
@@ -75,18 +138,16 @@ def api_events():
teams.append(match.team2.name)
participants_str = ' vs '.join(teams)
else:
- player_names = []
- for p in match.participants.all():
- player_names.append(p.player.username if p.player else 'Unknown Player')
+ player_names = [
+ p.player.username if p.player else 'Unknown Player'
+ for p in participants_by_match.get(match.id, [])
+ ]
participants_str = ', '.join(player_names) if player_names else 'No players'
start_time_str = match.start_time.strftime('%H:%M') if match.start_time else None
end_time_str = match.end_time.strftime('%H:%M') if match.end_time else None
- user_participant = MatchParticipant.query.filter_by(
- match_id=match.id,
- player_id=current_user.id,
- ).first()
+ user_participant = mine_by_match.get(match.id)
events.append(
{
@@ -625,7 +686,20 @@ def delete_match(match_id):
def get_players_available_at_time(date_str, time_str):
- """Get list of player IDs available at a specific date and time."""
+ """Player IDs whose weekly availability covers this date and time.
+
+ Two queries, whatever the size of the club. This used to load every
+ active player and then run one PlayerDisponibility query per player, on
+ an unindexed column — sixty players meant sixty-one round trips to
+ answer a question the database can answer in one (PERF-003).
+
+ Args:
+ date_str: 'YYYY-MM-DD'.
+ time_str: 'HH:MM'.
+
+ Returns:
+ list[int]: Player IDs, empty when the input does not parse.
+ """
try:
parsed_date = datetime.strptime(date_str, '%Y-%m-%d')
time_obj = datetime.strptime(time_str, '%H:%M').time()
@@ -633,22 +707,30 @@ def get_players_available_at_time(date_str, time_str):
return []
day_of_week = parsed_date.weekday()
+ active_player_ids = {
+ row.id
+ for row in User.query.with_entities(User.id)
+ .filter_by(role='player', is_active_account=True)
+ .all()
+ }
+ if not active_player_ids:
+ return []
- players = User.query.filter_by(role='player', is_active_account=True).all()
- available_players = []
- for player in players:
- disponibilities = PlayerDisponibility.query.filter_by(
- player_id=player.id,
- day_of_week=day_of_week,
- ).all()
- for disp in disponibilities:
- disp_start = disp.start_time.hour * 60 + disp.start_time.minute
- disp_end = disp.end_time.hour * 60 + disp.end_time.minute
- match_time = time_obj.hour * 60 + time_obj.minute
- if disp_start <= match_time < disp_end:
- available_players.append(player.id)
- break
- return available_players
+ # The comparison stays in Python: start_time and end_time are stored as
+ # time columns, and comparing them in SQL across three backends is not
+ # worth the portability risk for a single day's rows.
+ minutes = time_obj.hour * 60 + time_obj.minute
+ available = []
+ seen = set()
+ for disp in PlayerDisponibility.query.filter_by(day_of_week=day_of_week).all():
+ if disp.player_id in seen or disp.player_id not in active_player_ids:
+ continue
+ start = disp.start_time.hour * 60 + disp.start_time.minute
+ end = disp.end_time.hour * 60 + disp.end_time.minute
+ if start <= minutes < end:
+ available.append(disp.player_id)
+ seen.add(disp.player_id)
+ return available
@matches_bp.route('/api/available_players//