@@ -1,383 +0,0 @@
# 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"
```