Files
tableau-de-bord/docs/plans/2026-03-13-monthly-stats.md

12 KiB
Raw Blame History

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

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

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

.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 FAILEDmonthly_stats n'existe pas encore.

Step 3 : Implémenter monthly_stats() dans app/business/time_calc.py

Ajouter en bas du fichier :

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

.venv/bin/python -m pytest tests/test_time_calc.py -v

Résultat attendu : tous PASSED.

Step 5 : Commit

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 :

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

.venv/bin/python -m pytest tests/test_routes.py -v

Résultat attendu : tous PASSED (aucune régression).

Step 3 : Commit

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") :

<!-- Détail mensuel -->
<p class="font-display text-lg font-semibold mt-8 mb-3" style="color:var(--ink);">Détail mensuel</p>

{% for month_num in range(1, 13) %}
{% set m = monthly_data[month_num] %}
<div class="card card-ink mb-4">
  <div class="flex items-baseline justify-between mb-2">
    <p class="card-label">{{ m.month_name }}</p>
    <span class="text-xs" style="color:#9A9288;">{{ m.entry_count }} j</span>
  </div>

  {% if m.entry_count == 0 %}
    <p class="text-sm" style="color:#9A9288;">Aucune entrée</p>
  {% else %}
    {# --- Barre transport --- #}
    {% if m.km_total > 0 %}
      <p class="font-data font-semibold mb-1" style="font-size:1.2rem; color:var(--ink);">
        {{ m.km_total }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">km</span>
      </p>
      <div class="flex rounded overflow-hidden mb-1" style="height:10px; background:#e5e2dc;">
        {% for vehicle_id, km in m.km_by_vehicle.items() %}
          {% set v_info = vehicles.get(vehicle_id, {}) %}
          {% if v_info.get('type') == 'velo' %}
            {% set color = '#4ade80' %}
          {% elif v_info.get('fuel') == 'electric' %}
            {% set color = '#818cf8' %}
          {% else %}
            {% set color = '#d4a574' %}
          {% endif %}
          {% set pct = (km / m.km_total * 100) | round(1) %}
          <div style="width:{{ pct }}%; background-color:{{ color }};"></div>
        {% endfor %}
      </div>
      <div class="flex flex-wrap gap-x-3 mb-3">
        {% for vehicle_id, km in m.km_by_vehicle.items() %}
          {% set v_info = vehicles.get(vehicle_id, {}) %}
          {% if v_info.get('type') == 'velo' %}
            {% set color = '#4ade80' %}
          {% elif v_info.get('fuel') == 'electric' %}
            {% set color = '#818cf8' %}
          {% else %}
            {% set color = '#d4a574' %}
          {% endif %}
          <span class="text-xs" style="color:#8A8278;">
            <span style="display:inline-block; width:8px; height:8px; border-radius:2px; background:{{ color }}; margin-right:3px;"></span>
            {{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km)
          </span>
        {% endfor %}
      </div>
    {% else %}
      <p class="text-xs mb-3" style="color:#9A9288;">Aucun déplacement</p>
    {% endif %}

    {# --- Stats temporelles --- #}
    <div class="flex gap-6">
      <div>
        <p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / jour</p>
        <p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_daily_str }}</p>
      </div>
      <div>
        <p class="text-xs mb-0.5" style="color:#9A9288;">Médiane / semaine</p>
        <p class="font-data font-semibold" style="color:var(--ink);">{{ m.median_weekly_str }}</p>
      </div>
    </div>
  {% endif %}
</div>
{% endfor %}

Step 2 : Vérifier manuellement dans le navigateur

.venv/bin/python run.py

Naviguer vers http://localhost:5000/reports/ et vérifier :

  • Section "Détail mensuel" présente
  • 12 cartes affichées (mois sans données : "Aucune entrée")
  • Barre colorée pour les mois avec des km
  • Médianes affichées au format "Xh00"

Step 3 : Ajouter un test de route pour la section mensuelle

Dans tests/test_routes.py, ajouter :

def test_reports_monthly_section(client):
    response = client.get("/reports/")
    assert response.status_code == 200
    assert "Détail mensuel" in response.text
    assert "Janvier" in response.text
    assert "Décembre" in response.text

Step 4 : Vérifier que tous les tests passent

.venv/bin/python -m pytest -v

Résultat attendu : tous PASSED.

Step 5 : Commit final

git -c commit.gpgsign=false add app/templates/reports.html tests/test_routes.py
git -c commit.gpgsign=false commit -m "feat: section détail mensuel dans la page rapports"