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

384 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 -->
<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**
```bash
.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 :
```python
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**
```bash
.venv/bin/python -m pytest -v
```
Résultat attendu : tous PASSED.
**Step 5 : Commit final**
```bash
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"
```