From 3b3a5e4187aca3514fc8ea2785cb0bfe5a82ed3a Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Wed, 11 Mar 2026 15:26:30 +0100 Subject: [PATCH] docs: add implementation plan for work time tracking dashboard Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-11-tableau-de-bord-travail.md | 1644 +++++++++++++++++ 1 file changed, 1644 insertions(+) create mode 100644 docs/plans/2026-03-11-tableau-de-bord-travail.md diff --git a/docs/plans/2026-03-11-tableau-de-bord-travail.md b/docs/plans/2026-03-11-tableau-de-bord-travail.md new file mode 100644 index 0000000..884c4be --- /dev/null +++ b/docs/plans/2026-03-11-tableau-de-bord-travail.md @@ -0,0 +1,1644 @@ +# Tableau de bord temps de travail — Plan d'implémentation + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Application web Flask auto-hébergée pour tracer le temps de travail quotidien, les déplacements domicile-travail (CO2, frais réels fiscaux) et le solde congés/RTT. + +**Architecture:** Flask avec SQLAlchemy/SQLite pour la persistance, HTMX pour les interactions dynamiques sans JS custom, Tailwind CSS (CDN) pour le style mobile-first. Configuration véhicules/trajets/barème fiscal dans un fichier TOML rechargé au démarrage. + +**Tech Stack:** Python 3.12+, Flask, SQLAlchemy, tomllib (stdlib), pytest, pytest-flask, Gunicorn, HTMX, Tailwind CSS CDN + +--- + +## Référence design + +Voir `docs/plans/2026-03-11-tableau-de-bord-travail-design.md` pour le détail complet. + +**Types de journées :** `WORK | TT | GARDE | ASTREINTE | FORMATION | RTT | CONGE | MALADE | FERIE` + +**Référence horaire :** 7h45/jour, 38h45/semaine, GARDE = 10h (9h-19h) + +**Types sans déplacement :** `TT, MALADE, CONGE, RTT, FERIE` + +--- + +## Structure cible + +``` +tableau-de-bord/ +├── app/ +│ ├── __init__.py +│ ├── models.py +│ ├── config_loader.py +│ ├── business/ +│ │ ├── __init__.py +│ │ ├── time_calc.py +│ │ ├── travel_calc.py +│ │ └── leave_calc.py +│ └── routes/ +│ ├── __init__.py +│ ├── dashboard.py +│ ├── entries.py +│ └── reports.py +│ └── templates/ +│ ├── base.html +│ ├── dashboard.html +│ ├── entry_form.html +│ ├── entry_list.html +│ └── reports.html +├── tests/ +│ ├── conftest.py +│ ├── test_config_loader.py +│ ├── test_time_calc.py +│ ├── test_travel_calc.py +│ └── test_leave_calc.py +├── config.toml +├── requirements.txt +└── run.py +``` + +--- + +## Task 1 : Setup du projet + +**Files:** +- Create: `requirements.txt` +- Create: `run.py` +- Create: `app/__init__.py` +- Create: `tests/conftest.py` + +**Step 1: Créer `requirements.txt`** + +``` +flask>=3.0 +flask-sqlalchemy>=3.1 +pytest>=8.0 +pytest-flask>=1.3 +gunicorn>=22.0 +``` + +**Step 2: Créer `run.py`** + +```python +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(debug=True) +``` + +**Step 3: Créer `app/__init__.py`** + +```python +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +import tomllib +import os + +db = SQLAlchemy() + + +def create_app(config_path=None): + app = Flask(__name__, instance_relative_config=True) + + os.makedirs(app.instance_path, exist_ok=True) + + app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'worklog.db')}" + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-change-in-prod") + + # Load TOML config + if config_path is None: + config_path = os.path.join(os.path.dirname(app.root_path), "config.toml") + if os.path.exists(config_path): + with open(config_path, "rb") as f: + app.config["TOML"] = tomllib.load(f) + else: + app.config["TOML"] = {} + + db.init_app(app) + + from app.routes.dashboard import bp as dashboard_bp + from app.routes.entries import bp as entries_bp + from app.routes.reports import bp as reports_bp + app.register_blueprint(dashboard_bp) + app.register_blueprint(entries_bp) + app.register_blueprint(reports_bp) + + with app.app_context(): + db.create_all() + + return app +``` + +**Step 4: Créer `tests/conftest.py`** + +```python +import pytest +from app import create_app, db as _db +import tempfile, os + + +@pytest.fixture +def app(tmp_path): + config_path = tmp_path / "config.toml" + config_path.write_text(""" +[vehicles.voiture] +name = "Peugeot 308" +fuel = "diesel" +co2_per_km = 142 +cv = 5 + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 + +[journeys.voiture_seule] +name = "Voiture seule" +distances = { voiture = 25 } + +[journeys.voiture_velo] +name = "Voiture + Vélo" +distances = { voiture = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } + +[bareme_kilometrique.2025.cv_5.tranches] +data = [ + { km_max = 3000, taux = 0.548, forfait = 0 }, + { km_max = 6000, taux = 0.316, forfait = 699 }, + { km_max = 0, taux = 0.364, forfait = 0 }, +] +""", encoding="utf-8") + + application = create_app(config_path=str(config_path)) + application.config["TESTING"] = True + application.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" + + with application.app_context(): + _db.create_all() + yield application + _db.drop_all() + + +@pytest.fixture +def client(app): + return app.test_client() +``` + +**Step 5: Installer les dépendances** + +```bash +pip install -r requirements.txt +``` + +**Step 6: Vérifier que Flask démarre** + +```bash +python run.py +``` +Expected: serveur démarré sur http://127.0.0.1:5000 + +**Step 7: Commit** + +```bash +git add requirements.txt run.py app/ tests/ +git commit -m "feat: initial Flask project setup with factory pattern" +``` + +--- + +## Task 2 : Configuration TOML + fichier exemple + +**Files:** +- Create: `config.toml` +- Create: `app/config_loader.py` +- Create: `tests/test_config_loader.py` + +**Step 1: Créer `config.toml`** + +```toml +[vehicles.voiture] +name = "Peugeot 308" +fuel = "diesel" +co2_per_km = 142 +cv = 5 + +[vehicles.velo] +name = "Vélo" +fuel = "none" +co2_per_km = 0 + +[journeys.voiture_seule] +name = "Voiture seule" +distances = { voiture = 25 } + +[journeys.voiture_velo] +name = "Voiture + Vélo" +distances = { voiture = 14, velo = 8 } + +[journeys.velo_seul] +name = "Vélo seul" +distances = { velo = 24 } + +# Barème kilométrique officiel. +# km_max = 0 signifie "pas de limite" (dernière tranche). +# Formule : frais = km * taux + forfait + +[bareme_kilometrique.2025.cv_5] +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 3000 +taux = 0.548 +forfait = 0 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 6000 +taux = 0.316 +forfait = 699 + +[[bareme_kilometrique.2025.cv_5.tranches]] +km_max = 0 +taux = 0.364 +forfait = 0 + +[bareme_kilometrique.2025.cv_6_7] +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 3000 +taux = 0.655 +forfait = 0 + +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 6000 +taux = 0.374 +forfait = 836 + +[[bareme_kilometrique.2025.cv_6_7.tranches]] +km_max = 0 +taux = 0.435 +forfait = 0 +``` + +**Step 2: Créer `app/config_loader.py`** + +```python +from flask import current_app + + +def get_vehicles(): + """Retourne le dict des véhicules depuis la config TOML.""" + return current_app.config.get("TOML", {}).get("vehicles", {}) + + +def get_journeys(): + """Retourne le dict des profils de trajet depuis la config TOML.""" + return current_app.config.get("TOML", {}).get("journeys", {}) + + +def get_bareme(year: int, cv: int) -> list[dict]: + """ + Retourne les tranches du barème kilométrique pour une année et une puissance fiscale. + cv : puissance fiscale (ex: 5 → clé 'cv_5', 6 ou 7 → clé 'cv_6_7') + Retourne [] si non trouvé. + """ + bareme = current_app.config.get("TOML", {}).get("bareme_kilometrique", {}) + year_data = bareme.get(str(year), {}) + + if cv <= 5: + key = "cv_5" + elif cv <= 7: + key = "cv_6_7" + elif cv <= 9: + key = "cv_8_9" + else: + key = "cv_10_11" + + return year_data.get(key, {}).get("tranches", []) + + +def day_types_without_journey(): + """Types de journées sans déplacement domicile-travail.""" + return {"TT", "MALADE", "CONGE", "RTT", "FERIE"} +``` + +**Step 3: Écrire les tests `tests/test_config_loader.py`** + +```python +def test_get_vehicles_returns_configured_vehicles(app): + with app.app_context(): + from app.config_loader import get_vehicles + vehicles = get_vehicles() + assert "voiture" in vehicles + assert vehicles["voiture"]["co2_per_km"] == 142 + + +def test_get_journeys_returns_profiles(app): + with app.app_context(): + from app.config_loader import get_journeys + journeys = get_journeys() + assert "voiture_seule" in journeys + assert journeys["voiture_seule"]["distances"]["voiture"] == 25 + + +def test_get_bareme_returns_tranches(app): + with app.app_context(): + from app.config_loader import get_bareme + tranches = get_bareme(2025, 5) + assert len(tranches) == 3 + assert tranches[0]["taux"] == 0.548 + + +def test_day_types_without_journey(app): + with app.app_context(): + from app.config_loader import day_types_without_journey + types = day_types_without_journey() + assert "TT" in types + assert "WORK" not in types +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_config_loader.py -v +``` +Expected: 4 PASSED + +**Step 5: Commit** + +```bash +git add config.toml app/config_loader.py tests/test_config_loader.py +git commit -m "feat: TOML config loader for vehicles, journeys, and tax scales" +``` + +--- + +## Task 3 : Modèles SQLAlchemy + +**Files:** +- Create: `app/models.py` + +**Step 1: Créer `app/models.py`** + +```python +from app import db +from datetime import date, time, datetime +from enum import Enum as PyEnum +import sqlalchemy as sa +import sqlalchemy.orm as so + + +class DayType(PyEnum): + WORK = "WORK" + TT = "TT" + GARDE = "GARDE" + ASTREINTE = "ASTREINTE" + FORMATION = "FORMATION" + RTT = "RTT" + CONGE = "CONGE" + MALADE = "MALADE" + FERIE = "FERIE" + + +class WorkEntry(db.Model): + __tablename__ = "work_entries" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + date: so.Mapped[date] = so.mapped_column(sa.Date, unique=True, nullable=False) + journey_profile_id: so.Mapped[str | None] = so.mapped_column(sa.String(64), nullable=True) + day_type: so.Mapped[str] = so.mapped_column(sa.String(16), nullable=False, default="WORK") + comment: so.Mapped[str | None] = so.mapped_column(sa.Text, nullable=True) + created_at: so.Mapped[datetime] = so.mapped_column(sa.DateTime, default=datetime.utcnow) + updated_at: so.Mapped[datetime] = so.mapped_column( + sa.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow + ) + + time_slots: so.Mapped[list["TimeSlot"]] = so.relationship( + back_populates="entry", cascade="all, delete-orphan", order_by="TimeSlot.start_time" + ) + + def total_minutes(self) -> int: + """Somme des plages horaires en minutes.""" + total = 0 + for slot in self.time_slots: + start = slot.start_time.hour * 60 + slot.start_time.minute + end = slot.end_time.hour * 60 + slot.end_time.minute + # Gère le passage minuit (ex: 22h-00h) + if end <= start: + end += 24 * 60 + total += end - start + return total + + def total_hours_str(self) -> str: + """Retourne le temps total formaté en 'Xh YYmin'.""" + minutes = self.total_minutes() + return f"{minutes // 60}h{minutes % 60:02d}" + + +class TimeSlot(db.Model): + __tablename__ = "time_slots" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + entry_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("work_entries.id"), nullable=False) + start_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False) + end_time: so.Mapped[time] = so.mapped_column(sa.Time, nullable=False) + + entry: so.Mapped["WorkEntry"] = so.relationship(back_populates="time_slots") + + +class LeaveBalance(db.Model): + __tablename__ = "leave_balance" + + id: so.Mapped[int] = so.mapped_column(primary_key=True) + 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) + rtt_total: so.Mapped[int] = so.mapped_column(sa.Integer, default=18) +``` + +**Step 2: Vérifier la création des tables** + +```bash +python -c "from app import create_app, db; app = create_app(); app.app_context().push(); db.create_all(); print('Tables OK')" +``` +Expected: `Tables OK` + +**Step 3: Commit** + +```bash +git add app/models.py +git commit -m "feat: SQLAlchemy models for WorkEntry, TimeSlot, LeaveBalance" +``` + +--- + +## Task 4 : Business logic — calcul du temps + +**Files:** +- Create: `app/business/__init__.py` +- Create: `app/business/time_calc.py` +- Create: `tests/test_time_calc.py` + +**Step 1: Créer `app/business/__init__.py`** (vide) + +**Step 2: Écrire les tests `tests/test_time_calc.py`** + +```python +from app.business.time_calc import ( + minutes_to_str, + work_minutes_reference, + week_balance_minutes, +) +from datetime import date + + +def test_minutes_to_str_basic(): + assert minutes_to_str(465) == "7h45" + + +def test_minutes_to_str_zero(): + assert minutes_to_str(0) == "0h00" + + +def test_work_minutes_reference_normal_day(): + # WORK, TT, FORMATION, GARDE non-weekend = 7h45 = 465 min + assert work_minutes_reference("WORK") == 465 + assert work_minutes_reference("TT") == 465 + assert work_minutes_reference("FORMATION") == 465 + + +def test_work_minutes_reference_garde(): + # GARDE = 10h = 600 min + assert work_minutes_reference("GARDE") == 600 + + +def test_work_minutes_reference_absence(): + # RTT, CONGE, MALADE, FERIE = 0 (pas de temps à faire) + assert work_minutes_reference("RTT") == 0 + assert work_minutes_reference("CONGE") == 0 + assert work_minutes_reference("MALADE") == 0 + assert work_minutes_reference("FERIE") == 0 + + +def test_week_balance_positive(): + # 40h travaillées vs 38h45 référence = +75 min + balance = week_balance_minutes(actual_minutes=2400, reference_minutes=2325) + assert balance == 75 + + +def test_week_balance_negative(): + balance = week_balance_minutes(actual_minutes=2200, reference_minutes=2325) + assert balance == -125 +``` + +**Step 3: Lancer les tests pour vérifier qu'ils échouent** + +```bash +pytest tests/test_time_calc.py -v +``` +Expected: ImportError ou FAILED + +**Step 4: Créer `app/business/time_calc.py`** + +```python +def minutes_to_str(minutes: int) -> str: + """Convertit des minutes en chaîne 'Xh YY'.""" + sign = "-" if minutes < 0 else "" + minutes = abs(minutes) + return f"{sign}{minutes // 60}h{minutes % 60:02d}" + + +# Minutes de référence par type de journée +_REFERENCE_MINUTES = { + "WORK": 465, # 7h45 + "TT": 465, + "FORMATION": 465, + "GARDE": 600, # 10h (9h-19h) + "ASTREINTE": 0, # variable selon time_slots + "RTT": 0, + "CONGE": 0, + "MALADE": 0, + "FERIE": 0, +} + + +def work_minutes_reference(day_type: str) -> int: + """Retourne le nombre de minutes de référence pour un type de journée.""" + return _REFERENCE_MINUTES.get(day_type, 465) + + +def week_balance_minutes(actual_minutes: int, reference_minutes: int) -> int: + """Retourne l'écart en minutes entre temps réel et référence.""" + return actual_minutes - reference_minutes +``` + +**Step 5: Lancer les tests** + +```bash +pytest tests/test_time_calc.py -v +``` +Expected: 7 PASSED + +**Step 6: Commit** + +```bash +git add app/business/ tests/test_time_calc.py +git commit -m "feat: time calculation business logic" +``` + +--- + +## Task 5 : Business logic — calcul déplacements (km, CO2, frais réels) + +**Files:** +- Create: `app/business/travel_calc.py` +- Create: `tests/test_travel_calc.py` + +**Step 1: Écrire les tests `tests/test_travel_calc.py`** + +```python +from app.business.travel_calc import ( + compute_km_for_entry, + compute_co2_grams, + compute_frais_reels, +) + + +VEHICLES = { + "voiture": {"co2_per_km": 142, "cv": 5}, + "velo": {"co2_per_km": 0}, +} + +JOURNEYS = { + "voiture_seule": {"distances": {"voiture": 25}}, + "voiture_velo": {"distances": {"voiture": 14, "velo": 8}}, + "velo_seul": {"distances": {"velo": 24}}, +} + +TRANCHES_CV5 = [ + {"km_max": 3000, "taux": 0.548, "forfait": 0}, + {"km_max": 6000, "taux": 0.316, "forfait": 699}, + {"km_max": 0, "taux": 0.364, "forfait": 0}, +] + + +def test_compute_km_voiture_seule(): + km = compute_km_for_entry("voiture_seule", JOURNEYS) + assert km == {"voiture": 25} + + +def test_compute_km_voiture_velo(): + km = compute_km_for_entry("voiture_velo", JOURNEYS) + assert km == {"voiture": 14, "velo": 8} + + +def test_compute_km_no_journey(): + km = compute_km_for_entry(None, JOURNEYS) + assert km == {} + + +def test_compute_co2_voiture(): + co2 = compute_co2_grams({"voiture": 25}, VEHICLES) + assert co2 == 25 * 142 # 3550g + + +def test_compute_co2_velo(): + co2 = compute_co2_grams({"velo": 24}, VEHICLES) + assert co2 == 0 + + +def test_frais_reels_tranche1(): + # 2000 km → tranche 1 → 2000 * 0.548 = 1096€ + result = compute_frais_reels(2000, TRANCHES_CV5) + assert abs(result - 1096.0) < 0.01 + + +def test_frais_reels_tranche2(): + # 4000 km → tranche 2 → 4000 * 0.316 + 699 = 1963€ + result = compute_frais_reels(4000, TRANCHES_CV5) + assert abs(result - 1963.0) < 0.01 + + +def test_frais_reels_tranche3(): + # 7000 km → tranche 3 → 7000 * 0.364 = 2548€ + result = compute_frais_reels(7000, TRANCHES_CV5) + assert abs(result - 2548.0) < 0.01 +``` + +**Step 2: Lancer pour vérifier l'échec** + +```bash +pytest tests/test_travel_calc.py -v +``` +Expected: ImportError + +**Step 3: Créer `app/business/travel_calc.py`** + +```python +def compute_km_for_entry(journey_profile_id: str | None, journeys: dict) -> dict[str, int]: + """ + Retourne un dict {vehicle_id: km} pour un profil de trajet donné. + Retourne {} si pas de profil (TT, CONGE, etc.). + """ + if not journey_profile_id: + return {} + profile = journeys.get(journey_profile_id, {}) + return dict(profile.get("distances", {})) + + +def compute_co2_grams(km_by_vehicle: dict[str, int], vehicles: dict) -> float: + """Calcule le CO2 total en grammes pour un dict {vehicle_id: km}.""" + total = 0.0 + for vehicle_id, km in km_by_vehicle.items(): + vehicle = vehicles.get(vehicle_id, {}) + co2 = vehicle.get("co2_per_km", 0) + total += km * co2 + return total + + +def compute_frais_reels(total_km_voiture: float, tranches: list[dict]) -> float: + """ + Calcule les frais réels fiscaux selon le barème kilométrique. + Formule : km * taux + forfait pour la tranche applicable. + km_max = 0 signifie "pas de limite" (dernière tranche). + """ + if not tranches or total_km_voiture <= 0: + return 0.0 + + for tranche in tranches: + km_max = tranche["km_max"] + if km_max == 0 or total_km_voiture <= km_max: + return total_km_voiture * tranche["taux"] + tranche.get("forfait", 0) + + # Fallback : dernière tranche + last = tranches[-1] + return total_km_voiture * last["taux"] + last.get("forfait", 0) +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_travel_calc.py -v +``` +Expected: 7 PASSED + +**Step 5: Commit** + +```bash +git add app/business/travel_calc.py tests/test_travel_calc.py +git commit -m "feat: travel calculation (km, CO2, frais réels fiscaux)" +``` + +--- + +## Task 6 : Business logic — solde congés/RTT + +**Files:** +- Create: `app/business/leave_calc.py` +- Create: `tests/test_leave_calc.py` + +**Step 1: Écrire les tests `tests/test_leave_calc.py`** + +```python +from app.business.leave_calc import compute_leave_used, get_or_create_balance +from app.models import WorkEntry, LeaveBalance +from app import db +from datetime import date + + +def test_compute_leave_used_conges(app): + with app.app_context(): + entries = [ + WorkEntry(date=date(2025, 1, 6), day_type="CONGE"), + WorkEntry(date=date(2025, 1, 7), day_type="CONGE"), + WorkEntry(date=date(2025, 1, 8), day_type="RTT"), + WorkEntry(date=date(2025, 1, 9), day_type="WORK"), + ] + for e in entries: + db.session.add(e) + db.session.commit() + + used = compute_leave_used(2025) + assert used["conges"] == 2 + assert used["rtt"] == 1 + + +def test_get_or_create_balance_creates_default(app): + with app.app_context(): + balance = get_or_create_balance(2025) + assert balance.year == 2025 + assert balance.conges_total == 28 + assert balance.rtt_total == 18 + + +def test_get_or_create_balance_returns_existing(app): + with app.app_context(): + existing = LeaveBalance(year=2025, conges_total=25, rtt_total=15) + db.session.add(existing) + db.session.commit() + + balance = get_or_create_balance(2025) + assert balance.conges_total == 25 + assert balance.rtt_total == 15 +``` + +**Step 2: Lancer pour vérifier l'échec** + +```bash +pytest tests/test_leave_calc.py -v +``` +Expected: ImportError + +**Step 3: Créer `app/business/leave_calc.py`** + +```python +from app import db +from app.models import WorkEntry, LeaveBalance +import sqlalchemy as sa +from datetime import date + + +def compute_leave_used(year: int) -> dict[str, int]: + """ + Calcule dynamiquement les congés et RTT utilisés pour une année donnée. + Retourne {"conges": N, "rtt": N}. + """ + start = date(year, 1, 1) + end = date(year, 12, 31) + + conges = db.session.scalar( + sa.select(sa.func.count()).where( + WorkEntry.date.between(start, end), + WorkEntry.day_type == "CONGE", + ) + ) or 0 + + rtt = db.session.scalar( + sa.select(sa.func.count()).where( + WorkEntry.date.between(start, end), + WorkEntry.day_type == "RTT", + ) + ) or 0 + + return {"conges": conges, "rtt": rtt} + + +def get_or_create_balance(year: int) -> LeaveBalance: + """Récupère ou crée un solde de congés pour l'année donnée.""" + balance = db.session.scalar( + sa.select(LeaveBalance).where(LeaveBalance.year == year) + ) + if balance is None: + balance = LeaveBalance(year=year) + db.session.add(balance) + db.session.commit() + return balance +``` + +**Step 4: Lancer les tests** + +```bash +pytest tests/test_leave_calc.py -v +``` +Expected: 3 PASSED + +**Step 5: Lancer tous les tests** + +```bash +pytest -v +``` +Expected: tous PASSED + +**Step 6: Commit** + +```bash +git add app/business/leave_calc.py tests/test_leave_calc.py +git commit -m "feat: leave balance calculation (congés/RTT)" +``` + +--- + +## Task 7 : Routes + templates de base + +**Files:** +- Create: `app/routes/__init__.py` +- Create: `app/routes/dashboard.py` +- Create: `app/routes/entries.py` +- Create: `app/routes/reports.py` +- Create: `app/templates/base.html` + +**Step 1: Créer `app/routes/__init__.py`** (vide) + +**Step 2: Créer `app/templates/base.html`** + +```html + + + + + + {% block title %}Tableau de bord{% endblock %} + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + +``` + +**Step 3: Créer `app/routes/dashboard.py`** (stub) + +```python +from flask import Blueprint, render_template +from datetime import date, timedelta +import sqlalchemy as sa +from app import db +from app.models import WorkEntry, LeaveBalance +from app.business.time_calc import minutes_to_str, work_minutes_reference +from app.business.travel_calc import compute_km_for_entry, compute_co2_grams +from app.business.leave_calc import compute_leave_used, get_or_create_balance +from app.config_loader import get_vehicles, get_journeys + +bp = Blueprint("dashboard", __name__) + + +@bp.route("/") +def index(): + today = date.today() + year = today.year + + # Semaine courante (lun-dim) + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + week_entries = db.session.scalars( + sa.select(WorkEntry).where(WorkEntry.date.between(monday, sunday)) + ).all() + + week_actual = sum(e.total_minutes() for e in week_entries) + week_ref = sum(work_minutes_reference(e.day_type) for e in week_entries) + week_balance = week_actual - week_ref + + # Mois courant + month_start = today.replace(day=1) + month_entries = db.session.scalars( + sa.select(WorkEntry).where(WorkEntry.date.between(month_start, today)) + ).all() + + vehicles = get_vehicles() + journeys = get_journeys() + + month_km = {} + month_co2 = 0.0 + for entry in month_entries: + km = compute_km_for_entry(entry.journey_profile_id, journeys) + for v, d in km.items(): + month_km[v] = month_km.get(v, 0) + d + month_co2 += compute_co2_grams(km, vehicles) + + # Solde congés/RTT + balance = get_or_create_balance(year) + used = compute_leave_used(year) + + # Saisie du jour + today_entry = db.session.scalar( + sa.select(WorkEntry).where(WorkEntry.date == today) + ) + + return render_template( + "dashboard.html", + today=today, + today_entry=today_entry, + journeys=journeys, + week_actual_str=minutes_to_str(week_actual), + week_balance=week_balance, + week_balance_str=minutes_to_str(abs(week_balance)), + month_km=month_km, + month_co2_kg=round(month_co2 / 1000, 2), + balance=balance, + used=used, + ) +``` + +**Step 4: Créer `app/routes/entries.py`** (stub minimal) + +```python +from flask import Blueprint, render_template, request, redirect, url_for, flash +from datetime import date, time +import sqlalchemy as sa +from app import db +from app.models import WorkEntry, TimeSlot +from app.config_loader import get_journeys, day_types_without_journey + +bp = Blueprint("entries", __name__, url_prefix="/entries") + +DAY_TYPES = [ + ("WORK", "Travail"), + ("TT", "Télétravail"), + ("GARDE", "Garde"), + ("ASTREINTE", "Astreinte"), + ("FORMATION", "Formation"), + ("RTT", "RTT"), + ("CONGE", "Congé"), + ("MALADE", "Maladie"), + ("FERIE", "Férié"), +] + + +@bp.route("/") +def list_entries(): + entries = db.session.scalars( + sa.select(WorkEntry).order_by(WorkEntry.date.desc()) + ).all() + return render_template("entry_list.html", entries=entries) + + +@bp.route("/new", methods=["GET", "POST"]) +@bp.route("//edit", methods=["GET", "POST"]) +def entry_form(entry_id=None): + entry = None + if entry_id: + entry = db.session.get(WorkEntry, entry_id) + if not entry: + flash("Entrée introuvable.", "error") + return redirect(url_for("entries.list_entries")) + + if request.method == "POST": + entry_date = date.fromisoformat(request.form["date"]) + day_type = request.form["day_type"] + journey_profile_id = request.form.get("journey_profile_id") or None + comment = request.form.get("comment") or None + + # Si type sans déplacement, on ignore le profil + if day_type in day_types_without_journey(): + journey_profile_id = None + + if entry is None: + existing = db.session.scalar( + sa.select(WorkEntry).where(WorkEntry.date == entry_date) + ) + if existing: + flash(f"Une entrée existe déjà pour le {entry_date}.", "error") + return redirect(url_for("entries.entry_form")) + entry = WorkEntry(date=entry_date) + db.session.add(entry) + + entry.day_type = day_type + entry.journey_profile_id = journey_profile_id + entry.comment = comment + + # Supprime les anciens time_slots et recrée + for slot in list(entry.time_slots): + db.session.delete(slot) + + starts = request.form.getlist("start_time") + ends = request.form.getlist("end_time") + for s, e in zip(starts, ends): + if s and e: + db.session.add(TimeSlot( + entry=entry, + start_time=time.fromisoformat(s), + end_time=time.fromisoformat(e), + )) + + db.session.commit() + flash("Entrée enregistrée.", "success") + return redirect(url_for("dashboard.index")) + + journeys = get_journeys() + return render_template( + "entry_form.html", + entry=entry, + day_types=DAY_TYPES, + journeys=journeys, + day_types_without_journey=day_types_without_journey(), + today=date.today().isoformat(), + ) + + +@bp.route("//delete", methods=["POST"]) +def delete_entry(entry_id): + entry = db.session.get(WorkEntry, entry_id) + if entry: + db.session.delete(entry) + db.session.commit() + flash("Entrée supprimée.", "success") + return redirect(url_for("entries.list_entries")) +``` + +**Step 5: Créer `app/routes/reports.py`** (stub minimal) + +```python +from flask import Blueprint, render_template, request +from datetime import date +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.config_loader import get_vehicles, get_journeys, get_bareme + +bp = Blueprint("reports", __name__, url_prefix="/reports") + + +@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() + + total_km = {} + total_co2 = 0.0 + for entry in entries: + km = compute_km_for_entry(entry.journey_profile_id, journeys) + for v, d in km.items(): + total_km[v] = total_km.get(v, 0) + d + total_co2 += compute_co2_grams(km, vehicles) + + # Frais réels : uniquement pour les véhicules motorisés + 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) + frais_reels[vehicle_id] = round(compute_frais_reels(km, tranches), 2) + + 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, + ) +``` + +**Step 6: Commit** + +```bash +git add app/routes/ app/templates/base.html +git commit -m "feat: Flask routes stubs for dashboard, entries, reports" +``` + +--- + +## Task 8 : Templates Jinja2 + HTMX + +**Files:** +- Create: `app/templates/dashboard.html` +- Create: `app/templates/entry_form.html` +- Create: `app/templates/entry_list.html` +- Create: `app/templates/reports.html` + +**Step 1: Créer `app/templates/dashboard.html`** + +```html +{% extends "base.html" %} +{% block title %}Tableau de bord{% endblock %} +{% block content %} + + +
+

+ Aujourd'hui — {{ today.strftime('%A %d %B %Y') }} +

+ {% if today_entry %} +

{{ today_entry.day_type }} — {{ today_entry.total_hours_str() }}

+ Modifier + {% else %} + + + Saisir ma journée + + {% endif %} +
+ + +
+

Semaine courante

+
+ Total + {{ week_actual_str }} +
+
+ Écart vs 38h45 + + {{ '+' if week_balance >= 0 else '-' }}{{ week_balance_str }} + +
+
+ + +
+

Ce mois

+ {% for vehicle_id, km in month_km.items() %} +
+ {{ vehicle_id | capitalize }} + {{ km }} km +
+ {% endfor %} +
+ CO₂ + {{ month_co2_kg }} kg +
+
+ + +
+

Congés / RTT

+
+
+ Congés + {{ used.conges }} / {{ balance.conges_total }} j +
+
+
+
+
+
+
+ RTT + {{ used.rtt }} / {{ balance.rtt_total }} j +
+
+
+
+
+
+ +{% endblock %} +``` + +**Step 2: Créer `app/templates/entry_form.html`** + +```html +{% extends "base.html" %} +{% block title %}{% if entry %}Modifier{% else %}Nouvelle entrée{% endif %}{% endblock %} +{% block content %} + +

+ {% if entry %}Modifier le {{ entry.date }}{% else %}Nouvelle journée{% endif %} +

+ +
+ + +
+ + +
+ + +
+ +
+ {% for value, label in day_types %} + + {% endfor %} +
+
+ + +
+ + +
+ + +
+ +
+ {% if entry and entry.time_slots %} + {% for slot in entry.time_slots %} +
+ + + + +
+ {% endfor %} + {% else %} +
+ + + + +
+ {% endif %} +
+ +
+ + +
+ + +
+ + +
+ + + Annuler + +
+
+ + +{% endblock %} +``` + +**Step 3: Créer `app/templates/entry_list.html`** + +```html +{% extends "base.html" %} +{% block title %}Historique{% endblock %} +{% block content %} + +

Historique

+ +{% if not entries %} +

Aucune entrée pour le moment.

+{% endif %} + +
+{% for entry in entries %} +
+
+

{{ entry.date.strftime('%a %d %b %Y') }}

+

+ {{ entry.day_type }} + {% if entry.time_slots %} — {{ entry.total_hours_str() }}{% endif %} + {% if entry.journey_profile_id %} — {{ entry.journey_profile_id }}{% endif %} +

+ {% if entry.comment %} +

{{ entry.comment }}

+ {% endif %} +
+
+ Éditer +
+ +
+
+
+{% endfor %} +
+ +{% endblock %} +``` + +**Step 4: Créer `app/templates/reports.html`** + +```html +{% extends "base.html" %} +{% block title %}Rapports{% endblock %} +{% block content %} + +
+

Rapports {{ year }}

+
+ +
+
+ + +
+

Kilométrage annuel

+ {% for vehicle_id, km in total_km.items() %} +
+ {{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} + {{ km }} km +
+ {% endfor %} +
+ CO₂ total + {{ total_co2_kg }} kg +
+
+ + +
+

Frais réels (barème {{ year }})

+

Déduction fiscale estimée — véhicules motorisés uniquement

+ {% for vehicle_id, montant in frais_reels.items() %} +
+ {{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }} + {{ montant }} € +
+ {% endfor %} + {% if not frais_reels %} +

Aucune donnée pour {{ year }}.

+ {% endif %} +
+ +{% endblock %} +``` + +**Step 5: Tester manuellement l'interface** + +```bash +python run.py +``` +Ouvrir http://127.0.0.1:5000 et vérifier : +- Dashboard s'affiche sans erreur +- Formulaire de saisie fonctionne (ajouter/supprimer plages horaires) +- Liste des entrées s'affiche +- Rapports s'affichent + +**Step 6: Commit** + +```bash +git add app/templates/ +git commit -m "feat: Jinja2 templates for dashboard, entry form, list, and reports" +``` + +--- + +## Task 9 : Configuration Gunicorn + script de démarrage + +**Files:** +- Create: `gunicorn.conf.py` +- Create: `start.sh` + +**Step 1: Créer `gunicorn.conf.py`** + +```python +bind = "127.0.0.1:5000" +workers = 2 +timeout = 30 +accesslog = "-" +errorlog = "-" +``` + +**Step 2: Créer `start.sh`** + +```bash +#!/bin/bash +set -e +export SECRET_KEY="${SECRET_KEY:-changeme-in-production}" +exec gunicorn -c gunicorn.conf.py "app:create_app()" +``` + +**Step 3: Rendre exécutable** + +```bash +chmod +x start.sh +``` + +**Step 4: Tester le démarrage** + +```bash +./start.sh +``` +Expected: Gunicorn démarre sur 127.0.0.1:5000 + +**Step 5: Commit final** + +```bash +git add gunicorn.conf.py start.sh +git commit -m "feat: Gunicorn config and startup script" +``` + +--- + +## Task 10 : Tests d'intégration des routes + +**Files:** +- Create: `tests/test_routes.py` + +**Step 1: Écrire `tests/test_routes.py`** + +```python +from app.models import WorkEntry, TimeSlot +from app import db +from datetime import date, time + + +def test_dashboard_empty(client): + response = client.get("/") + assert response.status_code == 200 + assert "Tableau de bord" in response.text + + +def test_entry_form_get(client): + response = client.get("/entries/new") + assert response.status_code == 200 + assert "Nouvelle journée" in response.text + + +def test_create_entry(client, app): + response = client.post("/entries/new", data={ + "date": "2025-06-02", + "day_type": "WORK", + "journey_profile_id": "voiture_seule", + "start_time": ["09:00"], + "end_time": ["17:45"], + "comment": "", + }, follow_redirects=True) + assert response.status_code == 200 + + with app.app_context(): + entry = db.session.scalar( + __import__("sqlalchemy").select(WorkEntry).where( + WorkEntry.date == date(2025, 6, 2) + ) + ) + assert entry is not None + assert entry.day_type == "WORK" + assert len(entry.time_slots) == 1 + + +def test_entry_list(client, app): + with app.app_context(): + entry = WorkEntry(date=date(2025, 6, 3), day_type="TT") + db.session.add(entry) + db.session.commit() + + response = client.get("/entries/") + assert response.status_code == 200 + assert "TT" in response.text + + +def test_reports_page(client): + response = client.get("/reports/") + assert response.status_code == 200 + assert "Rapports" in response.text + + +def test_delete_entry(client, app): + with app.app_context(): + entry = WorkEntry(date=date(2025, 6, 4), day_type="RTT") + db.session.add(entry) + db.session.commit() + entry_id = entry.id + + response = client.post(f"/entries/{entry_id}/delete", follow_redirects=True) + assert response.status_code == 200 + + with app.app_context(): + import sqlalchemy as sa + deleted = db.session.scalar( + sa.select(WorkEntry).where(WorkEntry.id == entry_id) + ) + assert deleted is None +``` + +**Step 2: Lancer tous les tests** + +```bash +pytest -v +``` +Expected: tous PASSED + +**Step 3: Commit final** + +```bash +git add tests/test_routes.py +git commit -m "test: integration tests for all routes" +``` + +--- + +## Résumé des commits + +| # | Message | Contenu | +|---|---|---| +| 1 | feat: initial Flask project setup | setup, conftest | +| 2 | feat: TOML config loader | config.toml, config_loader.py | +| 3 | feat: SQLAlchemy models | models.py | +| 4 | feat: time calculation business logic | time_calc.py | +| 5 | feat: travel calculation | travel_calc.py | +| 6 | feat: leave balance calculation | leave_calc.py | +| 7 | feat: Flask routes stubs | routes/ | +| 8 | feat: Jinja2 templates | templates/ | +| 9 | feat: Gunicorn config | gunicorn.conf.py, start.sh | +| 10 | test: integration tests | test_routes.py |