# Monthly Stats Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Ajouter une section "Détail mensuel" dans `/reports` avec 12 cartes (une par mois) affichant distance totale, barre de répartition par transport, médiane journalière et médiane hebdomadaire. **Architecture:** Nouvelle fonction pure `monthly_stats()` dans `time_calc.py` pour les médianes. Groupement par mois + km dans `reports.py`. Rendu HTML avec barre CSS inline (contrainte Tailwind CDN). **Tech Stack:** Flask, Jinja2, SQLAlchemy, Python `statistics` stdlib (pas de dépendance à ajouter). --- ## Préparation : créer la branche ```bash git checkout master git checkout -b monthly-stats-reports ``` --- ### Task 1 : `monthly_stats()` dans `time_calc.py` **Files:** - Modify: `app/business/time_calc.py` - Test: `tests/test_time_calc.py` **Step 1 : Écrire les tests dans `tests/test_time_calc.py`** Ajouter en bas du fichier (après les imports existants, ajouter `TimeSlot` et `time`) : ```python from app.models import WorkEntry, TimeSlot from app.business.time_calc import monthly_stats from datetime import date, time as dtime def _entry(d: date, *slots: tuple[str, str]) -> WorkEntry: """Helper : crée un WorkEntry avec des TimeSlots.""" entry = WorkEntry(date=d, day_type="WORK") entry.time_slots = [ TimeSlot(start_time=dtime(*[int(x) for x in s.split(":")]), end_time=dtime(*[int(x) for x in e.split(":")])) for s, e in slots ] return entry def _absence(d: date) -> WorkEntry: """Helper : crée un WorkEntry sans time_slots (0 min).""" entry = WorkEntry(date=d, day_type="CONGE") entry.time_slots = [] return entry def test_monthly_stats_empty(): result = monthly_stats([]) assert result == {"median_daily_min": 0, "median_weekly_min": 0} def test_monthly_stats_median_daily_odd(): # 420, 465, 510 → médiane = 465 entries = [ _entry(date(2025, 1, 6), ("9:00", "16:00")), # 420 min _entry(date(2025, 1, 7), ("9:00", "16:45")), # 465 min _entry(date(2025, 1, 8), ("9:00", "17:30")), # 510 min ] result = monthly_stats(entries) assert result["median_daily_min"] == 465 def test_monthly_stats_includes_absences(): # 0, 465 → médiane de 2 valeurs = (0+465)/2 = 232 (int) entries = [ _absence(date(2025, 1, 6)), _entry(date(2025, 1, 7), ("9:00", "16:45")), ] result = monthly_stats(entries) assert result["median_daily_min"] == 232 def test_monthly_stats_median_weekly(): # Semaine 1 : 465+465 = 930 min # Semaine 2 : 420 min # médiane([930, 420]) = (420+930)/2 = 675 entries = [ _entry(date(2025, 1, 6), ("9:00", "16:45")), # sem 2 _entry(date(2025, 1, 7), ("9:00", "16:45")), # sem 2 _entry(date(2025, 1, 13), ("9:00", "16:00")), # sem 3 ] result = monthly_stats(entries) assert result["median_weekly_min"] == 675 ``` **Step 2 : Vérifier que les tests échouent** ```bash .venv/bin/python -m pytest tests/test_time_calc.py::test_monthly_stats_empty tests/test_time_calc.py::test_monthly_stats_median_daily_odd -v ``` Résultat attendu : `ImportError` ou `FAILED` — `monthly_stats` n'existe pas encore. **Step 3 : Implémenter `monthly_stats()` dans `app/business/time_calc.py`** Ajouter en bas du fichier : ```python import statistics as _stats def monthly_stats(entries: list) -> dict: """ Calcule médiane journalière et médiane hebdomadaire (semaines ISO) pour un groupe d'entrées. Les absences (total_minutes=0) sont incluses. """ if not entries: return {"median_daily_min": 0, "median_weekly_min": 0} daily = [e.total_minutes() for e in entries] median_daily = int(_stats.median(daily)) weekly: dict[tuple, int] = {} for e in entries: key = e.date.isocalendar()[:2] # (year, isoweek) weekly[key] = weekly.get(key, 0) + e.total_minutes() median_weekly = int(_stats.median(weekly.values())) return {"median_daily_min": median_daily, "median_weekly_min": median_weekly} ``` **Step 4 : Vérifier que tous les tests passent** ```bash .venv/bin/python -m pytest tests/test_time_calc.py -v ``` Résultat attendu : tous PASSED. **Step 5 : Commit** ```bash git -c commit.gpgsign=false add app/business/time_calc.py tests/test_time_calc.py git -c commit.gpgsign=false commit -m "feat: monthly_stats() — médiane journalière et hebdomadaire" ``` --- ### Task 2 : Calcul mensuel dans `reports.py` **Files:** - Modify: `app/routes/reports.py` **Step 1 : Mettre à jour `reports.py`** Remplacer le contenu complet par : ```python from flask import Blueprint, render_template, request from datetime import date from collections import defaultdict import sqlalchemy as sa from app import db from app.models import WorkEntry from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels from app.business.time_calc import count_day_types, monthly_stats, minutes_to_str from app.config_loader import get_vehicles, get_journeys, get_bareme bp = Blueprint("reports", __name__, url_prefix="/reports") MONTHS_FR = { 1: "Janvier", 2: "Février", 3: "Mars", 4: "Avril", 5: "Mai", 6: "Juin", 7: "Juillet", 8: "Août", 9: "Septembre", 10: "Octobre", 11: "Novembre", 12: "Décembre", } @bp.route("/") def index(): year = request.args.get("year", date.today().year, type=int) start = date(year, 1, 1) end = date(year, 12, 31) entries = db.session.scalars( sa.select(WorkEntry).where(WorkEntry.date.between(start, end)) ).all() vehicles = get_vehicles() journeys = get_journeys() # --- Stats annuelles (inchangées) --- total_km = {} total_co2 = 0.0 for entry in entries: km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id) for v, d in km.items(): total_km[v] = total_km.get(v, 0) + d total_co2 += compute_co2_grams(km, vehicles) frais_reels = {} for vehicle_id, km in total_km.items(): vehicle = vehicles.get(vehicle_id, {}) cv = vehicle.get("cv") if cv: tranches = get_bareme(year, cv) electric = vehicle.get("fuel") == "electric" frais_reels[vehicle_id] = round(compute_frais_reels(km, tranches, electric=electric), 2) day_type_counts = count_day_types(entries) # --- Stats mensuelles --- entries_by_month: dict[int, list] = defaultdict(list) for entry in entries: entries_by_month[entry.date.month].append(entry) monthly_data = {} for month in range(1, 13): month_entries = entries_by_month.get(month, []) month_km: dict[str, int] = {} for entry in month_entries: km = compute_km_for_entry(entry.journey_profile_id, journeys, entry.motor_vehicle_id) for v, d in km.items(): month_km[v] = month_km.get(v, 0) + d stats = monthly_stats(month_entries) monthly_data[month] = { "month_name": MONTHS_FR[month], "entry_count": len(month_entries), "km_by_vehicle": month_km, "km_total": sum(month_km.values()), "median_daily_str": minutes_to_str(stats["median_daily_min"]) if month_entries else "–", "median_weekly_str": minutes_to_str(stats["median_weekly_min"]) if month_entries else "–", } return render_template( "reports.html", year=year, total_km=total_km, total_co2_kg=round(total_co2 / 1000, 2), frais_reels=frais_reels, vehicles=vehicles, day_type_counts=day_type_counts, monthly_data=monthly_data, ) ``` **Step 2 : Vérifier que les tests existants passent toujours** ```bash .venv/bin/python -m pytest tests/test_routes.py -v ``` Résultat attendu : tous PASSED (aucune régression). **Step 3 : Commit** ```bash git -c commit.gpgsign=false add app/routes/reports.py git -c commit.gpgsign=false commit -m "feat: calcul stats mensuelles dans reports (km + médianes)" ``` --- ### Task 3 : Section mensuelle dans `reports.html` **Files:** - Modify: `app/templates/reports.html` **Step 1 : Ajouter la section "Détail mensuel" dans `reports.html`** Ajouter le bloc suivant avant `{% endblock %}` (après la carte "Répartition") : ```jinja2
Détail mensuel
{% for month_num in range(1, 13) %} {% set m = monthly_data[month_num] %}{{ m.month_name }}
{{ m.entry_count }} jAucune entrée
{% else %} {# --- Barre transport --- #} {% if m.km_total > 0 %}{{ m.km_total }}km
Aucun déplacement
{% endif %} {# --- Stats temporelles --- #}Médiane / jour
{{ m.median_daily_str }}
Médiane / semaine
{{ m.median_weekly_str }}