Rend les plages horaires et les rappels ntfy configurables, supprime le
classement, ajoute une suite de tests pytest - calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont paramétrables via config.json > plages (moteur de calcul de l'heure de sortie optimale inclus, pas seulement l'affichage). Défauts identiques à l'ancien comportement, validé par la suite de tests. - stats.py: les seuils de conformité utilisent la même config. - main.py/templates: warn de saisie et suffixes de cible dynamiques. - Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h) configurables par utilisateur depuis /settings, plus en dur dans le code. - Page classement supprimée (pas de compétition entre utilisateurs). - Nouvelle suite pytest (app/tests/), à lancer via `docker compose run --rm pointeuse pytest -v`.
This commit is contained in:
@ -1,12 +1,12 @@
|
||||
{% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %}
|
||||
{% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %}
|
||||
{% set matin_warn = not matin_cg and j.du_min > 0 and (
|
||||
(j.matin_entree and j.matin_entree > '09:00') or
|
||||
(j.matin_sortie and j.matin_sortie < '12:00')
|
||||
(j.matin_entree and j.matin_entree > plages.matin_debut) or
|
||||
(j.matin_sortie and j.matin_sortie < plages.matin_fin)
|
||||
) %}
|
||||
{% set aprem_warn = not aprem_cg and j.du_min > 0 and (
|
||||
(j.aprem_entree and j.aprem_entree > '14:00') or
|
||||
(j.aprem_sortie and j.aprem_sortie < '17:00')
|
||||
(j.aprem_entree and j.aprem_entree > plages.aprem_debut) or
|
||||
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
|
||||
) %}
|
||||
<tr id="row-{{ j.date }}">
|
||||
|
||||
@ -72,9 +72,9 @@
|
||||
{%- if not j.is_complete -%}
|
||||
{%- if j.entree_cible -%}
|
||||
{%- if 'aprem' in j.conge -%}
|
||||
<span class="cible-num">{{ j.entree_cible }}→12:00</span>
|
||||
<span class="cible-num">{{ j.entree_cible }}→{{ plages.matin_fin }}</span>
|
||||
{%- else -%}
|
||||
<span class="cible-num">{{ j.entree_cible }}→17:00</span>
|
||||
<span class="cible-num">{{ j.entree_cible }}→{{ plages.aprem_fin }}</span>
|
||||
{%- endif -%}
|
||||
{%- elif j.sortie_cible -%}
|
||||
<span class="cible-num">{{ j.sortie_cible }}</span>
|
||||
|
||||
@ -1007,7 +1007,6 @@
|
||||
{% if current_user is defined and current_user %}
|
||||
<a href="/" class="nav-link">Semaine courante</a>
|
||||
<a href="/stats" class="nav-link">Statistiques</a>
|
||||
<a href="/classement" class="nav-link">Classement</a>
|
||||
<a href="/settings" class="nav-link">Réglages</a>
|
||||
<span class="nav-user">{{ current_user }}</span>
|
||||
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
{% 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é (0–100)">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 %}
|
||||
@ -327,11 +327,11 @@ document.addEventListener('input', function(e) {
|
||||
var maDiv = document.getElementById('cg-matin-' + date);
|
||||
var apDiv = document.getElementById('cg-aprem-' + date);
|
||||
if (maDiv && !maDiv.classList.contains('cg')) {
|
||||
var warn = (me && me.value && me.value > '09:00') || (ms && ms.value && ms.value < '12:00');
|
||||
var warn = (me && me.value && me.value > '{{ plages.matin_debut }}') || (ms && ms.value && ms.value < '{{ plages.matin_fin }}');
|
||||
maDiv.classList.toggle('warn', !!warn);
|
||||
}
|
||||
if (apDiv && !apDiv.classList.contains('cg')) {
|
||||
var warnA = (ae && ae.value && ae.value > '14:00') || (as && as.value && as.value < '17:00');
|
||||
var warnA = (ae && ae.value && ae.value > '{{ plages.aprem_debut }}') || (as && as.value && as.value < '{{ plages.aprem_fin }}');
|
||||
apDiv.classList.toggle('warn', !!warnA);
|
||||
}
|
||||
});
|
||||
|
||||
@ -10,8 +10,9 @@
|
||||
<div class="settings-card">
|
||||
<span class="hist-label">Notifications (ntfy)</span>
|
||||
<p class="settings-p">
|
||||
Crée un topic sur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy.sh</a> (un identifiant
|
||||
secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici.
|
||||
Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
|
||||
(public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec
|
||||
l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
@ -19,6 +20,16 @@
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings" class="settings-form">
|
||||
<label class="login-label" for="ntfy_server">Serveur ntfy</label>
|
||||
<input
|
||||
id="ntfy_server"
|
||||
class="login-input"
|
||||
type="text"
|
||||
name="ntfy_server"
|
||||
value="{{ ntfy_server }}"
|
||||
placeholder="https://ntfy.sh"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="login-label" for="ntfy_topic">Topic ntfy</label>
|
||||
<input
|
||||
id="ntfy_topic"
|
||||
@ -29,6 +40,23 @@
|
||||
placeholder="ex: antoine-pointeuse-x7f2"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
|
||||
<div class="settings-range">
|
||||
<input
|
||||
id="rappel_debut_h"
|
||||
class="login-input settings-range-input"
|
||||
type="number" min="0" max="23" name="rappel_debut_h"
|
||||
value="{{ rappel_debut_h }}"
|
||||
>
|
||||
<span class="settings-range-sep">h → </span>
|
||||
<input
|
||||
id="rappel_fin_h"
|
||||
class="login-input settings-range-input"
|
||||
type="number" min="1" max="24" name="rappel_fin_h"
|
||||
value="{{ rappel_fin_h }}"
|
||||
>
|
||||
<span class="settings-range-sep">h, jours ouvrés</span>
|
||||
</div>
|
||||
<button class="login-btn" type="submit">Enregistrer</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -39,7 +67,7 @@
|
||||
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
|
||||
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans
|
||||
avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
|
||||
5 minutes (7h–20h, jours ouvrés).
|
||||
5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés).
|
||||
</p>
|
||||
<div class="settings-urlrow">
|
||||
<span class="settings-urllabel">Arrivée</span>
|
||||
@ -94,5 +122,8 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
.settings-hint { font-size: 11px; color: var(--muted); }
|
||||
.settings-range { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
|
||||
.settings-range-input { width: 70px; text-align: center; }
|
||||
.settings-range-sep { font-size: 12px; color: var(--muted); font-family: var(--mono); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -58,10 +58,10 @@
|
||||
{# ── 4 time-slot cards ── #}
|
||||
<div class="ts-grid">
|
||||
{% set slot_meta = [
|
||||
("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"),
|
||||
("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"),
|
||||
("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"),
|
||||
("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"),
|
||||
("matin_entree", "Entrée matin", "≤ " ~ stats.plages.matin_debut, "le", "matin"),
|
||||
("matin_sortie", "Sortie matin", "≥ " ~ stats.plages.matin_fin, "ge", "matin"),
|
||||
("aprem_entree", "Entrée après-midi","≤ " ~ stats.plages.aprem_debut,"le", "aprem"),
|
||||
("aprem_sortie", "Sortie après-midi","≥ " ~ stats.plages.aprem_fin, "ge", "aprem"),
|
||||
] %}
|
||||
{% for slot, label, constraint, op, half in slot_meta %}
|
||||
{% set ts = stats.time_stats[slot] %}
|
||||
|
||||
Reference in New Issue
Block a user