Répartit les préconisations de rattrapage sur les demi-journées restantes de la semaine

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.
This commit is contained in:
Antoine
2026-07-17 23:02:07 +02:00
parent c2d7d0e3ac
commit e6b5173018
7 changed files with 138 additions and 99 deletions

View File

@ -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"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"])
aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"])
pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"]) pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"])
matin_window = matin_fin_min - matin_debut_min
total_travaille = 0 total_travaille = 0
total_du = 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 cumul_chain_ok = False
jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok 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") today = datetime.today().strftime("%Y-%m-%d")
now_min = datetime.today().hour * 60 + datetime.today().minute restant = total_du - total_travaille
worked_before = 0 slots = [] # (jour, "matin"|"aprem", nominal_min)
for i, jour in enumerate(jours): for jour in jours:
if jour["date"] < today:
continue # jour passé : impossible d'y rattraper quoi que ce soit
cg = jour["conge"] cg = jour["conge"]
# Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis du = jour["du_min"]
is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree") if du == 0:
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"]
continue 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:]) extra_total = max(0, restant - sum(s[2] for s in slots)) if slots else 0
needed_today = max(0, total_du - worked_before - du_remaining) slots_restants = len(slots)
worked_morning = 0 if slots_restants:
if jour.get("matin_entree") and jour.get("matin_sortie"): base, remainder = divmod(extra_total, slots_restants)
worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) for i, (jour, half, nominal) in enumerate(slots):
elif jour.get("matin_entree") and jour["date"] == today: adj = base + (1 if i < remainder else 0)
# Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage duree_cible = nominal + adj
# (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée). if adj <= 0:
entree_min = hhmm_to_minutes(jour["matin_entree"]) continue # rythme normal suffit pour cette demi-journée, rien à signaler
elapsed = max(0, now_min - entree_min) if half == "matin":
projected_to_fin_matin = max(0, matin_fin_min - entree_min) debut_min = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else matin_debut_min
worked_morning = max(elapsed, projected_to_fin_matin) sortie_min = max(debut_min + duree_cible, matin_fin_min)
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: else:
jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" 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
entree_cible = None reste_min = max(0, restant)
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"]
return { return {
"jours": jours, "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), "total_du": minutes_to_hhmm(total_du),
"solde": minutes_to_hhmm(total_travaille - total_du), "solde": minutes_to_hhmm(total_travaille - total_du),
"solde_min": 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),
} }

View File

@ -154,7 +154,7 @@ def _oob_calc(date, jour):
) )
def _oob_week_extras(result, plages): def _oob_week_extras(result):
html = "" html = ""
for jour in result["jours"]: for jour in result["jours"]:
date = jour["date"] date = jour["date"]
@ -169,14 +169,12 @@ def _oob_week_extras(result, plages):
else: else:
cumul_inner = '<span class="delta-zero">—</span>' cumul_inner = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>' html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
cible = None parts = []
if not jour.get("is_complete"): if jour.get("cible_matin"):
if jour.get("entree_cible"): parts.append(f'<span class="cible-num">M {jour["cible_matin"]}</span>')
suffix = f'{plages["matin_fin"]}' if "aprem" in jour.get("conge", set()) else f'{plages["aprem_fin"]}' if jour.get("cible_aprem"):
cible = f'{jour["entree_cible"]}{suffix}' parts.append(f'<span class="cible-num">A {jour["cible_aprem"]}</span>')
elif jour.get("sortie_cible"): cible_inner = " ".join(parts) if parts else '<span class="delta-zero">—</span>'
cible = jour.get("sortie_cible")
cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>' html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html 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) _oob_time_cells(date, jour, plages)
+ btns_html + btns_html
+ _oob_calc(date, jour) + _oob_calc(date, jour)
+ _oob_week_extras(soldes_ctx["result"], plages) + _oob_week_extras(soldes_ctx["result"])
+ soldes_html + soldes_html
) )
@ -250,7 +248,7 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
return HTMLResponse( return HTMLResponse(
_oob_calc(date, jour) _oob_calc(date, jour)
+ _oob_time_cells(date, jour, plages) + _oob_time_cells(date, jour, plages)
+ _oob_week_extras(soldes_ctx["result"], plages) + _oob_week_extras(soldes_ctx["result"])
+ soldes_html + soldes_html
) )

View File

@ -69,18 +69,10 @@
<td class="col-cible"> <td class="col-cible">
<span id="calc-{{ j.date }}-cible"> <span id="calc-{{ j.date }}-cible">
{%- if not j.is_complete -%} {%- if j.cible_matin or j.cible_aprem -%}
{%- if j.entree_cible -%} {%- if j.cible_matin -%}<span class="cible-num">M {{ j.cible_matin }}</span>{%- endif -%}
{%- if 'aprem' in j.conge -%} {%- if j.cible_matin and j.cible_aprem %} {% endif -%}
<span class="cible-num">{{ j.entree_cible }}→{{ plages.matin_fin }}</span> {%- if j.cible_aprem -%}<span class="cible-num">A {{ j.cible_aprem }}</span>{%- endif -%}
{%- else -%}
<span class="cible-num">{{ j.entree_cible }}→{{ plages.aprem_fin }}</span>
{%- endif -%}
{%- elif j.sortie_cible -%}
<span class="cible-num">{{ j.sortie_cible }}</span>
{%- else -%}
<span class="delta-zero"></span>
{%- endif %}
{%- else -%} {%- else -%}
<span class="delta-zero"></span> <span class="delta-zero"></span>
{%- endif %} {%- endif %}

View File

@ -15,4 +15,19 @@
</div> </div>
</div> </div>
</div> </div>
{% if result.slots_restants > 0 %}
<div class="reste-info">
{% if result.extra_min > 0 %}
Reste à faire : <strong>{{ result.reste }}</strong>
sur {{ result.slots_restants }} demi-journée{{ 's' if result.slots_restants > 1 else '' }} restante{{ 's' if result.slots_restants > 1 else '' }}
— à rattraper : <strong class="reste-extra">+{{ result.extra }}</strong> (~+{{ result.moyenne_extra }} chacune)
{% elif result.reste_min > 0 %}
Reste à faire : <strong>{{ result.reste }}</strong>
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 %}
</div>
{% endif %}
</div> </div>

View File

@ -162,6 +162,16 @@
.solde-num.neg { color: var(--neg); } .solde-num.neg { color: var(--neg); }
.solde-num.neutral { color: var(--text); } .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 ── */
.table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; } .table-section { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { table {

View File

@ -218,12 +218,15 @@
} }
function getCible() { function getCible() {
var el = document.querySelector('#calc-' + todayStr() + '-cible .cible-num'); var wrap = document.getElementById('calc-' + todayStr() + '-cible');
if (!el) return null; if (!wrap) return null;
var txt = el.textContent.trim(); var nums = wrap.querySelectorAll('.cible-num');
// Formats: "17:13" ou "16:15→17:00" — on veut l'heure de DÉPART (dernière) if (!nums.length) return null;
var time = txt.indexOf('→') >= 0 ? txt.split('→').pop().trim() : txt; // Plusieurs cibles possibles (matin + après-midi) : on alerte sur la dernière,
return /^\d{2}:\d{2}$/.test(time) ? time : null; // 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() { function nowMin() {

View File

@ -95,12 +95,14 @@ def test_compute_week_cumul_visible_seulement_si_chaine_ininterrompue():
assert jours[4]["cumul_visible"] is False 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. """Régression: capture exactement les seuils 9h/12h/13h30/17h codés en dur.
Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé
seulement le matin 09:00-12:00 (180 min). Le calcul doit produire une seulement le matin 09:00-12:00 (180 min, sous les 234 min nominales de la
cible de sortie et d'entrée dérivées des seuils métier actuels. 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"] dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
pointages = _semaine_complete(dates_completes) 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) result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert vendredi["sortie_cible"] == "18:18" assert vendredi["cible_matin"] is None # matin déjà pointé (entrée+sortie), plus rien à y faire
assert vendredi["entree_cible"] == "12:12" assert vendredi["cible_aprem"] == "18:18"
def test_compute_week_jour_futur_non_entame_pas_de_cible(freeze_today): def test_compute_week_jour_futur_non_entame_recoit_une_cible_repartie(freeze_today):
"""Un jour strictement après "aujourd'hui" et pas encore entamé n'a pas de cible.""" """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", "2026-07-17"] dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
pointages = _semaine_complete(dates_completes) 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é pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé
result = compute_week(pointages, [], HEURES_JOUR_MIN) 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] lundi_suivant = result["jours"][5]
assert lundi_suivant["entree_cible"] is None assert vendredi["cible_aprem"] is not None
assert lundi_suivant["sortie_cible"] is 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): def test_compute_week_jour_futur_a_egalite_pas_de_cible(freeze_today):
""""Aujourd'hui" sans rien pointé n'est PAS traité comme futur (date > today est faux à égalité).""" """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"] dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"]
pointages = _semaine_complete(dates_completes) pointages = _semaine_complete(dates_completes)
pointages.append(_pointage("2026-07-17")) pointages.append(_pointage("2026-07-17"))
result = compute_week(pointages, [], HEURES_JOUR_MIN) result = compute_week(pointages, [], HEURES_JOUR_MIN)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert vendredi["sortie_cible"] == "21:18" assert vendredi["cible_matin"] is None
assert vendredi["entree_cible"] is None assert vendredi["cible_aprem"] is None
def test_compute_week_plages_personnalisees(freeze_today): 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 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"] 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 = [_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) result = compute_week(pointages, [], heures_jour, plages)
vendredi = result["jours"][4] vendredi = result["jours"][4]
assert vendredi["sortie_cible"] == "18:00" assert vendredi["cible_matin"] is None
assert vendredi["entree_cible"] is None assert vendredi["cible_aprem"] == "19:00"