diff --git a/app/business/time_calc.py b/app/business/time_calc.py index 10a5834..55f858f 100644 --- a/app/business/time_calc.py +++ b/app/business/time_calc.py @@ -25,6 +25,30 @@ def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int: return actual_minutes - reference_minutes +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} + + def count_day_types(entries: list) -> dict[str, int]: """Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros.""" counts: dict[str, int] = {} diff --git a/app/routes/reports.py b/app/routes/reports.py index f244f3c..294425b 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,14 +1,21 @@ 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 +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(): @@ -23,6 +30,7 @@ def index(): vehicles = get_vehicles() journeys = get_journeys() + # --- Stats annuelles (inchangées) --- total_km = {} total_co2 = 0.0 for entry in entries: @@ -42,6 +50,32 @@ def index(): 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, @@ -50,4 +84,5 @@ def index(): frais_reels=frais_reels, vehicles=vehicles, day_type_counts=day_type_counts, + monthly_data=monthly_data, ) diff --git a/app/templates/reports.html b/app/templates/reports.html index 18525f7..13e4e2b 100644 --- a/app/templates/reports.html +++ b/app/templates/reports.html @@ -78,4 +78,72 @@ {% endif %} + +

Détail mensuel

+ +{% for month_num in range(1, 13) %} +{% set m = monthly_data[month_num] %} +
+
+

{{ m.month_name }}

+ {{ m.entry_count }} j +
+ + {% if m.entry_count == 0 %} +

Aucune entrée

+ {% else %} + {# --- Barre transport --- #} + {% if m.km_total > 0 %} +

+ {{ m.km_total }}km +

+
+ {% 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) %} +
+ {% endfor %} +
+
+ {% 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 %} + + + {{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km) + + {% endfor %} +
+ {% else %} +

Aucun déplacement

+ {% endif %} + + {# --- Stats temporelles --- #} +
+
+

Médiane / jour

+

{{ m.median_daily_str }}

+
+
+

Médiane / semaine

+

{{ m.median_weekly_str }}

+
+
+ {% endif %} +
+{% endfor %} + {% endblock %} diff --git a/docs/plans/2026-03-13-monthly-stats-design.md b/docs/plans/2026-03-13-monthly-stats-design.md new file mode 100644 index 0000000..db7915e --- /dev/null +++ b/docs/plans/2026-03-13-monthly-stats-design.md @@ -0,0 +1,66 @@ +# Design : statistiques mensuelles dans la page Rapports + +Date : 2026-03-13 + +## Objectif + +Ajouter une section "Détail mensuel" sous les cartes annuelles existantes dans `/reports`. 12 cartes (une par mois) donnant en un coup d'œil : distance parcourue par mode de transport + durée de travail typique. + +## Décisions clés + +- **Types de jours inclus** : tous les jours enregistrés (y compris absences = 0 min). +- **Statistique temporelle** : médiane (plus robuste que la moyenne face aux GARDE à 10h et aux absences). +- **"Par semaine"** : médiane de la durée totale hebdomadaire (somme des minutes par semaine ISO, puis médiane). +- **Layout** : 12 cartes empilées sous un titre "Détail mensuel". +- **Couleurs transport** : évocatrices (vert vélo, indigo électrique, ambre diesel). + +## Architecture + +### Données calculées par mois + +```python +monthly_data[month_int] = { + "km_by_vehicle": {vehicle_id: km}, + "km_total": int, + "median_daily_min": int, # médiane de entry.total_minutes() sur toutes les entries du mois + "median_weekly_min": int, # médiane des sommes hebdomadaires (semaines ISO) + "entry_count": int, +} +``` + +### Répartition du code + +- **`app/business/time_calc.py`** : nouvelle fonction `monthly_stats(entries)` → calcule les médianes journalière et hebdomadaire. Pas de dépendance Flask. +- **`app/routes/reports.py`** : groupement des entries par mois + calcul des km par véhicule (déjà dans ce fichier), appel de `monthly_stats`, transmission du tout au template. +- **`app/templates/reports.html`** : section "Détail mensuel" avec les 12 cartes. + +## Visuel des cartes + +``` +┌─────────────────────────────────────────────┐ +│ JANVIER 21 jours │ +│ │ +│ 312 km │ +│ [████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░] │ +│ ■ Vélo (132 km) ■ Électrique (108 km) │ +│ ■ Diesel (72 km) │ +│ │ +│ Médiane/jour 7h52 Médiane/sem. 38h45 │ +└─────────────────────────────────────────────┘ +``` + +- **Barre transport** : `div` flex, sections `width: X%` en `style=` inline (contrainte Tailwind CDN). +- **Couleurs** : + - Vélo → `#4ade80` (vert vif) + - Électrique (fuel = "electric") → `#818cf8` (indigo) + - Diesel → `#d4a574` (ambre chaud) +- **Légende** : carré coloré + nom du véhicule + km, sous la barre. +- **Mois vide** (0 entrées) : carte discrète, texte "Aucune entrée", sans barre. + +## Tests + +- Nouvelle fonction `monthly_stats()` dans `tests/test_time_calc.py` : + - cas nominal (entries avec time_slots) + - absences (total_minutes = 0) incluses dans la médiane + - mois avec une seule semaine +- Route `reports` : vérifier que `monthly_data` est bien transmis au template (test existant à étendre si besoin). diff --git a/docs/plans/2026-03-13-monthly-stats.md b/docs/plans/2026-03-13-monthly-stats.md new file mode 100644 index 0000000..fb90bc4 --- /dev/null +++ b/docs/plans/2026-03-13-monthly-stats.md @@ -0,0 +1,383 @@ +# 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 }} j +
+ + {% if m.entry_count == 0 %} +

Aucune entrée

+ {% else %} + {# --- Barre transport --- #} + {% if m.km_total > 0 %} +

+ {{ m.km_total }}km +

+
+ {% 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) %} +
+ {% endfor %} +
+
+ {% 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 %} + + + {{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} ({{ km }} km) + + {% endfor %} +
+ {% else %} +

Aucun déplacement

+ {% endif %} + + {# --- Stats temporelles --- #} +
+
+

Médiane / jour

+

{{ m.median_daily_str }}

+
+
+

Médiane / semaine

+

{{ m.median_weekly_str }}

+
+
+ {% endif %} +
+{% 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" +``` diff --git a/tests/test_routes.py b/tests/test_routes.py index 543246d..4560d7f 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -91,3 +91,11 @@ def test_create_entry_velo_no_motor_vehicle(client, app): ) assert entry is not None assert entry.motor_vehicle_id is None + + +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 diff --git a/tests/test_time_calc.py b/tests/test_time_calc.py index 872df05..446d233 100644 --- a/tests/test_time_calc.py +++ b/tests/test_time_calc.py @@ -43,8 +43,9 @@ def test_week_balance_negative(): from app.business.time_calc import count_day_types -from app.models import WorkEntry -from datetime import date +from app.models import WorkEntry, TimeSlot +from app.business.time_calc import monthly_stats +from datetime import date, time as dtime def test_count_day_types_basic(): @@ -62,6 +63,63 @@ def test_count_day_types_empty(): assert count_day_types([]) == {} +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 + + def test_count_day_types_single_type(): entries = [ WorkEntry(date=date(2025, 2, 1), day_type="RTT"),