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