diff --git a/README.md b/README.md index aeac577..bd73e84 100644 --- a/README.md +++ b/README.md @@ -181,18 +181,28 @@ demi-journées restantes. ## Astreinte & repos de 11h -Le bouton **AS** marque un jour d'astreinte. Ce jour et son lendemain (la nuit qui -suit), une sous-ligne dédiée apparaît sous la ligne du jour, pleine largeur : un -menu dépliant fermé par défaut (seuls l'heure de retour et le total restent -visibles). Déplié, il offre autant de lignes que nécessaire -(**+ intervention**) sur trois colonnes : **début**, **fin** et -description libre. Une fin antérieure au début (fin < début) désigne une -intervention à cheval sur minuit. +Le bouton **AS** marque un jour d'astreinte : la période couverte va de **18:00 +ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans +trou). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du +jour, pleine largeur : un menu dépliant fermé par défaut (seuls l'heure de retour +et le total restent visibles). Déplié, il offre autant de lignes que nécessaire +(**+ intervention**) sur trois colonnes : **début**, **fin** et description +libre. Une fin antérieure au début (fin < début) désigne une intervention à +cheval sur minuit. Une durée forfaitaire peut être ajoutée au temps travaillé de +chaque jour d'astreinte déclaré (page **Réglages**, « Forfait par jour +d'astreinte »). + +La semaine affiche aussi **samedi et dimanche** (heures dues nulles, lignes +grisées) : on peut y déclarer des astreintes, y saisir les interventions et les +pointer si besoin — les exports `.ods` suivent. L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour -≥ 13:00 »). Fin 23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le -jour même. Un repos qui se termine avant l'ouverture ne contraint rien. +≥ 13:00 »). Tout quart d'heure entamé est travaillé : la fin est arrondie au quart +d'heure supérieur (fin 23:37 → comptée 23:45) ; les interventions hors fenêtre +18:00→18:00 ne sont pas de l'astreinte. Fin 23:30 → retour 10:30 le lendemain ; +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 : @@ -212,7 +222,8 @@ total pondéré. Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié** -(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension, Pentecôte) +(08:00→20:00) ×2. Ces ratios sont affichés sur la page **Réglages**. Les jours +fériés français (fixes + Pâques, Ascension, Pentecôte) sont calculés automatiquement. Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` : diff --git a/app/calcul.py b/app/calcul.py index 48d1815..3e62bec 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -9,10 +9,12 @@ DEFAULT_PLAGES = { "pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie "arrivee_visee": "", # arrivée du matin visée pour les préconisations (vide = matin_debut) "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) } REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau PAS_ASTREINTE_MIN = 15 # pas du décompte astreinte : toute tranche entamée compte en totalité +ASTREINTE_DEBUT_MIN = 18 * 60 # un jour d'astreinte déclaré couvre 18:00 → 18:00 le lendemain # Règles de comptage des heures d'astreinte : chaque tranche de 15 min est # attribuée à la première règle dont le type de jour et la fenêtre horaire la @@ -56,6 +58,23 @@ def jour_astreinte_qualifie(date_str: str, astreintes) -> bool: return date_str in astreintes or _veille(date_str) in astreintes +def fenetre_astreinte_jour(date_str: str, astreintes) -> tuple[int, int] | None: + """Bornes (minutes sur la timeline du jour, 0 = minuit du jour, > 1440 = + lendemain) de la période d'astreinte couvrant ce jour. Un jour déclaré AS + couvre 18:00 → 18:00 le lendemain : son propre soir à partir de 18:00, et le + lendemain jusqu'à 18:00 (le matin du jour AS lui-même n'est pas de + l'astreinte). Deux astreintes consécutives se recollent sans trou ni + chevauchement. None si le jour n'est pas qualifié.""" + astreintes = set(astreintes or []) + jour_as = date_str in astreintes + veille_as = _veille(date_str) in astreintes + if not (jour_as or veille_as): + return None + debut = 0 if veille_as else ASTREINTE_DEBUT_MIN + fin = 24 * 60 + ASTREINTE_DEBUT_MIN if jour_as else ASTREINTE_DEBUT_MIN + return debut, fin + + def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]: """Présence obligatoire (matin, aprem) tombant avant l'heure de retour au bureau, en minutes : c'est le temps de présence retiré du décompte de la semaine quand le @@ -144,7 +163,8 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]: ] -def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None) -> dict: +def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None, + fenetre: tuple[int, int] | None = None) -> dict: """Décompte des heures réalisées en astreinte pour un jour, par pas de 15 min : toute tranche entamée est comptée en totalité. Chaque tranche est attribuée à la première règle (type de jour × fenêtre horaire) qui la couvre, puis pondérée par @@ -153,6 +173,10 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une fois dans les totaux du jour. + `fenetre` (bornes en minutes sur la timeline du jour, cf. fenetre_astreinte_jour) + borne la période d'astreinte : seules les tranches à l'intérieur comptent, les + événements ou fragments hors fenêtre sont ignorés. None = tout compter. + Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h, events: [détail par événement], categories: [compteurs par règle]}. """ @@ -175,6 +199,12 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | continue deb_min, fin_min = hhmm_to_minutes(deb), hhmm_to_minutes(fin) duree = fin_min - deb_min if fin_min >= deb_min else 24 * 60 - deb_min + fin_min + if fenetre is not None: + debut_c = max(deb_min, fenetre[0]) + fin_c = min(deb_min + duree, fenetre[1]) + if fin_c <= debut_c: + continue + deb_min, duree = debut_c, fin_c - debut_c a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN, a0 + duree, PAS_ASTREINTE_MIN)) @@ -232,13 +262,18 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) -> int | None: """Heure de retour au bureau après les 11h de repos consécutif qui suivent la - dernière activité d'astreinte. +dernière activité d'astreinte. Chaque activité d'astreinte est un événement {debut, fin} saisi sur le jour de son début (les événements remplacent l'ancienne fin unique). fin < debut désigne une intervention à cheval sur minuit, dont la fin (heure du lendemain) - termine l'activité. Le retour s'applique au jour où se termine le repos : fin - 23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 le jour même. + termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain : une + intervention terminée avant 18:00 sur le jour déclaré n'est pas de l'astreinte + et ne déclenche pas de repos. Comme pour le décompte, tout quart d'heure + entamé est travaillé : la fin est arrondie au quart d'heure supérieur avant + d'ajouter le repos. Le retour s'applique au jour où se termine le repos : + fin 23:30 → retour 10:30 le lendemain ; fin 23:37 (comptée 23:45) → retour + 10:45 ; fin 02:00 → retour 13:00 le jour même. Renvoie des minutes depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture.""" astreintes = set(astreintes or []) @@ -252,21 +287,30 @@ def retour_apres_repos(date_str: str, p_by_date: dict, plages: dict, astreintes) if deb and fin: yield hhmm_to_minutes(deb), hhmm_to_minutes(fin) - # Événements du jour : activité terminée aujourd'hui, le repos s'achève donc - # aujourd'hui (une fin matinale est la fin d'une intervention nocturne du jour). - if jour_astreinte_qualifie(date_str, astreintes): + def _fin_comptee(fin: int) -> int: + """Fin d'activité effectivement travaillée : toute tranche de 15 min + entamée compte, la fin est arrondie au quart d'heure supérieur.""" + return -(-fin // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN + + # Événements du jour, fin matinale : le matin du jour n'est de l'astreinte + # que si la veille était déclarée AS (fenêtre 18:00→18:00) ; une fin matinale + # y termine une intervention nocturne et le repos s'achève aujourd'hui. + if veille in astreintes: for deb, fin in _fins(date_str): - if fin >= deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60: - cands.append(fin + REPOS_QUOTIDIEN_MIN) - # Événements de la veille : fin de soirée → le repos se termine aujourd'hui ; - # fin après minuit (fin < deb) → l'activité s'est achevée aujourd'hui à fin, - # le repos s'achève donc aujourd'hui. - if jour_astreinte_qualifie(veille, astreintes): + fin_c = _fin_comptee(fin) + if fin >= deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60: + cands.append(fin_c + REPOS_QUOTIDIEN_MIN) + # Événements de la veille déclarée AS : fin de soirée (après 18:00, dans la + # fenêtre) → le repos se termine aujourd'hui ; fin après minuit (fin < deb) → + # l'activité s'est achevée aujourd'hui à fin, le repos s'achève donc + # aujourd'hui. + if veille in astreintes: for deb, fin in _fins(veille): - if fin >= deb and fin + REPOS_QUOTIDIEN_MIN >= 24 * 60: - cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60) - if fin < deb and fin + REPOS_QUOTIDIEN_MIN < 24 * 60: - cands.append(fin + REPOS_QUOTIDIEN_MIN) + fin_c = _fin_comptee(fin) + if fin >= deb and fin_c > ASTREINTE_DEBUT_MIN and fin_c + REPOS_QUOTIDIEN_MIN >= 24 * 60: + cands.append(fin_c + REPOS_QUOTIDIEN_MIN - 24 * 60) + if fin < deb and fin_c + REPOS_QUOTIDIEN_MIN < 24 * 60: + cands.append(fin_c + REPOS_QUOTIDIEN_MIN) if not cands: return None best = max(cands) @@ -339,6 +383,7 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int """Compute totals for a list of pointages (one week).""" plages = {**DEFAULT_PLAGES, **(plages or {})} astreintes = set(astreintes or []) + forfait_as_min = hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0 matin_debut_min = hhmm_to_minutes(plages["matin_debut"]) matin_fin_min = hhmm_to_minutes(plages["matin_fin"]) aprem_fin_min = hhmm_to_minutes(plages["aprem_fin"]) @@ -364,9 +409,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int retour_min = retour_apres_repos(p["date"], p_by_date, plages, astreintes) # Décompte astreinte du jour : événements multiples, pas de 15 min entamées events = p.get("astreinte_events") or [] - dec = decompte_astreinte(p["date"], events, facteurs) if (jour_astreinte_qualifie(p["date"], astreintes) and events) else None + dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes)) + if jour_astreinte_qualifie(p["date"], astreintes) and events else None) matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) - travaille = heures_travaillees(p, retour_min) + forfait = forfait_as_min if p["date"] in astreintes else 0 + travaille = heures_travaillees(p, retour_min) + forfait du = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min) du_full = du + matin_ex + aprem_ex matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) @@ -382,6 +429,8 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), "conge": conges_date, + "forfait_min": forfait, + "forfait_h": minutes_to_hhmm(forfait) if forfait else "", "du_full_min": du_full, "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, diff --git a/app/export.py b/app/export.py index 849dc24..cdf434c 100644 --- a/app/export.py +++ b/app/export.py @@ -13,7 +13,7 @@ from models import ( load_plages, heures_jour_min, ) -JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] +JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] HEADERS = [ "Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie", "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Astreinte pondérée", "Retour", @@ -22,7 +22,7 @@ HEADERS = [ def _week_dates(year: int, week: int) -> list[str]: first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)] + return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)] def _new_doc(): diff --git a/app/main.py b/app/main.py index e78b4eb..4a3b163 100644 --- a/app/main.py +++ b/app/main.py @@ -32,7 +32,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, - nominaux_du_jour, retour_apres_repos, decompte_astreinte, + nominaux_du_jour, retour_apres_repos, decompte_astreinte, fenetre_astreinte_jour, ) from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods @@ -125,7 +125,7 @@ templates = Jinja2Templates(directory="templates") templates.env.filters["zip"] = zip app.mount("/static", StaticFiles(directory="static"), name="static") -JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] +JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$") _EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$") _HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$") @@ -288,8 +288,10 @@ def _require_user(request: Request): # ── Shared helpers ──────────────────────────────────────────────────────────── def week_dates(year: int, week: int) -> list[str]: + """Les 7 jours ISO de la semaine : le week-end est affiché (heures dues nulles) + pour pouvoir y déclarer des astreintes et y saisir les interventions.""" first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)] + return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(7)] def _weeks_by_year(all_weeks: list) -> dict: @@ -327,7 +329,9 @@ def _build_jour_ctx(date, conges, h_jour, user_id): # activité d'astreinte (saisie sur le jour ou la veille qualifiés) retour_min = retour_apres_repos(date, p_by_date, plages, astreintes) matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) - trav = heures_travaillees(p, retour_min) + 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 du = heures_dues(date, conges, h_jour, plages, retour_min) du_full = du + matin_ex + aprem_ex matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex) @@ -335,7 +339,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id): # Décompte astreinte du jour (compteur séparé, hors heures de semaine) : # seulement si des événements sont stockés et que le jour est qualifié. events = p.get("astreinte_events") or [] - dec = (decompte_astreinte(date, events, load_facteurs_astreinte()) + dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes)) if events and jour_astreinte_qualifie(date, astreintes) else None) return { **p, @@ -344,6 +348,8 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), "conge": conges_date, + "forfait_min": forfait, + "forfait_h": minutes_to_hhmm(forfait) if forfait else "", "du_full_min": du_full, "matin_excl_min": matin_ex, "aprem_excl_min": aprem_ex, "matin_nominal_min": matin_nom, "aprem_nominal_min": aprem_nom, @@ -476,8 +482,11 @@ 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("forfait_min"): + hint += (f'' + f'forfait + {_e(jour["forfait_h"])}') total = "" if jour.get("astreinte_min"): total = (f'' @@ -941,9 +950,12 @@ def settings_page(request: Request): "arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee", "depart_url": f"{base_url}/presence/{notif['token']}/depart", "plages": plages, + "facteurs": load_facteurs_astreinte(), "saved": request.query_params.get("saved") == "1", "plages_saved": request.query_params.get("plages_saved") == "1", "plages_error": request.query_params.get("plages_error") == "1", + "forfait_saved": request.query_params.get("forfait_saved") == "1", + "forfait_error": request.query_params.get("forfait_error") == "1", }) @@ -1085,6 +1097,27 @@ def settings_plages_save( return RedirectResponse("/settings?plages_saved=1", status_code=303) +@app.post("/settings/astreinte-forfait", response_class=HTMLResponse) +def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form("")): + """Durée forfaitaire ajoutée au temps travaillé de chaque jour d'astreinte + déclaré (réglage par utilisateur, stocké avec ses plages).""" + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + val = astreinte_forfait.strip() + if val and (not _HHMM_RE.match(val) or hhmm_to_minutes(val) > 12 * 60): + logger.warning("réglages forfait astreinte rejetés (invalide): user_id=%r valeur=%r", user_id, val) + return RedirectResponse("/settings?forfait_error=1", status_code=303) + + plages = load_plages(user_id) + plages["astreinte_forfait"] = val + save_plages(user_id, plages) + logger.info("réglages forfait astreinte mis à jour: user_id=%r durée=%r", user_id, val or "(aucune)") + append_log(user_id, "settings", "Forfait astreinte", f"durée={val or '(aucune)'}") + return RedirectResponse("/settings?forfait_saved=1", status_code=303) + + @app.post("/presence/{token}/arrivee") def presence_arrivee(request: Request, token: str): user_id = find_user_by_token(token) diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index c510c08..f3d78f4 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -12,7 +12,7 @@ (j.aprem_entree and j.aprem_entree > borne_aprem) or (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) ) %} - + {{ nom }} @@ -117,7 +117,7 @@ {% if j.astreinte_qualifiee %} {% set nb_as = (j.astreinte_events or []) | length %} - +
@@ -125,7 +125,8 @@ onclick="asToggle(this)"> ▸ {{ nb_as }} intervention{{ 's' if nb_as != 1 else '' }} - {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} + {% if j.forfait_min %}forfait + {{ j.forfait_h }}{% endif %} {% if j.astreinte_min %}astreinte : {{ j.astreinte_h }}{% endif %}
@@ -166,5 +167,5 @@ {% else %} -
+
{% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index a348122..fddcc35 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -320,6 +320,12 @@ tbody tr:hover .td-fill.cg { background: var(--conge-bg); } tbody tr:not(:last-child) td { border-bottom: 1px solid var(--rule-l); } + /* Week-end : affiché pour déclarer des astreintes, mais hors heures dues */ + tbody tr.we td { background: var(--ground); } + tbody tr.we:hover td, tbody tr.we:hover .td-fill { background: var(--ground); } + tbody tr.we .jour-nom { color: var(--muted); font-weight: 400; } + tbody tr.we .calc-num, tbody tr.we .delta-zero { color: var(--rule); } + td { padding: .5rem .65rem; vertical-align: middle; diff --git a/app/templates/settings.html b/app/templates/settings.html index 2e971ff..2f27713 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -50,6 +50,52 @@
+
+ Ratios des heures d'astreinte +

+ Pondération des heures d'astreinte selon le type de jour et la fenêtre horaire : + le décompte se fait par tranches de 15 min (toute tranche entamée compte en + totalité) et chaque tranche est multipliée par le ratio de la première règle qui + la couvre. Une tranche hors règle compte ×1 (« Autre »). Règles globales à tous + les comptes, ajustables dans data/config.json (clé + astreinte_facteurs). +

+
+ {% for f in facteurs %} +
+ {{ f.nom }} + {% for j in f.jours %}{{ {'semaine': 'Lun–Ven', 'sam': 'Samedi', 'dim': 'Dimanche', 'ferie': 'Férié'}[j] }}{% if not loop.last %} · {% endif %}{% endfor %} + {{ f.debut }} → {{ f.fin }} + ×{{ '%g' % f.facteur }} +
+ {% endfor %} +
+
+ +
+ Forfait par jour d'astreinte +

+ Durée ajoutée automatiquement au temps travaillé de chaque jour d'astreinte + déclaré (bouton AS), en plus des interventions saisies. Elle compte dans + le bilan de la semaine ; le lendemain de l'astreinte n'est pas concerné. + Vide = pas de forfait. +

+ + {% if forfait_saved %} +
Enregistré.
+ {% endif %} + {% if forfait_error %} +
Durée invalide (format HH:MM, 12h00 maximum).
+ {% endif %} + +
+ + + +
+
+
Notifications (ntfy) Aide

@@ -173,6 +219,30 @@ } .settings-p { font-size: 13px; color: var(--text); line-height: 1.6; } .settings-p a { color: var(--accent); } +.settings-p code { + font-family: var(--mono); + font-size: 12px; + background: var(--ground); + border: 1px solid var(--rule-l); + padding: 0 .25rem; +} +.as-ratios { border: 1px solid var(--rule-l); display: flex; flex-direction: column; } +.as-ratio-row { + display: grid; + grid-template-columns: minmax(110px, auto) 1fr auto auto; + gap: .5rem 1rem; + align-items: baseline; + padding: .45rem .8rem; +} +.as-ratio-row + .as-ratio-row { border-top: 1px solid var(--rule-l); } +.as-ratio-nom { font-size: 13px; font-weight: 500; } +.as-ratio-jours { font-size: 12px; color: var(--muted); } +.as-ratio-fenetre { font-family: var(--mono); font-size: 12px; color: var(--muted); } +.as-ratio-facteur { font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent-2); } +@media (max-width: 600px) { + .as-ratio-row { grid-template-columns: 1fr auto; row-gap: .1rem; } + .as-ratio-jours, .as-ratio-fenetre { grid-column: 1 / -1; } +} .settings-form { display: flex; flex-direction: column; gap: .5rem; max-width: 420px; } .settings-saved { background: var(--pos-bg); diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py index c83fca6..29d383b 100644 --- a/app/tests/test_astreinte.py +++ b/app/tests/test_astreinte.py @@ -1,8 +1,8 @@ """Astreinte : événements, décompte par tranches de 15 min, repos de 11h et retour décalé.""" from calcul import ( compute_week, decompte_astreinte, heures_dues, heures_travaillees, hhmm_to_minutes, - jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, - retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, + jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, fenetre_astreinte_jour, + retour_apres_repos, DEFAULT_PLAGES, PAS_ASTREINTE_MIN, ASTREINTE_DEBUT_MIN, ) import models @@ -82,12 +82,30 @@ def test_retour_fin_nocturne_sur_le_lendemain(): assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60 -def test_retour_activite_matinale_sur_le_jour_meme(): - """Event 05:00→06:00 sur le jour d'astreinte : l'activité s'est réellement - terminée à 06:00 ce jour-là → repos jusqu'à 17:00 le même jour.""" - p_by_date = {"2026-07-14": _pointage("2026-07-14", +def test_retour_activite_matinale_sur_le_lendemain_qualifie(): + """Event 05:00→06:00 saisi sur le lendemain de l'astreinte : le matin du + lendemain est dans la fenêtre 18:00→18:00, l'activité s'est terminée à 06:00 + ce jour-là → repos jusqu'à 17:00 le même jour.""" + p_by_date = {"2026-07-15": _pointage("2026-07-15", astreinte_events=[{"debut": "05:00", "fin": "06:00"}])} - assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 + + +def test_retour_intervention_avant_18h_sur_le_jour_as_ignoree(): + """L'astreinte couvre 18:00 → 18:00 : une intervention du matin du jour + déclaré (avant 18:00) n'est pas de l'astreinte et ne déclenche pas de repos.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "10:00", "fin": "12:00"}])} + assert retour_apres_repos("2026-07-14", p_by_date, PLAGES, ["2026-07-14"]) is None + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None + + +def test_retour_intervention_finissant_avant_18h_la_veille_ignoree(): + """Event 16:00→17:30 la veille déclarée AS : fin 17:30 arrondie 17:30, avant + le début de la fenêtre (18:00) → pas de contrainte de retour.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "16:00", "fin": "17:30"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None def test_retour_fin_du_soir_sur_le_jour_d_astreinte(): @@ -98,6 +116,21 @@ def test_retour_fin_du_soir_sur_le_jour_d_astreinte(): assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30 +def test_retour_fin_hors_quart_arrondie_au_quart_entame(): + """Event 21:00→23:37 : le quart d'heure entamé est travaillé, fin comptée + 23:45 → repos terminé 10:45 le lendemain.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "21:00", "fin": "23:37"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 45 + + +def test_retour_fin_nocturne_hors_quart_arrondie(): + """Event 23:00→01:07 (à cheval sur minuit) : fin comptée 01:15 → retour 12:15.""" + p_by_date = {"2026-07-14": _pointage("2026-07-14", + astreinte_events=[{"debut": "23:00", "fin": "01:07"}])} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 12 * 60 + 15 + + def test_retour_event_a_cheval_sur_minuit_saisi_la_veille(): """Event 23:00→01:00 (fin < début) saisi la veille : fin réelle 01:00 le jour même → 01:00 + 11h = 12:00.""" @@ -387,3 +420,103 @@ def test_toggle_astreinte_persiste(): def test_load_facteurs_astreinte_defaut_si_absent(): facteurs = models.load_facteurs_astreinte() assert any(f["nom"] == "Nuit" for f in facteurs) + + +# ── Fenêtre d'astreinte 18:00 → 18:00 ─────────────────────────────────────── + +def test_fenetre_astreinte_jour(): + """Le jour déclaré couvre 18:00 → 18:00 le lendemain ; le lendemain n'est + couvert que jusqu'à 18:00 ; deux astreintes consécutives se recollent.""" + assert fenetre_astreinte_jour("2026-07-14", ["2026-07-14"]) == (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14"]) == (0, ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-15", ["2026-07-14", "2026-07-15"]) == (0, 24 * 60 + ASTREINTE_DEBUT_MIN) + assert fenetre_astreinte_jour("2026-07-16", ["2026-07-14"]) is None + + +def test_decompte_hors_fenetre_18h_18h_ignore(): + # Matin du jour AS déclaré : avant 18:00, hors astreinte + d = decompte_astreinte("2026-07-14", [{"debut": "10:00", "fin": "12:00"}], + None, (ASTREINTE_DEBUT_MIN, 24 * 60 + ASTREINTE_DEBUT_MIN)) + assert d["total_min"] == 0 + # Lendemain après 18:00 : hors astreinte + d = decompte_astreinte("2026-07-15", [{"debut": "19:00", "fin": "20:00"}], + None, (0, ASTREINTE_DEBUT_MIN)) + assert d["total_min"] == 0 + # Lendemain matin : dans la fenêtre + d = decompte_astreinte("2026-07-15", [{"debut": "01:00", "fin": "02:00"}], + None, (0, ASTREINTE_DEBUT_MIN)) + assert d["total_min"] == 60 + # Événement à cheval sur la borne 18:00 du lendemain : seul le fragment + # intérieur compte (17:00→18:00 = 4 tranches) + d = decompte_astreinte("2026-07-15", [{"debut": "17:00", "fin": "19:00"}], + None, (0, ASTREINTE_DEBUT_MIN)) + assert d["total_min"] == 60 + + +def test_compute_week_intervention_avant_18h_hors_astreinte(freeze_today): + """Mardi AS : l'event 16:00→17:00 (avant 18:00) ne compte pas ; le même + créneau à 19:00→20:00 (dans la fenêtre) compte 1h (Journée ×1).""" + pointages = [ + _pointage("2026-07-13"), _pointage("2026-07-16"), _pointage("2026-07-17"), + _pointage("2026-07-14", astreinte_events=[{"debut": "16:00", "fin": "17:00"}]), + _pointage("2026-07-15"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + assert result["astreinte"]["total_min"] == 0 + assert result["jours"][1]["astreinte_min"] == 0 + + pointages[3] = _pointage("2026-07-14", astreinte_events=[{"debut": "19:00", "fin": "20:00"}]) + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + assert result["astreinte"]["total_min"] == 60 + assert result["astreinte"]["total_pondere_min"] == 120 # 14 juillet : férié ×2 + + +def test_compute_week_astreinte_samedi_dimanche(freeze_today): + """Samedi AS avec intervention nocturne : dû 0 (week-end), décompte en + compteur astreinte (Nuit vendredi ×1,5 puis Samedi ×1,5 sur 08:00→20:00).""" + 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"), + _pointage("2026-07-15", "09:00", "12:24", "13:30", "17:54"), + _pointage("2026-07-16", "09:00", "12:24", "13:30", "17:54"), + _pointage("2026-07-17", "09:00", "12:24", "13:30", "17:54"), + _pointage("2026-07-18", astreinte_events=[{"debut": "23:00", "fin": "09:00"}]), + _pointage("2026-07-19"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-18"]) + samedi = result["jours"][5] + assert samedi["du_min"] == 0 + assert samedi["astreinte_min"] == 10 * 60 # 23:00→09:00 = 10h + assert result["total_du"] == "39h00" # week-end hors dues + + +# ── Forfait par jour d'astreinte déclaré ─────────────────────────────────────── + +def test_compute_week_forfait_ajoute_au_travaille_du_jour_declare(freeze_today): + """Forfait 1h00 réglé : ajouté au travaillé du jour AS déclaré uniquement + (pas du lendemain), sans toucher aux heures dues.""" + plages = {**PLAGES, "astreinte_forfait": "01:00"} + 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"), + _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, plages, ["2026-07-14"]) + mardi = result["jours"][1] + assert mardi["forfait_min"] == 60 + assert mardi["travaille_min"] == HEURES_JOUR_MIN + 60 + assert result["jours"][2]["forfait_min"] == 0 # lendemain : rien + assert result["jours"][0]["forfait_min"] == 0 # jour sans astreinte + assert result["total_travaille"] == "16h36" # 2 × 7h48 + 1h + + +def test_compute_week_sans_forfait_comportement_inchange(freeze_today): + 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": "21:00", "fin": "22:00"}]), + _pointage("2026-07-15"), _pointage("2026-07-16"), _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + assert result["jours"][1]["forfait_min"] == 0 + assert result["total_travaille"] == "15h36"