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>
This commit is contained in:
191
app/stats.py
Normal file
191
app/stats.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Statistics computation across all recorded weeks."""
|
||||
import statistics as _st
|
||||
from datetime import datetime, timedelta, date as _date
|
||||
|
||||
from models import load_week_pointages, load_conges, list_all_weeks, load_config
|
||||
from calcul import hhmm_to_minutes, minutes_to_hhmm, heures_travaillees, heures_dues
|
||||
|
||||
|
||||
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 _to_hhmm(minutes_float: float) -> str:
|
||||
m = round(minutes_float)
|
||||
return f"{m // 60:02d}:{m % 60:02d}"
|
||||
|
||||
|
||||
def _day_compliant(p: dict, mc: bool, ac: bool) -> bool | None:
|
||||
if mc and ac:
|
||||
return None
|
||||
matin_ok = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie")))
|
||||
aprem_ok = ac or (bool(p.get("aprem_entree")) and bool(p.get("aprem_sortie")))
|
||||
if not (matin_ok and aprem_ok):
|
||||
return None
|
||||
if not mc:
|
||||
if p.get("matin_entree") and hhmm_to_minutes(p["matin_entree"]) > 9 * 60:
|
||||
return False
|
||||
if p.get("matin_sortie") and hhmm_to_minutes(p["matin_sortie"]) < 12 * 60:
|
||||
return False
|
||||
if not ac:
|
||||
if p.get("aprem_entree") and hhmm_to_minutes(p["aprem_entree"]) > 14 * 60:
|
||||
return False
|
||||
if p.get("aprem_sortie") and hhmm_to_minutes(p["aprem_sortie"]) < 17 * 60:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compliance_over(all_days: list[tuple], cg_set: set) -> dict:
|
||||
n = n_ok = 0
|
||||
for date, p in all_days:
|
||||
mc = (date, "matin") in cg_set or (date, "jour") in cg_set
|
||||
ac = (date, "aprem") in cg_set or (date, "jour") in cg_set
|
||||
result = _day_compliant(p, mc, ac)
|
||||
if result is None:
|
||||
continue
|
||||
n += 1
|
||||
if result:
|
||||
n_ok += 1
|
||||
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()
|
||||
h_jour = int(float(load_config().get("heures_jour", 7.8)) * 60)
|
||||
cg_set = {(c["date"], c["type"]) for c in conges}
|
||||
|
||||
today = _date.today()
|
||||
prev_week_date = today - timedelta(weeks=1)
|
||||
prev_iso = prev_week_date.isocalendar()
|
||||
prev_week_dates = set(_week_dates(prev_iso.year, prev_iso.week))
|
||||
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}
|
||||
|
||||
TARGETS = {
|
||||
"matin_entree": ("le", 9 * 60),
|
||||
"matin_sortie": ("ge", 12 * 60),
|
||||
"aprem_entree": ("le", 14 * 60),
|
||||
"aprem_sortie": ("ge", 17 * 60),
|
||||
}
|
||||
|
||||
weekly_balances = []
|
||||
all_days: list[tuple] = []
|
||||
month_days: list[tuple] = []
|
||||
prev_week_day_list: list[tuple] = []
|
||||
|
||||
for year, week in all_weeks:
|
||||
raw = load_week_pointages(year, week)
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
dates = _week_dates(year, week)
|
||||
p_by_date = {p["date"]: p for p in raw}
|
||||
week_delta = 0
|
||||
week_has_incomplete = False
|
||||
|
||||
for date in dates:
|
||||
p = p_by_date.get(date, {
|
||||
"date": date, "matin_entree": None, "matin_sortie": None,
|
||||
"aprem_entree": None, "aprem_sortie": None,
|
||||
})
|
||||
mc = (date, "matin") in cg_set or (date, "jour") in cg_set
|
||||
ac = (date, "aprem") in cg_set or (date, "jour") in cg_set
|
||||
|
||||
matin_filled = mc or (bool(p.get("matin_entree")) and bool(p.get("matin_sortie")))
|
||||
aprem_filled = ac or (bool(p.get("aprem_entree")) and bool(p.get("aprem_sortie")))
|
||||
|
||||
all_days.append((date, p))
|
||||
d = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
if (d.year, d.month) == current_ym:
|
||||
month_days.append((date, p))
|
||||
if date in prev_week_dates:
|
||||
prev_week_day_list.append((date, p))
|
||||
|
||||
if not (matin_filled and aprem_filled):
|
||||
week_has_incomplete = True
|
||||
continue
|
||||
|
||||
trav = heures_travaillees(p)
|
||||
du = heures_dues(date, conges, h_jour)
|
||||
week_delta += trav - du
|
||||
|
||||
if not mc:
|
||||
for slot in ("matin_entree", "matin_sortie"):
|
||||
if p.get(slot):
|
||||
v = hhmm_to_minutes(p[slot])
|
||||
slot_pairs[slot].append((v, date))
|
||||
op, thr = TARGETS[slot]
|
||||
comp_n[slot] += 1
|
||||
comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0
|
||||
if not ac:
|
||||
for slot in ("aprem_entree", "aprem_sortie"):
|
||||
if p.get(slot):
|
||||
v = hhmm_to_minutes(p[slot])
|
||||
slot_pairs[slot].append((v, date))
|
||||
op, thr = TARGETS[slot]
|
||||
comp_n[slot] += 1
|
||||
comp_ok[slot] += 1 if (v <= thr if op == "le" else v >= thr) else 0
|
||||
|
||||
weekly_balances.append({
|
||||
"year": year, "week": week,
|
||||
"label": f"S{week:02d}",
|
||||
"date": dates[0], # Monday — used for period filtering
|
||||
"delta_min": week_delta,
|
||||
"delta": minutes_to_hhmm(week_delta),
|
||||
"partial": week_has_incomplete,
|
||||
})
|
||||
|
||||
time_stats = {}
|
||||
for slot, pairs in slot_pairs.items():
|
||||
if len(pairs) < 2:
|
||||
time_stats[slot] = None
|
||||
continue
|
||||
vals = [v for v, _ in pairs]
|
||||
mean = _st.mean(vals)
|
||||
sigma = _st.stdev(vals)
|
||||
time_stats[slot] = {
|
||||
"mean": mean, "sigma": sigma,
|
||||
"mean_hhmm": _to_hhmm(mean),
|
||||
"sigma_min": round(sigma),
|
||||
"n": len(vals),
|
||||
"dated_values": [{"v": v, "d": d} for v, d in pairs],
|
||||
}
|
||||
|
||||
compliance = {
|
||||
slot: (round(100 * comp_ok[slot] / comp_n[slot]) if comp_n[slot] else None)
|
||||
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]
|
||||
avg_delta_min = round(_st.mean(deltas)) if deltas else 0
|
||||
|
||||
comp_globale = _compliance_over(all_days, cg_set)
|
||||
comp_mois = _compliance_over(month_days, cg_set)
|
||||
comp_sem_n1 = _compliance_over(prev_week_day_list, cg_set)
|
||||
|
||||
return {
|
||||
"time_stats": time_stats,
|
||||
"compliance": compliance,
|
||||
"overall_compliance": overall_slot,
|
||||
"weekly_balances": weekly_balances,
|
||||
"total_weeks": len(weekly_balances),
|
||||
"n_jours": n_jours,
|
||||
"avg_delta": minutes_to_hhmm(avg_delta_min),
|
||||
"avg_delta_min": avg_delta_min,
|
||||
"comp_globale": comp_globale,
|
||||
"comp_mois": comp_mois,
|
||||
"comp_sem_n1": comp_sem_n1,
|
||||
"mois_label": today.strftime("%B %Y"),
|
||||
"prev_week_label": f"S{prev_iso.week:02d} / {prev_iso.year}",
|
||||
"today": today.strftime("%Y-%m-%d"),
|
||||
}
|
||||
Reference in New Issue
Block a user