diff --git a/README.md b/README.md index 99c75cf..b4a5e4d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,37 @@ 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 ligne de saisie supplémentaire apparaît sous l'après-midi : + +- **Fin activité** : l'heure de fin de la **dernière activité** hors horaires de + travail. Elle peut tomber le matin (intervention ayant dépassé minuit) : saisie + sur le jour d'astreinte, une fin matinale (ex: 06:00) désigne la fin de sa nuit ; + saisie sur le lendemain, une fin matinale désigne la nuit de la veille. + +L'app en déduit l'heure de retour au bureau : **fin + 11h de repos consécutif** +(exigées par période de 24h), affichée sous le champ (« retour ≥ 13:00 »). Fin +23:30 → retour 10:30 le lendemain ; fin 02:00 → retour 13:00 ; fin 06:00 → retour +17:00. 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 : + +- la présence obligatoire avant l'heure de retour est retirée des heures dues + (ex: retour 13:00 → la plage 09:00-12:00 ne compte plus) ; +- une présence pointée avant l'heure de retour ne compte pas dans le travaillé ; +- les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin + décalée d'autant, sans avertissement de retard) ; +- une demi-journée entièrement couverte par le repos est considérée comme remplie. + +La fin d'activité ne s'applique que les jours qualifiés (astreinte ou lendemain) : +partout ailleurs elle est ignorée. Les colonnes **Astreinte** (fin saisie) et +**Retour** (calculé) apparaissent dans les exports `.ods`. + +--- + ## Stack `FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker` diff --git a/app/calcul.py b/app/calcul.py index 1454d0b..7ffd541 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,5 +1,5 @@ """Business logic: compute worked hours and expected hours per day/week.""" -from datetime import datetime +from datetime import datetime, timedelta DEFAULT_PLAGES = { "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin @@ -11,6 +11,8 @@ DEFAULT_PLAGES = { "depart_vise": "", # départ du soir visé, plafond souple des cibles aprem (vide = aucun) } +REPOS_QUOTIDIEN_MIN = 11 * 60 # repos consécutif minimum par période de 24h : décale l'heure de retour au bureau + def hhmm_to_minutes(val: str | None) -> int: if not val: @@ -25,32 +27,132 @@ def minutes_to_hhmm(minutes: int) -> str: return f"{sign}{minutes // 60}h{minutes % 60:02d}" -def heures_travaillees(p: dict) -> int: +def minutes_to_time(minutes: int) -> str: + return f"{minutes // 60:02d}:{minutes % 60:02d}" + + +def _veille(date_str: str) -> str: + d = datetime.strptime(date_str, "%Y-%m-%d").date() + return (d - timedelta(days=1)).strftime("%Y-%m-%d") + + +def jour_astreinte_qualifie(date_str: str, astreintes) -> bool: + """True si le jour permet la saisie d'opérations d'astreinte : jour d'astreinte, + ou lendemain d'un jour d'astreinte (la nuit qui suit l'astreinte).""" + astreintes = set(astreintes or []) + return date_str in astreintes or _veille(date_str) in astreintes + + +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 + 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: + return 0, 0 + matin_debut = hhmm_to_minutes(plages["matin_debut"]) + matin_fin = hhmm_to_minutes(plages["matin_fin"]) + aprem_debut = hhmm_to_minutes(plages["aprem_debut"]) + aprem_fin = hhmm_to_minutes(plages["aprem_fin"]) + matin_ex = 0 if ("matin" in conges_date or "jour" in conges_date) else max(0, min(matin_fin, retour_min) - matin_debut) + aprem_ex = 0 if ("aprem" in conges_date or "jour" in conges_date) else max(0, min(aprem_fin, retour_min) - aprem_debut) + 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: + m = a = 0 + elif "matin" in conges_date: + m, a = 0, du_full_min + elif "aprem" in conges_date: + m, a = du_full_min, 0 + else: + m, a = du_full_min // 2, du_full_min - du_full_min // 2 + return max(0, m - matin_excl_min), max(0, a - aprem_excl_min) + + +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. + + Seule l'heure de FIN de cette activité est saisie, sur le jour d'astreinte ou + son lendemain. La fin peut tomber le matin (l'intervention a dépassé minuit) : + saisie sur le jour d'astreinte, une fin matinale désigne la fin de SA nuit ; + saisie sur le lendemain, une fin matinale désigne la fin de la nuit de la + veille. Le retour s'applique au jour où se termine le repos : fin 23:30 → + retour 10:30 le lendemain ; fin 06:00 → retour 17:00. Renvoie des minutes + depuis minuit, ou None si rien ne contraint le retour au-delà de l'ouverture.""" + astreintes = set(astreintes or []) + matin_debut = hhmm_to_minutes(plages["matin_debut"]) + veille = _veille(date_str) + cands = [] + + def _fin_activite(date_op: str) -> int | None: + row = p_by_date.get(date_op) or {} + fin = row.get("astreinte_fin") + return hhmm_to_minutes(fin) if fin else None + + # Fin saisie sur la veille : fin de soirée → repos terminé aujourd'hui ; fin + # matinale sur un jour d'astreinte → la nuit a dépassé minuit, le repos se + # termine aussi aujourd'hui. + if jour_astreinte_qualifie(veille, astreintes): + fin = _fin_activite(veille) + if fin is not None: + if fin + REPOS_QUOTIDIEN_MIN >= 24 * 60: + cands.append(fin + REPOS_QUOTIDIEN_MIN - 24 * 60) + elif veille in astreintes and fin < matin_debut: + cands.append(fin + REPOS_QUOTIDIEN_MIN) + # Fin matinale saisie sur le jour même (lendemain d'astreinte) : la nuit de + # la veille s'est terminée après minuit. + if veille in astreintes: + fin = _fin_activite(date_str) + if fin is not None and fin < matin_debut: + cands.append(fin + REPOS_QUOTIDIEN_MIN) + if not cands: + return None + best = max(cands) + # Un repos qui se termine avant l'ouverture ne contraint rien + return best if best > matin_debut else None + + +def heures_travaillees(p: dict, retour_min: int | None = None) -> int: """Return worked minutes for a pointage row. If a half-day has started but has no exit time yet, count elapsed time up to now - (today only — past dates with missing exits are treated as 0). + (today only — past dates with missing exits are treated as 0). Quand une heure + de retour est posée (repos de 11h après astreinte), la présence avant cette + heure ne compte pas dans les heures de la semaine : les entrées sont décalées + au moins à l'heure de retour. """ today = datetime.today().strftime("%Y-%m-%d") now_min = datetime.today().hour * 60 + datetime.today().minute + def entree_min(v): + m = hhmm_to_minutes(v) + return max(m, retour_min) if retour_min is not None else m + matin = 0 if p["matin_entree"] and p["matin_sortie"]: - matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"])) + matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - entree_min(p["matin_entree"])) elif p["matin_entree"] and p.get("date") == today: - matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"])) + matin = max(0, now_min - entree_min(p["matin_entree"])) aprem = 0 if p["aprem_entree"] and p["aprem_sortie"]: - aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"])) + aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - entree_min(p["aprem_entree"])) elif p["aprem_entree"] and p.get("date") == today: - aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"])) + aprem = max(0, now_min - entree_min(p["aprem_entree"])) return matin + aprem -def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: - """Return expected minutes for a date, accounting for vacations.""" +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.""" d = datetime.strptime(date_str, "%Y-%m-%d").date() # Weekend = no hours due if d.weekday() >= 5: @@ -65,14 +167,21 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: half = heures_jour_min // 2 if "matin" in conges_date or "aprem" in conges_date: - return half + du = half + else: + du = heures_jour_min - return heures_jour_min + if retour_min is not None and plages: + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + du -= matin_ex + aprem_ex + + return max(0, du) -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: 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) -> dict: """Compute totals for a list of pointages (one week).""" plages = {**DEFAULT_PLAGES, **(plages or {})} + astreintes = set(astreintes or []) 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"]) @@ -83,10 +192,18 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int total_travaille = 0 total_du = 0 jours = [] + p_by_date = {p["date"]: p for p in pointages} for p in pointages: - travaille = heures_travaillees(p) - du = heures_dues(p["date"], conges, heures_jour_min) + conges_date = {c["type"] for c in conges if c["date"] == p["date"]} + # 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) + matin_ex, aprem_ex = exclusion_retour(conges_date, retour_min, plages) + travaille = heures_travaillees(p, retour_min) + 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) delta = travaille - du total_travaille += travaille total_du += du @@ -98,14 +215,25 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int "travaille": minutes_to_hhmm(travaille), "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), - "conge": {c["type"] for c in conges if c["date"] == p["date"]}, + "conge": conges_date, + "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, + "astreinte": p["date"] in astreintes, + "astreinte_qualifiee": jour_astreinte_qualifie(p["date"], astreintes), + "retour_min": retour_min, + "retour": minutes_to_time(retour_min) if retour_min is not None else None, }) - # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend) + # Mark each day as complete (all non-congé slots filled, or day is a congé/weekend). + # Une demi-journée entièrement couverte par le repos de 11h (retour après sa + # plage) n'a plus rien à remplir : elle compte comme faite. for jour in jours: cg = jour["conge"] - matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg - aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg + matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg or jour["matin_nominal_min"] == 0 + aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg or jour["aprem_nominal_min"] == 0 jour["is_complete"] = (jour["du_min"] == 0) or (matin_done and aprem_done) # Cumulative weekly delta — accumulated for all days, but visible only when chain is unbroken @@ -146,10 +274,11 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int for jour in jours: if jour["date"] != today: continue + retour = jour.get("retour_min") or 0 if jour.get("matin_entree") and not jour.get("matin_sortie"): - restant_planif += max(0, now_min - hhmm_to_minutes(jour["matin_entree"])) + restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["matin_entree"]), retour)) if jour.get("aprem_entree") and not jour.get("aprem_sortie"): - restant_planif += max(0, now_min - hhmm_to_minutes(jour["aprem_entree"])) + restant_planif += max(0, now_min - max(hhmm_to_minutes(jour["aprem_entree"]), retour)) slots = [] # {jour, half, nominal, debut, ...} — cap*/floor_room bornent le rattrapage for jour in jours: @@ -160,12 +289,13 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int du = jour["du_min"] if du == 0: continue - matin_nominal = 0 if "matin" in cg else (du if "aprem" in cg else du // 2) - aprem_nominal = 0 if "aprem" in cg else (du if "matin" in cg else du - du // 2) + matin_nominal = jour["matin_nominal_min"] + aprem_nominal = jour["aprem_nominal_min"] matin_done = bool(jour.get("matin_entree") and jour.get("matin_sortie")) or "matin" in cg or "jour" in cg aprem_done = bool(jour.get("aprem_entree") and jour.get("aprem_sortie")) or "aprem" in cg or "jour" in cg if not matin_done: - debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else arrivee_visee_min + # L'arrivée visée ne peut pas précéder l'heure de retour (repos de 11h) + debut = hhmm_to_minutes(jour["matin_entree"]) if jour.get("matin_entree") else max(arrivee_visee_min, jour["retour_min"] or 0) reprise = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min cap = max(0, min(matin_fin_min, reprise) - debut) # Le nominal matin qui ne tient pas avant la pause bascule sur l'après-midi @@ -175,9 +305,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int aprem_nominal += surplus # Le matin n'est jamais ajusté par le rattrapage hebdomadaire : seule sa # capacité avant la pause de midi compte, pour le calcul du surplus ci-dessus. - slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) + if cap > 0 and matin_nominal > 0: + slots.append({"jour": jour, "half": "matin", "nominal": min(matin_nominal, cap), "debut": debut}) if not aprem_done: - debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + debut = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else max(pause_dejeuner_fin_min, jour["retour_min"] or 0) cap = max(0, 23 * 60 + 59 - debut) cap_pref = min(cap, max(0, depart_vise_min - debut)) if depart_vise_min else cap # floor_room = marge disponible pour avancer le départ en cas d'avance, diff --git a/app/export.py b/app/export.py index 9c8a311..b812888 100644 --- a/app/export.py +++ b/app/export.py @@ -8,12 +8,15 @@ from odf.table import Table, TableColumn, TableRow, TableCell from odf.text import P from calcul import compute_week, minutes_to_hhmm -from models import load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min +from models import ( + load_week_pointages, load_conges, load_astreintes, list_all_weeks, + load_plages, heures_jour_min, +) JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] HEADERS = [ "Date", "Jour", "Matin entrée", "Matin sortie", "Aprem entrée", "Aprem sortie", - "Travaillé", "Dû", "Δ jour", "Congé", + "Travaillé", "Dû", "Δ jour", "Congé", "Astreinte", "Retour", ] @@ -57,6 +60,12 @@ def _congé_label(cg: set) -> str: return " + ".join(parts) +def _astreinte_label(jour: dict) -> str: + if jour.get("astreinte_fin"): + return f"fin {jour['astreinte_fin']}" + return "Astreinte" if jour.get("astreinte") else "" + + def _jour_row(jour: dict, nom: str) -> TableRow: row = TableRow() for v in ( @@ -66,6 +75,8 @@ def _jour_row(jour: dict, nom: str) -> TableRow: jour["travaille"], jour["du"], ("+" if jour["delta_min"] > 0 else "") + jour["delta"], _congé_label(jour["conge"]), + _astreinte_label(jour), + jour.get("retour"), ): row.addElement(_cell(v)) return row @@ -76,7 +87,7 @@ def _totals_row(bold: Style, travaille_min: int, du_min: int) -> TableRow: for v in ("Total", "", "", "", "", "", minutes_to_hhmm(travaille_min), minutes_to_hhmm(du_min), ("+" if travaille_min - du_min > 0 else "") + minutes_to_hhmm(travaille_min - du_min), - ""): + "", "", ""): row.addElement(_cell(v, bold)) return row @@ -92,6 +103,7 @@ def export_semaine_ods(year: int, week: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) + astreintes = load_astreintes(user_id) dates = _week_dates(year, week) raw = load_week_pointages(year, week, user_id) @@ -101,7 +113,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) + result = compute_week(pointages, conges, h_jour, plages, astreintes) table = Table(name=f"Semaine {week:02d}-{year}") _size_columns(table, wide_col) @@ -123,6 +135,7 @@ def export_annee_ods(year: int, user_id: str) -> bytes: h_jour = heures_jour_min() plages = load_plages(user_id) conges = load_conges(user_id) + astreintes = load_astreintes(user_id) weeks = sorted(wk for yr, wk in list_all_weeks(user_id) if yr == year) @@ -136,7 +149,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) + result = compute_week(pointages, conges, h_jour, plages, astreintes) 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 d590b5d..cf83401 100644 --- a/app/main.py +++ b/app/main.py @@ -21,14 +21,18 @@ from fastapi.staticfiles import StaticFiles from mail import send_mail from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, - toggle_conge, list_all_weeks, + toggle_conge, list_all_weeks, load_astreintes, toggle_astreinte, 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, ) -from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues +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, +) from stats import compute_all_stats from export import export_semaine_ods, export_annee_ods from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server @@ -303,21 +307,29 @@ 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)) + result = compute_week(pointages, conges, h_jour, load_plages(user_id), load_astreintes(user_id)) result["heures_jour"] = minutes_to_hhmm(h_jour) return {"result": result} def _build_jour_ctx(date, conges, h_jour, user_id): - from calcul import heures_travaillees d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week, user_id) + plages = load_plages(user_id) + astreintes = set(load_astreintes(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}) - trav = heures_travaillees(p) - du = heures_dues(date, conges, h_jour) + conges_date = {c["type"] for c in conges if c["date"] == date} + # 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) + trav = heures_travaillees(p, retour_min) + 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) delta = trav - du return { **p, @@ -325,7 +337,14 @@ def _build_jour_ctx(date, conges, h_jour, user_id): "travaille": minutes_to_hhmm(trav), "du": minutes_to_hhmm(du), "delta": minutes_to_hhmm(delta), - "conge": {c["type"] for c in conges if c["date"] == date}, + "conge": conges_date, + "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, + "astreinte": date in astreintes, + "astreinte_qualifiee": jour_astreinte_qualifie(date, astreintes), + "retour_min": retour_min, + "retour": minutes_to_time(retour_min) if retour_min is not None else None, } @@ -372,13 +391,18 @@ def _oob_time_cells(date, jour, plages): cg = jour["conge"] matin_cg = "matin" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg - matin_warn = not matin_cg and jour["du_min"] > 0 and ( - (jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or - (jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"]) + retour_min = jour.get("retour_min") or 0 + # Une demi-journée entièrement couverte par le repos de 11h n'est plus exigée + matin_requis = not matin_cg and jour["du_min"] > 0 and jour.get("matin_nominal_min", 0) > 0 + aprem_requis = not aprem_cg and jour["du_min"] > 0 and jour.get("aprem_nominal_min", 0) > 0 + # L'heure de retour décale d'autant la limite d'arrivée admise sans avertissement + matin_warn = matin_requis and ( + (jour.get("matin_entree") and hhmm_to_minutes(jour["matin_entree"]) > max(hhmm_to_minutes(plages["matin_debut"]), retour_min)) or + (jour.get("matin_sortie") and hhmm_to_minutes(jour["matin_sortie"]) < hhmm_to_minutes(plages["matin_fin"])) ) - aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( - (jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or - (jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"]) + aprem_warn = aprem_requis and ( + (jour.get("aprem_entree") and hhmm_to_minutes(jour["aprem_entree"]) > max(hhmm_to_minutes(plages["aprem_debut"]), retour_min)) or + (jour.get("aprem_sortie") and hhmm_to_minutes(jour["aprem_sortie"]) < hhmm_to_minutes(plages["aprem_fin"])) ) matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill") aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill") @@ -401,22 +425,49 @@ def _oob_time_cells(date, jour, plages): return matin_html + aprem_html -def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): - iso = d.isocalendar() - plages = load_plages(user_id) - jour = _build_jour_ctx(date, conges, h_jour, user_id) +def _btns_html(date, jour): + """Boutons congé + astreinte 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 "" - btns_html = ( + btn_j_on = " jour-on" if "jour" in cg else "" + btn_as_on = " as-on" if jour.get("astreinte") else "" + return ( f'
' f'
' f'' f'' f'' + f'' f'
' ) + + +def _as_block_html(date, jour): + """Bloc astreinte d'une ligne : heure de fin de la dernière activité (hors + horaires), qui déclenche les 11h de repos et décale d'autant l'heure de + retour. Rendu sur les jours qualifiés (astreinte ou lendemain) ; div vide + ailleurs, cible du swap.""" + if not jour.get("astreinte_qualifiee"): + return f'
' + hint = "" + if jour.get("retour_min") is not None: + hint = (f'' + f'retour ≥ {_e(jour["retour"])}') + return ( + f'
' + f'Fin activité' + f'' + f'{hint}' + f'
' + ) + + +def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): + iso = d.isocalendar() + plages = load_plages(user_id) + jour = _build_jour_ctx(date, conges, h_jour, user_id) + btns_html = _btns_html(date, jour) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( @@ -432,11 +483,19 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() plages = load_plages(user_id) jour = _build_jour_ctx(date, conges, h_jour, user_id) + # Le retour au plus tôt du lendemain dépend des opérations saisies aujourd'hui + as_blocks = _as_block_html(date, jour) + lendemain = d + timedelta(days=1) + lend_iso = lendemain.isocalendar() + if (lend_iso.year, lend_iso.week) == (iso.year, iso.week): + lendemain_str = lendemain.strftime("%Y-%m-%d") + as_blocks += _as_block_html(lendemain_str, _build_jour_ctx(lendemain_str, conges, h_jour, user_id)) soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( _oob_calc(date, jour) + _oob_time_cells(date, jour, plages) + + as_blocks + _oob_week_extras(soldes_ctx["result"]) + soldes_html ) @@ -633,7 +692,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) + result = compute_week(pointages, conges, h_jour, plages, load_astreintes(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) @@ -659,6 +718,7 @@ def save_pointage( request: Request, date: str, matin_entree: str = Form(""), matin_sortie: str = Form(""), aprem_entree: str = Form(""), aprem_sortie: str = Form(""), + astreinte_fin: str = Form(""), ): user_id = _get_user(request) if not user_id: @@ -674,10 +734,18 @@ def save_pointage( iso = d.isocalendar() raw = load_week_pointages(iso.year, iso.week, user_id) p_by_date = {p["date"]: p for p in raw} + # La fin d'activité n'est affichée (donc postée) que les jours qualifiés + # (astreinte ou lendemain) ; ailleurs on conserve la valeur déjà saisie au + # lieu de l'écraser à vide. + if jour_astreinte_qualifie(date, set(load_astreintes(user_id))): + champs_astreinte = {"astreinte_fin": clean(astreinte_fin)} + else: + champs_astreinte = {"astreinte_fin": p_by_date.get(date, {}).get("astreinte_fin")} p_by_date[date] = { "date": date, "matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie), "aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie), + **champs_astreinte, } save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id) conges = load_conges(user_id) @@ -710,6 +778,40 @@ def toggle_conge_route(date: str, type_conge: str, request: Request): return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) +@app.post("/astreinte/{date}/toggle", response_class=HTMLResponse) +def toggle_astreinte_route(date: str, request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + toggle_astreinte(date, user_id) + d = datetime.strptime(date, "%Y-%m-%d").date() + iso = d.isocalendar() + if not request.headers.get("HX-Request"): + return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) + + conges = load_conges(user_id) + h_jour = heures_jour_min() + plages = load_plages(user_id) + # Marquer une astreinte qualifie ce jour ET son lendemain (la nuit qui suit) : + # les deux lignes doivent rafraîchir saisie d'opération, avertissements et cibles. + dates_a_rafraichir = [date] + lendemain = d + timedelta(days=1) + lend_iso = lendemain.isocalendar() + if (lend_iso.year, lend_iso.week) == (iso.year, iso.week): + dates_a_rafraichir.append(lendemain.strftime("%Y-%m-%d")) + html = "" + for date_str in dates_a_rafraichir: + jour = _build_jour_ctx(date_str, conges, h_jour, user_id) + html += _oob_time_cells(date_str, jour, plages) + html += _as_block_html(date_str, jour) + html += _oob_calc(date_str, jour) + html += _btns_html(date, _build_jour_ctx(date, conges, h_jour, user_id)) + soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) + soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) + return HTMLResponse(html + _oob_week_extras(soldes_ctx["result"]) + soldes_html) + + @app.get("/stats", response_class=HTMLResponse) def stats_page(request: Request): user_id = _get_user(request) diff --git a/app/models.py b/app/models.py index 5f785ff..d8b3f85 100644 --- a/app/models.py +++ b/app/models.py @@ -146,6 +146,40 @@ def save_conges(conges: list[dict], user_id: str = ""): ) +# ── Astreintes ──────────────────────────────────────────────────────────────── + +def _astreintes_file(user_id: str) -> Path: + return _user_root(user_id) / "astreintes.json" + + +def load_astreintes(user_id: str = "") -> list[str]: + """Dates marquées comme jours d'astreinte (triées, dédupliquées).""" + f = _astreintes_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_astreintes(dates: list[str], user_id: str = ""): + _ensure_user_dirs(user_id) + _astreintes_file(user_id).write_text( + json.dumps(sorted(set(dates)), indent=2, ensure_ascii=False) + ) + + +def toggle_astreinte(date: str, user_id: str = ""): + dates = load_astreintes(user_id) + if date in dates: + dates.remove(date) + else: + dates.append(date) + save_astreintes(dates, user_id) + + # ── Auth (email + password) ──────────────────────────────────────────────────── def _auth_file(user_id: str) -> Path: diff --git a/app/stats.py b/app/stats.py index c9c3ec3..e34a1e4 100644 --- a/app/stats.py +++ b/app/stats.py @@ -3,9 +3,13 @@ import statistics as _st from datetime import datetime, timedelta, date as _date from models import ( - load_week_pointages, load_conges, list_all_weeks, load_plages, heures_jour_min, + load_week_pointages, load_conges, load_astreintes, list_all_weeks, + load_plages, heures_jour_min, +) +from calcul import ( + hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues, + retour_apres_repos, ) -from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues MOIS_FR = [ "janvier", "février", "mars", "avril", "mai", "juin", @@ -60,6 +64,7 @@ def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict: def compute_all_stats(user_id: str = "") -> dict: all_weeks = sorted(list_all_weeks(user_id)) conges = load_conges(user_id) + astreintes = set(load_astreintes(user_id)) h_jour = heures_jour_min() plages = load_plages(user_id) cg_set = {(c["date"], c["type"]) for c in conges} @@ -107,6 +112,9 @@ def compute_all_stats(user_id: str = "") -> dict: matin_filled = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie"))) aprem_filled = ac or (bool(p.get("aprem_entree")) and bool(p.get("aprem_sortie"))) + # 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) all_days.append((date, p)) d = datetime.strptime(date, "%Y-%m-%d").date() @@ -119,8 +127,8 @@ def compute_all_stats(user_id: str = "") -> dict: week_has_incomplete = True continue - trav = heures_travaillees(p) - du = heures_dues(date, conges, h_jour) + trav = heures_travaillees(p, retour_min) + du = heures_dues(date, conges, h_jour, plages, retour_min) week_delta += trav - du if not mc: @@ -129,6 +137,9 @@ def compute_all_stats(user_id: str = "") -> dict: v = hhmm_to_minutes(p[slot]) slot_pairs[slot].append((v, date)) op, thr = TARGETS[slot] + # Une arrivée décalée par le repos de 11h n'est pas un retard + if slot == "matin_entree" and retour_min: + thr = max(thr, retour_min) comp_n[slot] += 1 comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0 if not ac: @@ -137,6 +148,8 @@ def compute_all_stats(user_id: str = "") -> dict: v = hhmm_to_minutes(p[slot]) slot_pairs[slot].append((v, date)) op, thr = TARGETS[slot] + if slot == "aprem_entree" and retour_min: + thr = max(thr, retour_min) comp_n[slot] += 1 comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0 diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 69027bb..de03472 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -1,11 +1,15 @@ {% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %} {% set aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %} -{% set matin_warn = not matin_cg and j.du_min > 0 and ( - (j.matin_entree and j.matin_entree > plages.matin_debut) or +{% set matin_requis = not matin_cg and j.du_min > 0 and j.matin_nominal_min > 0 %} +{% set aprem_requis = not aprem_cg and j.du_min > 0 and j.aprem_nominal_min > 0 %} +{% set borne_matin = [plages.matin_debut, j.retour or plages.matin_debut] | max %} +{% set borne_aprem = [plages.aprem_debut, j.retour or plages.aprem_debut] | max %} +{% set matin_warn = matin_requis and ( + (j.matin_entree and j.matin_entree > borne_matin) or (j.matin_sortie and j.matin_sortie < plages.matin_fin) ) %} -{% set aprem_warn = not aprem_cg and j.du_min > 0 and ( - (j.aprem_entree and j.aprem_entree > plages.aprem_debut) or +{% set aprem_warn = aprem_requis and ( + (j.aprem_entree and j.aprem_entree > borne_aprem) or (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) ) %} @@ -37,6 +41,17 @@ data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}"> + {% if j.astreinte_qualifiee %} +
+ Fin activité + + {% if j.retour_min is not none %}retour ≥ {{ j.retour }}{% endif %} +
+ {% else %} +
+ {% endif %} @@ -102,6 +117,10 @@ hx-swap="none" class="btn-cg btn-jour {% if 'jour' in j.conge %}jour-on{% endif %}" title="Journée entière">J + diff --git a/app/templates/base.html b/app/templates/base.html index b7e4f3d..0c94e15 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -428,6 +428,33 @@ .btn-cg.on { background: var(--conge-bg); border-color: var(--conge-bd); color: var(--conge-txt); } .btn-cg.btn-jour { border-radius: 50%; width: 22px; height: 22px; font-size: 9px; } .btn-cg.jour-on { background: var(--accent-2); border-color: #955505; color: #fff; } + .btn-cg.btn-as { border-radius: 4px; } + .btn-cg.as-on { background: var(--accent); border-color: var(--accent); color: #fff; } + + /* Astreinte : opération hors horaires + heure de retour après 11h de repos */ + .as-block { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; + padding: .3rem .65rem .45rem; + border-top: 1px dashed var(--rule); + } + .as-label { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--accent-2); + white-space: nowrap; + } + .as-block .time-input { width: 68px; } + .as-hint { + font-family: var(--mono); + font-size: 10px; + color: var(--accent); + white-space: nowrap; + } /* ── Tableau hebdo en cartes empilées sous 700px (évite le scroll horizontal) ── */ @media (max-width: 700px) { @@ -490,6 +517,8 @@ .pointage-table .btn-valider { width: 40px; height: 40px; font-size: 16px; } .pointage-table .btn-cg { width: 34px; height: 34px; font-size: 11px; } .pointage-table .btn-cg.btn-jour { width: 32px; height: 32px; } + .as-block { padding: .4rem .75rem .55rem; } + .as-block .time-input { width: 92px; } } /* ── Historique ── */ diff --git a/app/tests/test_astreinte.py b/app/tests/test_astreinte.py new file mode 100644 index 0000000..da43c6b --- /dev/null +++ b/app/tests/test_astreinte.py @@ -0,0 +1,235 @@ +"""Astreinte : fin de dernière activité, repos de 11h et heure de retour décalée.""" +from calcul import ( + compute_week, heures_dues, heures_travaillees, hhmm_to_minutes, + jour_astreinte_qualifie, exclusion_retour, nominaux_du_jour, + retour_apres_repos, DEFAULT_PLAGES, +) +import models + +HEURES_JOUR_MIN = 468 # 7h48 +PLAGES = dict(DEFAULT_PLAGES) + + +def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None, + astreinte_fin=None): + return { + "date": date, + "matin_entree": matin_entree, "matin_sortie": matin_sortie, + "aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie, + "astreinte_fin": astreinte_fin, + } + + +# ── Qualification ───────────────────────────────────────────────────────────── + +def test_jour_astreinte_qualifie(): + astreintes = ["2026-07-14"] + assert jour_astreinte_qualifie("2026-07-14", astreintes) # jour d'astreinte + assert jour_astreinte_qualifie("2026-07-15", astreintes) # lendemain + assert not jour_astreinte_qualifie("2026-07-13", astreintes) + assert not jour_astreinte_qualifie("2026-07-15", []) + + +# ── Heures dues : exclusion avant l'heure de retour ─────────────────────────── + +def test_heures_dues_retour_avant_ouverture_sans_effet(): + assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("08:30")) == HEURES_JOUR_MIN + + +def test_heures_dues_retour_midi_retire_la_presence_du_matin(): + # 13:00 → plage matin 09:00-12:00 (180 min) retirée du décompte + assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == HEURES_JOUR_MIN - 180 + + +def test_heures_dues_retour_apres_midi_retire_matin_et_partie_aprem(): + # 15:00 → matin (180) + 14:00→15:00 (60) + assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == HEURES_JOUR_MIN - 240 + + +def test_heures_dues_conge_matin_pas_de_double_exclusion(): + conges = [{"date": "2026-07-13", "type": "matin"}] + # base = demi-journée aprem (234) ; le matin déjà en congé n'est pas décompté deux fois + assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("13:00")) == 234 + assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN, PLAGES, hhmm_to_minutes("15:00")) == 234 - 60 + + +def test_exclusion_retour_sans_retour(): + assert exclusion_retour(set(), None, PLAGES) == (0, 0) + + +# ── Heures travaillées : la présence avant le retour ne compte pas ──────────── + +def test_heures_travaillees_presence_avant_retour_non_comptee(): + p = _pointage("2026-07-13", "09:00", "12:00", "14:00", "18:00") + # matin 09:00→12:00 entièrement avant le retour 13:00 → 0 ; aprem 14:00→18:00 → 240 + assert heures_travaillees(p, hhmm_to_minutes("13:00")) == 240 + assert heures_travaillees(p) == 180 + 240 + + +def test_heures_travaillees_entree_chevauchant_le_retour(): + p = _pointage("2026-07-13", "08:00", "12:00") + # entrée décalée à 10:30 → 90 min comptées + assert heures_travaillees(p, hhmm_to_minutes("10:30")) == 90 + + +# ── Retour au plus tôt après 11h de repos ───────────────────────────────────── + +def test_retour_fin_matinee_sur_le_lendemain(): + # fin 02:00 saisie sur le lendemain → retour 13:00 le même jour + p_by_date = {"2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00")} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60 + + +def test_retour_fin_matinale_sur_le_jour_d_astreinte(): + # fin 06:00 saisie sur le jour d'astreinte (la nuit a dépassé minuit) → retour 17:00 le lendemain + p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="06:00")} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 17 * 60 + + +def test_retour_fin_du_soir_sur_le_jour_d_astreinte(): + # fin 23:30 sur le jour d'astreinte → repos terminé 10:30 le lendemain + p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 10 * 60 + 30 + + +def test_retour_la_plus_tardive_des_deux_fins_gagne(): + # fins saisies sur le jour d'astreinte (23:30) ET le lendemain (02:00) : la plus tardive gagne + p_by_date = { + "2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30"), + "2026-07-15": _pointage("2026-07-15", astreinte_fin="02:00"), + } + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) == 13 * 60 + + +def test_retour_fin_tot_ne_contraint_rien(): + # fin 17:30 → repos terminé 04:30, avant l'ouverture : pas de contrainte + p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="17:30")} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, ["2026-07-14"]) is None + + +def test_retour_fin_sans_astreinte_ignoree(): + p_by_date = {"2026-07-14": _pointage("2026-07-14", astreinte_fin="23:30")} + assert retour_apres_repos("2026-07-15", p_by_date, PLAGES, []) is None + + +# ── Nominaux ────────────────────────────────────────────────────────────────── + +def test_nominaux_exclusion_totale_du_matin(): + m, a = nominaux_du_jour(set(), 360, 180, 0) + assert (m, a) == (0, 180) + + +def test_nominaux_sans_exclusion_reste_la_moitié(): + m, a = nominaux_du_jour(set(), 468, 0, 0) + assert (m, a) == (234, 234) + + +# ── compute_week ────────────────────────────────────────────────────────────── + +def test_compute_week_fin_nocturne_decale_le_decompte_et_les_cibles(freeze_today): + """Mardi astreinte, activité terminée mercredi 02:00 (fin saisie sur le + lendemain) : retour mercredi 13:00, présence du matin retirée de la semaine, + aucune cible matin, après-midi 13:30→18:18 (nominal 288 = 234 + report).""" + 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", astreinte_fin="02:00"), + _pointage("2026-07-16"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + mercredi = result["jours"][2] + + assert mercredi["du_min"] == 468 - 180 + assert mercredi["retour"] == "13:00" + assert mercredi["cible_matin"] is None + assert mercredi["cible_aprem"] == "18:18" + assert result["total_du"] == "36h00" # 39h - 3h de présence retirées + + +def test_compute_week_fin_matinale_sur_jour_d_astreinte(freeze_today): + """Fin 06:00 saisie directement sur le mardi astreinte : le retour 17:00 + s'applique au mercredi (matin et après-midi exclus jusqu'à 17: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", astreinte_fin="06:00"), + _pointage("2026-07-15"), + _pointage("2026-07-16"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + mercredi = result["jours"][2] + + assert mercredi["retour"] == "17:00" + # matin (180) + 14:00→17:00 (180) exclus → dû = 108 + assert mercredi["du_min"] == 468 - 360 + assert mercredi["cible_matin"] is None + # aprem démarre à 17:00, nominal 108 (54 matin + 54 aprem) → 18:48 + assert mercredi["cible_aprem_entree"] == "17:00" + assert mercredi["cible_aprem"] == "18:48" + + +def test_compute_week_fin_ignoree_hors_jour_qualifie(freeze_today): + """Une fin d'activité saisie un jour non qualifié (pas d'astreinte la veille) + ne change rien au décompte.""" + 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_fin="02:00"), + _pointage("2026-07-15"), + _pointage("2026-07-16"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, []) + assert result["jours"][1]["du_min"] == HEURES_JOUR_MIN + assert result["jours"][1]["retour_min"] is None + + +def test_compute_week_arrivee_visee_ne_precede_jamais_le_retour(freeze_today): + """Fin 23:30 mardi → retour mercredi 10:30 : arrivée cible du matin 10:30, + sortie 12:00, et le nominal qui ne tient pas (234-90=144 → 54 manquantes) + bascule sur l'après-midi du même jour.""" + 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_fin="23:30"), + _pointage("2026-07-15"), + _pointage("2026-07-16"), + _pointage("2026-07-17"), + ] + result = compute_week(pointages, [], HEURES_JOUR_MIN, PLAGES, ["2026-07-14"]) + mercredi = result["jours"][2] + + assert mercredi["retour"] == "10:30" + assert mercredi["cible_matin_entree"] == "10:30" + assert mercredi["cible_matin"] == "12:00" + # aprem : nominal 234 + 54 reportées = 288 depuis 13:30 → 18:18 + assert mercredi["cible_aprem"] == "18:18" + + +def test_compute_week_demi_journee_entierement_en_repos_compte_complete(freeze_today): + """Avec un quota de 6h/jour (180 par demi-journée), une fin à 02:00 (retour + 13:00) couvre toute la plage du matin : plus rien à y remplir, la demi-journée + est complète d'office et l'absence de pointage matin ne casse pas le cumul.""" + heures_jour = 360 + pointages = [ + _pointage("2026-07-13", "09:00", "12:00", "13:30", "16:30"), + _pointage("2026-07-14", "09:00", "12:00", "13:30", "16:30"), + _pointage("2026-07-15", aprem_entree="13:30", aprem_sortie="16:30", astreinte_fin="02:00"), + _pointage("2026-07-16", "09:00", "12:00", "13:30", "16:30"), + _pointage("2026-07-17", "09:00", "12:00", "13:30", "16:30"), + ] + result = compute_week(pointages, [], heures_jour, PLAGES, ["2026-07-14"]) + mercredi = result["jours"][2] + + assert mercredi["matin_nominal_min"] == 0 + assert mercredi["is_complete"] is True + assert mercredi["cumul_visible"] is True + assert mercredi["du_min"] == 180 # 6h dues - 3h de présence matin retirées + + +# ── Stockage des astreintes ─────────────────────────────────────────────────── + +def test_toggle_astreinte_persiste(): + models.toggle_astreinte("2026-07-14", "u-astreinte") + assert models.load_astreintes("u-astreinte") == ["2026-07-14"] + models.toggle_astreinte("2026-07-14", "u-astreinte") + assert models.load_astreintes("u-astreinte") == []