Files
pointeuse-optimisator/app/main.py
toto 1a34bff1c6 Ajoute page statistiques avec graphiques et onglets de période
- Nouvelle page /stats avec histogrammes de distribution par créneau
  horaire (entrée/sortie matin et après-midi)
- Camemberts animés de conformité aux directives (≤09:00, ≥12:00,
  ≤14:00, ≥17:00) globale, par slot et par période (mois, sem. N-1)
- Onglets Tout / 1 an / 3 mois / 1 mois : filtrage côté client avec
  recalcul μ, σ, n et conformité en temps réel
- Graphique des soldes hebdomadaires (barres animées, toutes semaines
  sur l'axe X en rotation -45°, semaines partielles distinctes)
- Clic sur un graphique → modal plein écran avec axe X détaillé (15 min)
- Légende σ / n / directive / moyenne sur chaque histogramme
- Journées incomplètes exclues des stats ; heures dues retirées du solde
- Lien "Statistiques" dans la nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:09:37 +02:00

304 lines
12 KiB
Python

import json
from datetime import datetime, timedelta
from itertools import groupby
from fastapi import FastAPI, Form, Request
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,
)
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
from stats import compute_all_stats
app = FastAPI(title="Décompte Horaire")
templates = Jinja2Templates(directory="templates")
templates.env.filters["zip"] = zip
app.mount("/static", StaticFiles(directory="static"), name="static")
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
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)
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."""
dates = week_dates(year, week)
raw = load_week_pointages(year, week)
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}
def _build_jour_ctx(date: str, conges: list[dict], h_jour: int) -> dict:
"""Return template context for a single day row."""
from calcul import heures_travaillees
d = datetime.strptime(date, "%Y-%m-%d").date()
iso = d.isocalendar()
raw = load_week_pointages(iso.year, iso.week)
p_by_date = {p["date"]: p for p in raw}
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
"aprem_entree": None, "aprem_sortie": None})
trav = heures_travaillees(p)
du = heures_dues(date, conges, h_jour)
delta = trav - du
return {
**p,
"travaille_min": trav, "du_min": du, "delta_min": delta,
"travaille": minutes_to_hhmm(trav),
"du": minutes_to_hhmm(du),
"delta": minutes_to_hhmm(delta),
"conge": {c["type"] for c in conges if c["date"] == date},
}
def _oob_calc(date: str, jour: dict) -> str:
"""Build OOB <span> fragments for the two calc cells (safe non-table elements)."""
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")
sign = "+" if jour["delta_min"] > 0 else ""
delta_inner = f'<span class="{delta_cls}">{sign}{jour["delta"]}</span>'
else:
delta_inner = '<span class="delta-zero">—</span>'
return (
f'<span id="calc-{date}-trav" hx-swap-oob="true">{trav_inner}</span>'
f'<span id="calc-{date}-delta" hx-swap-oob="true">{delta_inner}</span>'
)
def _oob_week_extras(result: dict) -> str:
"""OOB <span> for all 5 days' cumulative delta and sortie cible."""
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:
cumul_inner = '<span class="delta-zero">0h00</span>'
else:
cls = "delta-pos" if cm > 0 else "delta-neg"
sign = "+" if cm > 0 else "-"
cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>'
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."""
cg = jour["conge"]
matin_cg = "matin" in cg or "jour" in cg
aprem_cg = "aprem" in cg or "jour" in cg
matin_warn = not matin_cg and jour["du_min"] > 0 and (
(jour.get("matin_entree") and jour["matin_entree"] > "09:00") or
(jour.get("matin_sortie") and jour["matin_sortie"] < "12:00")
)
aprem_warn = not aprem_cg and jour["du_min"] > 0 and (
(jour.get("aprem_entree") and jour["aprem_entree"] > "14:00") or
(jour.get("aprem_sortie") and jour["aprem_sortie"] < "17:00")
)
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")
matin_html = (
f'<div id="cg-matin-{date}" hx-swap-oob="true" class="{matin_cls}">'
f'<div class="time-pair">'
f'<input class="time-input" type="time" name="matin_entree" data-date="{date}" value="{jour["matin_entree"] or ""}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="matin_sortie" data-date="{date}" value="{jour["matin_sortie"] or ""}">'
f'</div></div>'
)
aprem_html = (
f'<div id="cg-aprem-{date}" hx-swap-oob="true" class="{aprem_cls}">'
f'<div class="time-pair">'
f'<input class="time-input" type="time" name="aprem_entree" data-date="{date}" value="{jour["aprem_entree"] or ""}">'
f'<span class="time-sep">→</span>'
f'<input class="time-input" type="time" name="aprem_sortie" data-date="{date}" value="{jour["aprem_sortie"] or ""}">'
f'</div></div>'
)
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."""
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
cg = jour["conge"]
btn_ma_on = " on" if "matin" in cg else ""
btn_am_on = " on" if "aprem" in cg else ""
btn_j_on = " jour-on" if "jour" in cg else ""
btns_html = (
f'<div id="cg-btn-{date}" hx-swap-oob="true">'
f'<div class="conge-btns">'
f'<button hx-post="/conge/{date}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
f'<button hx-post="/conge/{date}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
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_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(
_oob_time_cells(date, jour)
+ btns_html
+ _oob_calc(date, jour)
+ _oob_week_extras(soldes_ctx["result"])
+ soldes_html
)
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."""
iso = d.isocalendar()
jour = _build_jour_ctx(date, conges, h_jour)
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour)
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
return HTMLResponse(
_oob_calc(date, jour)
+ _oob_time_cells(date, jour)
+ _oob_week_extras(soldes_ctx["result"])
+ soldes_html
)
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
today = datetime.today()
iso = today.isocalendar()
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
def semaine(request: Request, year: int, week: int):
h_jour = heures_jour_min()
conges = load_conges()
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour)
dates = week_dates(year, week)
raw = load_week_pointages(year, week)
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)
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()
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,
"jours_fr": JOURS_FR,
"all_weeks": all_weeks,
"weeks_by_year": _weeks_by_year(all_weeks),
})
@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(""),
):
def clean(v: str):
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)
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),
}
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]))
h_jour = heures_jour_min()
conges = load_conges()
return _htmx_calc_and_soldes(request, date, d, conges, h_jour)
@app.get("/stats", response_class=HTMLResponse)
def stats_page(request: Request):
stats = compute_all_stats()
data_json = json.dumps({
"time_stats": {
slot: {
"mean": s["mean"], "sigma": s["sigma"],
"mean_hhmm": s["mean_hhmm"], "sigma_min": s["sigma_min"],
"n": s["n"], "dated_values": s["dated_values"],
} if s else None
for slot, s in stats["time_stats"].items()
},
"compliance": stats["compliance"],
"weekly_balances": stats["weekly_balances"],
"today": stats["today"],
})
return templates.TemplateResponse("stats.html", {
"request": request,
"stats": stats,
"data_json": data_json,
})
@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)