Astreintes : fenêtre 18h-18h, week-end affiché, forfait réglable

Un jour d'astreinte déclaré couvre désormais 18:00 → 18:00 le lendemain :
les interventions hors fenêtre ne comptent ni au décompte ni pour les
11h de repos, et deux astreintes consécutives se recollent sans trou.
La fin de la dernière intervention est arrondie au quart d'heure entamé
avant de poser le retour. Samedi et dimanche apparaissent dans la
semaine (heures dues nulles) pour pouvoir y déclarer des astreintes,
dans les exports aussi. Un réglage par utilisateur ajoute une durée
forfaitaire au temps travaillé de chaque jour d'astreinte déclaré.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
Jacquin Antoine
2026-09-08 23:06:59 +02:00
parent dc0e786428
commit 23271d736d
8 changed files with 351 additions and 48 deletions

View File

@ -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` :

View File

@ -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,

View File

@ -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():

View File

@ -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'<span class="as-hint" title="11h de repos consécutif après la dernière activité">'
hint = (f'<span class="as-hint" title="11h de repos consécutif après la dernière activité, fin arrondie au quart d\'heure entamé">'
f'retour ≥ {_e(jour["retour"])}</span>')
if jour.get("forfait_min"):
hint += (f'<span class="as-hint" title="Forfait réglé dans Réglages, ajouté au temps travaillé">'
f'forfait + {_e(jour["forfait_h"])}</span>')
total = ""
if jour.get("astreinte_min"):
total = (f'<span class="as-total" title="Compteur séparé, hors heures de semaine">'
@ -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)

View File

@ -12,7 +12,7 @@
(j.aprem_entree and j.aprem_entree > borne_aprem) or
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
) %}
<tr id="row-{{ j.date }}">
<tr id="row-{{ j.date }}"{% if nom in ['Samedi', 'Dimanche'] %} class="we"{% endif %}>
<td class="col-jour">
<span class="jour-nom">{{ nom }}</span>
@ -117,7 +117,7 @@
{% if j.astreinte_qualifiee %}
{% set nb_as = (j.astreinte_events or []) | length %}
<tr class="as-row">
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}">
<td colspan="9">
<div id="as-block-{{ j.date }}" class="as-block" data-date="{{ j.date }}" data-label="Astreinte">
<div class="as-head">
@ -125,7 +125,8 @@
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é">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.retour_min is not none %}<span class="as-hint" title="11h de repos consécutif après la dernière activité, fin arrondie au quart d'heure entamé">retour ≥ {{ j.retour }}</span>{% endif %}
{% if j.forfait_min %}<span class="as-hint" title="Forfait réglé dans Réglages, ajouté au temps travaillé">forfait + {{ j.forfait_h }}</span>{% endif %}
{% if j.astreinte_min %}<span class="as-total" title="Compteur séparé, hors heures de semaine">astreinte : {{ j.astreinte_h }}</span>{% endif %}
</div>
<div class="as-body">
@ -166,5 +167,5 @@
</td>
</tr>
{% else %}
<tr class="as-row"><td colspan="9"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr>
<tr class="as-row{% if nom in ['Samedi', 'Dimanche'] %} we{% endif %}"><td colspan="9"><div id="as-block-{{ j.date }}" class="as-block"></div></td></tr>
{% endif %}

View File

@ -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;

View File

@ -50,6 +50,52 @@
</form>
</div>
<div class="settings-card">
<span class="hist-label">Ratios des heures d'astreinte</span>
<p class="settings-p">
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 <code>data/config.json</code> (clé
<code>astreinte_facteurs</code>).
</p>
<div class="as-ratios">
{% for f in facteurs %}
<div class="as-ratio-row">
<span class="as-ratio-nom">{{ f.nom }}</span>
<span class="as-ratio-jours">{% for j in f.jours %}{{ {'semaine': 'Lun–Ven', 'sam': 'Samedi', 'dim': 'Dimanche', 'ferie': 'Férié'}[j] }}{% if not loop.last %} · {% endif %}{% endfor %}</span>
<span class="as-ratio-fenetre">{{ f.debut }} → {{ f.fin }}</span>
<span class="as-ratio-facteur">×{{ '%g' % f.facteur }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="settings-card">
<span class="hist-label">Forfait par jour d'astreinte</span>
<p class="settings-p">
Durée ajoutée automatiquement au temps travaillé de chaque jour d'astreinte
déclaré (bouton <b>AS</b>), 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.
</p>
{% if forfait_saved %}
<div class="settings-saved">Enregistré.</div>
{% endif %}
{% if forfait_error %}
<div class="settings-error">Durée invalide (format HH:MM, 12h00 maximum).</div>
{% endif %}
<form method="post" action="/settings/astreinte-forfait" class="settings-form">
<label class="login-label" for="astreinte_forfait">Durée ajoutée par jour d'astreinte</label>
<input id="astreinte_forfait" class="login-input time-range-input" type="time" step="900"
name="astreinte_forfait" value="{{ plages.astreinte_forfait }}">
<button class="login-btn" type="submit">Enregistrer</button>
</form>
</div>
<div class="settings-card">
<span class="hist-label">Notifications (ntfy) <a href="/aide" class="settings-help-link">Aide</a></span>
<p class="settings-p">
@ -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);

View File

@ -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"