Semaine parfaite : célébration kitsch quand tout boucle à +0h00
La dernière heure saisie qui clôt la semaine à +0h00 pile, sans
aucune demi-journée restante, déclenche une fête assumée : fond disco
en rotation de teintes, pluie d'une fourgonnée de confettis (emojis
et rectangles), trophée bondissant et titre doré défilant. Une seule
fois par semaine et par onglet, fermeture au clic ou après 8 s, et
animation réduite pour prefers-reduced-motion.
Le serveur pose un drapeau data-perfect (solde nul, plus rien à
remplir, heures dues non nulles) sur les soldes re-rendus à chaque
saisie et rafraîchissement ; le client le détecte et célèbre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@ -96,6 +96,12 @@ Une **alerte de départ** (bandeau ⏰ + notification navigateur) se déclenche
|
||||
quand l'heure de sortie cible est atteinte — y compris si elle a glissé plus tard dans
|
||||
la journée, l'alerte suit la cible la plus fraîche.
|
||||
|
||||
Et quand la dernière heure saisie boucle la semaine à **+0h00 pile**, une célébration
|
||||
kitsch assumée (pluie de confettis, fond disco, trophée bondissant) récompense la
|
||||
performance — une seule fois par semaine et par onglet, promis.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
|
||||
@ -1023,6 +1023,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
|
||||
"jours": jours,
|
||||
"total_travaille": minutes_to_hhmm(total_travaille),
|
||||
"total_du": minutes_to_hhmm(total_du),
|
||||
"total_du_min": total_du,
|
||||
"solde": minutes_to_hhmm(total_travaille - total_du),
|
||||
"solde_min": total_travaille - total_du,
|
||||
"reste_min": reste_min,
|
||||
|
||||
@ -327,6 +327,12 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
||||
contexte = _contexte_semaine_precedente(year, week, dates, user_id)
|
||||
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id), contexte)
|
||||
result["heures_jour"] = minutes_to_hhmm(h_jour)
|
||||
# Semaine parfaite : plus rien à remplir et solde à 0 pile — déclenche la
|
||||
# célébration côté client (une seule fois par onglet).
|
||||
result["perfect"] = (result["solde_min"] == 0 and result["slots_restants"] == 0
|
||||
and result["total_du_min"] > 0)
|
||||
if result["perfect"]:
|
||||
result["perfect_key"] = f"{year}-W{week:02d}"
|
||||
# Panneau « aujourd'hui » : uniquement si le jour courant appartient à la
|
||||
# semaine affichée (re-rendu à chaque enregistrement via _soldes.html).
|
||||
today_str = datetime.today().date().isoformat()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<div id="soldes-section" hx-swap-oob="true">
|
||||
<div id="soldes-section" hx-swap-oob="true"{% if result.perfect %} data-perfect="{{ result.perfect_key }}"{% endif %}>
|
||||
{% if result.today %}
|
||||
{% set jt = result.today.jour %}
|
||||
<div class="today-panel">
|
||||
|
||||
@ -1560,6 +1560,61 @@
|
||||
.chart-tooltip.visible { opacity: 1; }
|
||||
.chart-tooltip .ct-val { font-weight: 500; }
|
||||
.chart-tooltip .ct-label { color: var(--muted); margin-left: .35em; }
|
||||
|
||||
/* ── Semaine parfaite : célébration kitsch (+0h00 pile, rien à remplir) ── */
|
||||
#perfect-week {
|
||||
position: fixed; inset: 0; z-index: 400;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: .6rem; cursor: pointer;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255,0,128,.45), transparent 40%),
|
||||
radial-gradient(circle at 80% 30%, rgba(0,201,167,.45), transparent 45%),
|
||||
radial-gradient(circle at 30% 80%, rgba(255,215,0,.45), transparent 40%),
|
||||
radial-gradient(circle at 75% 85%, rgba(90,80,255,.45), transparent 45%),
|
||||
rgba(20, 16, 28, .88);
|
||||
animation: perfect-disco 1.6s linear infinite;
|
||||
}
|
||||
.perfect-trophy { font-size: 4.2rem; line-height: 1; animation: perfect-bounce .7s ease-in-out infinite alternate; }
|
||||
.perfect-title {
|
||||
font-size: clamp(1.9rem, 6vw, 3.6rem); font-weight: 800; letter-spacing: .04em;
|
||||
background: linear-gradient(90deg, #ffd700, #ff5ec4, #7af5ff, #ffd700);
|
||||
background-size: 300% 100%;
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
animation: perfect-shine 2.2s linear infinite, perfect-pop .8s cubic-bezier(.2,1.6,.4,1) 1;
|
||||
text-align: center; padding: 0 1rem;
|
||||
}
|
||||
.perfect-sub { color: #fff; font-size: 1.05rem; text-align: center; padding: 0 1rem; }
|
||||
.perfect-sub b { font-family: var(--mono); color: #ffd700; }
|
||||
.perfect-close { color: rgba(255,255,255,.75); font-size: .85rem; margin-top: 1.2rem; }
|
||||
.perfect-confetti {
|
||||
position: absolute; top: -8vh; user-select: none; pointer-events: none;
|
||||
animation: perfect-fall linear forwards;
|
||||
}
|
||||
@keyframes perfect-disco {
|
||||
0% { filter: hue-rotate(0deg) saturate(1.2); }
|
||||
100% { filter: hue-rotate(360deg) saturate(1.2); }
|
||||
}
|
||||
@keyframes perfect-shine {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 300% 50%; }
|
||||
}
|
||||
@keyframes perfect-pop {
|
||||
0% { transform: scale(.2) rotate(-6deg); opacity: 0; }
|
||||
70% { transform: scale(1.15) rotate(2deg); opacity: 1; }
|
||||
100% { transform: scale(1) rotate(0deg); opacity: 1; }
|
||||
}
|
||||
@keyframes perfect-bounce {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(-14px) scale(1.12); }
|
||||
}
|
||||
@keyframes perfect-fall {
|
||||
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateY(118vh) rotate(720deg); opacity: .85; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#perfect-week, .perfect-trophy, .perfect-title, .perfect-confetti { animation: none; }
|
||||
.perfect-confetti { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@ -230,6 +230,75 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Semaine parfaite : la dernière heure saisie boucle la semaine à +0h00 pile —
|
||||
// pluie de confettis, disco et trophée. Déclenchée dès que le drapeau
|
||||
// data-perfect apparaît (enregistrement de la dernière heure, poll 30 s ou
|
||||
// rechargement), une seule fois par semaine et par onglet (sessionStorage).
|
||||
(function () {
|
||||
var EMOJIS = ['🎉', '🎊', '✨', '🏆', '⭐', '💖', '🎈', '🥳', '💫', '🌟'];
|
||||
var COULEURS = ['#ffd700', '#ff5ec4', '#7af5ff', '#7fff9e', '#ff9d5c', '#b18cff'];
|
||||
|
||||
function rnd(a, b) { return a + Math.random() * (b - a); }
|
||||
|
||||
function confetti(overlay) {
|
||||
var total = Math.floor(rnd(70, 100));
|
||||
for (var i = 0; i < total; i++) {
|
||||
var c = document.createElement('span');
|
||||
c.className = 'perfect-confetti';
|
||||
if (Math.random() < .55) {
|
||||
c.textContent = EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
|
||||
c.style.fontSize = rnd(14, 34) + 'px';
|
||||
} else {
|
||||
c.textContent = '';
|
||||
c.style.width = rnd(6, 11) + 'px';
|
||||
c.style.height = rnd(8, 16) + 'px';
|
||||
c.style.background = COULEURS[Math.floor(Math.random() * COULEURS.length)];
|
||||
c.style.borderRadius = Math.random() < .3 ? '50%' : '2px';
|
||||
}
|
||||
c.style.left = rnd(0, 100) + 'vw';
|
||||
c.style.animationDuration = rnd(2.6, 5.2) + 's';
|
||||
c.style.animationDelay = rnd(0, 1.8) + 's';
|
||||
overlay.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
function celebrate() {
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'perfect-week';
|
||||
overlay.title = 'Félicitations !';
|
||||
overlay.innerHTML =
|
||||
'<div class="perfect-trophy">🏆</div>' +
|
||||
'<div class="perfect-title">SEMAINE PARFAITE !</div>' +
|
||||
'<div class="perfect-sub">Solde <b>+0h00</b> pile — pas une minute en trop, pas une en moins.<br>' +
|
||||
'Chapeau bas, horaire de maître ! ✨</div>' +
|
||||
'<div class="perfect-close">(cliquer ou attendre 8 s pour reprendre une activité normale)</div>';
|
||||
confetti(overlay);
|
||||
overlay.addEventListener('click', function () { overlay.remove(); });
|
||||
document.body.appendChild(overlay);
|
||||
setTimeout(function () { overlay.remove(); }, 8000);
|
||||
}
|
||||
|
||||
function check() {
|
||||
var el = document.getElementById('soldes-section');
|
||||
if (!el) return;
|
||||
var key = el.getAttribute('data-perfect');
|
||||
if (!key) return;
|
||||
var store = 'perfect-celebrated-' + key;
|
||||
try {
|
||||
if (sessionStorage.getItem(store)) return;
|
||||
sessionStorage.setItem(store, '1');
|
||||
} catch (e) { /* sessionStorage indisponible : on célèbre une fois par rendu */ }
|
||||
celebrate();
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:afterSwap', check);
|
||||
window.addEventListener('focus', check);
|
||||
setInterval(check, 30000);
|
||||
check();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// Alerte départ — surveille l'heure cible du jour courant
|
||||
(function () {
|
||||
|
||||
@ -77,6 +77,7 @@ def test_compute_week_semaine_entierement_complete_solde_zero():
|
||||
result = compute_week(pointages, [], HEURES_JOUR_MIN)
|
||||
assert result["total_travaille"] == "39h00"
|
||||
assert result["total_du"] == "39h00"
|
||||
assert result["total_du_min"] == 39 * 60
|
||||
assert result["solde_min"] == 0
|
||||
assert all(j["is_complete"] for j in result["jours"])
|
||||
|
||||
|
||||
@ -150,3 +150,31 @@ def test_refresh_regenerere_les_cibles_en_fonction_de_lheure(monkeypatch):
|
||||
# Le panneau aujourd'hui est rafraîchi avec la même sortie cible
|
||||
assert "today-panel" in r.text
|
||||
assert "19:00" in r.text
|
||||
|
||||
def test_semaine_parfaite_marquee_pour_la_celebration(monkeypatch):
|
||||
"""La dernière heure saisie qui boucle la semaine à +0h00 pile pose le
|
||||
drapeau data-perfect (clé semaine) sur les soldes : le client déclenche la
|
||||
célébration. Semaine incomplète ou solde non nul → pas de drapeau."""
|
||||
monkeypatch.setattr(main, "datetime", _FrozenDatetime)
|
||||
cookies = _login("u-ui-perfect")
|
||||
jours = {"matin_entree": "09:00", "matin_sortie": "12:24",
|
||||
"aprem_entree": "13:30", "aprem_sortie": "17:54"} # 7h48 pile
|
||||
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
|
||||
client.post(f"/pointage/{d}", cookies=cookies, data=jours,
|
||||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||||
|
||||
# Semaine incomplète (vendredi vide) : pas de drapeau
|
||||
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||||
data={"matin_entree": "09:00", "matin_sortie": "12:24",
|
||||
"aprem_entree": "13:30", "aprem_sortie": "17:54"},
|
||||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert 'data-perfect="2026-W29"' in r.text # 5×7h48 = 39h00 pile : parfait !
|
||||
|
||||
# Solde non nul (vendredi écourté) : le drapeau disparaît
|
||||
r = client.post(f"/pointage/{TODAY}", cookies=cookies,
|
||||
data={"matin_entree": "09:00", "matin_sortie": "12:24",
|
||||
"aprem_entree": "13:30", "aprem_sortie": "17:00"},
|
||||
headers={"HX-Request": "true"}, follow_redirects=False)
|
||||
assert r.status_code == 200
|
||||
assert "data-perfect" not in r.text
|
||||
|
||||
BIN
docs/screenshot-parfaite.png
Normal file
BIN
docs/screenshot-parfaite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 963 KiB |
Reference in New Issue
Block a user