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