diff --git a/README.md b/README.md index 4cdac94..18f2fb9 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d | Matin | 09:00 → 12:00 | | Après-midi | 14:00 → 17:00 | +Ces horaires sont configurables (voir [Configuration](#configuration)). + L'outil calcule — sans arrondi — **l'heure précise à laquelle partir** chaque jour pour que le total de la semaine tombe pile sur 39h00. --- @@ -76,6 +78,13 @@ Seul `data/config.example.json` est versionné. "user": "no-reply@exemple.fr", "password": "change-moi", "use_tls": true + }, + "plages": { + "matin_debut": "09:00", + "matin_fin": "12:00", + "aprem_debut": "14:00", + "aprem_fin": "17:00", + "pause_dejeuner_fin": "13:30" } } ``` @@ -85,6 +94,21 @@ Seul `data/config.example.json` est versionné. - `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter. - `mail_from` — adresse expéditrice des emails de connexion. - `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). +- `plages` — bornes de présence obligatoire matin/après-midi (voir [Principe](#principe)) et heure de reprise + par défaut de l'après-midi (`pause_dejeuner_fin`) tant que l'entrée aprem n'est pas encore saisie. Modifier + ces valeurs change à la fois les avertissements de saisie, les stats de conformité et le calcul de l'heure + de sortie optimale. + +Les rappels ntfy (topic, serveur, plage horaire) se règlent par utilisateur depuis la page **Réglages** de l'app, +pas dans `config.json`. + +--- + +## Tests + +```bash +docker compose run --rm pointeuse pytest -v +``` --- diff --git a/app/calcul.py b/app/calcul.py index ebd6cd3..8fcc5a5 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,7 +1,13 @@ """Business logic: compute worked hours and expected hours per day/week.""" from datetime import datetime, timedelta -APREM_DEBUT_MIN = 13 * 60 + 30 # 13:30 — pause déjeuner de base 12h00-13h30 +DEFAULT_PLAGES = { + "matin_debut": "09:00", # présence obligatoire à partir de cette heure le matin + "matin_fin": "12:00", # présence obligatoire jusqu'à cette heure le matin + "aprem_debut": "14:00", # présence obligatoire à partir de cette heure l'après-midi + "aprem_fin": "17:00", # présence obligatoire jusqu'à cette heure l'après-midi (plancher de sortie) + "pause_dejeuner_fin": "13:30", # reprise par défaut projetée tant que l'entrée aprem n'est pas saisie +} def hhmm_to_minutes(val: str | None) -> int: @@ -62,8 +68,15 @@ def heures_dues(date_str: str, conges: list[dict], heures_jour_min: int) -> int: return heures_jour_min -def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int) -> dict: +def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int, plages: dict | None = None) -> dict: """Compute totals for a list of pointages (one week).""" + plages = {**DEFAULT_PLAGES, **(plages 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"]) + pause_dejeuner_fin_min = hhmm_to_minutes(plages["pause_dejeuner_fin"]) + matin_window = matin_fin_min - matin_debut_min + total_travaille = 0 total_du = 0 jours = [] @@ -124,9 +137,10 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:]) if du_remaining_mat == 0 and not jour.get("matin_entree"): needed_today_mat = max(0, total_du - worked_before - du_remaining_mat) - if needed_today_mat > 3 * 60: # plus que le créneau 9h-12h obligatoire - entree_min = 12 * 60 - needed_today_mat - if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h + if needed_today_mat > matin_window: # plus que le créneau matin obligatoire + entree_min = matin_fin_min - needed_today_mat + floor_min = matin_debut_min - matin_window # pas avant ce plancher symétrique + if entree_min >= floor_min: jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}" worked_before += jour["travaille_min"] continue @@ -138,16 +152,16 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int if jour.get("matin_entree") and jour.get("matin_sortie"): worked_morning = max(0, hhmm_to_minutes(jour["matin_sortie"]) - hhmm_to_minutes(jour["matin_entree"])) elif jour.get("matin_entree") and jour["date"] == today: - # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à midi + # Pas de sortie pointée le matin : projeter une matinée normale jusqu'à la fin de la plage # (sinon la cible dumperait tout le quota du jour sur l'après-midi dès l'arrivée). entree_min = hhmm_to_minutes(jour["matin_entree"]) elapsed = max(0, now_min - entree_min) - projected_to_noon = max(0, 12 * 60 - entree_min) - worked_morning = max(elapsed, projected_to_noon) + projected_to_fin_matin = max(0, matin_fin_min - entree_min) + worked_morning = max(elapsed, projected_to_fin_matin) needed_aprem = max(0, needed_today - worked_morning) - aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else APREM_DEBUT_MIN - sortie_min = max(aprem_start + needed_aprem, 17 * 60) + aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else pause_dejeuner_fin_min + sortie_min = max(aprem_start + needed_aprem, aprem_fin_min) if sortie_min > 23 * 60 + 59: jour["sortie_cible"] = None # deficit too large to recover in one afternoon @@ -155,9 +169,9 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}" entree_cible = None - if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (17 * 60 - APREM_DEBUT_MIN): - entree_min = 17 * 60 - needed_aprem - if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner + if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min): + entree_min = aprem_fin_min - needed_aprem + if matin_fin_min < entree_min < pause_dejeuner_fin_min: # après le déjeuner, avant la reprise par défaut entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}" jour["entree_cible"] = entree_cible worked_before += jour["travaille_min"] diff --git a/app/classement.py b/app/classement.py deleted file mode 100644 index b42c8fe..0000000 --- a/app/classement.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Leaderboard stats: compare all users on overtime precision and directive compliance.""" -from models import list_users -from stats import compute_all_stats, _compliance_over, _day_compliant -from calcul import minutes_to_hhmm - - -def compute_classement() -> list[dict]: - """Return ranked list of users with their key metrics.""" - results = [] - - for user_id in list_users(): - s = compute_all_stats(user_id) - if not s["weekly_balances"] and s["n_jours"] == 0: - continue # skip empty accounts - - balances = [b["delta_min"] for b in s["weekly_balances"]] - - # Precision: % of weeks where |solde| ≤ 30 min (≈ at zero) - if balances: - pct_zero = round(100 * sum(1 for b in balances if abs(b) <= 30) / len(balances)) - mean_abs = round(sum(abs(b) for b in balances) / len(balances)) - else: - pct_zero = None - mean_abs = None - - conf_pct = s["comp_globale"]["pct"] - - # Combined score (SSO era: will weight per role/contract) - score = round( - (pct_zero or 0) * 0.6 + (conf_pct or 0) * 0.4 - ) - - results.append({ - "user_id": user_id, - "display_name": user_id.replace("_", " ").replace("-", " ").title(), - "n_semaines": len(balances), - "n_jours": s["n_jours"], - "pct_zero": pct_zero, # % weeks ≈ 0 HS — higher = better - "mean_abs_min": mean_abs, # mean abs(solde) in minutes — lower = better - "mean_abs_str": minutes_to_hhmm(mean_abs) if mean_abs is not None else "—", - "conf_pct": conf_pct, # directive compliance % — higher = better - "score": score, # combined 0-100 — higher = better - "avg_delta": s["avg_delta"], - "avg_delta_min": s["avg_delta_min"], - }) - - results.sort(key=lambda r: -r["score"]) - return results diff --git a/app/main.py b/app/main.py index d0facdd..c458948 100644 --- a/app/main.py +++ b/app/main.py @@ -19,9 +19,8 @@ from models import ( load_notif_config, save_notif_config, find_user_by_token, save_presence, load_auth, save_auth, find_user_by_reset_token, ) -from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues +from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues, DEFAULT_PLAGES from stats import compute_all_stats -from classement import compute_classement from notifications import reminder_loop logging.basicConfig( @@ -97,6 +96,10 @@ def heures_jour_min() -> int: return int(float(load_config().get("heures_jour", 7.8)) * 60) +def plages_config() -> dict: + return {**DEFAULT_PLAGES, **load_config().get("plages", {})} + + def _weeks_by_year(all_weeks: list) -> dict: result = {} for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]): @@ -113,7 +116,7 @@ def _build_soldes_ctx(year, week, conges, h_jour, user_id): "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - return {"result": compute_week(pointages, conges, h_jour)} + return {"result": compute_week(pointages, conges, h_jour, plages_config())} def _build_jour_ctx(date, conges, h_jour, user_id): @@ -151,7 +154,7 @@ def _oob_calc(date, jour): ) -def _oob_week_extras(result): +def _oob_week_extras(result, plages): html = "" for jour in result["jours"]: date = jour["date"] @@ -169,7 +172,7 @@ def _oob_week_extras(result): cible = None if not jour.get("is_complete"): if jour.get("entree_cible"): - suffix = "→12:00" if "aprem" in jour.get("conge", set()) else "→17:00" + suffix = f'→{plages["matin_fin"]}' if "aprem" in jour.get("conge", set()) else f'→{plages["aprem_fin"]}' cible = f'{jour["entree_cible"]}{suffix}' elif jour.get("sortie_cible"): cible = jour.get("sortie_cible") @@ -178,17 +181,17 @@ def _oob_week_extras(result): return html -def _oob_time_cells(date, jour): +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"] > "09:00") or - (jour.get("matin_sortie") and jour["matin_sortie"] < "12:00") + (jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or + (jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"]) ) aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( - (jour.get("aprem_entree") and jour["aprem_entree"] > "14:00") or - (jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00") + (jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or + (jour.get("aprem_sortie") and jour["aprem_sortie"] < 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") @@ -213,6 +216,7 @@ def _oob_time_cells(date, jour): def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() + plages = plages_config() jour = _build_jour_ctx(date, conges, h_jour, user_id) cg = jour["conge"] btn_ma_on = " on" if "matin" in cg else "" @@ -229,23 +233,24 @@ def _htmx_conge_and_soldes(request, date, d, 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_time_cells(date, jour) + _oob_time_cells(date, jour, plages) + btns_html + _oob_calc(date, jour) - + _oob_week_extras(soldes_ctx["result"]) + + _oob_week_extras(soldes_ctx["result"], plages) + soldes_html ) def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() + plages = plages_config() jour = _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( _oob_calc(date, jour) - + _oob_time_cells(date, jour) - + _oob_week_extras(soldes_ctx["result"]) + + _oob_time_cells(date, jour, plages) + + _oob_week_extras(soldes_ctx["result"], plages) + soldes_html ) @@ -403,6 +408,7 @@ def semaine(request: Request, year: int, week: int): return RedirectResponse("/login", status_code=302) h_jour = heures_jour_min() + plages = plages_config() conges = load_conges(user_id) soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id) @@ -414,7 +420,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) + result = compute_week(pointages, conges, h_jour, plages) 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) @@ -425,6 +431,7 @@ def semaine(request: Request, year: int, week: int): "year": year, "week": week, "result": result, **soldes_ctx, + "plages": plages, "prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week, "next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week, "jours_fr": JOURS_FR, @@ -527,6 +534,9 @@ def settings_page(request: Request): "request": request, "current_user": user_id, "ntfy_topic": notif.get("ntfy_topic") or "", + "ntfy_server": notif.get("ntfy_server") or "https://ntfy.sh", + "rappel_debut_h": notif.get("rappel_debut_h", 7), + "rappel_fin_h": notif.get("rappel_fin_h", 20), "arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee", "depart_url": f"{base_url}/presence/{notif['token']}/depart", "saved": request.query_params.get("saved") == "1", @@ -534,13 +544,22 @@ def settings_page(request: Request): @app.post("/settings", response_class=HTMLResponse) -def settings_save(request: Request, ntfy_topic: str = Form("")): +def settings_save( + request: Request, ntfy_topic: str = Form(""), ntfy_server: str = Form(""), + rappel_debut_h: int = Form(7), rappel_fin_h: int = Form(20), +): user_id = _get_user(request) if not user_id: return RedirectResponse("/login", status_code=302) - logger.info("réglages notif mis à jour: user_id=%r ntfy_topic=%r", user_id, ntfy_topic) - save_notif_config(user_id, ntfy_topic) + rappel_debut_h = max(0, min(23, rappel_debut_h)) + rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h)) + + logger.info( + "réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh", + user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h, + ) + save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h) return RedirectResponse("/settings?saved=1", status_code=303) @@ -566,15 +585,3 @@ def presence_depart(token: str): return {"ok": True} -@app.get("/classement", response_class=HTMLResponse) -def classement_page(request: Request): - user_id = _get_user(request) - if not user_id: - return RedirectResponse("/login", status_code=302) - - ranking = compute_classement() - return templates.TemplateResponse("classement.html", { - "request": request, - "ranking": ranking, - "current_user": user_id, - }) diff --git a/app/models.py b/app/models.py index 9bdc674..4812566 100644 --- a/app/models.py +++ b/app/models.py @@ -3,6 +3,8 @@ import json import secrets from pathlib import Path +from calcul import DEFAULT_PLAGES + DATA_DIR = Path("/data") CONFIG_FILE = DATA_DIR / "config.json" @@ -18,6 +20,7 @@ DEFAULT_CONFIG = { "password": "", "use_tls": True, }, + "plages": dict(DEFAULT_PLAGES), } # ── User path helpers ────────────────────────────────────────────────────────── @@ -142,24 +145,41 @@ def _notif_file(user_id: str) -> Path: return _user_root(user_id) / "notif.json" +DEFAULT_NTFY_SERVER = "https://ntfy.sh" +DEFAULT_RAPPEL_DEBUT_H = 7 +DEFAULT_RAPPEL_FIN_H = 20 + + def load_notif_config(user_id: str) -> dict: - """Return {"ntfy_topic": str|None, "token": str}, generating a token on first use.""" + """Return {"ntfy_topic", "ntfy_server", "rappel_debut_h", "rappel_fin_h", "token"}.""" f = _notif_file(user_id) if f.exists(): cfg = json.loads(f.read_text()) + cfg.setdefault("ntfy_server", DEFAULT_NTFY_SERVER) + cfg.setdefault("rappel_debut_h", DEFAULT_RAPPEL_DEBUT_H) + cfg.setdefault("rappel_fin_h", DEFAULT_RAPPEL_FIN_H) if cfg.get("token"): return cfg else: - cfg = {"ntfy_topic": None} + cfg = { + "ntfy_topic": None, "ntfy_server": DEFAULT_NTFY_SERVER, + "rappel_debut_h": DEFAULT_RAPPEL_DEBUT_H, "rappel_fin_h": DEFAULT_RAPPEL_FIN_H, + } cfg["token"] = secrets.token_hex(16) _ensure_user_dirs(user_id) f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) return cfg -def save_notif_config(user_id: str, ntfy_topic: str): +def save_notif_config( + user_id: str, ntfy_topic: str, ntfy_server: str = "", + rappel_debut_h: int = DEFAULT_RAPPEL_DEBUT_H, rappel_fin_h: int = DEFAULT_RAPPEL_FIN_H, +): cfg = load_notif_config(user_id) cfg["ntfy_topic"] = ntfy_topic.strip() or None + cfg["ntfy_server"] = ntfy_server.strip().rstrip("/") or DEFAULT_NTFY_SERVER + cfg["rappel_debut_h"] = rappel_debut_h + cfg["rappel_fin_h"] = rappel_fin_h _ensure_user_dirs(user_id) _notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) diff --git a/app/notifications.py b/app/notifications.py index 89fb353..b2c2ac1 100644 --- a/app/notifications.py +++ b/app/notifications.py @@ -8,24 +8,21 @@ from models import list_users, load_notif_config, load_presence, load_week_point logger = logging.getLogger("pointeuse.notifications") -NTFY_BASE = "https://ntfy.sh" CHECK_INTERVAL_SECONDS = 300 -RAPPEL_DEBUT_H = 7 -RAPPEL_FIN_H = 20 -def send_ntfy(topic: str, message: str, title: str): +def send_ntfy(server: str, topic: str, message: str, title: str): req = urllib.request.Request( - f"{NTFY_BASE}/{topic}", + f"{server}/{topic}", data=message.encode("utf-8"), headers={"Title": title.encode("utf-8"), "Priority": "high"}, method="POST", ) try: urllib.request.urlopen(req, timeout=10).close() - logger.info("notif ntfy envoyée: topic=%r title=%r", topic, title) + logger.info("notif ntfy envoyée: server=%r topic=%r title=%r", server, topic, title) except Exception: - logger.exception("échec de l'envoi ntfy: topic=%r title=%r", topic, title) + logger.exception("échec de l'envoi ntfy: server=%r topic=%r title=%r", server, topic, title) # best-effort: a missed reminder isn't worth crashing the loop @@ -39,11 +36,14 @@ def _todays_pointage(user_id: str, today: datetime) -> dict: async def check_user(user_id: str, now: datetime): notif = load_notif_config(user_id) topic = notif.get("ntfy_topic") + server = notif.get("ntfy_server", "https://ntfy.sh") + debut_h = notif.get("rappel_debut_h", 7) + fin_h = notif.get("rappel_fin_h", 20) if not topic: logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id) return - if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H): - logger.debug("check_user(%r): hors plage de rappel (%s)", user_id, now) + if now.weekday() >= 5 or not (debut_h <= now.hour < fin_h): + logger.debug("check_user(%r): hors plage de rappel (%s, plage %sh-%sh)", user_id, now, debut_h, fin_h) return presence = load_presence(user_id) @@ -55,10 +55,10 @@ async def check_user(user_id: str, now: datetime): if presence.get("present") and not jour.get("matin_entree"): logger.info("rappel 'pense à pointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer") + await asyncio.to_thread(send_ntfy, server, topic, "Tu es dans les locaux mais pas encore pointé ce matin.", "⏰ Pense à pointer") elif not presence.get("present") and jour.get("matin_entree") and not jour.get("aprem_sortie"): logger.info("rappel 'pense à dépointer' pour %r", user_id) - await asyncio.to_thread(send_ntfy, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") + await asyncio.to_thread(send_ntfy, server, topic, "Tu as quitté les locaux sans dépointer la sortie.", "⏰ Pense à dépointer") async def reminder_loop(): diff --git a/app/pytest.ini b/app/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/app/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests diff --git a/app/stats.py b/app/stats.py index c56038e..c4cf223 100644 --- a/app/stats.py +++ b/app/stats.py @@ -3,7 +3,7 @@ import statistics as _st from datetime import datetime, timedelta, date as _date from models import load_week_pointages, load_conges, list_all_weeks, load_config -from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues +from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues, DEFAULT_PLAGES MOIS_FR = [ "janvier", "février", "mars", "avril", "mai", "juin", @@ -21,7 +21,7 @@ def _to_hhmm(minutes_float: float) -> str: return f"{m // 60:02d}:{m % 60:02d}" -def _day_compliant(p: dict, mc: bool, ac: bool) -> bool | None: +def _day_compliant(p: dict, mc: bool, ac: bool, plages: dict) -> bool | None: if mc and ac: return None matin_ok = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie"))) @@ -29,24 +29,24 @@ def _day_compliant(p: dict, mc: bool, ac: bool) -> bool | None: if not (matin_ok and aprem_ok): return None if not mc: - if p.get("matin_entree") and hhmm_to_minutes(p["matin_entree"]) > 9 * 60: + if p.get("matin_entree") and hhmm_to_minutes(p["matin_entree"]) > hhmm_to_minutes(plages["matin_debut"]): return False - if p.get("matin_sortie") and hhmm_to_minutes(p["matin_sortie"]) < 12 * 60: + if p.get("matin_sortie") and hhmm_to_minutes(p["matin_sortie"]) < hhmm_to_minutes(plages["matin_fin"]): return False if not ac: - if p.get("aprem_entree") and hhmm_to_minutes(p["aprem_entree"]) > 14 * 60: + if p.get("aprem_entree") and hhmm_to_minutes(p["aprem_entree"]) > hhmm_to_minutes(plages["aprem_debut"]): return False - if p.get("aprem_sortie") and hhmm_to_minutes(p["aprem_sortie"]) < 17 * 60: + if p.get("aprem_sortie") and hhmm_to_minutes(p["aprem_sortie"]) < hhmm_to_minutes(plages["aprem_fin"]): return False return True -def _compliance_over(all_days: list[tuple], cg_set: set) -> dict: +def _compliance_over(all_days: list[tuple], cg_set: set, plages: dict) -> dict: n = n_ok = 0 for date, p in all_days: mc = (date, "matin") in cg_set or (date, "jour") in cg_set ac = (date, "aprem") in cg_set or (date, "jour") in cg_set - result = _day_compliant(p, mc, ac) + result = _day_compliant(p, mc, ac, plages) if result is None: continue n += 1 @@ -58,7 +58,9 @@ def _compliance_over(all_days: list[tuple], cg_set: set) -> dict: def compute_all_stats(user_id: str = "") -> dict: all_weeks = sorted(list_all_weeks(user_id)) conges = load_conges(user_id) - h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60) + cfg = load_config() + h_jour = int(float(cfg.get("heures_jour", 7.8)) * 60) + plages = {**DEFAULT_PLAGES, **cfg.get("plages", {})} cg_set = {(c["date"], c["type"]) for c in conges} today = _date.today() @@ -73,10 +75,10 @@ def compute_all_stats(user_id: str = "") -> dict: comp_ok = {s: 0 for s in SLOTS} TARGETS = { - "matin_entree": ("le", 9 * 60), - "matin_sortie": ("ge", 12 * 60), - "aprem_entree": ("le", 14 * 60), - "aprem_sortie": ("ge", 17 * 60), + "matin_entree": ("le", hhmm_to_minutes(plages["matin_debut"])), + "matin_sortie": ("ge", hhmm_to_minutes(plages["matin_fin"])), + "aprem_entree": ("le", hhmm_to_minutes(plages["aprem_debut"])), + "aprem_sortie": ("ge", hhmm_to_minutes(plages["aprem_fin"])), } weekly_balances = [] @@ -177,13 +179,14 @@ def compute_all_stats(user_id: str = "") -> dict: deltas = [b["delta_min"] for b in weekly_balances] avg_delta_min = round(_st.mean(deltas)) if deltas else 0 - comp_globale = _compliance_over(all_days, cg_set) - comp_mois = _compliance_over(month_days, cg_set) - comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set) + comp_globale = _compliance_over(all_days, cg_set, plages) + comp_mois = _compliance_over(month_days, cg_set, plages) + comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set, plages) return { "time_stats": time_stats, "compliance": compliance, + "plages": plages, "weekly_balances": weekly_balances, "total_weeks": len(weekly_balances), "n_jours": n_jours, diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index cbca643..b6bc298 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -1,12 +1,12 @@ {% 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 > '09:00') or - (j.matin_sortie and j.matin_sortie < '12:00') + (j.matin_entree and j.matin_entree > plages.matin_debut) 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 > '14:00') or - (j.aprem_sortie and j.aprem_sortie < '17:00') + (j.aprem_entree and j.aprem_entree > plages.aprem_debut) or + (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin) ) %} @@ -72,9 +72,9 @@ {%- if not j.is_complete -%} {%- if j.entree_cible -%} {%- if 'aprem' in j.conge -%} - {{ j.entree_cible }}→12:00 + {{ j.entree_cible }}→{{ plages.matin_fin }} {%- else -%} - {{ j.entree_cible }}→17:00 + {{ j.entree_cible }}→{{ plages.aprem_fin }} {%- endif -%} {%- elif j.sortie_cible -%} {{ j.sortie_cible }} diff --git a/app/templates/base.html b/app/templates/base.html index cebd014..ecb1e42 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1007,7 +1007,6 @@ {% if current_user is defined and current_user %} Semaine courante Statistiques - Classement Réglages {{ current_user }} Déconnexion diff --git a/app/templates/classement.html b/app/templates/classement.html deleted file mode 100644 index 84849b7..0000000 --- a/app/templates/classement.html +++ /dev/null @@ -1,278 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
- Compétition interne -

Classement

-

Précision sur le zéro heure supp · Conformité aux directives

-
- -{% if ranking %} - -{# ── Podium top 3 ── #} -{% if ranking|length >= 1 %} -
- {% set top3 = ranking[:3] %} - {# réordonner : 2e à gauche, 1er au centre, 3e à droite #} - {% if top3|length == 1 %} - {% set order = [top3[0]] %} - {% elif top3|length == 2 %} - {% set order = [top3[1], top3[0]] %} - {% else %} - {% set order = [top3[1], top3[0], top3[2]] %} - {% endif %} - {% for u in order %} - {% set rank = ranking.index(u) + 1 %} -
-
{{ u.score }}
-
{{ u.display_name }}
-
{% if rank == 1 %}🥇{% elif rank == 2 %}🥈{% else %}🥉{% endif %}
-
-
-
-
#{{ rank }}
-
- {% endfor %} -
-{% endif %} - -{# ── Tableau complet ── #} -
- - - - - - - - - - - - - - - {% for u in ranking %} - - - - - - - - - - - {% endfor %} - -
#UtilisateurScorePréc. zéroConformitéÉcart moy.SemainesJours
- {% if loop.index == 1 %}1 - {% elif loop.index == 2 %}2 - {% elif loop.index == 3 %}3 - {% else %}{{ loop.index }} - {% endif %} - - {{ u.display_name }} - {% if u.user_id == current_user %}moi{% endif %} - -
-
- {{ u.score }} -
-
{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}%{% endif %}{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}%{% endif %}{{ u.avg_delta }}{{ u.n_semaines }}{{ u.n_jours }}
-
- -
-
Score = préc. zéro × 0,6 + conformité × 0,4
-
Préc. zéro = % semaines avec |solde| ≤ 30 min
-
Conformité = % jours respectant les 4 créneaux (≤9h, ≥12h, ≤14h, ≥17h)
-
- -{% else %} -
Aucun utilisateur enregistré pour le moment.
-{% endif %} - - -{% endblock %} diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 3644681..fbc435f 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -327,11 +327,11 @@ document.addEventListener('input', function(e) { var maDiv = document.getElementById('cg-matin-' + date); var apDiv = document.getElementById('cg-aprem-' + date); if (maDiv && !maDiv.classList.contains('cg')) { - var warn = (me && me.value && me.value > '09:00') || (ms && ms.value && ms.value < '12:00'); + var warn = (me && me.value && me.value > '{{ plages.matin_debut }}') || (ms && ms.value && ms.value < '{{ plages.matin_fin }}'); maDiv.classList.toggle('warn', !!warn); } if (apDiv && !apDiv.classList.contains('cg')) { - var warnA = (ae && ae.value && ae.value > '14:00') || (as && as.value && as.value < '17:00'); + var warnA = (ae && ae.value && ae.value > '{{ plages.aprem_debut }}') || (as && as.value && as.value < '{{ plages.aprem_fin }}'); apDiv.classList.toggle('warn', !!warnA); } }); diff --git a/app/templates/settings.html b/app/templates/settings.html index 7be2f1b..3a3b42b 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -10,8 +10,9 @@
Notifications (ntfy)

- Crée un topic sur ntfy.sh (un identifiant - secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici. + Crée un topic sur ton serveur ntfy + (public ou auto-hébergé), un identifiant secret que toi seul connais, abonne-toi dessus avec + l'app ntfy sur ton téléphone, puis renseigne les deux champs ci-dessous.

{% if saved %} @@ -19,6 +20,16 @@ {% endif %}
+ + + +
+ + h → + + h, jours ouvrés +
@@ -39,7 +67,7 @@ Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'entrée de la zone, appelle l'URL "arrivée" ; à la sortie, l'URL "départ". Tant que tu es détecté présent sans avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les - 5 minutes (7h–20h, jours ouvrés). + 5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés).

Arrivée @@ -94,5 +122,8 @@ word-break: break-all; } .settings-hint { font-size: 11px; color: var(--muted); } +.settings-range { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; } +.settings-range-input { width: 70px; text-align: center; } +.settings-range-sep { font-size: 12px; color: var(--muted); font-family: var(--mono); } {% endblock %} diff --git a/app/templates/stats.html b/app/templates/stats.html index 133cecf..ff5f4a4 100644 --- a/app/templates/stats.html +++ b/app/templates/stats.html @@ -58,10 +58,10 @@ {# ── 4 time-slot cards ── #}
{% set slot_meta = [ - ("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"), - ("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"), - ("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"), - ("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"), + ("matin_entree", "Entrée matin", "≤ " ~ stats.plages.matin_debut, "le", "matin"), + ("matin_sortie", "Sortie matin", "≥ " ~ stats.plages.matin_fin, "ge", "matin"), + ("aprem_entree", "Entrée après-midi","≤ " ~ stats.plages.aprem_debut,"le", "aprem"), + ("aprem_sortie", "Sortie après-midi","≥ " ~ stats.plages.aprem_fin, "ge", "aprem"), ] %} {% for slot, label, constraint, op, half in slot_meta %} {% set ts = stats.time_stats[slot] %} diff --git a/app/tests/conftest.py b/app/tests/conftest.py new file mode 100644 index 0000000..a371149 --- /dev/null +++ b/app/tests/conftest.py @@ -0,0 +1,24 @@ +"""Freezes calcul.py's notion of "today" for deterministic tests.""" +import datetime as dt_module + +import pytest + +import calcul + + +class FrozenDatetime(dt_module.datetime): + _frozen = dt_module.datetime(2026, 7, 17, 10, 30) # vendredi + + @classmethod + def today(cls): + return cls._frozen + + @classmethod + def now(cls, tz=None): + return cls._frozen + + +@pytest.fixture +def freeze_today(monkeypatch): + monkeypatch.setattr(calcul, "datetime", FrozenDatetime) + return FrozenDatetime._frozen diff --git a/app/tests/test_calcul.py b/app/tests/test_calcul.py new file mode 100644 index 0000000..0f49059 --- /dev/null +++ b/app/tests/test_calcul.py @@ -0,0 +1,155 @@ +from calcul import ( + compute_week, heures_dues, heures_travaillees, hhmm_to_minutes, minutes_to_hhmm, +) + +HEURES_JOUR_MIN = 468 # 7h48 = 7.8 * 60 + + +def _pointage(date, matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None): + return { + "date": date, + "matin_entree": matin_entree, "matin_sortie": matin_sortie, + "aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie, + } + + +def test_hhmm_to_minutes(): + assert hhmm_to_minutes("09:00") == 540 + assert hhmm_to_minutes("00:00") == 0 + assert hhmm_to_minutes(None) == 0 + + +def test_minutes_to_hhmm(): + assert minutes_to_hhmm(468) == "7h48" + assert minutes_to_hhmm(0) == "0h00" + assert minutes_to_hhmm(-90) == "-1h30" + + +def test_heures_dues_weekend(): + assert heures_dues("2026-07-18", [], HEURES_JOUR_MIN) == 0 # samedi + + +def test_heures_dues_conge_jour(): + conges = [{"date": "2026-07-13", "type": "jour"}] + assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0 + + +def test_heures_dues_conge_demi_journee(): + conges = [{"date": "2026-07-13", "type": "matin"}] + assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == HEURES_JOUR_MIN // 2 + + +def test_heures_dues_matin_et_aprem_congés_egale_jour_complet(): + conges = [{"date": "2026-07-13", "type": "matin"}, {"date": "2026-07-13", "type": "aprem"}] + assert heures_dues("2026-07-13", conges, HEURES_JOUR_MIN) == 0 + + +def test_heures_dues_jour_normal(): + assert heures_dues("2026-07-13", [], HEURES_JOUR_MIN) == HEURES_JOUR_MIN + + +def test_heures_travaillees_journee_complete(): + p = _pointage("2026-07-13", "09:00", "12:24", "13:30", "17:54") + assert heures_travaillees(p) == 204 + 264 # 468 + + +def test_heures_travaillees_pas_pointe(): + assert heures_travaillees(_pointage("2026-07-13")) == 0 + + +def test_heures_travaillees_en_cours_aujourdhui(freeze_today): + # freeze_today = 2026-07-17 10:30 ; entrée à 09:00 -> 90 min écoulées + p = _pointage("2026-07-17", matin_entree="09:00") + assert heures_travaillees(p) == 90 + + +def _semaine_complete(dates_travaillees): + """4 jours complets à exactement HEURES_JOUR_MIN, pas de congé.""" + return [ + _pointage(d, "09:00", "12:24", "13:30", "17:54") + for d in dates_travaillees + ] + + +def test_compute_week_semaine_entierement_complete_solde_zero(): + dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"] + pointages = _semaine_complete(dates) + result = compute_week(pointages, [], HEURES_JOUR_MIN) + assert result["total_travaille"] == "39h00" + assert result["total_du"] == "39h00" + assert result["solde_min"] == 0 + assert all(j["is_complete"] for j in result["jours"]) + + +def test_compute_week_cumul_visible_seulement_si_chaine_ininterrompue(): + dates = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"] + pointages = _semaine_complete(dates) + # mercredi incomplet (pas d'aprem) + pointages[2] = _pointage("2026-07-15", "09:00", "12:24") + result = compute_week(pointages, [], HEURES_JOUR_MIN) + jours = result["jours"] + assert jours[0]["cumul_visible"] is True + assert jours[1]["cumul_visible"] is True + assert jours[2]["cumul_visible"] is False # jour incomplet + assert jours[3]["cumul_visible"] is False # chaîne cassée après + assert jours[4]["cumul_visible"] is False + + +def test_compute_week_sortie_cible_journee_en_cours(freeze_today): + """Régression: capture exactement les seuils 9h/12h/13h30/17h codés en dur. + + Lundi-jeudi complets à 468 min. Vendredi (aujourd'hui, frozen) pointé + seulement le matin 09:00-12:00 (180 min). Le calcul doit produire une + cible de sortie et d'entrée dérivées des seuils métier actuels. + """ + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] + pointages = _semaine_complete(dates_completes) + pointages.append(_pointage("2026-07-17", matin_entree="09:00", matin_sortie="12:00")) + + result = compute_week(pointages, [], HEURES_JOUR_MIN) + vendredi = result["jours"][4] + + assert vendredi["sortie_cible"] == "18:18" + assert vendredi["entree_cible"] == "12:12" + + +def test_compute_week_jour_futur_non_entame_pas_de_cible(freeze_today): + """Un jour strictement après "aujourd'hui" et pas encore entamé n'a pas de cible.""" + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16", "2026-07-17"] + pointages = _semaine_complete(dates_completes) + pointages.append(_pointage("2026-07-20")) # lundi suivant, rien pointé + + result = compute_week(pointages, [], HEURES_JOUR_MIN) + lundi_suivant = result["jours"][5] + assert lundi_suivant["entree_cible"] is None + assert lundi_suivant["sortie_cible"] is None + + +def test_compute_week_jour_courant_non_entame_calcule_quand_meme_une_cible(freeze_today): + """"Aujourd'hui" sans rien pointé n'est PAS traité comme futur (date > today est faux à égalité).""" + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] + pointages = _semaine_complete(dates_completes) + pointages.append(_pointage("2026-07-17")) + + result = compute_week(pointages, [], HEURES_JOUR_MIN) + vendredi = result["jours"][4] + assert vendredi["sortie_cible"] == "21:18" + assert vendredi["entree_cible"] is None + + +def test_compute_week_plages_personnalisees(freeze_today): + """Les seuils de plage sont bien paramétrables, pas juste figés sur les défauts.""" + plages = { + "matin_debut": "08:00", "matin_fin": "13:00", + "aprem_debut": "14:00", "aprem_fin": "18:00", + "pause_dejeuner_fin": "14:00", + } + heures_jour = (13 * 60 - 8 * 60) + (18 * 60 - 14 * 60) # 540 = journée complète sous ce schéma + dates_completes = ["2026-07-13", "2026-07-14", "2026-07-15", "2026-07-16"] + pointages = [_pointage(d, "08:00", "13:00", "14:00", "18:00") for d in dates_completes] + pointages.append(_pointage("2026-07-17", "08:00", "13:00")) # matin complet, aprem pas commencé + + result = compute_week(pointages, [], heures_jour, plages) + vendredi = result["jours"][4] + assert vendredi["sortie_cible"] == "18:00" + assert vendredi["entree_cible"] is None diff --git a/app/tests/test_stats.py b/app/tests/test_stats.py new file mode 100644 index 0000000..53c9610 --- /dev/null +++ b/app/tests/test_stats.py @@ -0,0 +1,42 @@ +from calcul import DEFAULT_PLAGES +from stats import _day_compliant + + +def _p(matin_entree=None, matin_sortie=None, aprem_entree=None, aprem_sortie=None): + return { + "matin_entree": matin_entree, "matin_sortie": matin_sortie, + "aprem_entree": aprem_entree, "aprem_sortie": aprem_sortie, + } + + +def test_day_compliant_journee_dans_les_clous(): + p = _p("08:50", "12:05", "13:55", "17:10") + assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is True + + +def test_day_compliant_entree_matin_en_retard(): + p = _p("09:10", "12:05", "13:55", "17:10") + assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is False + + +def test_day_compliant_sortie_aprem_trop_tot(): + p = _p("08:50", "12:05", "13:55", "16:45") + assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is False + + +def test_day_compliant_journee_incomplete_renvoie_none(): + p = _p("08:50", "12:05") # pas d'aprem + assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is None + + +def test_day_compliant_conge_journee_entiere_renvoie_none(): + p = _p() + assert _day_compliant(p, mc=True, ac=True, plages=DEFAULT_PLAGES) is None + + +def test_day_compliant_plages_personnalisees(): + plages = {**DEFAULT_PLAGES, "matin_debut": "08:00"} + p = _p("08:30", "12:05", "13:55", "17:10") + # 08:30 est après 08:00 (plage custom) -> non conforme, alors qu'avec le défaut (09:00) ça le serait + assert _day_compliant(p, mc=False, ac=False, plages=plages) is False + assert _day_compliant(p, mc=False, ac=False, plages=DEFAULT_PLAGES) is True diff --git a/data/config.example.json b/data/config.example.json index a106702..76f48d5 100644 --- a/data/config.example.json +++ b/data/config.example.json @@ -9,5 +9,12 @@ "user": "no-reply@exemple.fr", "password": "change-moi", "use_tls": true + }, + "plages": { + "matin_debut": "09:00", + "matin_fin": "12:00", + "aprem_debut": "14:00", + "aprem_fin": "17:00", + "pause_dejeuner_fin": "13:30" } } diff --git a/requirements.txt b/requirements.txt index e64c13d..529b4e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ jinja2==3.1.4 python-multipart==0.0.9 odfpy==1.4.1 bcrypt==4.2.0 +pytest==8.3.3