diff --git a/README.md b/README.md
index e675693..11940d5 100644
--- a/README.md
+++ b/README.md
@@ -228,12 +228,16 @@ arrondie au quart supérieur : fin 23:30 → retour 10:30 le lendemain ; 22:39
23:49 (fin comptée 23:54) → retour 11:00 ; fin 02:00 → retour 13:00 le jour même.
Un repos qui se termine avant l'ouverture ne contraint rien.
-À partir de cette heure de retour, tout ce qui la précède sur la journée quitte le
-décompte de la semaine :
+À partir de cette heure de retour, la présence obligatoire couverte par le repos
+est **comptée comme effectuée** :
-- la présence obligatoire avant l'heure de retour est retirée des heures dues
- (ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ;
-- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ;
+- les heures de présence obligatoire avant l'heure de retour sont créditées au
+ **Travaillé** du jour, affichées sous le total par un badge explicite
+ (« +2h00 repos », détail des plages au survol — ex. retour 11:00 → la plage
+ 09:00-11:00 compte comme faite) ; les heures dues restent complètes, le solde
+ est le même que lorsqu'on retirait ces heures des dues ;
+- une présence pointée avant l'heure de retour ne compte pas dans le travaillé
+ (seul le crédit de présence s'y ajoute) ;
- les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin
décalée d'autant, sans avertissement de retard) ;
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
diff --git a/app/calcul.py b/app/calcul.py
index 9a5a31e..f447ec2 100644
--- a/app/calcul.py
+++ b/app/calcul.py
@@ -108,8 +108,22 @@ def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, fer
return matin_ex, aprem_ex
+def couvert_detail(plages: dict, retour_min: int | None, matin_ex: int, aprem_ex: int) -> str:
+ """Description des plages de présence obligatoire couvertes par le repos de
+ 11h (affichée avec le crédit d'heures effectuées). N'apparaît que couvert > 0,
+ donc retour_min défini."""
+ parts = []
+ if matin_ex:
+ fin = minutes_to_time(min(hhmm_to_minutes(plages["matin_fin"]), retour_min or 0))
+ parts.append(f"matin {plages['matin_debut']}→{fin}")
+ if aprem_ex:
+ fin = minutes_to_time(min(hhmm_to_minutes(plages["aprem_fin"]), retour_min or 0))
+ parts.append(f"après-midi {plages['aprem_debut']}→{fin}")
+ return ", ".join(parts)
+
+
def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int,
- ferie: bool = False) -> tuple[int, int]:
+ ferie: bool = False) -> tuple[int, int]:
"""Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et
exclusion liée à l'heure de retour."""
if ferie or "jour" in conges_date:
@@ -470,9 +484,18 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
forfait = forfait_as_min if p["date"] in astreintes else 0
# Le forfait d'astreinte ne compte jamais dans les heures travaillées :
# il est suivi à part dans le récapitulatif d'astreinte.
- travaille = heures_travaillees(p, retour_min)
- du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min, jour_ferie)
- du_full = du + matin_ex + aprem_ex
+ #
+ # Les heures de présence obligatoire couvertes par le repos de 11h
+ # (matin_ex + aprem_ex) sont comptées comme effectuées : créditées au
+ # travaillé du jour, les heures dues restent complètes. Le solde est le
+ # même qu'en soustrayant couvert du dû, mais le calcul est explicite :
+ # travaillé = pointé + couvert, dû = nominal complet. Sans heures dues
+ # (week-end, férié, congé) il n'y a rien à créditer, et le crédit ne
+ # dépasse jamais le nominal du jour.
+ du_full = heures_dues(p["date"], conges, heures_jour_min, plages, None, jour_ferie)
+ couvert = min(matin_ex + aprem_ex, du_full)
+ travaille = heures_travaillees(p, retour_min) + couvert
+ du = du_full
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = travaille - du
total_travaille += travaille
@@ -492,6 +515,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"du_full_min": du_full,
"matin_excl_min": matin_ex,
"aprem_excl_min": aprem_ex,
+ "couvert_min": couvert,
+ "couvert_h": minutes_to_hhmm(couvert) if couvert else "",
+ "couvert_detail": couvert_detail(plages, retour_min, matin_ex, aprem_ex) if couvert else "",
"matin_nominal_min": matin_nom,
"aprem_nominal_min": aprem_nom,
"astreinte": p["date"] in astreintes,
diff --git a/app/main.py b/app/main.py
index aa2a685..864354a 100644
--- a/app/main.py
+++ b/app/main.py
@@ -33,7 +33,7 @@ from models import (
)
from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
- heures_travaillees, jour_astreinte_qualifie, exclusion_retour,
+ heures_travaillees, jour_astreinte_qualifie, exclusion_retour, couvert_detail,
nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour,
)
from stats import compute_all_stats
@@ -351,9 +351,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \
if date in astreintes else 0
- trav = heures_travaillees(p, retour_min) # forfait d'astreinte : hors heures travaillées
- du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie)
- du_full = du + matin_ex + aprem_ex
+ # Les heures de présence obligatoire couvertes par le repos de 11h sont
+ # comptées comme effectuées : créditées au travaillé, dues complètes (le
+ # forfait d'astreinte, lui, reste hors heures travaillées). Sans heures
+ # dues (week-end, férié, congé), rien à créditer.
+ du_full = heures_dues(date, conges, h_jour, plages, None, jour_ferie)
+ couvert = min(matin_ex + aprem_ex, du_full)
+ trav = heures_travaillees(p, retour_min) + couvert
+ du = du_full
matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = trav - du
# Décompte astreinte du jour (compteur séparé, hors heures de semaine) :
@@ -373,6 +378,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,
"matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex,
+ "couvert_min": couvert,
+ "couvert_h": minutes_to_hhmm(couvert) if couvert else "",
+ "couvert_detail": couvert_detail(plages, retour_min, matin_ex, aprem_ex) if couvert else "",
"matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom,
"astreinte": date in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes),
@@ -385,6 +393,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
def _oob_calc(date, jour):
trav_inner = f'{"—" if not jour["travaille_min"] else _e(jour["travaille"])}'
+ if jour.get("couvert_min"):
+ trav_inner += (f'+{_e(jour["couvert_h"])} repos')
if jour["travaille_min"]:
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
sign = "+" if jour["delta_min"] > 0 else ""
@@ -539,6 +550,9 @@ def _as_block_html(date, jour):
if jour.get("retour_min") is not None:
hint = (f''
f'retour ≥ {_e(jour["retour"])}')
+ if jour.get("couvert_min"):
+ hint += (f''
+ f'+{_e(jour["couvert_h"])} de présence comptées')
if jour.get("forfait_min"):
hint += (f''
f'forfait {_e(jour["forfait_h"])}')
@@ -843,9 +857,12 @@ def save_pointage(
# au lieu de les écraser à vide. Les paires début/fin invalides sont ignorées.
if jour_astreinte_qualifie(date, set(load_astreintes_effectives(user_id))):
events = []
- for deb, fin, desc in zip(astreinte_debut, astreinte_fin, astreinte_desc):
+ # Les descriptions sont optionnelles : sans elles (POST programmatique),
+ # zip s'arrêterait à la liste la plus courte et perdrait les événements.
+ for i, (deb, fin) in enumerate(zip(astreinte_debut, astreinte_fin)):
deb, fin = clean(deb), clean(fin)
if deb and fin:
+ desc = astreinte_desc[i] if i < len(astreinte_desc) else ""
events.append({"debut": deb, "fin": fin,
"desc": (desc or "").strip()[:200] or None})
else:
diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html
index fe5e011..9b3ef3a 100644
--- a/app/templates/_ligne.html
+++ b/app/templates/_ligne.html
@@ -52,6 +52,7 @@
{{ j.travaille if j.travaille_min else '—' }}
+ {% if j.couvert_min %}+{{ j.couvert_h }} repos{% endif %}
|
@@ -146,6 +147,7 @@
▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
{% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %}
+ {% if j.couvert_min %}+{{ j.couvert_h }} de présence comptées{% endif %}
{% if j.forfait_min %}forfait {{ j.forfait_h }}{% endif %}
{% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
diff --git a/app/templates/base.html b/app/templates/base.html
index 7c3a798..6e65e4d 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -499,6 +499,20 @@
.col-calc { text-align: right; padding-left: .5rem; }
.calc-num { font-family: var(--mono); font-size: 13px; color: var(--muted); }
+ /* Crédit d'heures de présence couvertes par le repos de 11h (retour décalé
+ après astreinte) : affiché sous le total travaillé, détail au survol. */
+ .calc-couvert {
+ display: block;
+ font-family: var(--mono);
+ font-size: 10px;
+ color: var(--accent-2);
+ margin-top: .12rem;
+ margin-left: auto;
+ width: fit-content;
+ cursor: help;
+ border-bottom: 1px dotted rgba(184,106,8,.45);
+ white-space: normal;
+ }
.delta-pos { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--pos); }
.delta-neg { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--neg); }
.delta-zero{ font-family: var(--mono); font-size: 13px; color: var(--rule); }
diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py
index ad8af7e..eb39c4e 100644
--- a/app/tests/test_astreinte.py
+++ b/app/tests/test_astreinte.py
@@ -311,7 +311,8 @@ def test_nominaux_sans_exclusion_reste_la_moitié():
def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
"""Lundi et jeudi complets, mardi astreinte avec event 23:00→01:00, mercredi
vide : les heures d'astreinte n'entrent jamais dans total_travaille/total_du,
- seulement dans result["astreinte"]."""
+ seulement dans result["astreinte"] ; seules les 3h de présence obligatoire du
+ matin couvertes par le repos (retour 12:00) sont créditées comme effectuées."""
pointages = [
_pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54"),
_pointage("2026-07-14", astreinte_events=[{"debut": "23:00", "fin": "01:00"}]),
@@ -321,8 +322,9 @@ def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
- # seul le pointé compte : 2 × 468 = 15h36, astreinte exclue
- assert result["total_travaille"] == "15h36"
+ # pointé (2 × 7h48) + 3h de présence couvertes créditées ; astreinte exclue
+ assert result["total_travaille"] == "18h36"
+ assert result["jours"][2]["couvert_min"] == 180
ast = result["astreinte"]
assert ast["actif"] is True
@@ -341,8 +343,8 @@ def test_compute_week_decompte_absent_des_heures_de_semaine(freeze_today):
def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today):
"""Mardi astreinte, event 01:00→02:00 saisi sur le mercredi (lendemain) :
- retour mercredi 13:00, présence du matin retirée de la semaine, aucune cible
- matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
+ retour mercredi 13:00, présence du matin (3h) créditée comme effectuée,
+ aucune cible matin, après-midi 13:30→18:18 (nominal 288 = 234 + report)."""
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"),
@@ -353,11 +355,16 @@ def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
mercredi = result["jours"][2]
- assert mercredi["du_min"] == 468 - 180 # matin 09:00→12:00 exclu par le retour 13:00
+ # dues complètes, présence matin 09:00→12:00 couverte créditée au travaillé
+ assert mercredi["du_min"] == 468
+ assert mercredi["couvert_min"] == 180
+ assert mercredi["travaille_min"] == 180
+ assert mercredi["couvert_detail"] == "matin 09:00→12:00"
assert mercredi["retour"] == "13:00" # 02:00 + 11h
assert mercredi["cible_matin"] is None
assert mercredi["cible_aprem"] == "18:18"
- assert result["total_du"] == "36h00" # 39h - 3h de présence retirées
+ assert result["total_du"] == "39h00" # dues nominales complètes
+ assert result["total_travaille"] == "18h36" # 2 × 7h48 + 3h de présence créditée
def test_compute_week_nuit_finissant_au_matin(freeze_today):
@@ -375,8 +382,10 @@ def test_compute_week_nuit_finissant_au_matin(freeze_today):
mercredi = result["jours"][2]
assert mercredi["retour"] == "17:00"
- # matin (180) + 14:00→17:00 (180) exclus → dû = 108
- assert mercredi["du_min"] == 468 - 360
+ # matin (180) + 14:00→17:00 (180) couverts → crédités 360, dues complètes 468
+ assert mercredi["du_min"] == 468
+ assert mercredi["couvert_min"] == 360
+ assert mercredi["couvert_h"] == "6h00"
assert mercredi["cible_matin"] is None
# aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48
assert mercredi["cible_aprem_entree"] == "17:00"
@@ -386,6 +395,32 @@ def test_compute_week_nuit_finissant_au_matin(freeze_today):
assert result["astreinte"]["total_pondere_min"] == 630
+def test_compute_week_presence_couvertes_comptees_effectuees(freeze_today):
+ """Retour décalé à 11:00 (event mardi 22:39→23:49) : les 2h de présence
+ obligatoire du matin (09:00→11:00) sont comptées comme effectuées —
+ créditées au travaillé, dues complètes, Δ inchangé par rapport à
+ l'ancienne soustraction des dues."""
+ 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",
+ astreinte_events=[{"debut": "22:39", "fin": "23:49"}]),
+ _pointage("2026-07-15", "11:00", "12:00", "14:00", "17:00"),
+ _pointage("2026-07-16"),
+ _pointage("2026-07-17"),
+ ]
+ result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"])
+ mercredi = result["jours"][2]
+
+ assert mercredi["retour"] == "11:00"
+ assert mercredi["couvert_min"] == 120 # présence 09:00→11:00
+ assert mercredi["couvert_h"] == "2h00"
+ assert mercredi["couvert_detail"] == "matin 09:00→11:00"
+ assert mercredi["travaille_min"] == 240 + 120 # pointé (1h + 3h) + couvert
+ assert mercredi["travaille"] == "6h00"
+ assert mercredi["du_min"] == 468 # dues complètes
+ assert mercredi["delta_min"] == 360 - 468
+
+
def test_compute_week_event_ignore_hors_jour_qualifie(freeze_today):
"""Un event saisi un jour non qualifié (pas d'astreinte) ne change rien au
décompte ni au décompte d'astreinte."""
@@ -447,7 +482,8 @@ def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_t
assert mercredi["matin_nominal_min"] == 0
assert mercredi["is_complete"] is True
assert mercredi["cumul_visible"] is True
- assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées
+ assert mercredi["du_min"] == 360 # dues complètes du jour (6h)
+ assert mercredi["couvert_min"] == 180 # 3h de présence matin couvertes, créditées
# ── Stockage des astreintes ───────────────────────────────────────────────────
diff --git a/app/tests/test_ui_refonte.py b/app/tests/test_ui_refonte.py
index 22f2479..36b93bc 100644
--- a/app/tests/test_ui_refonte.py
+++ b/app/tests/test_ui_refonte.py
@@ -76,3 +76,23 @@ def test_bascule_congen_regenere_le_menu_d_etat(monkeypatch):
# Le swap OOB régénère le bouton avec la pastille MA active
assert 'cg-chip st-ma">MA<' in r.text
assert 'id="cg-btn-' + TODAY + '"' in r.text
+
+
+def test_retour_decale_affiche_le_credit_de_presence(monkeypatch):
+ """Retour mercredi 11:00 (event mardi 22:39→23:49) : la colonne Travaillé
+ crédite les 2h de présence couvertes, avec badge et détail explicites."""
+ monkeypatch.setattr(main, "datetime", _FrozenDatetime)
+ cookies = _login("u-ui-credit")
+ client.post("/astreinte/2026-07-14/toggle", cookies=cookies, follow_redirects=False)
+ r = client.post("/pointage/2026-07-14", cookies=cookies,
+ data={"astreinte_debut": ["22:39"], "astreinte_fin": ["23:49"]},
+ headers={"HX-Request": "true"}, follow_redirects=False)
+ assert r.status_code == 200
+
+ # Le crédit s'affiche sur la ligne du lendemain (jour du retour décalé)
+ r = client.get("/semaine/2026/29", cookies=cookies, follow_redirects=False)
+ assert r.status_code == 200
+ assert 'class="calc-couvert"' in r.text
+ assert "+2h00 repos" in r.text
+ assert "matin 09:00→11:00" in r.text # détail des plages couvertes
+ assert "de présence comptées" in r.text # rappel dans le bloc astreinte