Rend les plages horaires et les rappels ntfy configurables, supprime le

classement, ajoute une suite de tests pytest

- calcul.py: matin_debut/fin, aprem_debut/fin et pause_dejeuner_fin sont
  paramétrables via config.json > plages (moteur de calcul de l'heure de
  sortie optimale inclus, pas seulement l'affichage). Défauts identiques
  à l'ancien comportement, validé par la suite de tests.
- stats.py: les seuils de conformité utilisent la même config.
- main.py/templates: warn de saisie et suffixes de cible dynamiques.
- Rappels ntfy: serveur, topic et plage horaire (rappel_debut_h/fin_h)
  configurables par utilisateur depuis /settings, plus en dur dans le code.
- Page classement supprimée (pas de compétition entre utilisateurs).
- Nouvelle suite pytest (app/tests/), à lancer via
  `docker compose run --rm pointeuse pytest -v`.
This commit is contained in:
Antoine
2026-07-17 22:40:55 +02:00
parent 0b47e4a625
commit c2d7d0e3ac
19 changed files with 419 additions and 415 deletions

View File

@ -30,6 +30,8 @@ La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages d
| Matin | 09:00 → 12:00 | | Matin | 09:00 → 12:00 |
| Après-midi | 14:00 → 17: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. 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", "user": "no-reply@exemple.fr",
"password": "change-moi", "password": "change-moi",
"use_tls": true "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. - `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
- `mail_from` — adresse expéditrice des emails de connexion. - `mail_from` — adresse expéditrice des emails de connexion.
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). - `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
```
--- ---

View File

@ -1,7 +1,13 @@
"""Business logic: compute worked hours and expected hours per day/week.""" """Business logic: compute worked hours and expected hours per day/week."""
from datetime import datetime, timedelta 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: 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 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).""" """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_travaille = 0
total_du = 0 total_du = 0
jours = [] 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:]) du_remaining_mat = sum(j["du_min"] for j in jours[i + 1:])
if du_remaining_mat == 0 and not jour.get("matin_entree"): if du_remaining_mat == 0 and not jour.get("matin_entree"):
needed_today_mat = max(0, total_du - worked_before - du_remaining_mat) 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 if needed_today_mat > matin_window: # plus que le créneau matin obligatoire
entree_min = 12 * 60 - needed_today_mat entree_min = matin_fin_min - needed_today_mat
if entree_min >= 6 * 60: # avant 9h obligatoire, pas avant 6h 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}" jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
worked_before += jour["travaille_min"] worked_before += jour["travaille_min"]
continue 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"): 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"])) 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: 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). # (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"]) entree_min = hhmm_to_minutes(jour["matin_entree"])
elapsed = max(0, now_min - entree_min) elapsed = max(0, now_min - entree_min)
projected_to_noon = max(0, 12 * 60 - entree_min) projected_to_fin_matin = max(0, matin_fin_min - entree_min)
worked_morning = max(elapsed, projected_to_noon) worked_morning = max(elapsed, projected_to_fin_matin)
needed_aprem = max(0, needed_today - worked_morning) 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 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, 17 * 60) sortie_min = max(aprem_start + needed_aprem, aprem_fin_min)
if sortie_min > 23 * 60 + 59: if sortie_min > 23 * 60 + 59:
jour["sortie_cible"] = None # deficit too large to recover in one afternoon 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}" jour["sortie_cible"] = f"{sortie_min // 60:02d}:{sortie_min % 60:02d}"
entree_cible = None entree_cible = None
if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (17 * 60 - APREM_DEBUT_MIN): if du_remaining == 0 and not jour.get("aprem_entree") and needed_aprem > (aprem_fin_min - pause_dejeuner_fin_min):
entree_min = 17 * 60 - needed_aprem entree_min = aprem_fin_min - needed_aprem
if 12 * 60 < entree_min < APREM_DEBUT_MIN: # avant l'heure de reprise par défaut, après le déjeuner 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}" entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}"
jour["entree_cible"] = entree_cible jour["entree_cible"] = entree_cible
worked_before += jour["travaille_min"] worked_before += jour["travaille_min"]

View File

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

View File

@ -19,9 +19,8 @@ from models import (
load_notif_config, save_notif_config, find_user_by_token, save_presence, load_notif_config, save_notif_config, find_user_by_token, save_presence,
load_auth, save_auth, find_user_by_reset_token, 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 stats import compute_all_stats
from classement import compute_classement
from notifications import reminder_loop from notifications import reminder_loop
logging.basicConfig( logging.basicConfig(
@ -97,6 +96,10 @@ def heures_jour_min() -> int:
return int(float(load_config().get("heures_jour", 7.8)) * 60) 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: def _weeks_by_year(all_weeks: list) -> dict:
result = {} result = {}
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]): 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}) "aprem_entree": None, "aprem_sortie": None})
for d in dates 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): 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 = "" html = ""
for jour in result["jours"]: for jour in result["jours"]:
date = jour["date"] date = jour["date"]
@ -169,7 +172,7 @@ def _oob_week_extras(result):
cible = None cible = None
if not jour.get("is_complete"): if not jour.get("is_complete"):
if jour.get("entree_cible"): 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}' cible = f'{jour["entree_cible"]}{suffix}'
elif jour.get("sortie_cible"): elif jour.get("sortie_cible"):
cible = jour.get("sortie_cible") cible = jour.get("sortie_cible")
@ -178,17 +181,17 @@ def _oob_week_extras(result):
return html return html
def _oob_time_cells(date, jour): def _oob_time_cells(date, jour, plages):
cg = jour["conge"] cg = jour["conge"]
matin_cg = "matin" in cg or "jour" in cg matin_cg = "matin" in cg or "jour" in cg
aprem_cg = "aprem" 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 ( 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_entree") and jour["matin_entree"] > plages["matin_debut"]) or
(jour.get("matin_sortie") and jour["matin_sortie"] < "12:00") (jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"])
) )
aprem_warn = not aprem_cg and jour["du_min"] > 0 and ( 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_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or
(jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00") (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") 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") 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): def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar() iso = d.isocalendar()
plages = plages_config()
jour = _build_jour_ctx(date, conges, h_jour, user_id) jour = _build_jour_ctx(date, conges, h_jour, user_id)
cg = jour["conge"] cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else "" 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_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}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
_oob_time_cells(date, jour) _oob_time_cells(date, jour, plages)
+ btns_html + btns_html
+ _oob_calc(date, jour) + _oob_calc(date, jour)
+ _oob_week_extras(soldes_ctx["result"]) + _oob_week_extras(soldes_ctx["result"], plages)
+ soldes_html + soldes_html
) )
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
iso = d.isocalendar() iso = d.isocalendar()
plages = plages_config()
jour = _build_jour_ctx(date, conges, h_jour, user_id) 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_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}) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse( return HTMLResponse(
_oob_calc(date, jour) _oob_calc(date, jour)
+ _oob_time_cells(date, jour) + _oob_time_cells(date, jour, plages)
+ _oob_week_extras(soldes_ctx["result"]) + _oob_week_extras(soldes_ctx["result"], plages)
+ soldes_html + soldes_html
) )
@ -403,6 +408,7 @@ def semaine(request: Request, year: int, week: int):
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
h_jour = heures_jour_min() h_jour = heures_jour_min()
plages = plages_config()
conges = load_conges(user_id) conges = load_conges(user_id)
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, 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}) "aprem_entree": None, "aprem_sortie": None})
for d in dates 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) 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) 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, "year": year, "week": week,
"result": result, "result": result,
**soldes_ctx, **soldes_ctx,
"plages": plages,
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week, "prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week, "next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
"jours_fr": JOURS_FR, "jours_fr": JOURS_FR,
@ -527,6 +534,9 @@ def settings_page(request: Request):
"request": request, "request": request,
"current_user": user_id, "current_user": user_id,
"ntfy_topic": notif.get("ntfy_topic") or "", "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", "arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
"depart_url": f"{base_url}/presence/{notif['token']}/depart", "depart_url": f"{base_url}/presence/{notif['token']}/depart",
"saved": request.query_params.get("saved") == "1", "saved": request.query_params.get("saved") == "1",
@ -534,13 +544,22 @@ def settings_page(request: Request):
@app.post("/settings", response_class=HTMLResponse) @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) user_id = _get_user(request)
if not user_id: if not user_id:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
logger.info("réglages notif mis à jour: user_id=%r ntfy_topic=%r", user_id, ntfy_topic) rappel_debut_h = max(0, min(23, rappel_debut_h))
save_notif_config(user_id, ntfy_topic) 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) return RedirectResponse("/settings?saved=1", status_code=303)
@ -566,15 +585,3 @@ def presence_depart(token: str):
return {"ok": True} 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,
})

View File

@ -3,6 +3,8 @@ import json
import secrets import secrets
from pathlib import Path from pathlib import Path
from calcul import DEFAULT_PLAGES
DATA_DIR = Path("/data") DATA_DIR = Path("/data")
CONFIG_FILE = DATA_DIR / "config.json" CONFIG_FILE = DATA_DIR / "config.json"
@ -18,6 +20,7 @@ DEFAULT_CONFIG = {
"password": "", "password": "",
"use_tls": True, "use_tls": True,
}, },
"plages": dict(DEFAULT_PLAGES),
} }
# ── User path helpers ────────────────────────────────────────────────────────── # ── User path helpers ──────────────────────────────────────────────────────────
@ -142,24 +145,41 @@ def _notif_file(user_id: str) -> Path:
return _user_root(user_id) / "notif.json" 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: 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) f = _notif_file(user_id)
if f.exists(): if f.exists():
cfg = json.loads(f.read_text()) 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"): if cfg.get("token"):
return cfg return cfg
else: 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) cfg["token"] = secrets.token_hex(16)
_ensure_user_dirs(user_id) _ensure_user_dirs(user_id)
f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) f.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
return cfg 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 = load_notif_config(user_id)
cfg["ntfy_topic"] = ntfy_topic.strip() or None 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) _ensure_user_dirs(user_id)
_notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) _notif_file(user_id).write_text(json.dumps(cfg, indent=2, ensure_ascii=False))

View File

@ -8,24 +8,21 @@ from models import list_users, load_notif_config, load_presence, load_week_point
logger = logging.getLogger("pointeuse.notifications") logger = logging.getLogger("pointeuse.notifications")
NTFY_BASE = "https://ntfy.sh"
CHECK_INTERVAL_SECONDS = 300 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( req = urllib.request.Request(
f"{NTFY_BASE}/{topic}", f"{server}/{topic}",
data=message.encode("utf-8"), data=message.encode("utf-8"),
headers={"Title": title.encode("utf-8"), "Priority": "high"}, headers={"Title": title.encode("utf-8"), "Priority": "high"},
method="POST", method="POST",
) )
try: try:
urllib.request.urlopen(req, timeout=10).close() 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: 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 # 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): async def check_user(user_id: str, now: datetime):
notif = load_notif_config(user_id) notif = load_notif_config(user_id)
topic = notif.get("ntfy_topic") 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: if not topic:
logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id) logger.debug("check_user(%r): pas de topic ntfy configuré, ignoré", user_id)
return return
if now.weekday() >= 5 or not (RAPPEL_DEBUT_H <= now.hour < RAPPEL_FIN_H): if now.weekday() >= 5 or not (debut_h <= now.hour < fin_h):
logger.debug("check_user(%r): hors plage de rappel (%s)", user_id, now) logger.debug("check_user(%r): hors plage de rappel (%s, plage %sh-%sh)", user_id, now, debut_h, fin_h)
return return
presence = load_presence(user_id) 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"): if presence.get("present") and not jour.get("matin_entree"):
logger.info("rappel 'pense à pointer' pour %r", user_id) 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"): 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) 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(): async def reminder_loop():

3
app/pytest.ini Normal file
View File

@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests

View File

@ -3,7 +3,7 @@ import statistics as _st
from datetime import datetime, timedelta, date as _date from datetime import datetime, timedelta, date as _date
from models import load_week_pointages, load_conges, list_all_weeks, load_config 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 = [ MOIS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin", "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}" 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: if mc and ac:
return None return None
matin_ok = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie"))) 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): if not (matin_ok and aprem_ok):
return None return None
if not mc: 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 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 return False
if not ac: 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 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 False
return True 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 n = n_ok = 0
for date, p in all_days: for date, p in all_days:
mc = (date, "matin") in cg_set or (date, "jour") in cg_set mc = (date, "matin") in cg_set or (date, "jour") in cg_set
ac = (date, "aprem") 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: if result is None:
continue continue
n += 1 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: def compute_all_stats(user_id: str = "") -> dict:
all_weeks = sorted(list_all_weeks(user_id)) all_weeks = sorted(list_all_weeks(user_id))
conges = load_conges(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} cg_set = {(c["date"], c["type"]) for c in conges}
today = _date.today() today = _date.today()
@ -73,10 +75,10 @@ def compute_all_stats(user_id: str = "") -> dict:
comp_ok = {s: 0 for s in SLOTS} comp_ok = {s: 0 for s in SLOTS}
TARGETS = { TARGETS = {
"matin_entree": ("le", 9 * 60), "matin_entree": ("le", hhmm_to_minutes(plages["matin_debut"])),
"matin_sortie": ("ge", 12 * 60), "matin_sortie": ("ge", hhmm_to_minutes(plages["matin_fin"])),
"aprem_entree": ("le", 14 * 60), "aprem_entree": ("le", hhmm_to_minutes(plages["aprem_debut"])),
"aprem_sortie": ("ge", 17 * 60), "aprem_sortie": ("ge", hhmm_to_minutes(plages["aprem_fin"])),
} }
weekly_balances = [] weekly_balances = []
@ -177,13 +179,14 @@ def compute_all_stats(user_id: str = "") -> dict:
deltas = [b["delta_min"] for b in weekly_balances] deltas = [b["delta_min"] for b in weekly_balances]
avg_delta_min = round(_st.mean(deltas)) if deltas else 0 avg_delta_min = round(_st.mean(deltas)) if deltas else 0
comp_globale = _compliance_over(all_days, cg_set) comp_globale = _compliance_over(all_days, cg_set, plages)
comp_mois = _compliance_over(month_days, cg_set) comp_mois = _compliance_over(month_days, cg_set, plages)
comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set) comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set, plages)
return { return {
"time_stats": time_stats, "time_stats": time_stats,
"compliance": compliance, "compliance": compliance,
"plages": plages,
"weekly_balances": weekly_balances, "weekly_balances": weekly_balances,
"total_weeks": len(weekly_balances), "total_weeks": len(weekly_balances),
"n_jours": n_jours, "n_jours": n_jours,

View File

@ -1,12 +1,12 @@
{% set matin_cg = 'matin' in j.conge or 'jour' in j.conge %} {% 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 aprem_cg = 'aprem' in j.conge or 'jour' in j.conge %}
{% set matin_warn = not matin_cg and j.du_min > 0 and ( {% set matin_warn = not matin_cg and j.du_min > 0 and (
(j.matin_entree and j.matin_entree > '09:00') or (j.matin_entree and j.matin_entree > plages.matin_debut) or
(j.matin_sortie and j.matin_sortie < '12:00') (j.matin_sortie and j.matin_sortie < plages.matin_fin)
) %} ) %}
{% set aprem_warn = not aprem_cg and j.du_min > 0 and ( {% set aprem_warn = not aprem_cg and j.du_min > 0 and (
(j.aprem_entree and j.aprem_entree > '14:00') or (j.aprem_entree and j.aprem_entree > plages.aprem_debut) or
(j.aprem_sortie and j.aprem_sortie < '17:00') (j.aprem_sortie and j.aprem_sortie < plages.aprem_fin)
) %} ) %}
<tr id="row-{{ j.date }}"> <tr id="row-{{ j.date }}">
@ -72,9 +72,9 @@
{%- if not j.is_complete -%} {%- if not j.is_complete -%}
{%- if j.entree_cible -%} {%- if j.entree_cible -%}
{%- if 'aprem' in j.conge -%} {%- if 'aprem' in j.conge -%}
<span class="cible-num">{{ j.entree_cible }}→12:00</span> <span class="cible-num">{{ j.entree_cible }}→{{ plages.matin_fin }}</span>
{%- else -%} {%- else -%}
<span class="cible-num">{{ j.entree_cible }}→17:00</span> <span class="cible-num">{{ j.entree_cible }}→{{ plages.aprem_fin }}</span>
{%- endif -%} {%- endif -%}
{%- elif j.sortie_cible -%} {%- elif j.sortie_cible -%}
<span class="cible-num">{{ j.sortie_cible }}</span> <span class="cible-num">{{ j.sortie_cible }}</span>

View File

@ -1007,7 +1007,6 @@
{% if current_user is defined and current_user %} {% if current_user is defined and current_user %}
<a href="/" class="nav-link">Semaine courante</a> <a href="/" class="nav-link">Semaine courante</a>
<a href="/stats" class="nav-link">Statistiques</a> <a href="/stats" class="nav-link">Statistiques</a>
<a href="/classement" class="nav-link">Classement</a>
<a href="/settings" class="nav-link">Réglages</a> <a href="/settings" class="nav-link">Réglages</a>
<span class="nav-user">{{ current_user }}</span> <span class="nav-user">{{ current_user }}</span>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a> <a href="/logout" class="nav-link nav-logout">Déconnexion</a>

View File

@ -1,278 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="clmt-head">
<span class="clmt-eyebrow">Compétition interne</span>
<h1 class="clmt-title">Classement</h1>
<p class="clmt-sub">Précision sur le zéro heure supp · Conformité aux directives</p>
</div>
{% if ranking %}
{# ── Podium top 3 ── #}
{% if ranking|length >= 1 %}
<div class="podium">
{% 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 %}
<div class="podium-slot podium-rank-{{ rank }}{% if u.user_id == current_user %} podium-me{% endif %}">
<div class="podium-score">{{ u.score }}</div>
<div class="podium-name">{{ u.display_name }}</div>
<div class="podium-medal">{% if rank == 1 %}🥇{% elif rank == 2 %}🥈{% else %}🥉{% endif %}</div>
<div class="podium-bar-wrap">
<div class="podium-bar podium-bar-{{ rank }}"></div>
</div>
<div class="podium-rank-label">#{{ rank }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ── Tableau complet ── #}
<div class="clmt-table-wrap">
<table class="clmt-table">
<thead>
<tr>
<th class="col-rank">#</th>
<th class="col-name">Utilisateur</th>
<th class="col-num" title="Score combiné (0100)">Score</th>
<th class="col-num" title="% semaines avec |solde| ≤ 30 min">Préc. zéro</th>
<th class="col-num" title="Conformité aux horaires direction">Conformité</th>
<th class="col-num" title="Écart moyen absolu en heures">Écart moy.</th>
<th class="col-num" title="Nombre de semaines">Semaines</th>
<th class="col-num" title="Nombre de jours complets">Jours</th>
</tr>
</thead>
<tbody>
{% for u in ranking %}
<tr class="clmt-row{% if u.user_id == current_user %} clmt-row-me{% endif %}">
<td class="col-rank">
{% if loop.index == 1 %}<span class="rank-badge rank-1">1</span>
{% elif loop.index == 2 %}<span class="rank-badge rank-2">2</span>
{% elif loop.index == 3 %}<span class="rank-badge rank-3">3</span>
{% else %}<span class="rank-num">{{ loop.index }}</span>
{% endif %}
</td>
<td class="col-name">
{{ u.display_name }}
{% if u.user_id == current_user %}<span class="me-tag">moi</span>{% endif %}
</td>
<td class="col-num">
<div class="score-bar-wrap">
<div class="score-bar" style="width:{{ u.score }}%"></div>
<span class="score-val">{{ u.score }}</span>
</div>
</td>
<td class="col-num">{{ u.pct_zero if u.pct_zero is not none else '—' }}{% if u.pct_zero is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num">{{ u.conf_pct if u.conf_pct is not none else '—' }}{% if u.conf_pct is not none %}<span class="pct-sym">%</span>{% endif %}</td>
<td class="col-num {% if u.avg_delta_min > 0 %}num-pos{% elif u.avg_delta_min < 0 %}num-neg{% endif %}">{{ u.avg_delta }}</td>
<td class="col-num col-minor">{{ u.n_semaines }}</td>
<td class="col-num col-minor">{{ u.n_jours }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="clmt-legend">
<div class="clmt-legend-item"><strong>Score</strong> = préc. zéro × 0,6 + conformité × 0,4</div>
<div class="clmt-legend-item"><strong>Préc. zéro</strong> = % semaines avec |solde| ≤ 30 min</div>
<div class="clmt-legend-item"><strong>Conformité</strong> = % jours respectant les 4 créneaux (≤9h, ≥12h, ≤14h, ≥17h)</div>
</div>
{% else %}
<div class="clmt-empty">Aucun utilisateur enregistré pour le moment.</div>
{% endif %}
<style>
.clmt-head { border-bottom: 2px solid var(--text); padding-bottom: .75rem; }
.clmt-eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
display: block;
margin-bottom: .3rem;
}
.clmt-title {
font-family: var(--mono);
font-size: 26px;
font-weight: 500;
letter-spacing: -.02em;
}
.clmt-sub { font-size: 12px; color: var(--muted); margin-top: .3rem; }
/* ── Podium ── */
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 1rem;
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 2rem 2rem 0;
min-height: 200px;
}
.podium-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: .3rem;
flex: 1;
max-width: 160px;
}
.podium-slot.podium-me .podium-name { color: var(--accent); }
.podium-score {
font-family: var(--mono);
font-size: 28px;
font-weight: 500;
color: var(--text);
}
.podium-name {
font-size: 13px;
font-weight: 500;
text-align: center;
}
.podium-medal { font-size: 22px; }
.podium-bar-wrap { width: 100%; }
.podium-bar {
width: 100%;
background: var(--accent);
opacity: .25;
}
.podium-bar-1 { height: 80px; }
.podium-bar-2 { height: 55px; }
.podium-bar-3 { height: 35px; }
.podium-rank-1 .podium-bar { opacity: .4; }
.podium-rank-label {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
background: var(--surface);
width: 100%;
text-align: center;
padding: .3rem 0;
border-top: 1px solid var(--rule);
}
/* ── Table ── */
.clmt-table-wrap { overflow-x: auto; }
.clmt-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
background: var(--surface);
border: 1.5px solid var(--rule);
}
.clmt-table th {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
text-align: right;
padding: .6rem .9rem;
border-bottom: 1.5px solid var(--rule);
background: var(--ground);
white-space: nowrap;
}
.clmt-table th.col-name { text-align: left; }
.clmt-table th.col-rank { text-align: center; }
.clmt-row td {
padding: .6rem .9rem;
border-bottom: 1px solid var(--rule-l);
vertical-align: middle;
}
.clmt-row:last-child td { border-bottom: none; }
.clmt-row-me td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.clmt-row-me:hover td { background: color-mix(in srgb, var(--accent) 10%, transparent); }
.clmt-row:hover td { background: var(--ground); }
.col-rank { text-align: center; }
.col-num {
font-family: var(--mono);
font-size: 13px;
text-align: right;
white-space: nowrap;
}
.col-minor { opacity: .65; }
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
font-family: var(--mono);
font-size: 11px;
font-weight: 500;
}
.rank-1 { background: #F0C040; color: #3A2800; }
.rank-2 { background: #C8C8C8; color: #2A2A2A; }
.rank-3 { background: #D4956A; color: #3A1A00; }
.rank-num { font-family: var(--mono); font-size: 12px; color: var(--muted); }
.me-tag {
font-family: var(--mono);
font-size: 9px;
letter-spacing: .06em;
text-transform: uppercase;
background: var(--accent);
color: #fff;
padding: .1rem .35rem;
margin-left: .4rem;
vertical-align: middle;
}
.pct-sym { font-size: 10px; color: var(--muted); margin-left: 1px; }
.num-pos { color: var(--pos); }
.num-neg { color: var(--neg); }
.score-bar-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
gap: .4rem;
}
.score-bar {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 16px;
background: var(--accent);
opacity: .12;
transition: width .4s ease;
min-width: 0;
}
.score-val {
position: relative;
font-family: var(--mono);
font-size: 13px;
z-index: 1;
}
.clmt-legend {
display: flex;
flex-wrap: wrap;
gap: .5rem 2rem;
font-size: 11px;
color: var(--muted);
padding: .5rem 0;
border-top: 1px solid var(--rule-l);
}
.clmt-empty {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 3rem;
}
</style>
{% endblock %}

View File

@ -327,11 +327,11 @@ document.addEventListener('input', function(e) {
var maDiv = document.getElementById('cg-matin-' + date); var maDiv = document.getElementById('cg-matin-' + date);
var apDiv = document.getElementById('cg-aprem-' + date); var apDiv = document.getElementById('cg-aprem-' + date);
if (maDiv && !maDiv.classList.contains('cg')) { 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); maDiv.classList.toggle('warn', !!warn);
} }
if (apDiv && !apDiv.classList.contains('cg')) { 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); apDiv.classList.toggle('warn', !!warnA);
} }
}); });

View File

@ -10,8 +10,9 @@
<div class="settings-card"> <div class="settings-card">
<span class="hist-label">Notifications (ntfy)</span> <span class="hist-label">Notifications (ntfy)</span>
<p class="settings-p"> <p class="settings-p">
Crée un topic sur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy.sh</a> (un identifiant Crée un topic sur ton serveur <a href="https://ntfy.sh" target="_blank" rel="noopener">ntfy</a>
secret que toi seul connais), abonne-toi dessus avec l'app ntfy sur ton téléphone, puis renseigne-le ici. (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.
</p> </p>
{% if saved %} {% if saved %}
@ -19,6 +20,16 @@
{% endif %} {% endif %}
<form method="post" action="/settings" class="settings-form"> <form method="post" action="/settings" class="settings-form">
<label class="login-label" for="ntfy_server">Serveur ntfy</label>
<input
id="ntfy_server"
class="login-input"
type="text"
name="ntfy_server"
value="{{ ntfy_server }}"
placeholder="https://ntfy.sh"
autocomplete="off"
>
<label class="login-label" for="ntfy_topic">Topic ntfy</label> <label class="login-label" for="ntfy_topic">Topic ntfy</label>
<input <input
id="ntfy_topic" id="ntfy_topic"
@ -29,6 +40,23 @@
placeholder="ex: antoine-pointeuse-x7f2" placeholder="ex: antoine-pointeuse-x7f2"
autocomplete="off" autocomplete="off"
> >
<label class="login-label" for="rappel_debut_h">Plage horaire des rappels</label>
<div class="settings-range">
<input
id="rappel_debut_h"
class="login-input settings-range-input"
type="number" min="0" max="23" name="rappel_debut_h"
value="{{ rappel_debut_h }}"
>
<span class="settings-range-sep">h → </span>
<input
id="rappel_fin_h"
class="login-input settings-range-input"
type="number" min="1" max="24" name="rappel_fin_h"
value="{{ rappel_fin_h }}"
>
<span class="settings-range-sep">h, jours ouvrés</span>
</div>
<button class="login-btn" type="submit">Enregistrer</button> <button class="login-btn" type="submit">Enregistrer</button>
</form> </form>
</div> </div>
@ -39,7 +67,7 @@
Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la Crée une automatisation Tasker déclenchée par une géofence autour du bureau : à l'<b>entrée</b> de la
zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, l'URL "départ". Tant que tu es détecté présent sans zone, appelle l'URL "arrivée" ; à la <b>sortie</b>, 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 avoir pointé l'entrée du matin, ou parti sans avoir pointé la sortie, tu reçois un rappel toutes les
5 minutes (7h20h, jours ouvrés). 5 minutes, dans la plage horaire réglée ci-dessus (jours ouvrés).
</p> </p>
<div class="settings-urlrow"> <div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span> <span class="settings-urllabel">Arrivée</span>
@ -94,5 +122,8 @@
word-break: break-all; word-break: break-all;
} }
.settings-hint { font-size: 11px; color: var(--muted); } .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); }
</style> </style>
{% endblock %} {% endblock %}

View File

@ -58,10 +58,10 @@
{# ── 4 time-slot cards ── #} {# ── 4 time-slot cards ── #}
<div class="ts-grid"> <div class="ts-grid">
{% set slot_meta = [ {% set slot_meta = [
("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"), ("matin_entree", "Entrée matin", "≤ " ~ stats.plages.matin_debut, "le", "matin"),
("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"), ("matin_sortie", "Sortie matin", "≥ " ~ stats.plages.matin_fin, "ge", "matin"),
("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"), ("aprem_entree", "Entrée après-midi","≤ " ~ stats.plages.aprem_debut,"le", "aprem"),
("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"), ("aprem_sortie", "Sortie après-midi","≥ " ~ stats.plages.aprem_fin, "ge", "aprem"),
] %} ] %}
{% for slot, label, constraint, op, half in slot_meta %} {% for slot, label, constraint, op, half in slot_meta %}
{% set ts = stats.time_stats[slot] %} {% set ts = stats.time_stats[slot] %}

24
app/tests/conftest.py Normal file
View File

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

155
app/tests/test_calcul.py Normal file
View File

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

42
app/tests/test_stats.py Normal file
View File

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

View File

@ -9,5 +9,12 @@
"user": "no-reply@exemple.fr", "user": "no-reply@exemple.fr",
"password": "change-moi", "password": "change-moi",
"use_tls": true "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"
} }
} }

View File

@ -4,3 +4,4 @@ jinja2==3.1.4
python-multipart==0.0.9 python-multipart==0.0.9
odfpy==1.4.1 odfpy==1.4.1
bcrypt==4.2.0 bcrypt==4.2.0
pytest==8.3.3