# 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 |