Implémente système multi-utilisateurs avec classement

- 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 <noreply@anthropic.com>
This commit is contained in:
toto
2026-06-24 17:29:07 +02:00
parent 1a34bff1c6
commit b6e0fcd925
7 changed files with 673 additions and 131 deletions

48
app/classement.py Normal file
View File

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

View File

@ -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 <span> fragments for the two calc cells (safe non-table elements)."""
def _oob_calc(date, jour):
trav_inner = f'<span class="calc-num">{"" if not jour["travaille_min"] else jour["travaille"]}</span>'
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'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
delta_inner = f'<span class="{cls}">{sign}{jour["delta"]}</span>'
else:
delta_inner = '<span class="delta-zero">—</span>'
return (
@ -91,12 +108,10 @@ def _oob_calc(date: str, jour: dict) -> str:
)
def _oob_week_extras(result: dict) -> str:
"""OOB <span> 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 = '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
# Sortie cible — only for incomplete days
cible = jour.get("sortie_cible") if not jour.get("is_complete") else None
cible_inner = f'<span class="cible-num">{cible}</span>' if cible else '<span class="delta-zero">—</span>'
html += f'<span id="calc-{date}-cible" hx-swap-oob="true">{cible_inner}</span>'
return html
def _oob_time_cells(date: str, jour: dict) -> str:
"""OOB <div> 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 <div>/<span> 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'<button hx-post="/conge/{date}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
f'</div></div>'
)
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 <span>/<div> 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, 132 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,
})

View File

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

View File

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

View File

@ -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 @@
<span class="spacer"></span>
<a href="/" class="nav-link">Semaine courante</a>
<a href="/stats" class="nav-link">Statistiques</a>
<a href="/classement" class="nav-link">Classement</a>
{% if current_user is defined and current_user %}
<span class="nav-user">{{ current_user }}</span>
<a href="/logout" class="nav-link nav-logout">Déconnexion</a>
{% endif %}
</nav>
<div class="container">
{% block content %}{% endblock %}

View File

@ -0,0 +1,278 @@
{% 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 %}

109
app/templates/login.html Normal file
View File

@ -0,0 +1,109 @@
{% extends "base.html" %}
{% block content %}
<div class="login-wrap">
<div class="login-card">
<div class="login-header">
<span class="login-eyebrow">Décompte Horaire</span>
<h1 class="login-title">Connexion</h1>
</div>
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
<form method="post" action="/login" class="login-form">
<label class="login-label" for="username">Identifiant</label>
<input
id="username"
class="login-input"
type="text"
name="username"
autocomplete="username"
autofocus
placeholder="prenom.nom"
pattern="[a-zA-Z0-9_\-]{1,32}"
required
>
<p class="login-hint">Lettres, chiffres, tirets — 1 à 32 caractères.</p>
<button class="login-btn" type="submit">Entrer</button>
</form>
</div>
</div>
<style>
.login-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 4rem;
}
.login-card {
background: var(--surface);
border: 1.5px solid var(--rule);
padding: 2.5rem 3rem;
width: 380px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-header { display: flex; flex-direction: column; gap: .35rem; }
.login-eyebrow {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
}
.login-title {
font-family: var(--mono);
font-size: 22px;
font-weight: 500;
color: var(--text);
letter-spacing: -.01em;
}
.login-error {
background: var(--neg-bg);
border-left: 3px solid var(--neg);
color: var(--neg);
font-size: 12px;
padding: .6rem .9rem;
font-family: var(--mono);
}
.login-form { display: flex; flex-direction: column; gap: .75rem; }
.login-label {
font-size: 11px;
font-weight: 500;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.login-input {
font-family: var(--mono);
font-size: 15px;
padding: .6rem .75rem;
border: 1.5px solid var(--rule);
background: var(--ground);
color: var(--text);
outline: none;
transition: border-color .15s;
}
.login-input:focus { border-color: var(--accent); }
.login-hint { font-size: 11px; color: var(--muted); }
.login-btn {
margin-top: .5rem;
font-family: var(--mono);
font-size: 13px;
font-weight: 500;
letter-spacing: .08em;
text-transform: uppercase;
padding: .7rem 1.5rem;
background: var(--text);
color: var(--surface);
border: none;
cursor: pointer;
transition: background .15s;
}
.login-btn:hover { background: var(--accent); }
</style>
{% endblock %}