Files
team-tryouts/app/templates/pages/team_matches.html
T
GGThedandClaude Opus 5 838b247649 feat(perf): borner les vues de liste, et choisir le motif une bonne fois
MNT-14. Chaque vue de liste faisait .all() sur sa table. L'audit evaluait
l'impact a nul -- justement, a l'echelle d'une association etudiante -- et
recommandait de choisir le motif maintenant plutot que de le retro-adapter
plus tard. C'est ce que ceci est.

Applique a list_users, list_evaluations et team_matches.list_matches. Pour
cette derniere, la pagination borne aussi la boucle sur les participants,
qui est le N+1 que le constat designait comme le premier a se degrader.

Trois decisions, parce que ce sont celles qui se prennent deux fois
differemment sinon.

error_out=False : les numeros de page arrivent par l'URL, donc ?page=999 est
une chose qu'on tape ou qu'un signet perime contient. Le defaut de
Flask-SQLAlchemy y repond par un 404, ce qui est deroutant pour quelqu'un qui
est simplement alle une page trop loin.

Un plafond sur per_page : c'est aussi un parametre d'URL, et sans plafond
?per_page=100000 redonne a la main exactement la requete non bornee que la
pagination existe pour empecher.

page_url est un global Jinja plutot qu'une valeur que chaque vue passe. Ce
qui se rate avec des liens de pagination, c'est le reste de la chaine de
requete : la liste d'evaluations porte sort et order, celle des matchs
d'equipe porte team_id, et un lien qui les perd reinitialise silencieusement
la vue que la personne regardait. Les deux tests qui l'epinglent tombent si
page_url cesse de les recopier -- verifie par mutation.

Les tris sont completes par une cle unique : une requete paginee sans ORDER
BY stable peut montrer la meme ligne deux fois et jamais une autre.

Au passage, huit entrees fuzzy corrigees dans les catalogues, dont deux
laissees par le commit SEC-16 : une entree fuzzy est ignoree a l'execution,
donc ces messages retombaient en anglais.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-11 20:20:42 -04:00

239 lines
9.0 KiB
HTML

{% extends "layouts/base.html" %}
{% import 'layouts/_pagination.html' as pager %}
{% block title %}{{ _('Team Matches') }} - TryoutPro{% endblock %}
{% block page_title %}{{ _('Team Matches') }}{% endblock %}
{% block breadcrumb %}<span class="breadcrumb">Home / Team Matches</span>{% endblock %}
{% block header_actions %}
{% if teams %}
<div class="header-actions">
<select id="teamSelect" class="form-select" style="width:200px;" data-navigate="/team-matches/{value}/create">
<option value="">{{ _('+ Schedule Match') }}</option>
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
{% endblock %}
{% block content %}
{% if match_data %}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{{ _('Match') }}</th>
<th>{{ _('Team') }}</th>
<th>{{ _('Opponent') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Time') }}</th>
<th>{{ _('Location') }}</th>
<th>{{ _('Presence') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for item in match_data %}
{% set m = item.match %}
<tr>
<td class="cell-title">{{ m.title }}</td>
<td>
<span class="badge badge-info">{{ m.org_team.name }}</span>
</td>
<td>
{% if m.opponent %}
{{ m.opponent }}
{% else %}
<span class="badge badge-info">Practice</span>
{% endif %}
</td>
<td>{{ m.date.strftime('%m/%d/%Y') }}</td>
<td>
{% if m.start_time and m.end_time %}
{{ m.start_time.strftime('%H:%M') }} - {{ m.end_time.strftime('%H:%M') }}
{% else %}
TBD
{% endif %}
</td>
<td>{{ m.location or '—' }}</td>
<td>
{% if item.total_count > 0 %}
<div class="presence-bar" title="{{ item.confirmed_count }} of {{ item.total_count }} confirmed">
<div class="presence-progress">
{% set pct = (item.confirmed_count / item.total_count * 100) | int %}
<div class="presence-fill" style="width: {{ pct }}%;"></div>
</div>
<span class="presence-text">
{% if item.confirmed_count == item.total_count and item.total_count > 0 %}
✅ {{ item.confirmed_count }}/{{ item.total_count }}
{% elif item.confirmed_count > 0 %}
⏳ {{ item.confirmed_count }}/{{ item.total_count }}
{% else %}
❌ 0/{{ item.total_count }}
{% endif %}
</span>
</div>
<!-- Player presence details -->
<div class="presence-players">
{% for p in item.participants %}
<span class="presence-player-tag {% if p.is_confirmed %}confirmed{% else %}pending{% endif %}"
title="{{ p.player.username }}{% if p.is_confirmed %} - Confirmed{% else %} - Pending{% endif %}">
{{ p.player.username[:2] | upper }} {{ p.player.username }}
{% if p.is_confirmed %}✅{% else %}⏳{% endif %}
</span>
{% endfor %}
</div>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge badge-{{ m.status }}">{{ m.status }}</span>
</td>
<td class="eval-actions">
{% set can_manage_this = (current_user.role in ['admin', 'manager']) or (current_user.role == 'coach' and m.org_team.coaches.filter_by(id=current_user.id).first()) or (current_user.role == 'coach' and m.org_team.coach_id == current_user.id) %}
{% if can_manage_this %}
<a href="{{ url_for('team_matches.edit_match', match_id=m.id) }}" class="btn btn-sm btn-outline" title="{{ _('Edit Match') }}">
<i class="fas fa-edit"></i>
</a>
<form method="POST" action="{{ url_for('team_matches.delete_match', match_id=m.id) }}" class="inline-form" data-confirm="{{ _('Delete this match?') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm btn-danger" title="{{ _('Delete Match') }}">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
<!-- Toggle presence for each participant if user is player -->
{% if current_user.role == 'player' %}
{% for p in item.participants %}
{% if p.player.id == current_user.id %}
<button class="btn btn-sm {% if p.is_confirmed %}btn-success{% else %}btn-outline{% endif %} presence-toggle-btn"
data-match-id="{{ m.id }}"
data-participant-id="{{ p.id }}"
data-action="toggle-presence">
{% if p.is_confirmed %}✅ Confirmed{% else %}Confirm{% endif %}
</button>
{% endif %}
{% endfor %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ pager.controls(pagination) }}
</div>
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<div class="empty-state">
<i class="fas fa-futbol fa-3x text-muted mb-3"></i>
<h3>{{ _('No Team Matches') }}</h3>
<p class="text-muted">{{ _('Regular season matches have not been scheduled yet.') }}</p>
{% if teams %}
<div class="mt-3">
<select id="teamSelectEmpty" class="form-select" style="width:220px; display:inline;" data-navigate="/team-matches/{value}/create">
<option value="">{{ _('-- Schedule a Match --') }}</option>
{% for t in teams %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
<script nonce="{{ csp_nonce }}">
function togglePresence(btn) {
var matchId = btn.getAttribute('data-match-id');
var participantId = btn.getAttribute('data-participant-id');
fetch('/team-matches/' + matchId + '/toggle-presence/' + participantId, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token() }}',
'Content-Type': 'application/json'
}
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.is_confirmed) {
btn.classList.add('btn-success');
btn.classList.remove('btn-outline');
btn.innerHTML = '✅ Confirmed';
} else {
btn.classList.remove('btn-success');
btn.classList.add('btn-outline');
btn.innerHTML = 'Confirm';
}
// Reload to update presence bar
location.reload();
})
.catch(function(error) {
console.error('Error:', error);
});
}
// Behaviours are declared in the markup with data-action / data-change and
// dispatched by the delegated listener in main.js. This replaces inline
// onclick attributes, which no CSP nonce is able to authorise.
registerActions({
'toggle-presence': togglePresence,
});
</script>
<style>
.presence-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.presence-progress {
width: 60px;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.presence-fill {
height: 100%;
background: #10b981;
border-radius: 3px;
}
.presence-text {
font-size: 0.8rem;
font-weight: 600;
}
.presence-players {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-top: 3px;
}
.presence-player-tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 1px 5px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
}
.presence-player-tag.confirmed {
background: #d1fae5;
color: #065f46;
}
.presence-player-tag.pending {
background: #fef3c7;
color: #92400e;
}
.presence-toggle-btn {
margin-left: 4px;
}
</style>
{% endblock %}