# 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
{{ today_entry.day_type }} — {{ today_entry.total_hours_str() }}
Modifier {% else %} + Saisir ma journée {% endif %}Aucune entrée pour le moment.
{% endif %}{{ 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 %}Déduction fiscale estimée — véhicules motorisés uniquement
{% for vehicle_id, montant in frais_reels.items() %}Aucune donnée pour {{ year }}.
{% endif %}