From e6b5173018ce1da85058a45f0df4a755701e08a8 Mon Sep 17 00:00:00 2001 From: Antoine Date: Fri, 17 Jul 2026 23:02:07 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A9partit=20les=20pr=C3=A9conisations=20de?= =?UTF-8?q?=20rattrapage=20sur=20les=20demi-journ=C3=A9es=20restantes=20de?= =?UTF-8?q?=20la=20semaine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au lieu de reporter tout le manque horaire sur la sortie du jour courant, le calcul distribue désormais le retard à parts égales sur chaque demi-journée non traitée (aujourd'hui et jours à venir), avec un plafond de sécurité si le rattrapage dépasse minuit. La table affiche une cible par demi-journée (matin/après-midi) et le bandeau de soldes indique le reste à faire et le nombre de demi-journées restantes. --- app/calcul.py | 105 ++++++++++++++++++------------------- app/main.py | 20 ++++--- app/templates/_ligne.html | 16 ++---- app/templates/_soldes.html | 15 ++++++ app/templates/base.html | 10 ++++ app/templates/semaine.html | 15 +++--- app/tests/test_calcul.py | 56 ++++++++++++++------ 7 files changed, 138 insertions(+), 99 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 8fcc5a5..cfd680a 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -75,7 +75,6 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"]) - matin_window = matin_fin_min - matin_debut_min total_travaille = 0 total_du = 0 @@ -117,64 +116,57 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int cumul_chain_ok = False jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok - # Sortie cible: optimal aprem exit so the week totals exactly the target hours + # Préconisations : répartit le manque restant à parts égales sur les demi-journées + # restantes (au lieu de tout reporter sur la fin de journée courante), pour permettre + # un rattrapage progressif et confortable jusqu'à la fin de semaine. + for jour in jours: + jour["cible_matin"] = None + jour["cible_aprem"] = None + today = datetime.today().strftime("%Y-%m-%d") - now_min = datetime.today().hour * 60 + datetime.today().minute - worked_before = 0 - for i, jour in enumerate(jours): + restant = total_du - total_travaille + slots = [] # (jour, "matin"|"aprem", nominal_min) + for jour in jours: + if jour["date"] < today: + continue # jour passé : impossible d'y rattraper quoi que ce soit cg = jour["conge"] - # Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis - is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree") - if is_future_untouched: - jour["sortie_cible"] = None - jour["entree_cible"] = None - worked_before += jour["travaille_min"] - continue - if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg: - jour["sortie_cible"] = None - jour["entree_cible"] = None - if "aprem" in cg and "matin" not in cg and jour["du_min"] > 0: - du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:]) - if du_remaining_mat == 0 and not jour.get("matin_entree"): - needed_today_mat = max(0, total_du - worked_before - du_remaining_mat) - if needed_today_mat > matin_window: # plus que le créneau matin obligatoire - entree_min = matin_fin_min - needed_today_mat - floor_min = matin_debut_min - matin_window # pas avant ce plancher symétrique - if entree_min >= floor_min: - jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}" - worked_before += jour["travaille_min"] + du = jour["du_min"] + if du == 0: continue + matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2) + aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2) + matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg + aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg + if not matin_done: + slots.append((jour, "matin", matin_nominal)) + if not aprem_done: + slots.append((jour, "aprem", aprem_nominal)) - du_remaining = sum(j["du_min"] for j in jours[i + 1:]) - needed_today = max(0, total_du - worked_before - du_remaining) + extra_total = max(0, restant - sum(s[2] for s in slots)) if slots else 0 + slots_restants = len(slots) - worked_morning = 0 - if jour.get("matin_entree") and jour.get("matin_sortie"): - worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) - elif jour.get("matin_entree") and jour["date"] == today: - # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage - # (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée). - entree_min = hhmm_to_minutes(jour["matin_entree"]) - elapsed = max(0, now_min - entree_min) - projected_to_fin_matin = max(0, matin_fin_min - entree_min) - worked_morning = max(elapsed, projected_to_fin_matin) + if slots_restants: + base, remainder = divmod(extra_total, slots_restants) + for i, (jour, half, nominal) in enumerate(slots): + adj = base + (1 if i < remainder else 0) + duree_cible = nominal + adj + if adj <= 0: + continue # rythme normal suffit pour cette demi-journée, rien à signaler + if half == "matin": + debut_min = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min + sortie_min = max(debut_min + duree_cible, matin_fin_min) + else: + debut_min = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + sortie_min = max(debut_min + duree_cible, aprem_fin_min) + if sortie_min > 23 * 60 + 59: + continue # rattrapage impossible en une seule demi-journée + cible = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" + if half == "matin": + jour["cible_matin"] = cible + else: + jour["cible_aprem"] = cible - needed_aprem = max(0, needed_today - worked_morning) - aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min - sortie_min = max(aprem_start + needed_aprem, aprem_fin_min) - - if sortie_min > 23 * 60 + 59: - jour["sortie_cible"] = None # deficit too large to recover in one afternoon - else: - jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" - - entree_cible = None - if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min): - entree_min = aprem_fin_min - needed_aprem - if matin_fin_min < entree_min < pause_dejeuner_fin_min: # après le déjeuner, avant la reprise par défaut - entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}" - jour["entree_cible"] = entree_cible - worked_before += jour["travaille_min"] + reste_min = max(0, restant) return { "jours": jours, @@ -182,4 +174,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "total_du": minutes_to_hhmm(total_du), "solde": minutes_to_hhmm(total_travaille - total_du), "solde_min": total_travaille - total_du, + "reste_min": reste_min, + "reste": minutes_to_hhmm(reste_min), + "extra_min": extra_total, + "extra": minutes_to_hhmm(extra_total), + "slots_restants": slots_restants, + "moyenne_extra_min": (extra_total // slots_restants) if slots_restants else 0, + "moyenne_extra": minutes_to_hhmm((extra_total // slots_restants) if slots_restants else 0), } diff --git a/app/main.py b/app/main.py index c458948..4d1c14f 100644 --- a/app/main.py +++ b/app/main.py @@ -154,7 +154,7 @@ def _oob_calc(date, jour): ) -def _oob_week_extras(result, plages): +def _oob_week_extras(result): html = "" for jour in result["jours"]: date = jour["date"] @@ -169,14 +169,12 @@ def _oob_week_extras(result, plages): else: cumul_inner = '' html += f'{cumul_inner}' - cible = None - if not jour.get("is_complete"): - if jour.get("entree_cible"): - suffix = f'→{plages["matin_fin"]}' if "aprem" in jour.get("conge", set()) else f'→{plages["aprem_fin"]}' - cible = f'{jour["entree_cible"]}{suffix}' - elif jour.get("sortie_cible"): - cible = jour.get("sortie_cible") - cible_inner = f'{cible}' if cible else '' + parts = [] + if jour.get("cible_matin"): + parts.append(f'M {jour["cible_matin"]}') + if jour.get("cible_aprem"): + parts.append(f'A {jour["cible_aprem"]}') + cible_inner = " ".join(parts) if parts else '' html += f'{cible_inner}' return html @@ -236,7 +234,7 @@ def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): _oob_time_cells(date, jour, plages) + btns_html + _oob_calc(date, jour) - + _oob_week_extras(soldes_ctx["result"], plages) + + _oob_week_extras(soldes_ctx["result"]) + soldes_html ) @@ -250,7 +248,7 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): return HTMLResponse( _oob_calc(date, jour) + _oob_time_cells(date, jour, plages) - + _oob_week_extras(soldes_ctx["result"], plages) + + _oob_week_extras(soldes_ctx["result"]) + soldes_html ) diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index b6bc298..f444f52 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -69,18 +69,10 @@ - {%- if not j.is_complete -%} - {%- if j.entree_cible -%} - {%- if 'aprem' in j.conge -%} - {{ j.entree_cible }}→{{ plages.matin_fin }} - {%- else -%} - {{ j.entree_cible }}→{{ plages.aprem_fin }} - {%- endif -%} - {%- elif j.sortie_cible -%} - {{ j.sortie_cible }} - {%- else -%} - - {%- endif %} + {%- if j.cible_matin or j.cible_aprem -%} + {%- if j.cible_matin -%}M {{ j.cible_matin }}{%- endif -%} + {%- if j.cible_matin and j.cible_aprem %} {% endif -%} + {%- if j.cible_aprem -%}A {{ j.cible_aprem }}{%- endif -%} {%- else -%} {%- endif %} diff --git a/app/templates/_soldes.html b/app/templates/_soldes.html index 449c8ae..5d26d21 100644 --- a/app/templates/_soldes.html +++ b/app/templates/_soldes.html @@ -15,4 +15,19 @@ + {% if result.slots_restants > 0 %} +
+ {% if result.extra_min > 0 %} + Reste à faire : {{ result.reste }} + sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }} + — à rattraper : +{{ result.extra }} (~+{{ result.moyenne_extra }} chacune) + {% elif result.reste_min > 0 %} + Reste à faire : {{ result.reste }} + sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }} + — le rythme habituel suffit, rien à rattraper. + {% else %} + Vous êtes à jour ou en avance — rien à rattraper cette semaine. + {% endif %} +
+ {% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index ecb1e42..144d9df 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -162,6 +162,16 @@ .solde-num.neg { color: var(--neg); } .solde-num.neutral { color: var(--text); } + .reste-info { + padding: .6rem 1.25rem; + border-top: 1px solid var(--rule); + background: var(--ground); + font-size: 12px; + color: var(--muted); + } + .reste-info strong { color: var(--text); font-family: var(--mono); font-weight: 500; } + .reste-info strong.reste-extra { color: var(--neg); } + /* ── Table ── */ .table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; } table { diff --git a/app/templates/semaine.html b/app/templates/semaine.html index fbc435f..e20329f 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -218,12 +218,15 @@ } function getCible() { - var el = document.querySelector('#calc-' + todayStr() + '-cible .cible-num'); - if (!el) return null; - var txt = el.textContent.trim(); - // Formats: "17:13" ou "16:15→17:00" — on veut l'heure de DÉPART (dernière) - var time = txt.indexOf('→') >= 0 ? txt.split('→').pop().trim() : txt; - return /^\d{2}:\d{2}$/.test(time) ? time : null; + var wrap = document.getElementById('calc-' + todayStr() + '-cible'); + if (!wrap) return null; + var nums = wrap.querySelectorAll('.cible-num'); + if (!nums.length) return null; + // Plusieurs cibles possibles (matin + après-midi) : on alerte sur la dernière, + // qui correspond au départ effectif de la journée. + var txt = nums[nums.length - 1].textContent.trim(); + var m = txt.match(/(\d{2}:\d{2})$/); + return m ? m[1] : null; } function nowMin() { diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py index 0f49059..da3ed76 100644 --- a/app/tests/test_calcul.py +++ b/app/tests/test_calcul.py @@ -95,12 +95,14 @@ def test_compute_week_cumul_visible_seulement_si_chaine_ininterrompue(): assert jours[4]["cumul_visible"] is False -def test_compute_week_sortie_cible_journee_en_cours(freeze_today): +def test_compute_week_cible_repartie_sur_seule_demi_journee_restante(freeze_today): """Régression: capture exactement les seuils 9h/12h/13h30/17h codés en dur. Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé - seulement le matin 09:00-12:00 (180 min). Le calcul doit produire une - cible de sortie et d'entrée dérivées des seuils métier actuels. + seulement le matin 09:00-12:00 (180 min, sous les 234 min nominales de la + demi-journée). Comme il ne reste qu'une seule demi-journée non traitée + (l'après-midi de vendredi), tout le manque de la semaine s'y retrouve — + même résultat que l'ancien algorithme "cliff-edge" dans ce cas précis. """ dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] pointages = _semaine_complete(dates_completes) @@ -109,32 +111,50 @@ def test_compute_week_sortie_cible_journee_en_cours(freeze_today): result = compute_week(pointages, [], HEURES_JOUR_MIN) vendredi = result["jours"][4] - assert vendredi["sortie_cible"] == "18:18" - assert vendredi["entree_cible"] == "12:12" + assert vendredi["cible_matin"] is None # matin déjà pointé (entrée+sortie), plus rien à y faire + assert vendredi["cible_aprem"] == "18:18" -def test_compute_week_jour_futur_non_entame_pas_de_cible(freeze_today): - """Un jour strictement après "aujourd'hui" et pas encore entamé n'a pas de cible.""" - dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"] +def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_today): + """Un jour futur non entamé fait maintenant partie de la répartition (spread sur la semaine).""" + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] pointages = _semaine_complete(dates_completes) + pointages.append(_pointage("2026-07-17", matin_entree="09:00", matin_sortie="12:00")) pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé result = compute_week(pointages, [], HEURES_JOUR_MIN) + # Le manque (288 min) est maintenant réparti entre l'après-midi de vendredi et + # les deux demi-journées de lundi, au lieu d'être entièrement dumpé sur vendredi soir. + vendredi = result["jours"][4] lundi_suivant = result["jours"][5] - assert lundi_suivant["entree_cible"] is None - assert lundi_suivant["sortie_cible"] is None + assert vendredi["cible_aprem"] is not None + assert lundi_suivant["cible_matin"] is not None or lundi_suivant["cible_aprem"] is not None -def test_compute_week_jour_courant_non_entame_calcule_quand_meme_une_cible(freeze_today): - """"Aujourd'hui" sans rien pointé n'est PAS traité comme futur (date > today est faux à égalité).""" +def test_compute_week_jour_futur_a_egalite_pas_de_cible(freeze_today): + """Quand le rythme normal des demi-journées restantes suffit déjà, aucune cible n'est affichée.""" + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"] + pointages = _semaine_complete(dates_completes) + pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé — mais la semaine est déjà à l'équilibre + + result = compute_week(pointages, [], HEURES_JOUR_MIN) + lundi_suivant = result["jours"][5] + assert lundi_suivant["cible_matin"] is None + assert lundi_suivant["cible_aprem"] is None + + +def test_compute_week_jour_courant_non_entame_rythme_normal_suffit(freeze_today): + """"Aujourd'hui" sans rien pointé n'est pas traité comme un jour futur figé : ses deux + demi-journées entrent dans la répartition. Ici le manque (468 min) correspond exactement + au nominal des deux demi-journées restantes : le rythme normal suffit, pas de cible.""" dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] pointages = _semaine_complete(dates_completes) pointages.append(_pointage("2026-07-17")) result = compute_week(pointages, [], HEURES_JOUR_MIN) vendredi = result["jours"][4] - assert vendredi["sortie_cible"] == "21:18" - assert vendredi["entree_cible"] is None + assert vendredi["cible_matin"] is None + assert vendredi["cible_aprem"] is None def test_compute_week_plages_personnalisees(freeze_today): @@ -147,9 +167,11 @@ def test_compute_week_plages_personnalisees(freeze_today): heures_jour = (13 * 60 - 8 * 60) + (18 * 60 - 14 * 60) # 540 = journée complète sous ce schéma dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] pointages = [_pointage(d, "08:00", "13:00", "14:00", "18:00") for d in dates_completes] - pointages.append(_pointage("2026-07-17", "08:00", "13:00")) # matin complet, aprem pas commencé + # Matin écourté (240 min au lieu des 300 nominales) : le manque doit être + # rattrapé sur l'unique demi-journée restante, avec les seuils personnalisés. + pointages.append(_pointage("2026-07-17", "08:00", "12:00")) result = compute_week(pointages, [], heures_jour, plages) vendredi = result["jours"][4] - assert vendredi["sortie_cible"] == "18:00" - assert vendredi["entree_cible"] is None + assert vendredi["cible_matin"] is None + assert vendredi["cible_aprem"] == "19:00"