diff --git a/README.md b/README.md index 11940d5..63ddad9 100644 --- a/README.md +++ b/README.md @@ -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), diff --git a/app/calcul.py b/app/calcul.py index f447ec2..becf2f8 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -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 diff --git a/app/main.py b/app/main.py index 864354a..8b4d82c 100644 --- a/app/main.py +++ b/app/main.py @@ -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'{"—" 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") @@ -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'' + hint = (f'' f'retour ≥ {_e(jour["retour"])}') if jour.get("couvert_min"): hint += (f'' @@ -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, diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 9b3ef3a..318b7b0 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -52,7 +52,7 @@
+ La loi impose, une fois par période de 7 jours glissants, un repos de + 35h consécutives (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 à dernière activité + 35h quand aucune période de + 35h sans activité n'existe dans les 7 derniers jours — et toute nouvelle + intervention avant cette échéance redémarre 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. +
+ + {% if repos_hebdo_saved %} +