Astreinte : repos hebdomadaire de 35h en option (7 jours glissants)

Un week-end d'astreinte fragmenté peut ne contenir aucun trou de 35h
consécutives sans activité : le retour est alors repoussé à dernière
activité + 35h, et toute nouvelle intervention redémarre le décompte.
Désactivé par défaut, activable par compte dans Réglages ; la journée
couverte est créditée comme pour les 11h quotidiennes.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-09 21:49:19 +02:00
parent 77ae115c4f
commit f6fef650a7
6 changed files with 471 additions and 17 deletions

View File

@ -242,6 +242,30 @@ est **comptée comme effectuée** :
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.
### Repos hebdomadaire de 35h (option)
La loi exige en plus, **une fois par période de 7 jours glissants**, un repos de
**35h consécutives** (24h de repos hebdomadaire + 11h de repos quotidien). Une
semaine normale le satisfait d'elle-même — le week-end offre le trou nécessaire.
Un **week-end d'astreinte fragmenté** par plusieurs interventions, lui, peut ne
plus contenir aucun trou de 35h.
La prise en compte est une **préférence personnelle** (Réglages → « Repos
hebdomadaire de 35h », désactivée par défaut). Activée :
- quand aucune période de 35h sans activité (pointages + interventions) n'existe
dans les 7 derniers jours, l'heure de retour devient **dernière activité +
35h** — à la place des 11h si elle est plus tardive ;
- **toute nouvelle perturbation avant cette échéance redémarre le décompte** à
zéro (le retour se recalcule depuis la nouvelle dernière activité) ;
- un repos qui déborde sur le(s) jour(s) suivant(s) affiche le jour visé
(« retour ≥ Mar 05:00 ») et couvre la journée de la même façon que les 11h
(présence obligatoire créditée, rien à planifier).
La fenêtre glissante puise aussi dans la semaine précédente : les jours
manquants y sont lus automatiquement ; sans eux, le début de la fenêtre compte
comme repos (jamais de faux positif).
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
sont décomptées à part — la **durée réelle** de chaque intervention est **arrondie
au quart d'heure supérieur** (2, 5 ou 12 min réelles → 15 min ; 22 ou 28 → 30 min),

View File

@ -11,9 +11,11 @@ DEFAULT_PLAGES = {
"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)
"astreinte_active": True, # préférence personnelle : ce compte gère-t-il les astreintes ?
"repos_hebdo_active": False, # préférence personnelle : respecter le repos hebdomadaire de 35h (7 jours glissants)
}
REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau
REPOS_HEBDO_MIN = 35 * 60 # repos hebdomadaire : 24h + 11h consécutives, une fois par 7 jours glissants
PAS_ASTREINTE_MIN = 15 # granularité du décompte astreinte : durée réelle arrondie au quart d'heure supérieur
ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain
@ -381,6 +383,118 @@ dernière activité d'astreinte.
return best if best > hhmm_to_minutes(plages["matin_debut"]) else None
# ── Repos hebdomadaire : 35h consécutives par 7 jours glissants ────────────────
def _activites_repos(p_by_date: dict, astreintes, jour0: date) -> list[tuple[int, int]]:
"""Intervalles d'activité (minutes absolues depuis _EPOCH_DATE) autour du
jour : créneaux pointés matin/après-midi, et interventions d'astreinte des
jours qualifiés — la fin portée à la durée arrondie au quart d'heure
supérieur, à cheval sur minuit le cas échéant. Une demi-journée entamée
aujourd'hui (entrée sans sortie) court jusqu'à maintenant. Intervalles
fusionnés et triés."""
today = datetime.today()
today_str = today.strftime("%Y-%m-%d")
now_min = today.hour * 60 + today.minute
intervals = []
for i in range(-7, 2):
d = jour0 + timedelta(days=i)
row = p_by_date.get(d.isoformat())
if not row:
continue
base = (d - _EPOCH_DATE).days * 1440
for deb_k, fin_k in (("matin_entree", "matin_sortie"), ("aprem_entree", "aprem_sortie")):
deb, fin = row.get(deb_k), row.get(fin_k)
if deb and fin:
intervals.append((base + hhmm_to_minutes(deb), base + hhmm_to_minutes(fin)))
elif deb and d.isoformat() == today_str:
intervals.append((base + hhmm_to_minutes(deb), base + now_min))
if jour_astreinte_qualifie(d.isoformat(), astreintes):
for ev in row.get("astreinte_events") or []:
deb, fin = ev.get("debut"), ev.get("fin")
if not (deb and fin):
continue
deb_m, fin_m = hhmm_to_minutes(deb), hhmm_to_minutes(fin)
duree = fin_m - deb_m if fin_m >= deb_m else 24 * 60 - deb_m + fin_m
fin_c = -(-duree // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
intervals.append((base + deb_m, base + deb_m + fin_c))
intervals.sort()
fusionnes = []
for deb, fin in intervals:
if fin <= deb:
continue
if fusionnes and deb <= fusionnes[-1][1]:
if fin > fusionnes[-1][1]:
fusionnes[-1][1] = fin
else:
fusionnes.append([deb, fin])
return [(deb, fin) for deb, fin in fusionnes]
def retour_repos_hebdo(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
"""Repos hebdomadaire : 35h consécutives (24h de repos hebdomadaire + 11h de
repos quotidien) à obtenir une fois par période de 7 jours glissants. Une
semaine normale le satisfait d'elle-même — le week-end offre le trou
nécessaire ; un week-end d'astreinte fragmenté par plusieurs interventions
peut ne plus contenir aucun trou de 35h : le retour au bureau est alors
repoussé à dernière activité + 35h, et toute nouvelle perturbation avant
cette échéance redémarre le décompte.
Sont comptées comme activité, sur la fenêtre [J-6 00:00, J+1 00:00[ : les
créneaux pointés et les interventions d'astreinte des jours qualifiés (une
demi-journée en cours aujourd'hui court jusqu'à maintenant). Un trou de 35h
ou plus ENTRE deux activités (ou avant la première) valide le repos ; le
trou final, après la dernière activité, ne compte pas : il ne deviendra
repos que si l'heure de retour le respecte.
Préférence personnelle (Réglages) : désactivée, ne renvoie jamais rien.
Renvoie des minutes depuis minuit du jour — possiblement ≥ 1440 quand le
repos déborde sur les jours suivants (le jour courant est alors entièrement
couvert) — ou None."""
if not plages.get("repos_hebdo_active"):
return None
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
jour0_abs = (jour0 - _EPOCH_DATE).days * 1440
fenetre_debut = jour0_abs - 6 * 1440
fenetre_fin = jour0_abs + 2 * 1440
actifs = [(max(deb, fenetre_debut), min(fin, fenetre_fin))
for deb, fin in _activites_repos(p_by_date, astreintes, jour0)
if fin > fenetre_debut and deb < fenetre_fin]
if not actifs:
return None
fin_prec = None
for deb, fin in actifs:
trou_debut = fenetre_debut if fin_prec is None else fin_prec
if deb - trou_debut >= REPOS_HEBDO_MIN:
return None # repos hebdomadaire déjà pris dans la fenêtre glissante
fin_prec = fin
repos_fin = -(-(actifs[-1][1] + REPOS_HEBDO_MIN) // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN
rel = repos_fin - jour0_abs
if rel < 1440 and rel <= hhmm_to_minutes(plages["matin_debut"]):
return None # le repos s'achève avant l'ouverture : aucune contrainte
return rel
def retour_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None:
"""Heure de retour au bureau la plus tôt admise : repos quotidien de 11h
(toujours) et, si activée, contrainte de repos hebdomadaire de 35h — la
plus tardive des deux l'emporte. Minutes depuis minuit du jour, ≥ 1440 si
le repos déborde sur les jours suivants, ou None."""
candidats = [r for r in (
retour_apres_repos(date_str, p_by_date, plages, astreintes),
retour_repos_hebdo(date_str, p_by_date, plages, astreintes),
) if r is not None]
return max(candidats) if candidats else None
def retour_txt(retour_min: int, date_str: str) -> str:
"""« 13:00 », ou « Mar 07:00 » quand le retour déborde sur un jour suivant."""
if retour_min < 1440:
return minutes_to_time(retour_min)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
d = jour0 + timedelta(days=retour_min // 1440)
return f"{JOURS_COURTS[d.weekday()]} {minutes_to_time(retour_min % 1440)}"
def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
"""Return worked minutes for a pointage row.
@ -443,8 +557,12 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
return max(0, du)
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None) -> dict:
"""Compute totals for a list of pointages (one week)."""
def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None, astreintes: list[str] | set[str] | None = None, facteurs: list[dict] | None = None, feries: list[str] | set[str] | None = None, contexte: list[dict] | None = None) -> dict:
"""Compute totals for a list of pointages (one week).
`contexte` : pointages des jours précédant la semaine (semaine ISO
précédente en pratique) — la fenêtre glissante de 7 jours du repos
hebdomadaire de 35h déborde sur la semaine d'avant."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
feries = set(feries or [])
@ -460,6 +578,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
total_du = 0
jours = []
p_by_date = {p["date"]: p for p in pointages}
p_ctx = {r["date"]: r for r in (contexte or []) if r.get("date")}
p_ctx.update(p_by_date)
# Cumuls astreinte de la semaine, suivis à part : les heures astreinte ne
# comptent ni dans travaille ni dans du (compteurs séparés pondérés).
as_jours = []
@ -471,9 +591,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
for p in pointages:
conges_date = {c["type"] for c in conges if c["date"] == p["date"]}
jour_ferie = p["date"] in feries
# Retour au bureau après 11h de repos, calculé depuis l'heure de fin de la
# dernière activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes)
# Retour au bureau après repos, calculé depuis l'heure de fin de la
# dernière activité d'astreinte (11h quotidiennes) et, si activée, la
# contrainte de repos hebdomadaire de 35h sur 7 jours glissants
retour_min = retour_repos(p["date"], p_ctx, plages, astreintes)
# Décompte astreinte du jour : événements multiples, durée réelle arrondie
# au quart d'heure supérieur ; un férié marqué à la main qualifie les
# minutes pour les règles « ferie »
@ -526,7 +647,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
"astreinte_h": dec["total_h"] if dec else "",
"astreinte_pondere_min": dec["total_pondere_min"] if dec else 0,
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
"retour": retour_txt(retour_min, p["date"]) if retour_min is not None else None,
})
# Jours repris dans le récapitulatif d'astreinte : ceux avec des heures
# décomptées, mais aussi les jours AS déclarés portant un forfait (sans
@ -628,6 +749,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int
du = jour["du_min"]
if du == 0:
continue
# Jour entièrement couvert par un repos hebdomadaire qui déborde
# (retour au-delà de minuit suivant) : rien à planifier ce jour-là.
if (jour.get("retour_min") or 0) >= 24 * 60:
continue
matin_nominal = jour["matin_nominal_min"]
aprem_nominal = jour["aprem_nominal_min"]
matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg

View File

@ -34,7 +34,8 @@ 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, couvert_detail,
nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour,
nominaux_du_jour, retour_apres_repos, retour_repos, retour_txt, decompte_astreinte,
fenetre_astreinte_jour,
)
from stats import compute_all_stats
from export import export_semaine_ods, export_annee_ods
@ -304,6 +305,16 @@ def _weeks_by_year(all_weeks: list) -> dict:
return result
def _contexte_semaine_precedente(year: int, week: int, dates: list[str], user_id: str) -> list[dict]:
"""Pointages de la semaine ISO précédente (jours avant la semaine affichée) :
la fenêtre glissante de 7 jours du repos hebdomadaire de 35h déborde sur la
semaine d'avant."""
prev = (datetime.strptime(dates[0], "%Y-%m-%d").date() - timedelta(days=7)).isocalendar()
if (prev.year, prev.week) == (year, week):
return []
return [r for r in load_week_pointages(prev.year, prev.week, user_id) if r.get("date") < dates[0]]
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
dates = week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
@ -313,7 +324,8 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id))
contexte = _contexte_semaine_precedente(year, week, dates, user_id)
result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id), contexte)
result["heures_jour"] = minutes_to_hhmm(h_jour)
# Panneau « aujourd'hui » : uniquement si le jour courant appartient à la
# semaine affichée (re-rendu à chaque enregistrement via _soldes.html).
@ -341,13 +353,20 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
astreintes = set(load_astreintes_effectives(user_id))
feries = set(load_feries(user_id))
p_by_date = {p["date"]: p for p in raw}
# Repos hebdomadaire de 35h (7 jours glissants) : la fenêtre des 6 jours
# précédents peut déborder sur la semaine ISO précédente.
debut_fenetre = (d - timedelta(days=6)).isocalendar()
if (debut_fenetre.year, debut_fenetre.week) != (iso.year, iso.week):
for row in load_week_pointages(debut_fenetre.year, debut_fenetre.week, user_id):
p_by_date.setdefault(row.get("date"), row)
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
conges_date = {c["type"] for c in conges if c["date"] == date}
jour_ferie = date in feries
# Retour au bureau après 11h de repos, calculé depuis la fin de la dernière
# activité d'astreinte (saisie sur le jour ou la veille qualifiés)
retour_min = retour_apres_repos(date, p_by_date, plages, astreintes)
# Retour au bureau après repos, calculé depuis la fin de la dernière
# activité d'astreinte (11h quotidiennes) et, si activée, la contrainte de
# repos hebdomadaire de 35h sur 7 jours glissants
retour_min = retour_repos(date, p_by_date, plages, astreintes)
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
@ -385,7 +404,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"astreinte": date in astreintes,
"astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes),
"retour_min": retour_min,
"retour": minutes_to_time(retour_min) if retour_min is not None else None,
"retour": retour_txt(retour_min, date) if retour_min is not None else None,
"astreinte_min": dec["total_min"] if dec else 0,
"astreinte_h": dec["total_h"] if dec else "",
}
@ -394,7 +413,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
if jour.get("couvert_min"):
trav_inner += (f'<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h '
trav_inner += (f'<span class="calc-couvert" title="Présence obligatoire couverte par le repos (11h quotidiennes, ou 35h hebdomadaires si activées) '
f'({_e(jour["couvert_detail"])}) : comptée comme effectuée">+{_e(jour["couvert_h"])} repos</span>')
if jour["travaille_min"]:
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
@ -548,7 +567,7 @@ def _as_block_html(date, jour):
rows = "".join(_as_event_html(date, ev) for ev in events) or _as_event_html(date, {})
hint = ""
if jour.get("retour_min") is not None:
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d\'heure supérieur), retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
hint = (f'<span class="as-hint" title="Repos après la dernière activité : 11h consécutives (quotidien), et 35h sur 7 jours glissants si le repos hebdomadaire est activé (Réglages). Toute intervention pendant le repos redémarre le décompte ; retour arrondi au quart d\'horloge supérieur : :00, :15, :30 ou :45">'
f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("couvert_min"):
hint += (f'<span class="as-hint" title="Présence obligatoire couverte par le repos ({_e(jour["couvert_detail"])}) : comptée comme effectuées dans la colonne Travaillé">'
@ -809,7 +828,7 @@ def semaine(request: Request, year: int, week: int):
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
result = compute_week(pointages, conges, h_jour, plages, load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id))
result = compute_week(pointages, conges, h_jour, plages, load_astreintes_effectives(user_id), load_facteurs_astreinte(), load_feries(user_id), _contexte_semaine_precedente(year, week, dates, user_id))
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
@ -1052,6 +1071,7 @@ def settings_page(request: Request):
"forfait_saved": request.query_params.get("forfait_saved") == "1",
"forfait_error": request.query_params.get("forfait_error") == "1",
"astreinte_saved": request.query_params.get("astreinte_saved") == "1",
"repos_hebdo_saved": request.query_params.get("repos_hebdo_saved") == "1",
"facteurs_saved": request.query_params.get("facteurs_saved") == "1",
"facteurs_error": request.query_params.get("facteurs_error") == "1",
})
@ -1172,6 +1192,28 @@ def settings_astreinte_active(request: Request, astreinte_active: str = Form("")
return RedirectResponse("/settings?astreinte_saved=1", status_code=303)
@app.post("/settings/repos-hebdo", response_class=HTMLResponse)
def settings_repos_hebdo(request: Request, repos_hebdo_active: str = Form("")):
"""Préférence personnelle : respect du repos hebdomadaire de 35h (24h + 11h
consécutives, une fois par période de 7 jours glissants). Activée, un
week-end d'astreinte sans trou de 35h repousse l'heure de retour à dernière
activité + 35h — toute nouvelle intervention avant cette échéance
redémarre le décompte."""
user_id = _get_user(request)
if not user_id:
return RedirectResponse("/login", status_code=302)
actif = repos_hebdo_active == "1"
plages = load_plages(user_id)
plages["repos_hebdo_active"] = actif
save_plages(user_id, plages)
logger.info("repos hebdomadaire 35h %s: user_id=%r", "activé" if actif else "désactivé", user_id)
append_log(user_id, "settings", "Repos hebdomadaire 35h",
"activé (retour repoussé à dernière activité + 35h quand la semaine glissante n'a pas son repos)"
if actif else "désactivé (seul le repos quotidien de 11h décale le retour)")
return RedirectResponse("/settings?repos_hebdo_saved=1", status_code=303)
@app.post("/settings/plages", response_class=HTMLResponse)
def settings_plages_save(
request: Request,

View File

@ -52,7 +52,7 @@
<td class="col-calc" data-label="Travaillé">
<span id="calc-{{ j.date }}-trav">
<span class="calc-num">{{ j.travaille if j.travaille_min else '—' }}</span>
{% if j.couvert_min %}<span class="calc-couvert" title="Présence obligatoire couverte par le repos de 11h ({{ j.couvert_detail }}) : comptée comme effectuée">+{{ j.couvert_h }} repos</span>{% endif %}
{% if j.couvert_min %}<span class="calc-couvert" title="Présence obligatoire couverte par le repos (11h quotidiennes, ou 35h hebdomadaires si activées) ({{ j.couvert_detail }}) : comptée comme effectuée">+{{ j.couvert_h }} repos</span>{% endif %}
</span>
</td>
@ -146,7 +146,7 @@
onclick="asToggle(this)">
<span class="as-caret">▸</span> {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }}
</button>
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité (durée arrondie au quart d'heure supérieur), retour arrondi au quart d'horloge supérieur : :00, :15, :30 ou :45">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.retour_min is not none %}<span class="as-hint" title="Repos après la dernière activité : 11h consécutives (quotidien), et 35h sur 7 jours glissants si le repos hebdomadaire est activé (Réglages). Toute intervention pendant le repos redémarre le décompte ; retour arrondi au quart d'horloge supérieur : :00, :15, :30 ou :45">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.couvert_min %}<span class="as-hint" title="Présence obligatoire couverte par le repos ({{ j.couvert_detail }}) : comptée comme effectuées dans la colonne Travaillé">+{{ j.couvert_h }} de présence comptées</span>{% endif %}
{% if j.forfait_min %}<span class="as-hint" title="Forfait du jour AS déclaré (Réglages) : suivi dans le récapitulatif d'astreinte, hors heures travaillées">forfait {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Durée réelle des interventions arrondie au quart d'heure supérieur (2, 5 ou 12 min → 15 ; 22 ou 28 → 30), même sans tomber sur un quart d'heure de l'horloge — compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}

View File

@ -72,6 +72,35 @@
</form>
</div>
{% if plages.astreinte_active %}
<div class="settings-card">
<span class="hist-label">Repos hebdomadaire de 35h</span>
<p class="settings-p">
La loi impose, une fois par période de <b>7 jours glissants</b>, un repos de
<b>35h consécutives</b> (24h de repos hebdomadaire + 11h de repos quotidien).
Un week-end normal le satisfait tout seul ; un week-end d'astreinte fragmenté
par plusieurs interventions, pas forcément. Activée, cette option repousse
l'heure de retour à <b>dernière activité + 35h</b> quand aucune période de
35h sans activité n'existe dans les 7 derniers jours — et toute nouvelle
intervention avant cette échéance <b>redémarre</b> le décompte. La présence
couverte par ce repos est créditée comme effectuée, comme pour les 11h
quotidiennes. Désactivée (défaut), seul le repos de 11h décale le retour.
</p>
{% if repos_hebdo_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
<form method="post" action="/settings/repos-hebdo" class="settings-form">
<label class="settings-check">
<input type="checkbox" name="repos_hebdo_active" value="1" {% if plages.repos_hebdo_active %}checked{% endif %}>
<span>Respecter le repos hebdomadaire de 35h (7 jours glissants)</span>
</label>
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
{% endif %}
{% if plages.astreinte_active %}
<div class="settings-card">
<span class="hist-label">Ratios des heures d'astreinte</span>

View File

@ -0,0 +1,234 @@
"""Repos hebdomadaire de 35h (24h + 11h consécutives, une fois par 7 jours
glissants) : préférence personnelle désactivée par défaut. Un week-end
d'astreinte fragmenté par des interventions peut ne contenir aucun trou de
35h — le retour est alors repoussé à dernière activité + 35h, et toute nouvelle
perturbation avant cette échéance redémarre le décompte."""
import sys
import models # noqa: F401 (DATA_DIR temporaire posé par conftest)
sys.path.insert(0, ".")
from calcul import (
DEFAULT_PLAGES, retour_repos, retour_repos_hebdo, retour_txt, compute_week,
)
import main
from fastapi.testclient import TestClient
client = TestClient(main.app)
HEURES_JOUR_MIN = 468 # 7h48
PLAGES_OFF = dict(DEFAULT_PLAGES)
PLAGES_ON = {**DEFAULT_PLAGES, "repos_hebdo_active": True}
def _p(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None,
astreinte_events=None):
return {
"date": date,
"matin_entree": matin_entree, "matin_sortie": matin_sortie,
"aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie,
"astreinte_events": astreinte_events,
}
def _semaine_ouvree(premier_lundi: str) -> list[dict]:
"""Lundi→vendredi d'une semaine sans astreinte, journées complètes."""
from datetime import date, timedelta
d0 = date.fromisoformat(premier_lundi)
return [
_p((d0 + timedelta(days=i)).isoformat(), "09:00", "12:24", "13:30", "17:54")
for i in range(5)
]
# Semaine de référence : lundi 2026-07-13 (semaine ISO 29), week-end précédent
# sam 11 / dim 12 juillet, aujourd'hui figé au vendredi 17/07 10:30.
WE_ALEATOIRE = ["2026-07-11", "2026-07-12"]
def _we_fragmente(event_sam, event_dim, extra=None):
p = {r["date"]: r for r in _semaine_ouvree("2026-07-06")}
p["2026-07-11"] = _p("2026-07-11", astreinte_events=[event_sam])
p["2026-07-12"] = _p("2026-07-12", astreinte_events=[event_dim])
p.update(extra or {})
return p
# ── retour_repos_hebdo : fenêtre glissante de 7 jours ─────────────────────────
def test_fenetre_vide_sans_contrainte(freeze_today):
assert retour_repos_hebdo("2026-07-13", {}, PLAGES_ON, WE_ALEATOIRE) is None
def test_weekend_astreinte_fragmente_repousse_le_lundi(freeze_today):
"""Sam 10:00→11:00 et dim 10:00→11:00 : trous de 16h, 23h puis final — aucun
35h dans la fenêtre → retour = dim 11:00 + 35h = lundi 22:00."""
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 22 * 60
def test_desactivee_le_weekend_fragmente_ne_contraint_rien(freeze_today):
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
assert retour_repos("2026-07-13", p, PLAGES_OFF, WE_ALEATOIRE) is None
def test_trou_de_35h_entre_interventions_valide_le_repos(freeze_today):
"""Sam 08:00→09:00, dim 20:00→21:00 : le trou sam 09:00 → dim 20:00 fait
exactement 35h — repos pris, aucune contrainte (alors que la dernière
activité + 35h tomberait mardi 08:00)."""
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "20:00", "fin": "21:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_repos_fini_avant_ouverture_sans_contrainte(freeze_today):
"""Sam 06:00→07:00 seulement : trou insuffisant (12h depuis vendredi), mais
sam 07:00 + 35h = dimanche 18:00 — le repos s'achève avant l'ouverture du
lundi → aucune contrainte."""
p = _we_fragmente({"debut": "06:00", "fin": "07:00"}, None)
p["2026-07-12"] = _p("2026-07-12")
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_repos_qui_deborde_sur_le_lendemain(freeze_today):
"""Sam 08:00→09:00, dim 17:00→18:00 : trou de 32h, retour = dim 18:00 + 35h
= mardi 05:00 → lundi entièrement couvert (retour ≥ 1440), mardi libre dès
l'ouverture (05:00 < 09:00)."""
p = _we_fragmente({"debut": "08:00", "fin": "09:00"}, {"debut": "17:00", "fin": "18:00"})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 5 * 60
assert retour_repos_hebdo("2026-07-14", p, PLAGES_ON, WE_ALEATOIRE) is None
def test_nouvelle_intervention_pendant_le_repos_redemarre_35h(freeze_today):
"""Week-end fragmenté (retour lundi 22:00) + intervention lundi matin
07:00→08:00 sur le jour qualifié (veille dim AS) : le décompte repart de
lundi 08:00 → retour mardi 19:00. Le retour combine 11h (19:00 le lundi)
et 35h (mardi 19:00) — le plus tardif gagne."""
p = _we_fragmente({"debut": "10:00", "fin": "11:00"}, {"debut": "10:00", "fin": "11:00"},
extra={"2026-07-13": _p("2026-07-13", astreinte_events=[{"debut": "07:00", "fin": "08:00"}])})
assert retour_repos_hebdo("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
assert retour_repos("2026-07-13", p, PLAGES_ON, WE_ALEATOIRE) == 24 * 60 + 19 * 60
def test_semaine_normale_avec_option_active_sans_contrainte(freeze_today):
"""Lundi→vendredi pointés, week-ends sans activité : chaque fenêtre de 7
jours contient le week-end libre (trou ≥ 35h) — aucun jour ouvré
contraint."""
p = {r["date"]: r for r in _semaine_ouvree("2026-07-13")}
for d in ("2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"):
assert retour_repos_hebdo(d, p, PLAGES_ON, []) is None, d
def test_repos_txt_jour_suivant():
assert retour_txt(13 * 60, "2026-07-13") == "13:00"
assert retour_txt(24 * 60 + 5 * 60, "2026-07-13") == "Mar 05:00"
assert retour_txt(24 * 60 + 19 * 60, "2026-07-13") == "Mar 19:00"
# ── compute_week : contexte de la semaine précédente ──────────────────────────
def test_compute_week_sans_contexte_le_trou_initial_valide_le_repos(freeze_today):
"""Sans les jours précédents (ancien comportement, ex. données absentes),
le trou initial de la fenêtre couvre tout : aucune contrainte."""
pointages = [_p("2026-07-13"), _p("2026-07-14"), _p("2026-07-15")]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] is None
def test_compute_week_contexte_reprise_le_weekend_fragmente(freeze_today):
"""Le week-end d'astreinte appartient à la semaine précédente : passé via
`contexte`, le lundi est couvert par le repos jusqu'à mardi 05:00."""
contexte = _semaine_ouvree("2026-07-06") + [
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
]
pointages = [
_p("2026-07-13"),
_p("2026-07-14"),
_p("2026-07-15", "09:00", "12:24", "13:30", "17:54"),
_p("2026-07-16", "09:00", "12:24", "13:30", "17:54"),
]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_ON, WE_ALEATOIRE,
contexte=contexte)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] == 24 * 60 + 5 * 60
assert lundi["retour"] == "Mar 05:00"
assert lundi["couvert_min"] == 6 * 60 # plages 09-12 et 14-17 couvertes
assert lundi["travaille_min"] == 6 * 60 # créditées comme effectuées
assert lundi["delta_min"] == 6 * 60 - HEURES_JOUR_MIN
assert lundi["cible_matin"] is None and lundi["cible_aprem"] is None # rien à planifier
# Mardi (14 juillet, férié) : repos fini à 05:00, avant l'ouverture
mardi = next(j for j in result["jours"] if j["date"] == "2026-07-14")
assert mardi["retour_min"] is None
def test_compute_week_contexte_ignore_si_option_desactivee(freeze_today):
contexte = _semaine_ouvree("2026-07-06") + [
_p("2026-07-11", astreinte_events=[{"debut": "08:00", "fin": "09:00"}]),
_p("2026-07-12", astreinte_events=[{"debut": "17:00", "fin": "18:00"}]),
]
pointages = [_p("2026-07-13")]
result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES_OFF, WE_ALEATOIRE,
contexte=contexte)
lundi = next(j for j in result["jours"] if j["date"] == "2026-07-13")
assert lundi["retour_min"] is None
# ── Préférence Réglages ───────────────────────────────────────────────────────
def _login(uid: str) -> dict:
import bcrypt
models.save_auth(uid, email=f"{uid}@x.fr",
password_hash=bcrypt.hashpw(b"x" * 12, bcrypt.gensalt()).decode())
r = client.post("/login/password",
data={"email": f"{uid}@x.fr", "password": "x" * 12},
follow_redirects=False)
assert r.status_code == 302, r.text
return {"user_id": r.cookies.get("user_id")}
def test_bascule_de_la_preference():
uid = "u-rh-pref"
cookies = _login(uid)
assert models.load_plages(uid).get("repos_hebdo_active") is False # désactivée par défaut
r = client.post("/settings/repos-hebdo", cookies=cookies,
data={"repos_hebdo_active": "1"}, follow_redirects=False)
assert r.status_code == 303
assert models.load_plages(uid)["repos_hebdo_active"] is True
r = client.post("/settings/repos-hebdo", cookies=cookies, follow_redirects=False)
assert r.status_code == 303
assert models.load_plages(uid)["repos_hebdo_active"] is False
def test_carte_visible_et_cochee_dans_reglages():
uid = "u-rh-ui"
cookies = _login(uid)
plages = models.load_plages(uid)
plages["repos_hebdo_active"] = True
models.save_plages(uid, plages)
r = client.get("/settings", cookies=cookies, follow_redirects=False)
assert "Repos hebdomadaire de 35h" in r.text
assert 'name="repos_hebdo_active" value="1" checked' in r.text
plages = models.load_plages(uid)
plages["astreinte_active"] = False
models.save_plages(uid, plages)
r = client.get("/settings", cookies=cookies, follow_redirects=False)
assert "Repos hebdomadaire de 35h" not in r.text
def test_sauvegarde_plages_conserve_la_preference():
uid = "u-rh-merge"
cookies = _login(uid)
plages = models.load_plages(uid)
plages["repos_hebdo_active"] = True
models.save_plages(uid, plages)
r = client.post("/settings/plages", cookies=cookies, data={
"matin_debut": "09:00", "matin_fin": "12:00",
"aprem_debut": "14:00", "aprem_fin": "17:00",
"pause_dejeuner_fin": "13:30", "arrivee_visee": "", "depart_vise": "",
}, follow_redirects=False)
assert r.status_code == 303, r.text
assert models.load_plages(uid)["repos_hebdo_active"] is True