From b6e0fcd9257f9f64dc8a728725bee11b49f667c3 Mon Sep 17 00:00:00 2001 From: toto Date: Wed, 24 Jun 2026 17:29:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Impl=C3=A9mente=20syst=C3=A8me=20multi-util?= =?UTF-8?q?isateurs=20avec=20classement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auth cookie simple (SSO-ready) : login/logout, redirection si non authentifié - Données isolées par utilisateur dans /data/users/{user_id}/ - Migration automatique des données legacy au premier login - Page classement avec podium et tableau comparatif (score, précision zéro, conformité) - Nav mise à jour : Classement, affichage utilisateur courant, déconnexion Co-Authored-By: Claude Sonnet 4.6 --- app/classement.py | 48 ++++++ app/main.py | 208 ++++++++++++++++--------- app/models.py | 136 ++++++++++------- app/stats.py | 13 +- app/templates/base.html | 12 ++ app/templates/classement.html | 278 ++++++++++++++++++++++++++++++++++ app/templates/login.html | 109 +++++++++++++ 7 files changed, 673 insertions(+), 131 deletions(-) create mode 100644 app/classement.py create mode 100644 app/templates/classement.html create mode 100644 app/templates/login.html diff --git a/app/classement.py b/app/classement.py new file mode 100644 index 0000000..b42c8fe --- /dev/null +++ b/app/classement.py @@ -0,0 +1,48 @@ +"""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 7e876eb..70c6ef7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,20 @@ import json +import re from datetime import datetime, timedelta from itertools import groupby -from fastapi import FastAPI, Form, Request +from fastapi import FastAPI, Form, Request, Response from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from fastapi.staticfiles import StaticFiles from models import ( load_config, load_week_pointages, save_week_pointages, load_conges, - toggle_conge, list_all_weeks, + toggle_conge, list_all_weeks, needs_migration, migrate_legacy_to_user, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats +from classement import compute_classement app = FastAPI(title="Décompte Horaire") templates = Jinja2Templates(directory="templates") @@ -20,46 +22,62 @@ templates.env.filters["zip"] = zip app.mount("/static", StaticFiles(directory="static"), name="static") JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"] +_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,32}$") +# ── Auth helpers ────────────────────────────────────────────────────────────── + +def _get_user(request: Request) -> str | None: + """Extract current user from cookie. + TODO: replace with SSO token validation when integrating corporate SSO. + """ + uid = request.cookies.get("user_id", "").strip() + return uid if _USERNAME_RE.match(uid) else None + + +def _require_user(request: Request): + """Return user_id or redirect to login.""" + uid = _get_user(request) + if not uid: + return None + return uid + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + def week_dates(year: int, week: int) -> list[str]: first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)] def heures_jour_min() -> int: - cfg = load_config() - return int(float(cfg.get("heures_jour", 7.8)) * 60) + return int(float(load_config().get("heures_jour", 7.8)) * 60) def _weeks_by_year(all_weeks: list) -> dict: - """Group sorted weeks by year, most recent first.""" result = {} for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]): result[yr] = sorted(wks, key=lambda x: x[1], reverse=True) return result -def _build_soldes_ctx(year: int, week: int, conges: list[dict], h_jour: int) -> dict: - """Compute week result for the soldes partial.""" +def _build_soldes_ctx(year, week, conges, h_jour, user_id): dates = week_dates(year, week) - raw = load_week_pointages(year, week) + raw = load_week_pointages(year, week, user_id) p_by_date = {p["date"]: p for p in raw} pointages = [ p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None, "aprem_entree": None, "aprem_sortie": None}) for d in dates ] - result = compute_week(pointages, conges, h_jour) - return {"result": result} + return {"result": compute_week(pointages, conges, h_jour)} -def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: - """Return template context for a single day row.""" +def _build_jour_ctx(date, conges, h_jour, user_id): from calcul import heures_travaillees d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() - raw = load_week_pointages(iso.year, iso.week) + raw = load_week_pointages(iso.year, iso.week, user_id) p_by_date = {p["date"]: p for p in raw} p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None, "aprem_entree": None, "aprem_sortie": None}) @@ -76,13 +94,12 @@ def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict: } -def _oob_calc(date: str, jour: dict) -> str: - """Build OOB fragments for the two calc cells (safe non-table elements).""" +def _oob_calc(date, jour): trav_inner = f'{"—" if not jour["travaille_min"] else jour["travaille"]}' if jour["travaille_min"]: - delta_cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") + cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero") sign = "+" if jour["delta_min"] > 0 else "" - delta_inner = f'{sign}{jour["delta"]}' + delta_inner = f'{sign}{jour["delta"]}' else: delta_inner = '' return ( @@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str: ) -def _oob_week_extras(result: dict) -> str: - """OOB for all 5 days' cumulative delta and sortie cible.""" +def _oob_week_extras(result): html = "" for jour in result["jours"]: date = jour["date"] - # Cumulative delta — only when chain of complete days is unbroken cm = jour["delta_cumul_min"] if jour.get("cumul_visible"): if cm == 0: @@ -108,15 +123,13 @@ def _oob_week_extras(result: dict) -> str: else: cumul_inner = '' html += f'{cumul_inner}' - # Sortie cible — only for incomplete days cible = jour.get("sortie_cible") if not jour.get("is_complete") else None cible_inner = f'{cible}' if cible else '' html += f'{cible_inner}' return html -def _oob_time_cells(date: str, jour: dict) -> str: - """OOB
for matin/aprem cells with congé state and warn class.""" +def _oob_time_cells(date, jour): cg = jour["conge"] matin_cg = "matin" in cg or "jour" in cg aprem_cg = "aprem" in cg or "jour" in cg @@ -149,12 +162,10 @@ def _oob_time_cells(date: str, jour: dict) -> str: return matin_html + aprem_html -def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: - """Return OOB
/ fragments for congé state + calc + soldes. No table elements.""" +def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() - jour = _build_jour_ctx(date, conges, h_jour) + jour = _build_jour_ctx(date, conges, h_jour, user_id) cg = jour["conge"] - btn_ma_on = " on" if "matin" in cg else "" btn_am_on = " on" if "aprem" in cg else "" btn_j_on = " jour-on" if "jour" in cg else "" @@ -166,8 +177,7 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h f'' f'
' ) - - soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) + soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id) soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx}) return HTMLResponse( _oob_time_cells(date, jour) @@ -178,11 +188,10 @@ def _htmx_conge_and_soldes(request: Request, date: str, d, conges: list[dict], h ) -def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_jour: int) -> HTMLResponse: - """Return OOB /
for calc cells, time cells, week extras, and soldes.""" +def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): iso = d.isocalendar() - jour = _build_jour_ctx(date, conges, h_jour) - soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour) + 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) @@ -192,8 +201,45 @@ def _htmx_calc_and_soldes(request: Request, date: str, d, conges: list[dict], h_ ) +# ── Login / logout ──────────────────────────────────────────────────────────── + +@app.get("/login", response_class=HTMLResponse) +def login_page(request: Request): + if _get_user(request): + return RedirectResponse("/", status_code=302) + return templates.TemplateResponse("login.html", {"request": request, "error": None}) + + +@app.post("/login", response_class=HTMLResponse) +def login_submit(request: Request, response: Response, username: str = Form("")): + username = username.strip().lower() + if not _USERNAME_RE.match(username): + return templates.TemplateResponse("login.html", { + "request": request, + "error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 1–32 caractères).", + }) + # First-ever login: migrate legacy flat data to this user's directory + if needs_migration(): + migrate_legacy_to_user(username) + + resp = RedirectResponse("/", status_code=302) + resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) + return resp + + +@app.get("/logout") +def logout(): + resp = RedirectResponse("/login", status_code=302) + resp.delete_cookie("user_id") + return resp + + +# ── Main routes ─────────────────────────────────────────────────────────────── + @app.get("/", response_class=HTMLResponse) def index(request: Request): + if not _get_user(request): + return RedirectResponse("/login", status_code=302) today = datetime.today() iso = today.isocalendar() return RedirectResponse(f"/semaine/{iso.year}/{iso.week}") @@ -201,12 +247,16 @@ def index(request: Request): @app.get("/semaine/{year}/{week}", response_class=HTMLResponse) def semaine(request: Request, year: int, week: int): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + h_jour = heures_jour_min() - conges = load_conges() - soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour) + conges = load_conges(user_id) + soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id) dates = week_dates(year, week) - raw = load_week_pointages(year, week) + raw = load_week_pointages(year, week, user_id) p_by_date = {p["date"]: p for p in raw} pointages = [ p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None, @@ -217,58 +267,71 @@ def semaine(request: Request, year: int, week: int): 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) - prev_iso = prev_d.isocalendar() - next_iso = next_d.isocalendar() - - all_weeks = list_all_weeks() + all_weeks = list_all_weeks(user_id) return templates.TemplateResponse("semaine.html", { "request": request, "year": year, "week": week, "result": result, **soldes_ctx, - "prev_year": prev_iso.year, "prev_week": prev_iso.week, - "next_year": next_iso.year, "next_week": next_iso.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, "jours_fr": JOURS_FR, "all_weeks": all_weeks, "weeks_by_year": _weeks_by_year(all_weeks), + "current_user": user_id, }) @app.post("/pointage/{date}", response_class=HTMLResponse) def save_pointage( - request: Request, - date: str, - matin_entree: str = Form(""), - matin_sortie: str = Form(""), - aprem_entree: str = Form(""), - aprem_sortie: str = Form(""), + request: Request, date: str, + matin_entree: str = Form(""), matin_sortie: str = Form(""), + aprem_entree: str = Form(""), aprem_sortie: str = Form(""), ): - def clean(v: str): - v = v.strip() - return v if v else None + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + def clean(v): + v = v.strip(); return v if v else None d = datetime.strptime(date, "%Y-%m-%d").date() iso = d.isocalendar() - raw = load_week_pointages(iso.year, iso.week) + raw = load_week_pointages(iso.year, iso.week, user_id) p_by_date = {p["date"]: p for p in raw} p_by_date[date] = { "date": date, - "matin_entree": clean(matin_entree), - "matin_sortie": clean(matin_sortie), - "aprem_entree": clean(aprem_entree), - "aprem_sortie": clean(aprem_sortie), + "matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie), + "aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie), } - save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"])) + save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id) + conges = load_conges(user_id) + return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id) - h_jour = heures_jour_min() - conges = load_conges() - return _htmx_calc_and_soldes(request, date, d, conges, h_jour) + +@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse) +def toggle_conge_route(date: str, type_conge: str, request: Request): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + toggle_conge(date, type_conge, user_id) + d = datetime.strptime(date, "%Y-%m-%d").date() + if request.headers.get("HX-Request"): + conges = load_conges(user_id) + return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id) + iso = d.isocalendar() + return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) @app.get("/stats", response_class=HTMLResponse) def stats_page(request: Request): - stats = compute_all_stats() + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + stats = compute_all_stats(user_id) data_json = json.dumps({ "time_stats": { slot: { @@ -286,18 +349,19 @@ def stats_page(request: Request): "request": request, "stats": stats, "data_json": data_json, + "current_user": user_id, }) -@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse) -def toggle_conge_route(date: str, type_conge: str, request: Request): - toggle_conge(date, type_conge) - d = datetime.strptime(date, "%Y-%m-%d").date() - if request.headers.get("HX-Request"): - h_jour = heures_jour_min() - conges = load_conges() - return _htmx_conge_and_soldes(request, date, d, conges, h_jour) - iso = d.isocalendar() - return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303) - +@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 aa462bb..997450a 100644 --- a/app/models.py +++ b/app/models.py @@ -1,25 +1,75 @@ """Flat-file storage: JSON files per week for pointages, one file for congés.""" import json +import shutil from pathlib import Path DATA_DIR = Path("/data") -POINTAGES_DIR = DATA_DIR / "pointages" -CONGES_FILE = DATA_DIR / "conges.json" CONFIG_FILE = DATA_DIR / "config.json" DEFAULT_CONFIG = { "heures_jour": 7.8, # 7h48 = 39h/semaine } +# ── User path helpers ────────────────────────────────────────────────────────── -def _ensure_dirs(): - POINTAGES_DIR.mkdir(parents=True, exist_ok=True) +def _user_root(user_id: str) -> Path: + """Return data directory for a user. + + Legacy fallback: if no users/ directory exists yet but /data/pointages/ does, + the first login migrates the data automatically (see migrate_legacy_to_user). + After migration, each user gets /data/users/{user_id}/. + """ + return DATA_DIR / "users" / user_id -# ---------- config ---------- +def _pointages_dir(user_id: str) -> Path: + return _user_root(user_id) / "pointages" + + +def _conges_file(user_id: str) -> Path: + return _user_root(user_id) / "conges.json" + + +def _ensure_user_dirs(user_id: str): + _pointages_dir(user_id).mkdir(parents=True, exist_ok=True) + + +# ── Legacy migration (called once on first login) ───────────────────────────── + +def needs_migration() -> bool: + """True when legacy flat data exists and no users/ directory has been created yet.""" + return (DATA_DIR / "pointages").exists() and not (DATA_DIR / "users").exists() + + +def migrate_legacy_to_user(user_id: str): + """Move /data/pointages/ and /data/conges.json into /data/users/{user_id}/.""" + user_root = _user_root(user_id) + user_root.mkdir(parents=True, exist_ok=True) + + legacy_pt = DATA_DIR / "pointages" + dest_pt = user_root / "pointages" + if legacy_pt.exists() and not dest_pt.exists(): + shutil.copytree(legacy_pt, dest_pt) + + legacy_cg = DATA_DIR / "conges.json" + dest_cg = user_root / "conges.json" + if legacy_cg.exists() and not dest_cg.exists(): + shutil.copy2(legacy_cg, dest_cg) + + +# ── User list ───────────────────────────────────────────────────────────────── + +def list_users() -> list[str]: + users_dir = DATA_DIR / "users" + if not users_dir.exists(): + return [] + return sorted(p.name for p in users_dir.iterdir() if p.is_dir()) + + +# ── Config (global) ─────────────────────────────────────────────────────────── def load_config() -> dict: - _ensure_dirs() + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) if CONFIG_FILE.exists(): return json.loads(CONFIG_FILE.read_text()) CONFIG_FILE.write_text(json.dumps(DEFAULT_CONFIG, indent=2)) @@ -27,78 +77,62 @@ def load_config() -> dict: def save_config(cfg: dict): - _ensure_dirs() + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) CONFIG_FILE.write_text(json.dumps(cfg, indent=2, ensure_ascii=False)) -# ---------- pointages ---------- +# ── Pointages ───────────────────────────────────────────────────────────────── -def _week_file(year: int, week: int) -> Path: - return POINTAGES_DIR / f"{year}-W{week:02d}.json" +def _week_file(user_id: str, year: int, week: int) -> Path: + return _pointages_dir(user_id) / f"{year}-W{week:02d}.json" -def load_week_pointages(year: int, week: int) -> list[dict]: - f = _week_file(year, week) - if f.exists(): - return json.loads(f.read_text()) - return [] +def load_week_pointages(year: int, week: int, user_id: str = "") -> list[dict]: + f = _week_file(user_id, year, week) + return json.loads(f.read_text()) if f.exists() else [] -def save_week_pointages(year: int, week: int, pointages: list[dict]): - _ensure_dirs() - _week_file(year, week).write_text(json.dumps(pointages, indent=2, ensure_ascii=False)) +def save_week_pointages(year: int, week: int, pointages: list[dict], user_id: str = ""): + _ensure_user_dirs(user_id) + _week_file(user_id, year, week).write_text( + json.dumps(pointages, indent=2, ensure_ascii=False) + ) -def upsert_pointages(records: list[dict]): - """Insert or update pointage records, grouped by ISO week.""" - from datetime import datetime - by_week: dict[tuple, dict[str, dict]] = {} - for r in records: - d = datetime.strptime(r["date"], "%Y-%m-%d").date() - iso = d.isocalendar() - key = (iso.year, iso.week) - by_week.setdefault(key, {}) - by_week[key][r["date"]] = r - - for (year, week), new_by_date in by_week.items(): - existing = {p["date"]: p for p in load_week_pointages(year, week)} - existing.update(new_by_date) - save_week_pointages(year, week, sorted(existing.values(), key=lambda p: p["date"])) - - -def list_all_weeks() -> list[tuple[int, int]]: - """Return sorted list of (year, week) for which we have data.""" - _ensure_dirs() +def list_all_weeks(user_id: str = "") -> list[tuple[int, int]]: + pt_dir = _pointages_dir(user_id) + if not pt_dir.exists(): + return [] weeks = [] - for f in POINTAGES_DIR.glob("????-W??.json"): + for f in pt_dir.glob("????-W??.json"): parts = f.stem.split("-W") if len(parts) == 2: weeks.append((int(parts[0]), int(parts[1]))) return sorted(weeks) -# ---------- congés ---------- +# ── Congés ──────────────────────────────────────────────────────────────────── -def load_conges() -> list[dict]: - if CONGES_FILE.exists(): - return json.loads(CONGES_FILE.read_text()) - return [] +def load_conges(user_id: str = "") -> list[dict]: + f = _conges_file(user_id) + return json.loads(f.read_text()) if f.exists() else [] -def save_conges(conges: list[dict]): - _ensure_dirs() - CONGES_FILE.write_text(json.dumps(conges, indent=2, ensure_ascii=False)) +def save_conges(conges: list[dict], user_id: str = ""): + _ensure_user_dirs(user_id) + _conges_file(user_id).write_text( + json.dumps(conges, indent=2, ensure_ascii=False) + ) -def toggle_conge(date: str, type_conge: str): +def toggle_conge(date: str, type_conge: str, user_id: str = ""): assert type_conge in ("jour", "matin", "aprem") - conges = load_conges() + conges = load_conges(user_id) already = any(c["date"] == date and c["type"] == type_conge for c in conges) if already: conges = [c for c in conges if not (c["date"] == date and c["type"] == type_conge)] else: - # Adding: remove conflicting entries first if type_conge == "jour": conges = [c for c in conges if c["date"] != date] else: @@ -106,4 +140,4 @@ def toggle_conge(date: str, type_conge: str): conges.append({"date": date, "type": type_conge}) conges.sort(key=lambda c: (c["date"], c["type"])) - save_conges(conges) + save_conges(conges, user_id) diff --git a/app/stats.py b/app/stats.py index d38014a..7ddc507 100644 --- a/app/stats.py +++ b/app/stats.py @@ -50,9 +50,9 @@ def _compliance_over(all_days: list[tuple], cg_set: set) -> dict: return {"pct": round(100 * n_ok / n) if n else None, "n": n, "n_ok": n_ok} -def compute_all_stats() -> dict: - all_weeks = sorted(list_all_weeks()) - conges = load_conges() +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) cg_set = {(c["date"], c["type"]) for c in conges} @@ -63,7 +63,6 @@ def compute_all_stats() -> dict: current_ym = (today.year, today.month) SLOTS = ["matin_entree", "matin_sortie", "aprem_entree", "aprem_sortie"] - # Store (value, date) pairs per slot slot_pairs: dict[str, list[tuple[int, str]]] = {s: [] for s in SLOTS} comp_n = {s: 0 for s in SLOTS} comp_ok = {s: 0 for s in SLOTS} @@ -81,7 +80,7 @@ def compute_all_stats() -> dict: prev_week_day_list: list[tuple] = [] for year, week in all_weeks: - raw = load_week_pointages(year, week) + raw = load_week_pointages(year, week, user_id) if not raw: continue @@ -136,7 +135,7 @@ def compute_all_stats() -> dict: weekly_balances.append({ "year": year, "week": week, "label": f"S{week:02d}", - "date": dates[0], # Monday — used for period filtering + "date": dates[0], "delta_min": week_delta, "delta": minutes_to_hhmm(week_delta), "partial": week_has_incomplete, @@ -163,7 +162,6 @@ def compute_all_stats() -> dict: for slot in comp_n } rates = [v for v in compliance.values() if v is not None] - overall_slot = round(sum(rates) / len(rates)) if rates else None n_jours = max(len(slot_pairs["matin_entree"]), len(slot_pairs["aprem_entree"])) deltas = [b["delta_min"] for b in weekly_balances] @@ -176,7 +174,6 @@ def compute_all_stats() -> dict: return { "time_stats": time_stats, "compliance": compliance, - "overall_compliance": overall_slot, "weekly_balances": weekly_balances, "total_weeks": len(weekly_balances), "n_jours": n_jours, diff --git a/app/templates/base.html b/app/templates/base.html index d137f22..278ba8a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -79,6 +79,13 @@ transition: color .15s; } nav a.nav-link:hover { color: var(--ground); } + .nav-user { + font-family: var(--mono); + font-size: 11px; + color: var(--accent-2); + letter-spacing: .05em; + } + .nav-logout { opacity: .6; font-size: 11px; } /* ── Layout ── */ .container { @@ -836,6 +843,11 @@ Semaine courante Statistiques + Classement + {% if current_user is defined and current_user %} + {{ current_user }} + Déconnexion + {% endif %}
{% block content %}{% endblock %} diff --git a/app/templates/classement.html b/app/templates/classement.html new file mode 100644 index 0000000..84849b7 --- /dev/null +++ b/app/templates/classement.html @@ -0,0 +1,278 @@ +{% 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/login.html b/app/templates/login.html new file mode 100644 index 0000000..dfa8d6e --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% block content %} + + + +{% endblock %} From 761a86986a927063aceb16fe1d96a7cd67ec81bb Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 16 Jul 2026 14:12:03 +0200 Subject: [PATCH 2/2] =?UTF-8?q?Projette=20les=20cibles=20en=20temps=20r?= =?UTF-8?q?=C3=A9el=20et=20corrige=20le=20fuseau=20horaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calcule les heures travaillées et cibles d'entrée/sortie même quand une demi-journée est en cours (pas encore de pointage de sortie), au lieu d'attendre la fin de journée. Ajoute un endpoint /refresh pour rafraîchir l'affichage sans repointer. Désambiguïse les libellés de semaine sur plusieurs années et force TZ=Europe/Paris dans le conteneur. Co-Authored-By: Claude Sonnet 5 --- app/calcul.py | 64 ++++++++++++++++++--- app/main.py | 19 ++++++- app/stats.py | 14 ++++- app/templates/_ligne.html | 14 ++++- app/templates/base.html | 87 ++++++++++++++++++++++++++++ app/templates/semaine.html | 111 ++++++++++++++++++++++++++++++++++++ app/templates/stats.html | 113 +++++++++++++++++++++++++++++++++---- compose.yml | 2 + 8 files changed, 400 insertions(+), 24 deletions(-) diff --git a/app/calcul.py b/app/calcul.py index 01b8ff8..ebd6cd3 100644 --- a/app/calcul.py +++ b/app/calcul.py @@ -1,6 +1,8 @@ """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 + def hhmm_to_minutes(val: str | None) -> int: if not val: @@ -16,14 +18,26 @@ def minutes_to_hhmm(minutes: int) -> str: def heures_travaillees(p: dict) -> int: - """Return worked minutes for a pointage row.""" - matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"])) - aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"])) - # only count if entry AND exit are present - if not (p["matin_entree"] and p["matin_sortie"]): - matin = 0 - if not (p["aprem_entree"] and p["aprem_sortie"]): - aprem = 0 + """Return worked minutes for a pointage row. + + If a half-day has started but has no exit time yet, count elapsed time up to now + (today only — past dates with missing exits are treated as 0). + """ + today = datetime.today().strftime("%Y-%m-%d") + now_min = datetime.today().hour * 60 + datetime.today().minute + + matin = 0 + if p["matin_entree"] and p["matin_sortie"]: + matin = max(0, hhmm_to_minutes(p["matin_sortie"]) - hhmm_to_minutes(p["matin_entree"])) + elif p["matin_entree"] and p.get("date") == today: + matin = max(0, now_min - hhmm_to_minutes(p["matin_entree"])) + + aprem = 0 + if p["aprem_entree"] and p["aprem_sortie"]: + aprem = max(0, hhmm_to_minutes(p["aprem_sortie"]) - hhmm_to_minutes(p["aprem_entree"])) + elif p["aprem_entree"] and p.get("date") == today: + aprem = max(0, now_min - hhmm_to_minutes(p["aprem_entree"])) + return matin + aprem @@ -91,11 +105,29 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int jour["cumul_visible"] = jour["is_complete"] and cumul_chain_ok # Sortie cible: optimal aprem exit so the week totals exactly the target hours + today = datetime.today().strftime("%Y-%m-%d") + now_min = datetime.today().hour * 60 + datetime.today().minute worked_before = 0 for i, jour in enumerate(jours): cg = jour["conge"] + # Jour futur pas encore entamé : pas de cible, le rattrapage n'est pas figé sur ce jour précis + is_future_untouched = jour["date"] > today and not jour.get("matin_entree") and not jour.get("aprem_entree") + if is_future_untouched: + jour["sortie_cible"] = None + jour["entree_cible"] = None + worked_before += jour["travaille_min"] + continue if jour["du_min"] == 0 or "aprem" in cg or "jour" in cg: jour["sortie_cible"] = None + jour["entree_cible"] = None + if "aprem" in cg and "matin" not in cg and jour["du_min"] > 0: + 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 + jour["entree_cible"] = f"{entree_min // 60:02d}:{entree_min % 60:02d}" worked_before += jour["travaille_min"] continue @@ -105,15 +137,29 @@ def compute_week(pointages: list[dict], conges: list[dict], heures_jour_min: int worked_morning = 0 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 + # (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) needed_aprem = max(0, needed_today - worked_morning) - aprem_start = hhmm_to_minutes(jour["aprem_entree"]) if jour.get("aprem_entree") else 14 * 60 + 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) if sortie_min > 23 * 60 + 59: jour["sortie_cible"] = None # deficit too large to recover in one afternoon else: 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 + entree_cible = f"{entree_min // 60:02d}:{entree_min % 60:02d}" + jour["entree_cible"] = entree_cible worked_before += jour["travaille_min"] return { diff --git a/app/main.py b/app/main.py index 70c6ef7..ef2425e 100644 --- a/app/main.py +++ b/app/main.py @@ -123,7 +123,13 @@ def _oob_week_extras(result): else: cumul_inner = '' html += f'{cumul_inner}' - cible = jour.get("sortie_cible") if not jour.get("is_complete") else None + 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" + cible = f'{jour["entree_cible"]}{suffix}' + elif jour.get("sortie_cible"): + cible = jour.get("sortie_cible") cible_inner = f'{cible}' if cible else '' html += f'{cible_inner}' return html @@ -310,6 +316,17 @@ def save_pointage( return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id) +@app.get("/refresh/{date}", response_class=HTMLResponse) +def refresh_pointage(request: Request, date: str): + user_id = _get_user(request) + if not user_id: + return RedirectResponse("/login", status_code=302) + + d = datetime.strptime(date, "%Y-%m-%d").date() + conges = load_conges(user_id) + return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id) + + @app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse) def toggle_conge_route(date: str, type_conge: str, request: Request): user_id = _get_user(request) diff --git a/app/stats.py b/app/stats.py index 7ddc507..c56038e 100644 --- a/app/stats.py +++ b/app/stats.py @@ -5,6 +5,11 @@ 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 +MOIS_FR = [ + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre", +] + def _week_dates(year: int, week: int) -> list[str]: first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() @@ -134,13 +139,18 @@ def compute_all_stats(user_id: str = "") -> dict: weekly_balances.append({ "year": year, "week": week, - "label": f"S{week:02d}", "date": dates[0], "delta_min": week_delta, "delta": minutes_to_hhmm(week_delta), "partial": week_has_incomplete, }) + # Suffix the label with a 2-digit year when weeks span more than one year, + # otherwise "S24" is ambiguous between e.g. 2024 and 2026. + spans_multiple_years = len({b["year"] for b in weekly_balances}) > 1 + for b in weekly_balances: + b["label"] = f"S{b['week']:02d}'{b['year'] % 100:02d}" if spans_multiple_years else f"S{b['week']:02d}" + time_stats = {} for slot, pairs in slot_pairs.items(): if len(pairs) < 2: @@ -182,7 +192,7 @@ def compute_all_stats(user_id: str = "") -> dict: "comp_globale": comp_globale, "comp_mois": comp_mois, "comp_sem_n1": comp_sem_n1, - "mois_label": today.strftime("%B %Y"), + "mois_label": f"{MOIS_FR[today.month - 1].capitalize()} {today.year}", "prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}", "today": today.strftime("%Y-%m-%d"), } diff --git a/app/templates/_ligne.html b/app/templates/_ligne.html index 40b102c..cbca643 100644 --- a/app/templates/_ligne.html +++ b/app/templates/_ligne.html @@ -69,8 +69,18 @@ - {%- if j.sortie_cible and not j.is_complete -%} - {{ j.sortie_cible }} + {%- if not j.is_complete -%} + {%- if j.entree_cible -%} + {%- if 'aprem' in j.conge -%} + {{ j.entree_cible }}→12:00 + {%- else -%} + {{ j.entree_cible }}→17:00 + {%- endif -%} + {%- elif j.sortie_cible -%} + {{ j.sortie_cible }} + {%- else -%} + + {%- endif %} {%- else -%} {%- endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 278ba8a..5af9d3b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -750,6 +750,7 @@ margin-bottom: .75rem; } .wkly-wrap { + position: relative; overflow-x: auto; -webkit-overflow-scrolling: touch; } @@ -774,6 +775,71 @@ /* Zoomable canvas hint */ canvas.zoomable { cursor: zoom-in; } + /* ── Alerte départ ── */ + #alerte-depart { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + background: var(--neg); + color: #fff; + border-left: 4px solid #ff6b5b; + box-shadow: 0 6px 28px rgba(0,0,0,.3); + min-width: 240px; + animation: alerte-slide .35s cubic-bezier(.2,.8,.3,1) both; + } + .alerte-inner { + display: flex; + align-items: center; + gap: .75rem; + padding: .85rem 1rem; + } + .alerte-icon { font-size: 1.5rem; flex-shrink: 0; animation: alerte-pulse 1s ease-in-out 3; } + .alerte-body { flex: 1; } + .alerte-label { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + opacity: .7; + margin-bottom: .2rem; + } + .alerte-time { + font-family: var(--mono); + font-size: 1.75rem; + font-weight: 500; + letter-spacing: -.03em; + line-height: 1; + } + .alerte-close { + background: none; + border: 1px solid rgba(255,255,255,.3); + color: #fff; + width: 24px; height: 24px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + font-size: 12px; + flex-shrink: 0; + align-self: flex-start; + transition: background .15s; + } + .alerte-close:hover { background: rgba(255,255,255,.15); } + .alerte-supp { + font-family: var(--mono); + font-size: 11px; + opacity: .8; + margin-top: .25rem; + letter-spacing: .02em; + } + @keyframes alerte-slide { + from { opacity: 0; transform: translateX(120%); } + to { opacity: 1; transform: translateX(0); } + } + @keyframes alerte-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.25); } + } + /* ── Chart zoom modal ── */ .chart-modal { position: fixed; @@ -831,10 +897,31 @@ .chart-modal-close:hover { border-color: var(--text); color: var(--text); } .chart-modal-close:focus { outline: 2px solid var(--accent); outline-offset: 2px; } .chart-modal-body { + position: relative; padding: 1.25rem; overflow-x: auto; } #chart-modal-canvas { display: block; } + + .chart-tooltip { + position: absolute; + pointer-events: none; + background: var(--surface); + border: 1.5px solid var(--rule); + box-shadow: 0 2px 8px rgba(28,23,18,.12); + padding: .35rem .55rem; + font-family: var(--mono); + font-size: 11px; + color: var(--text); + white-space: nowrap; + opacity: 0; + transform: translate(-50%, -100%); + transition: opacity .1s; + z-index: 5; + } + .chart-tooltip.visible { opacity: 1; } + .chart-tooltip .ct-val { font-weight: 500; } + .chart-tooltip .ct-label { color: var(--muted); margin-left: .35em; } diff --git a/app/templates/semaine.html b/app/templates/semaine.html index 48c847c..3644681 100644 --- a/app/templates/semaine.html +++ b/app/templates/semaine.html @@ -204,6 +204,117 @@ })(); + + + +