diff --git a/app/main.py b/app/main.py index ff55c2f..7e876eb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from itertools import groupby @@ -11,6 +12,7 @@ from models import ( 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") @@ -264,6 +266,29 @@ def save_pointage( 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) diff --git a/app/stats.py b/app/stats.py new file mode 100644 index 0000000..d38014a --- /dev/null +++ b/app/stats.py @@ -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"), + } diff --git a/app/templates/base.html b/app/templates/base.html index a824765..d137f22 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -484,6 +484,350 @@ } .cal-grid .cal-current:hover td { background: rgba(47,95,160,.06); } .cal-grid .cal-current:hover td.cal-wn { background: var(--accent); } + + /* ── Stats page ── */ + .stats-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + border-bottom: 2px solid var(--text); + padding-bottom: .75rem; + } + .stats-title { + font-family: var(--mono); + font-size: 1.75rem; + font-weight: 500; + letter-spacing: -.02em; + line-height: 1; + margin-bottom: .4rem; + } + .stats-meta { + font-size: 12px; + color: var(--muted); + font-family: var(--mono); + } + .stats-sep { margin: 0 .35em; color: var(--rule); } + .stat-pos { color: var(--pos); font-weight: 500; } + .stat-neg { color: var(--neg); font-weight: 500; } + + .stats-overall { + text-align: center; + padding: .75rem 1.1rem; + border: 1.5px solid var(--rule); + background: var(--surface); + flex-shrink: 0; + } + .stats-overall-num { + font-family: var(--mono); + font-size: 2rem; + font-weight: 500; + letter-spacing: -.04em; + line-height: 1; + } + .stats-overall-pct { font-size: 1.1rem; font-weight: 300; } + .stats-overall-label { + font-size: 9px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--muted); + margin-top: .35rem; + line-height: 1.4; + } + .comp-ok { color: var(--pos); border-color: var(--pos) !important; background: var(--pos-bg) !important; } + .comp-warn { color: #7A5300; border-color: var(--conge-bd) !important; background: var(--conge-bg) !important; } + .comp-bad { color: var(--neg); border-color: var(--neg) !important; background: var(--neg-bg) !important; } + + /* ── 3 period-compliance cards ── */ + .comp-periods { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + background: var(--rule); + border: 1.5px solid var(--rule); + } + @media (max-width: 520px) { .comp-periods { grid-template-columns: 1fr; } } + + .comp-period-card { + background: var(--surface); + padding: 1rem 1.25rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: .2rem; + text-align: center; + } + .cp-eyebrow { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + } + .cp-sub { + font-family: var(--mono); + font-size: 10px; + color: var(--text); + font-weight: 500; + margin-bottom: .35rem; + } + .cp-donut-wrap { margin: .25rem 0; } + .cp-donut { display: block; } + .cp-n { + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + margin-top: .2rem; + } + .cp-n.comp-ok { color: var(--pos); } + .cp-n.comp-warn { color: #7A5300; } + .cp-n.comp-bad { color: var(--neg); } + .cp-nodata { color: var(--rule); } + + /* ── Period tabs ── */ + .period-tabs-wrap { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + } + .period-tabs-label { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + flex-shrink: 0; + } + .period-tabs { + display: flex; + gap: 1px; + background: var(--rule); + border: 1.5px solid var(--rule); + } + .ptab { + padding: .38rem .9rem; + font-family: var(--mono); + font-size: 11px; + background: var(--surface); + border: none; + cursor: pointer; + color: var(--muted); + letter-spacing: .04em; + transition: background .12s, color .12s; + white-space: nowrap; + } + .ptab:hover { background: var(--ground); color: var(--text); } + .ptab.active { background: var(--text); color: var(--ground); } + .ptab:focus { outline: 2px solid var(--accent); outline-offset: -2px; } + + /* 2×2 grid, 4-in-a-row on wide screens */ + .ts-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1px; + background: var(--rule); + border: 1.5px solid var(--rule); + } + @media (min-width: 800px) { .ts-grid { grid-template-columns: repeat(4, 1fr); } } + + .ts-card { + background: var(--surface); + padding: 1rem 1.2rem .9rem; + display: flex; + flex-direction: column; + gap: .3rem; + min-width: 0; + } + .ts-card.ts-matin { border-top: 3px solid var(--accent); } + .ts-card.ts-aprem { border-top: 3px solid var(--accent-2); } + + .ts-eyebrow { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + } + .ts-mean { + font-family: var(--mono); + font-size: 2rem; + font-weight: 500; + letter-spacing: -.04em; + line-height: 1; + color: var(--text); + margin-top: .15rem; + } + .ts-mean.ts-empty { color: var(--rule); } + .ts-sigma { + font-family: var(--mono); + font-size: 10px; + color: var(--muted); + margin-bottom: .25rem; + } + + .ts-mean-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + } + .donut-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: .15rem; + flex-shrink: 0; + } + .donut-canvas { display: block; } + .donut-label { + font-family: var(--mono); + font-size: 8px; + letter-spacing: .04em; + color: var(--muted); + text-align: center; + } + + .dist-canvas { + width: 100%; + display: block; + margin: .25rem 0 .2rem; + background: transparent; + } + + .dist-legend { + display: flex; + flex-wrap: wrap; + gap: .5rem .75rem; + font-family: var(--mono); + font-size: 9px; + color: var(--muted); + margin-bottom: .35rem; + } + .dl-item { display: flex; align-items: center; gap: .28rem; white-space: nowrap; } + .dl-mean-line { + display: inline-block; + width: 14px; height: 1.5px; + background: var(--text); + flex-shrink: 0; + } + .dl-target-line { + display: inline-block; + width: 14px; height: 0; + border-top: 1.5px dashed; + flex-shrink: 0; + } + .dl-target-line.dl-neg { border-color: var(--neg); } + .dl-target-line.dl-pos { border-color: var(--pos); } + .dl-band { + display: inline-block; + width: 10px; height: 10px; + flex-shrink: 0; + } + .dl-band.ts-matin { background: rgba(47,95,160,.12); border: 1px solid rgba(47,95,160,.25); } + .dl-band.ts-aprem { background: rgba(184,106,8,.12); border: 1px solid rgba(184,106,8,.25); } + .dl-n { color: var(--muted); } + + /* Weekly balance chart */ + .wkly-section { + background: var(--surface); + border: 1.5px solid var(--rule); + padding: 1rem 1.25rem; + } + .wkly-eyebrow { + font-family: var(--mono); + font-size: 9px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: .75rem; + } + .wkly-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + #c-weekly { display: block; width: 100%; } + .wkly-note { font-weight: 300; color: var(--muted); letter-spacing: 0; text-transform: none; font-size: 9px; } + .wkly-legend { + margin-top: .5rem; + font-size: 10px; + color: var(--muted); + font-family: var(--mono); + display: flex; + align-items: center; + gap: .4rem; + } + .legend-dot { + width: 10px; height: 10px; + display: inline-block; + flex-shrink: 0; + } + .partial-dot { background: repeating-linear-gradient(45deg, var(--muted) 0, var(--muted) 2px, transparent 2px, transparent 4px); } + + /* Zoomable canvas hint */ + canvas.zoomable { cursor: zoom-in; } + + /* ── Chart zoom modal ── */ + .chart-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + } + .chart-modal[hidden] { display: none; } + .chart-modal-bd { + position: absolute; + inset: 0; + background: rgba(28,23,18,.55); + backdrop-filter: blur(3px); + } + .chart-modal-inner { + position: relative; + background: var(--surface); + border: 1.5px solid var(--rule); + max-width: 92vw; + max-height: 90vh; + overflow: auto; + display: flex; + flex-direction: column; + } + .chart-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: .65rem 1rem; + border-bottom: 1px solid var(--rule); + background: var(--ground); + } + .chart-modal-title { + font-family: var(--mono); + font-size: 10px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--muted); + } + .chart-modal-close { + background: none; + border: 1.5px solid var(--rule); + width: 26px; height: 26px; + display: flex; align-items: center; justify-content: center; + cursor: pointer; + font-size: 12px; + color: var(--muted); + flex-shrink: 0; + transition: border-color .15s, color .15s; + } + .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 { + padding: 1.25rem; + overflow-x: auto; + } + #chart-modal-canvas { display: block; } @@ -491,6 +835,7 @@ Décompte Horaire Semaine courante + Statistiques
{% block content %}{% endblock %} diff --git a/app/templates/stats.html b/app/templates/stats.html new file mode 100644 index 0000000..6e4c8c6 --- /dev/null +++ b/app/templates/stats.html @@ -0,0 +1,558 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
Statistiques
+
+ {{ stats.n_jours }} jours complets analysés + · + {{ stats.total_weeks }} semaines + {% if stats.avg_delta_min != 0 %} + · + solde moyen + {{ stats.avg_delta }}/sem + {% endif %} +
+
+
+ +{# ── 3 période-conformité cards ── #} +
+ {% set periods = [ + (stats.comp_globale, "Conformité globale", "Toutes périodes"), + (stats.comp_mois, "Ce mois", stats.mois_label), + (stats.comp_sem_n1, "Semaine précédente", stats.prev_week_label), + ] %} + {% for comp, title, sub in periods %} + {% set pct = comp.pct %} + {% set cls = 'comp-ok' if pct is not none and pct >= 95 else ('comp-warn' if pct is not none and pct >= 80 else 'comp-bad') %} +
+
{{ title }}
+
{{ sub }}
+
+ +
+
+ {% if pct is not none %}{{ comp.n_ok }}/{{ comp.n }} jours conformes + {% else %}pas de données{% endif %} +
+
+ {% endfor %} +
+ +{# ── Onglets de période ── #} +
+ Période d'analyse +
+ + + + +
+
+ +{# ── 4 time-slot cards ── #} +
+ {% set slot_meta = [ + ("matin_entree", "Entrée matin", "≤ 09:00", "le", "matin"), + ("matin_sortie", "Sortie matin", "≥ 12:00", "ge", "matin"), + ("aprem_entree", "Entrée après-midi","≤ 14:00", "le", "aprem"), + ("aprem_sortie", "Sortie après-midi","≥ 17:00", "ge", "aprem"), + ] %} + {% for slot, label, constraint, op, half in slot_meta %} + {% set ts = stats.time_stats[slot] %} + {% set comp = stats.compliance[slot] %} +
+
{{ label }}
+ {% if ts %} +
+
{{ ts.mean_hhmm }}
+ {% if comp is not none %} +
+ +
{{ constraint }}
+
+ {% endif %} +
+
σ = {{ ts.sigma_min }} min · n = {{ ts.n }}
+ +
+ μ = {{ ts.mean_hhmm }} + {{ constraint }} + ±1σ ({{ ts.sigma_min }} min) + n = {{ ts.n }} jours +
+ {% else %} +
+
pas de données
+ {% endif %} +
+ {% endfor %} +
+ +{# ── Weekly balance chart ── #} +{% if stats.weekly_balances %} +
+
Soldes hebdomadaires (jours incomplets exclus)
+
+ +
+ {% set has_partial = stats.weekly_balances | selectattr("partial") | list %} + {% if has_partial %} +
+ semaine avec jours non renseignés +
+ {% endif %} +
+{% endif %} + +{# ── Zoom modal ── #} + + + + +{% endblock %}