Files
tableau-de-bord/docs/plans/2026-03-11-tableau-de-bord-travail.md

46 KiB
Raw Blame History

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

from app import create_app

app = create_app()

if __name__ == "__main__":
    app.run(debug=True)

Step 3: Créer app/__init__.py

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

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

pip install -r requirements.txt

Step 6: Vérifier que Flask démarre

python run.py

Expected: serveur démarré sur http://127.0.0.1:5000

Step 7: Commit

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

[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

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

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

pytest tests/test_config_loader.py -v

Expected: 4 PASSED

Step 5: Commit

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

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

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

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

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

pytest tests/test_time_calc.py -v

Expected: ImportError ou FAILED

Step 4: Créer app/business/time_calc.py

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

pytest tests/test_time_calc.py -v

Expected: 7 PASSED

Step 6: Commit

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

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

pytest tests/test_travel_calc.py -v

Expected: ImportError

Step 3: Créer app/business/travel_calc.py

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

pytest tests/test_travel_calc.py -v

Expected: 7 PASSED

Step 5: Commit

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

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

pytest tests/test_leave_calc.py -v

Expected: ImportError

Step 3: Créer app/business/leave_calc.py

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

pytest tests/test_leave_calc.py -v

Expected: 3 PASSED

Step 5: Lancer tous les tests

pytest -v

Expected: tous PASSED

Step 6: Commit

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

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Tableau de bord{% endblock %}</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body class="bg-gray-100 min-h-screen">
    <nav class="bg-blue-700 text-white px-4 py-3 flex items-center justify-between">
        <a href="/" class="font-bold text-lg">⏱ Temps de travail</a>
        <div class="flex gap-4 text-sm">
            <a href="/entries/new" class="hover:underline">+ Saisir</a>
            <a href="/entries" class="hover:underline">Historique</a>
            <a href="/reports" class="hover:underline">Rapports</a>
        </div>
    </nav>
    <main class="max-w-2xl mx-auto px-4 py-6">
        {% with messages = get_flashed_messages(with_categories=true) %}
          {% for category, message in messages %}
            <div class="mb-4 p-3 rounded {% if category == 'error' %}bg-red-100 text-red-800{% else %}bg-green-100 text-green-800{% endif %}">
              {{ message }}
            </div>
          {% endfor %}
        {% endwith %}
        {% block content %}{% endblock %}
    </main>
</body>
</html>

Step 3: Créer app/routes/dashboard.py (stub)

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)

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("/<int:entry_id>/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("/<int:entry_id>/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)

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

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

{% extends "base.html" %}
{% block title %}Tableau de bord{% endblock %}
{% block content %}

<!-- Carte aujourd'hui -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
  <h2 class="font-semibold text-gray-700 mb-2">
    Aujourd'hui — {{ today.strftime('%A %d %B %Y') }}
  </h2>
  {% if today_entry %}
    <p class="text-green-700 font-medium">{{ today_entry.day_type }} — {{ today_entry.total_hours_str() }}</p>
    <a href="/entries/{{ today_entry.id }}/edit"
       class="mt-2 inline-block text-sm text-blue-600 hover:underline">Modifier</a>
  {% else %}
    <a href="/entries/new"
       class="block w-full text-center bg-blue-600 text-white py-3 rounded-lg font-semibold text-lg hover:bg-blue-700 active:bg-blue-800 transition">
      + Saisir ma journée
    </a>
  {% endif %}
</div>

<!-- Synthèse semaine -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
  <h2 class="font-semibold text-gray-700 mb-3">Semaine courante</h2>
  <div class="flex justify-between items-center">
    <span class="text-gray-600">Total</span>
    <span class="font-bold text-xl">{{ week_actual_str }}</span>
  </div>
  <div class="flex justify-between items-center mt-1">
    <span class="text-gray-600">Écart vs 38h45</span>
    <span class="font-medium {% if week_balance >= 0 %}text-green-600{% else %}text-red-600{% endif %}">
      {{ '+' if week_balance >= 0 else '-' }}{{ week_balance_str }}
    </span>
  </div>
</div>

<!-- Synthèse mois -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
  <h2 class="font-semibold text-gray-700 mb-3">Ce mois</h2>
  {% for vehicle_id, km in month_km.items() %}
  <div class="flex justify-between text-sm text-gray-600 mb-1">
    <span>{{ vehicle_id | capitalize }}</span>
    <span>{{ km }} km</span>
  </div>
  {% endfor %}
  <div class="flex justify-between text-sm text-gray-600 mt-2">
    <span>CO₂</span>
    <span>{{ month_co2_kg }} kg</span>
  </div>
</div>

<!-- Solde congés/RTT -->
<div class="bg-white rounded-xl shadow p-4">
  <h2 class="font-semibold text-gray-700 mb-3">Congés / RTT</h2>
  <div class="mb-3">
    <div class="flex justify-between text-sm mb-1">
      <span>Congés</span>
      <span>{{ used.conges }} / {{ balance.conges_total }} j</span>
    </div>
    <div class="w-full bg-gray-200 rounded-full h-2">
      <div class="bg-blue-500 h-2 rounded-full"
           style="width: {{ [(used.conges / balance.conges_total * 100), 100] | min }}%"></div>
    </div>
  </div>
  <div>
    <div class="flex justify-between text-sm mb-1">
      <span>RTT</span>
      <span>{{ used.rtt }} / {{ balance.rtt_total }} j</span>
    </div>
    <div class="w-full bg-gray-200 rounded-full h-2">
      <div class="bg-purple-500 h-2 rounded-full"
           style="width: {{ [(used.rtt / balance.rtt_total * 100), 100] | min }}%"></div>
    </div>
  </div>
</div>

{% endblock %}

Step 2: Créer app/templates/entry_form.html

{% extends "base.html" %}
{% block title %}{% if entry %}Modifier{% else %}Nouvelle entrée{% endif %}{% endblock %}
{% block content %}

<h1 class="text-xl font-bold mb-4">
  {% if entry %}Modifier le {{ entry.date }}{% else %}Nouvelle journée{% endif %}
</h1>

<form method="POST" class="space-y-5">

  <!-- Date -->
  <div>
    <label class="block text-sm font-medium text-gray-700 mb-1">Date</label>
    <input type="date" name="date"
           value="{{ entry.date.isoformat() if entry else today }}"
           class="w-full border rounded-lg px-3 py-2 text-sm" required>
  </div>

  <!-- Type de journée -->
  <div>
    <label class="block text-sm font-medium text-gray-700 mb-2">Type de journée</label>
    <div class="grid grid-cols-3 gap-2">
      {% for value, label in day_types %}
      <label class="cursor-pointer">
        <input type="radio" name="day_type" value="{{ value }}"
               {% if entry and entry.day_type == value %}checked{% elif not entry and value == 'WORK' %}checked{% endif %}
               class="sr-only peer"
               hx-on:change="updateJourneyVisibility(this.value)">
        <div class="text-center text-sm py-2 px-1 rounded-lg border-2 border-gray-200
                    peer-checked:border-blue-500 peer-checked:bg-blue-50 peer-checked:text-blue-700
                    hover:border-gray-300 transition">
          {{ label }}
        </div>
      </label>
      {% endfor %}
    </div>
  </div>

  <!-- Profil de trajet -->
  <div id="journey-section"
       class="{% if entry and entry.day_type in day_types_without_journey %}hidden{% endif %}">
    <label class="block text-sm font-medium text-gray-700 mb-1">Trajet domicile-travail</label>
    <select name="journey_profile_id"
            class="w-full border rounded-lg px-3 py-2 text-sm">
      <option value="">— Pas de déplacement —</option>
      {% for jid, jdata in journeys.items() %}
      <option value="{{ jid }}"
              {% if entry and entry.journey_profile_id == jid %}selected{% endif %}>
        {{ jdata.name }}
        ({% for v, d in jdata.distances.items() %}{{ d }} km {{ v }}{% if not loop.last %} + {% endif %}{% endfor %})
      </option>
      {% endfor %}
    </select>
  </div>

  <!-- Plages horaires -->
  <div>
    <label class="block text-sm font-medium text-gray-700 mb-2">Plages horaires</label>
    <div id="time-slots" class="space-y-2">
      {% if entry and entry.time_slots %}
        {% for slot in entry.time_slots %}
        <div class="flex gap-2 items-center time-slot-row">
          <input type="time" name="start_time" value="{{ slot.start_time.strftime('%H:%M') }}"
                 class="flex-1 border rounded-lg px-3 py-2 text-sm" required>
          <span class="text-gray-400"></span>
          <input type="time" name="end_time" value="{{ slot.end_time.strftime('%H:%M') }}"
                 class="flex-1 border rounded-lg px-3 py-2 text-sm" required>
          <button type="button" onclick="this.closest('.time-slot-row').remove()"
                  class="text-red-400 hover:text-red-600 text-xl leading-none">×</button>
        </div>
        {% endfor %}
      {% else %}
        <div class="flex gap-2 items-center time-slot-row">
          <input type="time" name="start_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
          <span class="text-gray-400"></span>
          <input type="time" name="end_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
          <button type="button" onclick="this.closest('.time-slot-row').remove()"
                  class="text-red-400 hover:text-red-600 text-xl leading-none">×</button>
        </div>
      {% endif %}
    </div>
    <button type="button" onclick="addTimeSlot()"
            class="mt-2 text-sm text-blue-600 hover:underline">+ Ajouter une plage</button>
  </div>

  <!-- Commentaire -->
  <div>
    <label class="block text-sm font-medium text-gray-700 mb-1">Commentaire</label>
    <textarea name="comment" rows="2"
              class="w-full border rounded-lg px-3 py-2 text-sm"
              placeholder="Formation, déplacement exceptionnel...">{{ entry.comment if entry and entry.comment else '' }}</textarea>
  </div>

  <!-- Actions -->
  <div class="flex gap-3 pt-2">
    <button type="submit"
            class="flex-1 bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition">
      Enregistrer
    </button>
    <a href="/" class="flex-1 text-center bg-gray-100 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-200 transition">
      Annuler
    </a>
  </div>
</form>

<script>
const NO_JOURNEY_TYPES = {{ day_types_without_journey | list | tojson }};

function updateJourneyVisibility(dayType) {
  const section = document.getElementById('journey-section');
  section.classList.toggle('hidden', NO_JOURNEY_TYPES.includes(dayType));
}

function addTimeSlot() {
  const container = document.getElementById('time-slots');
  const row = document.createElement('div');
  row.className = 'flex gap-2 items-center time-slot-row';
  row.innerHTML = `
    <input type="time" name="start_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
    <span class="text-gray-400">→</span>
    <input type="time" name="end_time" class="flex-1 border rounded-lg px-3 py-2 text-sm">
    <button type="button" onclick="this.closest('.time-slot-row').remove()"
            class="text-red-400 hover:text-red-600 text-xl leading-none">×</button>
  `;
  container.appendChild(row);
}
</script>
{% endblock %}

Step 3: Créer app/templates/entry_list.html

{% extends "base.html" %}
{% block title %}Historique{% endblock %}
{% block content %}

<h1 class="text-xl font-bold mb-4">Historique</h1>

{% if not entries %}
  <p class="text-gray-500 text-center py-8">Aucune entrée pour le moment.</p>
{% endif %}

<div class="space-y-2">
{% for entry in entries %}
<div class="bg-white rounded-xl shadow p-3 flex items-center justify-between">
  <div>
    <p class="font-medium text-sm">{{ entry.date.strftime('%a %d %b %Y') }}</p>
    <p class="text-xs text-gray-500">
      {{ entry.day_type }}
      {% if entry.time_slots %} — {{ entry.total_hours_str() }}{% endif %}
      {% if entry.journey_profile_id %} — {{ entry.journey_profile_id }}{% endif %}
    </p>
    {% if entry.comment %}
    <p class="text-xs text-gray-400 mt-0.5 italic">{{ entry.comment }}</p>
    {% endif %}
  </div>
  <div class="flex gap-2">
    <a href="/entries/{{ entry.id }}/edit" class="text-blue-500 text-sm hover:underline">Éditer</a>
    <form method="POST" action="/entries/{{ entry.id }}/delete"
          onsubmit="return confirm('Supprimer cette entrée ?')">
      <button class="text-red-400 text-sm hover:underline">Suppr.</button>
    </form>
  </div>
</div>
{% endfor %}
</div>

{% endblock %}

Step 4: Créer app/templates/reports.html

{% extends "base.html" %}
{% block title %}Rapports{% endblock %}
{% block content %}

<div class="flex items-center justify-between mb-4">
  <h1 class="text-xl font-bold">Rapports {{ year }}</h1>
  <form method="GET">
    <select name="year" onchange="this.form.submit()"
            class="border rounded-lg px-2 py-1 text-sm">
      {% for y in range(2024, year + 2) %}
      <option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
      {% endfor %}
    </select>
  </form>
</div>

<!-- Kilométrage -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
  <h2 class="font-semibold text-gray-700 mb-3">Kilométrage annuel</h2>
  {% for vehicle_id, km in total_km.items() %}
  <div class="flex justify-between py-1 border-b border-gray-100 last:border-0">
    <span class="text-sm text-gray-600">{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }}</span>
    <span class="font-medium text-sm">{{ km }} km</span>
  </div>
  {% endfor %}
  <div class="flex justify-between pt-2 mt-1">
    <span class="text-sm text-gray-500">CO₂ total</span>
    <span class="font-medium text-sm">{{ total_co2_kg }} kg</span>
  </div>
</div>

<!-- Frais réels -->
<div class="bg-white rounded-xl shadow p-4 mb-4">
  <h2 class="font-semibold text-gray-700 mb-1">Frais réels (barème {{ year }})</h2>
  <p class="text-xs text-gray-400 mb-3">Déduction fiscale estimée — véhicules motorisés uniquement</p>
  {% for vehicle_id, montant in frais_reels.items() %}
  <div class="flex justify-between py-1 border-b border-gray-100 last:border-0">
    <span class="text-sm text-gray-600">{{ vehicles.get(vehicle_id, {}).get('name', vehicle_id) }}</span>
    <span class="font-bold text-green-700">{{ montant }} €</span>
  </div>
  {% endfor %}
  {% if not frais_reels %}
  <p class="text-sm text-gray-400">Aucune donnée pour {{ year }}.</p>
  {% endif %}
</div>

{% endblock %}

Step 5: Tester manuellement l'interface

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

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

bind = "127.0.0.1:5000"
workers = 2
timeout = 30
accesslog = "-"
errorlog = "-"

Step 2: Créer start.sh

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

chmod +x start.sh

Step 4: Tester le démarrage

./start.sh

Expected: Gunicorn démarre sur 127.0.0.1:5000

Step 5: Commit final

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

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

pytest -v

Expected: tous PASSED

Step 3: Commit final

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