Cibles : reprise ancrée au présent et rafraîchissement en direct

L'heure de reprise de pause n'est qu'indicative : le jour même, une
fois l'heure désirée passée, la cible d'un après-midi non commencé
ancre la reprise à maintenant et la sortie s'ajuste au reste à faire —
suivre les cibles mène au solde 0 exact, et aucune cible ne recommande
de reprendre dans le passé.

La page semaine rafraîchit cibles, panneau Aujourd'hui et compteurs
toutes les 30 s, et immédiatement au retour sur l'onglet (les
minuteurs des navigateurs sont gelés en arrière-plan, l'affichage ne
peit plus rester périmé).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-11 14:29:50 +02:00
parent 45b450c1ca
commit e797098e79
5 changed files with 99 additions and 15 deletions

View File

@ -57,9 +57,12 @@ est réparti à parts égales sur les demi-journées restantes. Les règles, dan
s'il atteint ou dépasse le nominal du créneau (**créneau saturé**, ex. sortie de
midi oubliée), la cible devient « sortir maintenant » et l'écart se reporte sur les
autres créneaux — jamais de double comptage du temps déjà fait.
6. Une entrée déjà pointée n'est jamais déplacée, et aucune cible du jour courant ne
recommande une heure déjà passée. Ce qui ne tient dans aucune plage reste visible
dans les compteurs « reste à faire » / avance.
6. Une entrée déjà pointée n'est jamais déplacée. Les heures `arrivee_visee`,
`pause_dejeuner_fin` et `depart_vise` sont **indicatives** : seules les plages de
présence (matin et après-midi) sont contraignantes ; le jour courant, la reprise
visée d'un après-midi non commencé ne recule jamais dans le passé — ancrée à
maintenant une fois l'heure désirée passée. Ce qui ne tient dans aucune plage
reste visible dans les compteurs « reste à faire » / avance.
---
@ -80,7 +83,10 @@ dans la colonne Jour. Plus de bouton à cliquer.
Quand la semaine affichée contient le jour courant, un **panneau Aujourd'hui**
s'invite en tête de page : heure de sortie cible en grand et barre de progression
travaillé/dû de la semaine, actualisés à chaque saisie.
travaillé/dû de la semaine, actualisés à chaque saisie **et en continu** — les
cibles suivent l'avancement de la journée (recalcul toutes les 30 s, et immédiat
au retour sur l'onglet) : reprise ancrée à l'heure qu'il est une fois l'heure
désirée passée, sorties cibles qui glissent avec le reste à faire.
Le bandeau au-dessus du tableau ajoute, quand il reste du retard à rattraper, une ligne
**Reste à faire** : le total encore dû, le nombre de demi-journées restantes, et la part

View File

@ -6,7 +6,7 @@ DEFAULT_PLAGES = {
"matin_fin": "12:00", # présence obligatoire jusqu'à cette heure le matin
"aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi
"aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie)
"pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie
"pause_dejeuner_fin": "13:30", # reprise désirée (indicative) : projetée par défaut tant que l'entrée aprem n'est pas saisie, ancrée à maintenant le jour même une fois passée
"arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut)
"depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun)
"astreinte_forfait": "", # durée ajoutée au temps travaillé de chaque jour d'astreinte déclaré (HH:MM, vide = aucune)
@ -846,7 +846,17 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
slots.append({"jour": jour, "half": "matin", "nominal": nominal, "debut": debut,
"sature": sature})
if not aprem_done:
debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
if jour.get("aprem_entree"):
debut = hhmm_to_minutes(jour["aprem_entree"])
else:
# La reprise de pause n'est qu'une heure désirée (indicative) :
# projection par défaut, bornée par l'heure de retour après repos.
# Le jour même, une fois l'heure passée, la reprise visée est
# ancrée à maintenant — on ne recommande pas de reprendre dans
# le passé (l'entrée restera saisissable après coup à la main).
debut = max(pause_dejeuner_fin_min, jour["retour_min"] or 0)
if jour["date"] == today:
debut = max(debut, now_min)
cap = max(0, 23 * 60 + 59 - debut)
cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap
# floor_room = marge disponible pour avancer le départ en cas d'avance,

View File

@ -325,7 +325,11 @@
</script>
<script>
// Rafraîchit automatiquement les compteurs (travaillé/delta/soldes) du jour courant
// Rafraîchit automatiquement les compteurs (travaillé/delta/soldes) et les
// heures cibles du jour courant : celles-ci suivent l'avancement de la journée
// (reprise ancrée à maintenant, créneaux en cours, sorties cibles) et doivent
// donc être recalculées serveur toutes les 30 s — et immédiatement au retour
// sur l'onglet, les navigateurs gelant les minuteurs en arrière-plan.
(function () {
function todayStr() {
var d = new Date();
@ -340,7 +344,11 @@
htmx.ajax('GET', '/refresh/' + date, { swap: 'none' });
}
setInterval(refresh, 60000);
setInterval(refresh, 30000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
window.addEventListener('focus', refresh);
})();
</script>

View File

@ -580,8 +580,7 @@ def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
dans le travaillé, une fois réintégré au plan — si bien que suivre les
cibles sur-livrait de 105 min. Désormais le créneau saturé propose
« sortie maintenant », ne réintègre rien, et l'après-midi est allégé
d'autant : suivi des cibles → solde 0 (à ~12 min près bornées par les
plages : reprise ≥ maintenant, plancher 17:00)."""
d'autant : suivi des cibles → solde 0 exactement."""
_Freezer.freeze(monkeypatch, 13, 45)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
@ -595,16 +594,16 @@ def test_en_cours_sature_matin_pas_de_double_comptage(monkeypatch):
# Sortie du matin = maintenant (285 min déjà faites, créneau saturé)
assert vendredi["cible_matin"] == "13:45"
# L'après-midi absorbe l'excédent du matin : départ au plancher 17:00
# (nominal 288 − 78 de marge) et reprise retardée à maintenant.
assert vendredi["cible_aprem_entree"] == "13:45"
# (nominal 288 − 93 de marge), reprise ancrée à maintenant puis retardée
# de 12 min dans la marge avant le début de présence (14:00).
assert vendredi["cible_aprem_entree"] == "13:57"
assert vendredi["cible_aprem"] == "17:00"
# Suivre les cibles ne sur-livre plus de 105 min : reste ≤ 12 min
# (le solde résiduel vient des bornes de plages, pas du calcul).
# Suivre les cibles ne sur-livre plus de 105 min : solde exactement 0.
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", vendredi["cible_matin"],
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert 0 <= apres["solde_min"] <= 12
assert apres["solde_min"] == 0
def test_en_cours_sature_aprem_propose_sortie_immediate(monkeypatch):
@ -649,6 +648,30 @@ def test_en_cours_non_sature_reste_inchange(monkeypatch):
assert apres["solde_min"] == 0
def test_reprise_desiree_depassee_ancree_a_maintenant(monkeypatch):
"""La reprise de pause (pause_dejeuner_fin) n'est qu'une heure désirée :
le jour même, quand elle est dépassée et que l'après-midi n'a pas commencé,
la cible doit ancrer la reprise à maintenant — jamais dans le passé — et
la sortie s'ajuste pour que le suivi donne exactement le solde visé."""
_Freezer.freeze(monkeypatch, 14, 12)
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-17", "09:00", "12:00"), # aujourd'hui 14:12, aprem non repris
]
result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4]
assert vendredi["cible_aprem_entree"] == "14:12" # maintenant, pas 13:30
assert vendredi["cible_aprem"] == "19:00" # 14:12 + 234 nominal + 54 retard
suivis = [dict(p) for p in pointages]
suivis[4] = _pointage("2026-07-17", "09:00", "12:00",
vendredi["cible_aprem_entree"], vendredi["cible_aprem"])
apres = compute_week(suivis, [], HEURES_JOUR_MIN)
assert apres["solde_min"] == 0
def test_entree_apres_fin_matin_le_surplus_ne_double_compte_pas(monkeypatch):
"""Régression : entrée du matin pointée à 12:30 (après la fin de Présence
matin), sans sortie, il est 13:45. Aucun créneau matin n'existe (capacité

View File

@ -113,3 +113,40 @@ def test_forfait_bonifie_journee_affiche_dans_le_recap(monkeypatch):
assert "forfait 1h00 → 1h30 pondérées" in r.text # ligne du jour (bloc AS)
assert "forfaits 1h00 → 1h30 pondérés" in r.text # totaux du récapitulatif
assert "considéré réalisé en journée" in r.text # explication au survol
def test_refresh_regenerere_les_cibles_en_fonction_de_lheure(monkeypatch):
"""Le poll /refresh (toutes les 30 s, et au retour sur l'onglet) régénère
les cibles OOB avec l'heure courante : à 14:12, l'après-midi non commencé
du vendredi ancre sa reprise à maintenant — jamais à l'heure désirée 13:30
passée — et la sortie s'ajuste au reste à faire de la semaine."""
class _Frozen14(dt_module.datetime):
@classmethod
def today(cls):
return cls(2026, 7, 17, 14, 12)
monkeypatch.setattr(main, "datetime", _Frozen14)
import calcul
monkeypatch.setattr(calcul, "datetime", _Frozen14)
cookies = _login("u-ui-refresh")
# Lundi-jeudi complets, vendredi matin seulement : reste 4h42,
# seul créneau ouvert = vendredi aprem, reprise ancrée à 14:12.
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
client.post(f"/pointage/{d}", 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)
client.post(f"/pointage/{TODAY}", cookies=cookies,
data={"matin_entree": "09:00", "matin_sortie": "12:00"},
headers={"HX-Request": "true"}, follow_redirects=False)
r = client.get(f"/refresh/{TODAY}", cookies=cookies, follow_redirects=False)
assert r.status_code == 200
# Cible du jour régénérée, ancrée à maintenant (14:12), pas à 13:30
assert f'id="calc-{TODAY}-cible"' in r.text
assert "A 14:12 → 19:00" in r.text
# Toutes les cibles de la semaine suivent (swaps OOB), pas seulement le jour
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"):
assert f'id="calc-{d}-cible"' in r.text
# 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