Implémente système multi-utilisateurs avec classement

- Auth cookie simple (SSO-ready) : login/logout, redirection si non authentifié
- Données isolées par utilisateur dans /data/users/{user_id}/
- Migration automatique des données legacy au premier login
- Page classement avec podium et tableau comparatif (score, précision zéro, conformité)
- Nav mise à jour : Classement, affichage utilisateur courant, déconnexion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
toto
2026-06-24 17:29:07 +02:00
parent 1a34bff1c6
commit b6e0fcd925
7 changed files with 673 additions and 131 deletions

View File

@ -0,0 +1,278 @@
{% extends "base.html" %}
{% block content %}
<div class="clmt-head">
<span class="clmt-eyebrow">Compétition interne</span>
<h1 class="clmt-title">Classement</h1>
<p class="clmt-sub">Précision sur le zéro heure supp · Conformité aux directives</p>
</div>
{% if ranking %}
{# ── Podium top 3 ── #}
{% if ranking|length >= 1 %}
<div class="podium">
{% set top3 = ranking[:3] %}
{# réordonner : 2e à gauche, 1er au centre, 3e à droite #}
{% if top3|length == 1 %}
{% set order = [top3[0]] %}
{% elif top3|length == 2 %}
{% set order = [top3[1], top3[0]] %}
{% else %}
{% set order = [top3[1], top3[0], top3[2]] %}
{% endif %}
{% for u in order %}
{% set rank = ranking.index(u) + 1 %}
<div class="podium-slot podium-rank-{{ rank }}{% if u.user_id == current_user %} podium-me{% endif %}">
<div class="podium-score">{{ u.score }}</div>
<div class="podium-name">{{ u.display_name }}</div>
<div class="podium-medal">{% if rank == 1 %}🥇{% elif rank == 2 %}🥈{% else %}🥉{% endif %}</div>
<div class="podium-bar-wrap">
<div class="podium-bar podium-bar-{{ rank }}"></div>
</div>
<div class="podium-rank-label">#{{ rank }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ── Tableau complet ── #}
<div class="clmt-table-wrap">
<table class="clmt-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Utilisateur</th>
<th class="col-num" title="Score combiné (0100)">Score</th>
<th class="col-num" title="% semaines avec |solde| ≤ 30 min">Préc. zéro</th>
<th class="col-num" title="Conformité aux horaires direction">Conformité</th>
<th class="col-num" title="Écart moyen absolu en heures">Écart moy.</th>
<th class="col-num" title="Nombre de semaines">Semaines</th>
<th class="col-num" title="Nombre de jours complets">Jours</th>
</tr>
</thead>
<tbody>
{% for u in ranking %}
<tr class="clmt-row{% if u.user_id == current_user %} clmt-row-me{% endif %}">
<td class="col-rank">
{% if loop.index == 1 %}<span class="rank-badge rank-1">1</span>
{% elif loop.index == 2 %}<span class="rank-badge rank-2">2</span>
{% elif loop.index == 3 %}<span class="rank-badge rank-3">3</span>
{% else %}<span class="rank-num">{{ loop.index }}</span>
{% endif %}
</td>
<td class="col-name">
{{ u.display_name }}
{% if u.user_id == current_user %}<span class="me-tag">moi</span>{% endif %}
</td>
<td class="col-num">
<div class="score-bar-wrap">
<div class="score-bar" style="width:{{ u.score }}%"></div>
<span class="score-val">{{ u.score }}</span>
</div>
</td>
<td class="col-num">{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num">{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num {% if u.avg_delta_min > 0 %}num-pos{% elif u.avg_delta_min < 0 %}num-neg{% endif %}">{{ u.avg_delta }}</td>
<td class="col-num col-minor">{{ u.n_semaines }}</td>
<td class="col-num col-minor">{{ u.n_jours }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="clmt-legend">
<div class="clmt-legend-item"><strong>Score</strong> = préc. zéro × 0,6 + conformité × 0,4</div>
<div class="clmt-legend-item"><strong>Préc. zéro</strong> = % semaines avec |solde| ≤ 30 min</div>
<div class="clmt-legend-item"><strong>Conformité</strong> = % jours respectant les 4 créneaux (≤9h, ≥12h, ≤14h, ≥17h)</div>
</div>
{% else %}
<div class="clmt-empty">Aucun utilisateur enregistré pour le moment.</div>
{% endif %}
<style>
.clmt-head { border-bottom: 2px solid var(--text); padding-bottom: .75rem; }
.clmt-eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
display: block;
margin-bottom: .3rem;
}
.clmt-title {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
letter-spacing: -.02em;
}
.clmt-sub { font-size: 12px; color: var(--muted); margin-top: .3rem; }
/* ── Podium ── */
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1rem;
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 2rem 2rem 0;
min-height: 200px;
}
.podium-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
flex: 1;
max-width: 160px;
}
.podium-slot.podium-me .podium-name { color: var(--accent); }
.podium-score {
font-family: var(--mono);
font-size: 28px;
font-weight: 500;
color: var(--text);
}
.podium-name {
font-size: 13px;
font-weight: 500;
text-align: center;
}
.podium-medal { font-size: 22px; }
.podium-bar-wrap { width: 100%; }
.podium-bar {
width: 100%;
background: var(--accent);
opacity: .25;
}
.podium-bar-1 { height: 80px; }
.podium-bar-2 { height: 55px; }
.podium-bar-3 { height: 35px; }
.podium-rank-1 .podium-bar { opacity: .4; }
.podium-rank-label {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
background: var(--surface);
width: 100%;
text-align: center;
padding: .3rem 0;
border-top: 1px solid var(--rule);
}
/* ── Table ── */
.clmt-table-wrap { overflow-x: auto; }
.clmt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: var(--surface);
border: 1.5px solid var(--rule);
}
.clmt-table th {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
text-align: right;
padding: .6rem .9rem;
border-bottom: 1.5px solid var(--rule);
background: var(--ground);
white-space: nowrap;
}
.clmt-table th.col-name { text-align: left; }
.clmt-table th.col-rank { text-align: center; }
.clmt-row td {
padding: .6rem .9rem;
border-bottom: 1px solid var(--rule-l);
vertical-align: middle;
}
.clmt-row:last-child td { border-bottom: none; }
.clmt-row-me td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.clmt-row-me:hover td { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.clmt-row:hover td { background: var(--ground); }
.col-rank { text-align: center; }
.col-num {
font-family: var(--mono);
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.col-minor { opacity: .65; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
}
.rank-1 { background: #F0C040; color: #3A2800; }
.rank-2 { background: #C8C8C8; color: #2A2A2A; }
.rank-3 { background: #D4956A; color: #3A1A00; }
.rank-num { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.me-tag {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .06em;
text-transform: uppercase;
background: var(--accent);
color: #fff;
padding: .1rem .35rem;
margin-left: .4rem;
vertical-align: middle;
}
.pct-sym { font-size: 10px; color: var(--muted); margin-left: 1px; }
.num-pos { color: var(--pos); }
.num-neg { color: var(--neg); }
.score-bar-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: .4rem;
}
.score-bar {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 16px;
background: var(--accent);
opacity: .12;
transition: width .4s ease;
min-width: 0;
}
.score-val {
position: relative;
font-family: var(--mono);
font-size: 13px;
z-index: 1;
}
.clmt-legend {
display: flex;
flex-wrap: wrap;
gap: .5rem 2rem;
font-size: 11px;
color: var(--muted);
padding: .5rem 0;
border-top: 1px solid var(--rule-l);
}
.clmt-empty {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 3rem;
}
</style>
{% endblock %}