diff --git a/README.md b/README.md
index bd73e84..ef5b971 100644
--- a/README.md
+++ b/README.md
@@ -171,11 +171,15 @@ docker compose run --rm pointeuse pytest -v
---
-## Congés
+## Congés & jours fériés
-Trois boutons par ligne : **MA** (matin), **AM** (après-midi), **J** (journée entière).\
-Ils réduisent les heures dues et recalculent les préconisations **Fin cible** pour les
-demi-journées restantes.
+Quatre boutons par ligne : **MA** (congé matin), **AM** (congé après-midi), **J**
+(journée entière) et **F** (jour férié). Les trois premiers réduisent les heures
+dues et recalculent les préconisations **Fin cible** pour les demi-journées
+restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures
+dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans
+consommer de congé. Un jour marqué **F** compte aussi comme férié pour les ratios
+d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-dessous.
---
@@ -183,7 +187,9 @@ demi-journées restantes.
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
+trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00**
+(journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain
+18:00 pour ne pas tronquer les interventions nocturnes). 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
@@ -215,21 +221,26 @@ décompte de la semaine :
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
-sont décomptées à part, par pas de 15 minutes (toute tranche entamée compte en
-totalité), dans des compteurs pondérés selon le jour et l'heure. Sous le bandeau
-de la semaine s'affiche le détail par événement, les totaux par compteur et le
-total pondéré.
+sont décomptées à part, par quarts d'heure de l'horloge (:00, :15, :30, :45) —
+toute tranche entamée compte en totalité, et un quart partagé par deux
+interventions qui se suivent ou se chevauchent n'est compté **qu'une fois**
+(attribué à la première, les lignes d'événements somment au total du jour) — dans
+des compteurs pondérés selon le jour et l'heure. Sous le bandeau de la semaine
+s'affiche le détail par événement, les totaux par compteur et le 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. 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.
+(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension,
+Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F**.
-Ces règles sont ajustables dans `data/config.json`, clé `astreinte_facteurs` :
-chaque règle porte un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`,
-`ferie`), un `debut`, une `fin` et un `facteur`. Une tranche qui ne correspond à
-aucune règle est rangée dans « Autre » avec un facteur ×1.
+Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios
+des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque
+règle, ajout et suppression de lignes. La première règle qui couvre un quart
+gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi
+ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte
+un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`,
+une `fin` et un `facteur`. Une tranche qui ne correspond à aucune règle est rangée
+dans « Autre » avec un facteur ×1.
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte
diff --git a/app/calcul.py b/app/calcul.py
index 3e62bec..d30ced0 100644
--- a/app/calcul.py
+++ b/app/calcul.py
@@ -58,29 +58,44 @@ 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:
+def fenetre_astreinte_jour(date_str: str, astreintes, feries=None) -> 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é."""
+ chevauchement. Samedi, dimanche et jour férié (légal ou marqué à la main via
+ `feries`, dates « YYYY-MM-DD ») : la journée compte dès 0h00 — un jour AS
+ déclaré garde sa borne du lendemain 18:00 (interventions nocturnes), un
+ sam/dim/férié lendemain d'astreinte compte jusqu'à minuit.
+ 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
+ d = datetime.strptime(date_str, "%Y-%m-%d").date()
+ feries_eff = _jours_feries(d.year) | _jours_feries(d.year + 1)
+ for s in (feries or []):
+ try:
+ feries_eff.add(datetime.strptime(s, "%Y-%m-%d").date())
+ except (TypeError, ValueError):
+ continue
+ plein = d.weekday() >= 5 or d in feries_eff
+ debut = 0 if (veille_as or plein) else ASTREINTE_DEBUT_MIN
+ if jour_as:
+ fin = 24 * 60 + ASTREINTE_DEBUT_MIN
+ else:
+ fin = 24 * 60 if plein else ASTREINTE_DEBUT_MIN
return debut, fin
-def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) -> tuple[int, int]:
+def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict, ferie: bool = False) -> 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
repos de 11h décale l'arrivée. Les demi-journées en congé ne sont pas décomptées
- deux fois."""
- if retour_min is None:
+ deux fois. Un jour férié n'a aucune présence obligatoire."""
+ if retour_min is None or ferie:
return 0, 0
matin_debut = hhmm_to_minutes(plages["matin_debut"])
matin_fin = hhmm_to_minutes(plages["matin_fin"])
@@ -91,10 +106,11 @@ def exclusion_retour(conges_date: set, retour_min: int | None, plages: dict) ->
return matin_ex, aprem_ex
-def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int) -> tuple[int, int]:
- """Nominaux (attendus) matin et aprem en minutes, après congés et exclusion liée
- à l'heure de retour."""
- if "jour" in conges_date:
+def nominaux_du_jour(conges_date: set, du_full_min: int, matin_excl_min: int, aprem_excl_min: int,
+ ferie: bool = False) -> tuple[int, int]:
+ """Nominaux (attendus) matin et aprem en minutes, après congés, jour férié et
+ exclusion liée à l'heure de retour."""
+ if ferie or "jour" in conges_date:
m = a = 0
elif "matin" in conges_date:
m, a = 0, du_full_min
@@ -164,25 +180,36 @@ def _regles_astreinte(facteurs: list[dict] | None) -> list[dict]:
def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] | None = None,
- fenetre: tuple[int, int] | None = None) -> dict:
+ fenetre: tuple[int, int] | None = None,
+ feries_declares: list[str] | set[str] | 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
son facteur. Les événements vivent sur la timeline réelle : fin < début =
intervention à cheval sur minuit, dont la partie après minuit compte sur le jour
- suivant. Les tranches couvertes par plusieurs événements ne sont comptées qu'une
- fois dans les totaux du jour.
+ suivant. Les tranches couvertes par plusieurs événements ne sont comptées
+ qu'une fois, attribuées au premier événement qui les entame (ordre
+ chronologique) : les compteurs des événements somment au total 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.
+ `feries_declares` (dates « YYYY-MM-DD » marquées à la main) complète les jours
+ fériés légaux calculés pour l'attribution des règles de type « ferie ».
Renvoie {tranches, total_min, total_h, total_pondere_min, total_pondere_h,
events: [détail par événement], categories: [compteurs par règle]}.
"""
regles = _regles_astreinte(facteurs)
jour0 = datetime.strptime(date_str, "%Y-%m-%d").date()
+ # Fériés légaux (calculés) + fériés marqués à la main : les deux qualifient les
+ # quarts pour les règles de type « ferie ».
feries = _jours_feries(jour0.year) | _jours_feries(jour0.year + 1)
+ for s in (feries_declares or []):
+ try:
+ feries.add(datetime.strptime(s, "%Y-%m-%d").date())
+ except (TypeError, ValueError):
+ continue
def _regle_du_quart(qm: int) -> dict | None:
cat_j = _categorie_jour(_EPOCH_DATE + timedelta(days=qm // 1440), feries)
@@ -193,6 +220,9 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
# la première affectation gagne si des événements se chevauchent.
quart_regles: dict[int, dict | None] = {}
events_out = []
+ # Événements triés par début : un quart entamé par plusieurs interventions qui
+ # se suivent ou se chevauchent n'est compté qu'une fois, attribué à la première.
+ parses = []
for e in events:
deb, fin = e.get("debut"), e.get("fin")
if not deb or not fin:
@@ -205,9 +235,15 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
if fin_c <= debut_c:
continue
deb_min, duree = debut_c, fin_c - debut_c
+ parses.append((e, deb, fin, deb_min, duree))
+ parses.sort(key=lambda t: (t[3], t[3] + t[4]))
+ vus: set[int] = set()
+ for e, deb, fin, deb_min, duree in parses:
a0 = (jour0 - _EPOCH_DATE).days * 1440 + deb_min
- quarts = list(range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
- a0 + duree, PAS_ASTREINTE_MIN))
+ quarts = [qm for qm in range((a0 // PAS_ASTREINTE_MIN) * PAS_ASTREINTE_MIN,
+ a0 + duree, PAS_ASTREINTE_MIN)
+ if qm not in vus]
+ vus.update(quarts)
for qm in quarts:
quart_regles.setdefault(qm, _regle_du_quart(qm))
ev_cats: dict[str, dict] = {}
@@ -227,6 +263,8 @@ def decompte_astreinte(date_str: str, events: list[dict], facteurs: list[dict] |
ev_pondere = sum(d["pondere_min"] for d in ev_details)
events_out.append({
"debut": deb, "fin": fin, "desc": e.get("desc") or "",
+ "tranches_debut": minutes_to_time(quarts[0] % 1440) if quarts else "",
+ "tranches_fin": minutes_to_time((quarts[-1] + PAS_ASTREINTE_MIN) % 1440) if quarts else "",
"tranches": len(quarts), "min": ev_min, "min_h": minutes_to_hhmm(ev_min),
"pondere_min": ev_pondere, "pondere_h": minutes_to_hhmm(ev_pondere),
"details": ev_details,
@@ -267,9 +305,10 @@ 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é. 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
+ termine l'activité. L'astreinte couvre 18:00 → 18:00 le lendemain — sauf
+ samedi, dimanche et jour férié, où la journée entière compte (0h00 → 0h00) :
+ hors ces jours, 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
@@ -350,13 +389,14 @@ def heures_travaillees(p: dict, retour_min: int | None = None) -> int:
def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int,
- plages: dict | None = None, retour_min: int | None = None) -> int:
- """Return expected minutes for a date, accounting for vacations. Si une heure de
- retour est définie (repos de 11h après astreinte), la présence obligatoire avant
- cette heure est retirée du décompte de la semaine."""
+ plages: dict | None = None, retour_min: int | None = None,
+ ferie: bool = False) -> int:
+ """Return expected minutes for a date, accounting for vacations and public
+ holidays. Si une heure de retour est définie (repos de 11h après astreinte), la
+ présence obligatoire avant cette heure est retirée du décompte de la semaine."""
d = datetime.strptime(date_str, "%Y-%m-%d").date()
- # Weekend = no hours due
- if d.weekday() >= 5:
+ # Weekend ou jour férié = no hours due
+ if d.weekday() >= 5 or ferie:
return 0
conges_date = {c["type"] for c in conges if c["date"] == date_str}
@@ -379,10 +419,11 @@ 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) -> dict:
+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)."""
plages = {**DEFAULT_PLAGES, **(plages or {})}
astreintes = set(astreintes or [])
+ feries = set(feries 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"])
@@ -404,19 +445,21 @@ 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)
- # Décompte astreinte du jour : événements multiples, pas de 15 min entamées
+ # Décompte astreinte du jour : événements multiples, pas de 15 min entamées ;
+ # un férié marqué à la main qualifie les quarts pour les règles « ferie »
events = p.get("astreinte_events") or []
- dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes))
+ dec = (decompte_astreinte(p["date"], events, facteurs, fenetre_astreinte_jour(p["date"], astreintes, feries), feries)
if jour_astreinte_qualifie(p["date"], astreintes) and events else None)
- matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
+ matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
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 = heures_dues(p["date"], conges, heures_jour_min, plages, retour_min, jour_ferie)
du_full = du + matin_ex + aprem_ex
- matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
+ matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = travaille - du
total_travaille += travaille
total_du += du
@@ -429,6 +472,7 @@ 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,
+ "ferie": jour_ferie,
"forfait_min": forfait,
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,
diff --git a/app/export.py b/app/export.py
index cdf434c..21f611d 100644
--- a/app/export.py
+++ b/app/export.py
@@ -9,7 +9,7 @@ from odf.text import P
from calcul import compute_week, minutes_to_hhmm
from models import (
- load_week_pointages, load_conges, load_astreintes, list_all_weeks,
+ load_week_pointages, load_conges, load_astreintes, load_feries, list_all_weeks,
load_plages, heures_jour_min,
)
@@ -106,6 +106,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
+ feries = load_feries(user_id)
dates = _week_dates(year, week)
raw = load_week_pointages(year, week, user_id)
@@ -115,7 +116,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
- result = compute_week(pointages, conges, h_jour, plages, astreintes)
+ result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries)
table = Table(name=f"Semaine {week:02d}-{year}")
_size_columns(table, wide_col)
@@ -140,6 +141,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
plages = load_plages(user_id)
conges = load_conges(user_id)
astreintes = load_astreintes(user_id)
+ feries = load_feries(user_id)
weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year)
@@ -153,7 +155,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes:
"aprem_entree": None, "aprem_sortie": None})
for d in dates
]
- result = compute_week(pointages, conges, h_jour, plages, astreintes)
+ result = compute_week(pointages, conges, h_jour, plages, astreintes, None, feries)
travaille_min = sum(j["travaille_min"] for j in result["jours"])
du_min = sum(j["du_min"] for j in result["jours"])
par_semaine.append((week, result, travaille_min, du_min))
diff --git a/app/main.py b/app/main.py
index 4a3b163..8b97b03 100644
--- a/app/main.py
+++ b/app/main.py
@@ -22,12 +22,13 @@ from mail import send_mail
from models import (
load_config, load_week_pointages, save_week_pointages, load_conges,
toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte,
+ load_feries, toggle_ferie,
load_notif_config, save_notif_config, find_user_by_token, save_presence,
regenerate_presence_token,
load_auth, save_auth, find_user_by_reset_token,
load_plages, save_plages, heures_jour_min,
append_log, load_logs, load_or_create_secret,
- load_facteurs_astreinte,
+ load_facteurs_astreinte, save_facteurs_astreinte,
)
from calcul import (
compute_week, minutes_to_hhmm, minutes_to_time, hhmm_to_minutes, heures_dues,
@@ -129,6 +130,7 @@ JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanc
_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$")
+_JOURS_AS_VALIDES = ("semaine", "sam", "dim", "ferie")
# Échappement HTML pour les fragments construits en f-string hors templates Jinja
# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké.
@@ -310,7 +312,7 @@ 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(user_id), load_facteurs_astreinte())
+ result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id), load_facteurs_astreinte(), load_feries(user_id))
result["heures_jour"] = minutes_to_hhmm(h_jour)
return {"result": result}
@@ -321,25 +323,27 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
raw = load_week_pointages(iso.year, iso.week, user_id)
plages = load_plages(user_id)
astreintes = set(load_astreintes(user_id))
+ feries = set(load_feries(user_id))
p_by_date = {p["date"]: p for p in raw}
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)
- matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages)
+ matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages, jour_ferie)
forfait = (hhmm_to_minutes(plages["astreinte_forfait"]) if plages.get("astreinte_forfait") else 0) \
if date in astreintes else 0
trav = heures_travaillees(p, retour_min) + forfait
- du = heures_dues(date, conges, h_jour, plages, retour_min)
+ du = heures_dues(date, conges, h_jour, plages, retour_min, jour_ferie)
du_full = du + matin_ex + aprem_ex
- matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex)
+ matin_nom, aprem_nom = nominaux_du_jour(conges_date, du_full, matin_ex, aprem_ex, jour_ferie)
delta = trav - du
# Décompte astreinte du jour (compteur séparé, hors heures de semaine) :
# 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(), fenetre_astreinte_jour(date, astreintes))
+ dec = (decompte_astreinte(date, events, load_facteurs_astreinte(), fenetre_astreinte_jour(date, astreintes, feries), feries)
if events and jour_astreinte_qualifie(date, astreintes) else None)
return {
**p,
@@ -348,6 +352,7 @@ def _build_jour_ctx(date, conges, h_jour, user_id):
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": conges_date,
+ "ferie": jour_ferie,
"forfait_min": forfait,
"forfait_h": minutes_to_hhmm(forfait) if forfait else "",
"du_full_min": du_full,
@@ -440,18 +445,20 @@ def _oob_time_cells(date, jour, plages):
def _btns_html(date, jour):
- """Boutons congé + astreinte d'une ligne (cible du swap HTMX)."""
+ """Boutons congé + astreinte + férié d'une ligne (cible du swap HTMX)."""
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else ""
btn_as_on = " as-on" if jour.get("astreinte") else ""
+ btn_f_on = " ferie-on" if jour.get("ferie") else ""
return (
f'
'
f'
'
f''
f''
f''
+ f''
f''
f'
'
)
@@ -489,7 +496,7 @@ def _as_block_html(date, jour):
f'forfait + {_e(jour["forfait_h"])}')
total = ""
if jour.get("astreinte_min"):
- total = (f''
+ total = (f''
f'astreinte : {_e(jour["astreinte_h"])}')
nb = len(events)
label = f'{nb} intervention' + ('s' if nb != 1 else '')
@@ -740,7 +747,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(user_id), load_facteurs_astreinte())
+ result = compute_week(pointages, conges, h_jour, plages, load_astreintes(user_id), load_facteurs_astreinte(), load_feries(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)
@@ -866,6 +873,23 @@ def toggle_astreinte_route(date: str, request: Request):
return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html)
+@app.post("/ferie/{date}/toggle", response_class=HTMLResponse)
+def toggle_ferie_route(date: str, request: Request):
+ """Marque/démarque un jour férié : dû à 0, aucune présence obligatoire, et
+ fenêtre d'astreinte étendue à la journée entière (0h00 → 0h00)."""
+ user_id = _get_user(request)
+ if not user_id:
+ return RedirectResponse("/login", status_code=302)
+
+ toggle_ferie(date, user_id)
+ d = datetime.strptime(date, "%Y-%m-%d").date()
+ iso = d.isocalendar()
+ if request.headers.get("HX-Request"):
+ conges = load_conges(user_id)
+ return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
+ return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
+
+
@app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request):
user_id = _get_user(request)
@@ -956,6 +980,8 @@ def settings_page(request: Request):
"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",
+ "facteurs_saved": request.query_params.get("facteurs_saved") == "1",
+ "facteurs_error": request.query_params.get("facteurs_error") == "1",
})
@@ -1118,6 +1144,46 @@ def settings_astreinte_forfait(request: Request, astreinte_forfait: str = Form("
return RedirectResponse("/settings?forfait_saved=1", status_code=303)
+@app.post("/settings/astreinte-facteurs", response_class=HTMLResponse)
+async def settings_astreinte_facteurs(request: Request):
+ """Règles de pondération des heures d'astreinte, éditables à la main :
+ nom, types de jours, fenêtre horaire et ratio de chaque règle. Une règle au
+ nom vide est supprimée ; une règle invalide rejette l'enregistrement."""
+ user_id = _get_user(request)
+ if not user_id:
+ return RedirectResponse("/login", status_code=302)
+
+ form = await request.form()
+ indices = sorted({int(m.group(1)) for k in form.keys()
+ if (m := re.fullmatch(r"f_nom_(\d+)", k))})
+ regles = []
+ for i in indices:
+ nom = str(form.get(f"f_nom_{i}") or "").strip()
+ if not nom:
+ continue
+ debut = str(form.get(f"f_debut_{i}") or "")
+ fin = str(form.get(f"f_fin_{i}") or "")
+ facteur_txt = str(form.get(f"f_facteur_{i}") or "").strip().replace(",", ".")
+ jours = [j for j in form.getlist(f"f_jours_{i}") if j in _JOURS_AS_VALIDES]
+ try:
+ facteur = float(facteur_txt)
+ except ValueError:
+ facteur = 0.0
+ if not _HHMM_RE.match(debut) or not _HHMM_RE.match(fin) or facteur <= 0 or not jours:
+ logger.warning("règle astreinte rejetée (invalide): user_id=%r nom=%r debut=%r fin=%r facteur=%r jours=%r",
+ user_id, nom, debut, fin, facteur_txt, jours)
+ return RedirectResponse("/settings?facteurs_error=1", status_code=303)
+ regles.append({"nom": nom, "jours": jours, "debut": debut, "fin": fin, "facteur": facteur})
+
+ if not regles:
+ logger.warning("règles astreinte vides, rien n'est enregistré: user_id=%r", user_id)
+ return RedirectResponse("/settings?facteurs_error=1", status_code=303)
+ save_facteurs_astreinte(regles)
+ logger.info("règles astreinte mises à jour: user_id=%r n=%d", user_id, len(regles))
+ append_log(user_id, "settings", "Ratios astreinte", f"{len(regles)} règle(s)")
+ return RedirectResponse("/settings?facteurs_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/models.py b/app/models.py
index 7264814..4130cf7 100644
--- a/app/models.py
+++ b/app/models.py
@@ -181,6 +181,40 @@ def toggle_astreinte(date: str, user_id: str = ""):
save_astreintes(dates, user_id)
+# ── Jours fériés (marqués à la main) ──────────────────────────────────────────
+
+def _feries_file(user_id: str) -> Path:
+ return _user_root(user_id) / "feries.json"
+
+
+def load_feries(user_id: str = "") -> list[str]:
+ """Dates marquées comme jours fériés (triées, dédupliquées)."""
+ f = _feries_file(user_id)
+ if not f.exists():
+ return []
+ try:
+ dates = json.loads(f.read_text())
+ except json.JSONDecodeError:
+ return []
+ return sorted(set(d for d in dates if isinstance(d, str)))
+
+
+def save_feries(dates: list[str], user_id: str = ""):
+ _ensure_user_dirs(user_id)
+ _feries_file(user_id).write_text(
+ json.dumps(sorted(set(dates)), indent=2, ensure_ascii=False)
+ )
+
+
+def toggle_ferie(date: str, user_id: str = ""):
+ dates = load_feries(user_id)
+ if date in dates:
+ dates.remove(date)
+ else:
+ dates.append(date)
+ save_feries(dates, user_id)
+
+
def load_facteurs_astreinte() -> list[dict]:
"""Règles globales de comptage des heures d'astreinte (type de jour × fenêtre
horaire × facteur) ; défauts cohérents si absentes ou invalides dans la config."""
@@ -190,6 +224,13 @@ def load_facteurs_astreinte() -> list[dict]:
return DEFAULT_FACTEURS_ASTREINTE
+def save_facteurs_astreinte(facteurs: list[dict]):
+ """Règles globales de comptage des heures d'astreinte (config partagée)."""
+ cfg = load_config()
+ cfg["astreinte_facteurs"] = facteurs
+ save_config(cfg)
+
+
# ── Auth (email + password) ────────────────────────────────────────────────────
def _auth_file(user_id: str) -> Path:
diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html
index f3d78f4..0dbc0ec 100644
--- a/app/templates/_ligne.html
+++ b/app/templates/_ligne.html
@@ -12,11 +12,12 @@
(j.aprem_entree and j.aprem_entree > borne_aprem) or
(j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
) %}
-
+
{{ nom }}{{ j.date[8:] }}/{{ j.date[5:7] }}
+ {% if j.ferie %}férié{% endif %}
@@ -106,6 +107,10 @@
hx-swap="none"
class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}"
title="Journée entière">J
+