From e797098e7998aaebdf746ace7f778e12b69c98de Mon Sep 17 00:00:00 2001 From: Jacquin Antoine Date: Fri, 11 Sep 2026 14:29:50 +0200 Subject: [PATCH] =?UTF-8?q?Cibles=20:=20reprise=20ancr=C3=A9e=20au=20pr?= =?UTF-8?q?=C3=A9sent=20et=20rafra=C3=AEchissement=20en=20direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 14 ++++++++++---- app/calcul.py | 14 ++++++++++++-- app/templates/semaine.html | 12 ++++++++++-- app/tests/test_calcul.py | 37 +++++++++++++++++++++++++++++------- app/tests/test_ui_refonte.py | 37 ++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4829483..e0778a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/calcul.py b/app/calcul.py index a00201d..24fa7a2 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -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, diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 3e91707..4f243fb 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -325,7 +325,11 @@ diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index d7fdfb6..a4e5cb5 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -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é diff --git a/app/tests/test_ui_refonte.py b/app/tests/test_ui_refonte.py index d44cc07..afe2666 100644 --- a/app/tests/test_ui_refonte.py +++ b/app/tests/test_ui_refonte.py @@ -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