46 KiB
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 |