8 Commits

Author SHA1 Message Date
7e2f158c09 Merge pull request 'feat: statistiques mensuelles dans la page rapports' (#2) from monthly-stats-reports into master
Reviewed-on: #2
2026-03-13 14:42:37 +01:00
cc664b3555 feat: section détail mensuel dans la page rapports 2026-03-13 14:36:32 +01:00
59d64885d4 feat: calcul stats mensuelles dans reports (km + médianes) 2026-03-13 14:35:18 +01:00
6d5e58273f feat: monthly_stats() — médiane journalière et hebdomadaire 2026-03-13 14:34:49 +01:00
d768156e0d docs: plan d'implémentation statistiques mensuelles 2026-03-13 14:16:31 +01:00
e781c06593 docs: design statistiques mensuelles dans la page rapports 2026-03-13 14:16:27 +01:00
d97565f1a1 Merge pull request 'feat: increase RTT from 18 to 19' (#1) from increase-rtt-to-19 into master
Reviewed-on: #1
2026-03-13 13:50:12 +01:00
4c0093c2b0 feat: increase RTT from 18 to 19
- Update LeaveBalance model default rtt_total to 19
- Update all tests to verify 19 RTT instead of 18
- Update documentation (design and technical plan)
- Update run.py to bind to 0.0.0.0 for external access
- Update CLAUDE.md deployment instructions

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-13 13:45:19 +01:00
14 changed files with 841 additions and 12 deletions

View File

@@ -21,8 +21,9 @@ python -m venv .venv
# Run a single test # Run a single test
.venv/bin/python -m pytest tests/test_routes.py::test_create_entry -v .venv/bin/python -m pytest tests/test_routes.py::test_create_entry -v
# Production (Gunicorn) # Production (systemd) — déployé dans /var/www/tableau-de-bord-pro/
SECRET_KEY=<secret> ./start.sh sudo systemctl edit --full tableau-de-bord-pro # configurer SECRET_KEY
sudo systemctl restart tableau-de-bord-pro
# Git commit (GPG signing désactivé — pinentry inaccessible dans cet env) # Git commit (GPG signing désactivé — pinentry inaccessible dans cet env)
git -c commit.gpgsign=false commit -m "..." git -c commit.gpgsign=false commit -m "..."

View File

@@ -25,6 +25,30 @@ def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int:
return actual_minutes - reference_minutes 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]: 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.""" """Retourne un dict {day_type: count} pour une liste d'entrées, sans les zéros."""
counts: dict[str, int] = {} counts: dict[str, int] = {}

View File

@@ -54,4 +54,4 @@ class LeaveBalance(db.Model):
id: so.Mapped[int] = so.mapped_column(primary_key=True) id: so.Mapped[int] = so.mapped_column(primary_key=True)
year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False) year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False)
conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28) conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28)
rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=18) rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=19)

View File

@@ -1,14 +1,21 @@
from flask import Blueprint, render_template, request from flask import Blueprint, render_template, request
from datetime import date from datetime import date
from collections import defaultdict
import sqlalchemy as sa import sqlalchemy as sa
from app import db from app import db
from app.models import WorkEntry from app.models import WorkEntry
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels 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 from app.config_loader import get_vehicles, get_journeys, get_bareme
bp = Blueprint("reports", __name__, url_prefix="/reports") 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("/") @bp.route("/")
def index(): def index():
@@ -23,6 +30,7 @@ def index():
vehicles = get_vehicles() vehicles = get_vehicles()
journeys = get_journeys() journeys = get_journeys()
# --- Stats annuelles (inchangées) ---
total_km = {} total_km = {}
total_co2 = 0.0 total_co2 = 0.0
for entry in entries: for entry in entries:
@@ -42,6 +50,32 @@ def index():
day_type_counts = count_day_types(entries) 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( return render_template(
"reports.html", "reports.html",
year=year, year=year,
@@ -50,4 +84,5 @@ def index():
frais_reels=frais_reels, frais_reels=frais_reels,
vehicles=vehicles, vehicles=vehicles,
day_type_counts=day_type_counts, day_type_counts=day_type_counts,
monthly_data=monthly_data,
) )

View File

@@ -78,4 +78,72 @@
{% endif %} {% endif %}
</div> </div>
<!-- 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 %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,186 @@
# Répartition par type de jour — Carte rapport
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Ajouter une carte dans `/reports/` listant le nombre de jours travaillés par type (Travail, Garde, Formation…) pour l'année sélectionnée, en n'affichant que les types avec au moins 1 occurrence.
**Architecture:** Une fonction pure `count_day_types(entries)` est ajoutée dans `app/business/time_calc.py` (cohérent avec les autres fonctions de comptage). La route `reports.py` l'appelle et passe le résultat au template. Le template affiche une nouvelle carte avec la liste, en utilisant le filtre `day_type_fr` existant.
**Tech Stack:** Python 3, Flask, Jinja2, pytest
---
## Tâche 1 : Fonction `count_day_types` dans `time_calc.py`
**Files:**
- Modify: `app/business/time_calc.py`
- Test: `tests/test_time_calc.py`
### Étape 1 : Écrire le test qui échoue
Ajouter à la fin de `tests/test_time_calc.py` :
```python
from app.business.time_calc import count_day_types
from app.models import WorkEntry
from datetime import date
def test_count_day_types_basic():
entries = [
WorkEntry(date=date(2025, 1, 2), day_type="WORK"),
WorkEntry(date=date(2025, 1, 3), day_type="WORK"),
WorkEntry(date=date(2025, 1, 6), day_type="TT"),
WorkEntry(date=date(2025, 1, 7), day_type="GARDE"),
]
result = count_day_types(entries)
assert result == {"WORK": 2, "TT": 1, "GARDE": 1}
def test_count_day_types_empty():
assert count_day_types([]) == {}
def test_count_day_types_single_type():
entries = [
WorkEntry(date=date(2025, 2, 1), day_type="RTT"),
WorkEntry(date=date(2025, 2, 2), day_type="RTT"),
]
result = count_day_types(entries)
assert result == {"RTT": 2}
```
### Étape 2 : Vérifier que le test échoue
```bash
.venv/bin/python -m pytest tests/test_time_calc.py::test_count_day_types_basic -v
```
Attendu : `FAILED``ImportError: cannot import name 'count_day_types'`
### Étape 3 : Implémenter la fonction
Ajouter à la fin de `app/business/time_calc.py` :
```python
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] = {}
for entry in entries:
counts[entry.day_type] = counts.get(entry.day_type, 0) + 1
return counts
```
### Étape 4 : Vérifier que les tests passent
```bash
.venv/bin/python -m pytest tests/test_time_calc.py -v
```
Attendu : tous `PASSED`.
### Étape 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: count_day_types — compte les jours par type"
```
---
## Tâche 2 : Brancher la fonction dans la route `reports.py`
**Files:**
- Modify: `app/routes/reports.py`
### Étape 1 : Importer et appeler `count_day_types`
Dans `app/routes/reports.py`, modifier l'import :
```python
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
```
Puis, juste avant le `return render_template(...)`, ajouter :
```python
day_type_counts = count_day_types(entries)
```
Et passer la variable au template :
```python
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,
)
```
### Étape 2 : Vérifier que les tests passent
```bash
.venv/bin/python -m pytest -v
```
Attendu : tous `PASSED`.
---
## Tâche 3 : Carte dans le template `reports.html`
**Files:**
- Modify: `app/templates/reports.html`
### Étape 1 : Ajouter la carte après la carte "Frais réels"
Insérer avant `{% endblock %}` :
```html
<!-- Répartition par type de jour -->
<div class="card card-amber">
<p class="card-label">Répartition {{ year }}</p>
{% if day_type_counts %}
<div class="space-y-2 mt-1">
{% for day_type, count in day_type_counts.items() %}
<div class="flex items-baseline justify-between">
<span class="text-xs" style="color:#8A8278;">{{ day_type | day_type_fr }}</span>
<span class="font-data font-semibold text-sm" style="color:var(--ink);">
{{ count }}<span class="font-normal text-xs ml-1" style="color:#9A9288;">j</span>
</span>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm" style="color:#9A9288;">Aucune entrée pour {{ year }}.</p>
{% endif %}
</div>
```
### Étape 2 : Vérifier visuellement
```bash
.venv/bin/python run.py
```
Naviguer sur `http://localhost:5000/reports/` — la carte doit apparaître avec la liste des types.
### Étape 3 : Vérifier les tests
```bash
.venv/bin/python -m pytest -v
```
Attendu : tous `PASSED`.
### Étape 4 : Commit
```bash
git -c commit.gpgsign=false add app/routes/reports.py app/templates/reports.html
git -c commit.gpgsign=false commit -m "feat: carte répartition par type de jour dans le rapport"
```

View File

@@ -88,7 +88,7 @@ Une journée peut avoir N plages horaires non-consécutives (ex: 9h-18h + 22h-00
| id | INTEGER PK | | | id | INTEGER PK | |
| year | INTEGER UNIQUE | Année | | year | INTEGER UNIQUE | Année |
| conges_total | INTEGER | Total congés (défaut 28) | | conges_total | INTEGER | Total congés (défaut 28) |
| rtt_total | INTEGER | Total RTT (défaut 18) | | rtt_total | INTEGER | Total RTT (défaut 19) |
Les jours utilisés sont calculés dynamiquement depuis `work_entries`. Les jours utilisés sont calculés dynamiquement depuis `work_entries`.
@@ -156,7 +156,7 @@ Toutes les vues sont conçues pour mobile en premier (Tailwind responsive).
- **Carte "Aujourd'hui"** : boutons rapides "Arrivée" / "Départ" (horodatage automatique), sélecteur de profil de trajet - **Carte "Aujourd'hui"** : boutons rapides "Arrivée" / "Départ" (horodatage automatique), sélecteur de profil de trajet
- **Synthèse semaine courante** (lun-dim) : total heures, écart vs objectif (5 × 7h45 = 38h45) - **Synthèse semaine courante** (lun-dim) : total heures, écart vs objectif (5 × 7h45 = 38h45)
- **Synthèse mensuelle** : jours travaillés, km par véhicule, CO2 - **Synthèse mensuelle** : jours travaillés, km par véhicule, CO2
- **Solde congés/RTT** : jauge visuelle (ex: "12/18 RTT utilisés") - **Solde congés/RTT** : jauge visuelle (ex: "12/19 RTT utilisés")
### Page Saisie / Édition d'un jour ### Page Saisie / Édition d'un jour

View File

@@ -454,7 +454,7 @@ class LeaveBalance(db.Model):
id: so.Mapped[int] = so.mapped_column(primary_key=True) id: so.Mapped[int] = so.mapped_column(primary_key=True)
year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False) year: so.Mapped[int] = so.mapped_column(sa.Integer, unique=True, nullable=False)
conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28) conges_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=28)
rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=18) rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=19)
``` ```
**Step 2: Vérifier la création des tables** **Step 2: Vérifier la création des tables**
@@ -769,7 +769,7 @@ def test_get_or_create_balance_creates_default(app):
balance = get_or_create_balance(2025) balance = get_or_create_balance(2025)
assert balance.year == 2025 assert balance.year == 2025
assert balance.conges_total == 28 assert balance.conges_total == 28
assert balance.rtt_total == 18 assert balance.rtt_total == 19
def test_get_or_create_balance_returns_existing(app): def test_get_or_create_balance_returns_existing(app):

View File

@@ -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).

View File

@@ -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 -->
<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"
```

2
run.py
View File

@@ -3,4 +3,4 @@ from app import create_app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=True, host="0.0.0.0")

View File

@@ -27,7 +27,7 @@ def test_get_or_create_balance_creates_default(app):
balance = get_or_create_balance(2025) balance = get_or_create_balance(2025)
assert balance.year == 2025 assert balance.year == 2025
assert balance.conges_total == 28 assert balance.conges_total == 28
assert balance.rtt_total == 18 assert balance.rtt_total == 19
def test_get_or_create_balance_returns_existing(app): def test_get_or_create_balance_returns_existing(app):

View File

@@ -91,3 +91,11 @@ def test_create_entry_velo_no_motor_vehicle(client, app):
) )
assert entry is not None assert entry is not None
assert entry.motor_vehicle_id is 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

View File

@@ -43,8 +43,9 @@ def test_week_balance_negative():
from app.business.time_calc import count_day_types from app.business.time_calc import count_day_types
from app.models import WorkEntry from app.models import WorkEntry, TimeSlot
from datetime import date from app.business.time_calc import monthly_stats
from datetime import date, time as dtime
def test_count_day_types_basic(): def test_count_day_types_basic():
@@ -62,6 +63,63 @@ def test_count_day_types_empty():
assert count_day_types([]) == {} 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(): def test_count_day_types_single_type():
entries = [ entries = [
WorkEntry(date=date(2025, 2, 1), day_type="RTT"), WorkEntry(date=date(2025, 2, 1), day_type="RTT"),