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

1645 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!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)
```python
from flask import Blueprint, render_template
from datetime import date, timedelta
import sqlalchemy as sa
from app import db
from app.models import WorkEntry, LeaveBalance
from app.business.time_calc import minutes_to_str, work_minutes_reference
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams
from app.business.leave_calc import compute_leave_used, get_or_create_balance
from app.config_loader import get_vehicles, get_journeys
bp = Blueprint("dashboard", __name__)
@bp.route("/")
def index():
today = date.today()
year = today.year
# Semaine courante (lun-dim)
monday = today - timedelta(days=today.weekday())
sunday = monday + timedelta(days=6)
week_entries = db.session.scalars(
sa.select(WorkEntry).where(WorkEntry.date.between(monday, sunday))
).all()
week_actual = sum(e.total_minutes() for e in week_entries)
week_ref = sum(work_minutes_reference(e.day_type) for e in week_entries)
week_balance = week_actual - week_ref
# Mois courant
month_start = today.replace(day=1)
month_entries = db.session.scalars(
sa.select(WorkEntry).where(WorkEntry.date.between(month_start, today))
).all()
vehicles = get_vehicles()
journeys = get_journeys()
month_km = {}
month_co2 = 0.0
for entry in month_entries:
km = compute_km_for_entry(entry.journey_profile_id, journeys)
for v, d in km.items():
month_km[v] = month_km.get(v, 0) + d
month_co2 += compute_co2_grams(km, vehicles)
# Solde congés/RTT
balance = get_or_create_balance(year)
used = compute_leave_used(year)
# Saisie du jour
today_entry = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.date == today)
)
return render_template(
"dashboard.html",
today=today,
today_entry=today_entry,
journeys=journeys,
week_actual_str=minutes_to_str(week_actual),
week_balance=week_balance,
week_balance_str=minutes_to_str(abs(week_balance)),
month_km=month_km,
month_co2_kg=round(month_co2 / 1000, 2),
balance=balance,
used=used,
)
```
**Step 4: Créer `app/routes/entries.py`** (stub minimal)
```python
from flask import Blueprint, render_template, request, redirect, url_for, flash
from datetime import date, time
import sqlalchemy as sa
from app import db
from app.models import WorkEntry, TimeSlot
from app.config_loader import get_journeys, day_types_without_journey
bp = Blueprint("entries", __name__, url_prefix="/entries")
DAY_TYPES = [
("WORK", "Travail"),
("TT", "Télétravail"),
("GARDE", "Garde"),
("ASTREINTE", "Astreinte"),
("FORMATION", "Formation"),
("RTT", "RTT"),
("CONGE", "Congé"),
("MALADE", "Maladie"),
("FERIE", "Férié"),
]
@bp.route("/")
def list_entries():
entries = db.session.scalars(
sa.select(WorkEntry).order_by(WorkEntry.date.desc())
).all()
return render_template("entry_list.html", entries=entries)
@bp.route("/new", methods=["GET", "POST"])
@bp.route("/<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)
```python
from flask import Blueprint, render_template, request
from datetime import date
import sqlalchemy as sa
from app import db
from app.models import WorkEntry
from app.business.travel_calc import compute_km_for_entry, compute_co2_grams, compute_frais_reels
from app.config_loader import get_vehicles, get_journeys, get_bareme
bp = Blueprint("reports", __name__, url_prefix="/reports")
@bp.route("/")
def index():
year = request.args.get("year", date.today().year, type=int)
start = date(year, 1, 1)
end = date(year, 12, 31)
entries = db.session.scalars(
sa.select(WorkEntry).where(WorkEntry.date.between(start, end))
).all()
vehicles = get_vehicles()
journeys = get_journeys()
total_km = {}
total_co2 = 0.0
for entry in entries:
km = compute_km_for_entry(entry.journey_profile_id, journeys)
for v, d in km.items():
total_km[v] = total_km.get(v, 0) + d
total_co2 += compute_co2_grams(km, vehicles)
# Frais réels : uniquement pour les véhicules motorisés
frais_reels = {}
for vehicle_id, km in total_km.items():
vehicle = vehicles.get(vehicle_id, {})
cv = vehicle.get("cv")
if cv:
tranches = get_bareme(year, cv)
frais_reels[vehicle_id] = round(compute_frais_reels(km, tranches), 2)
return render_template(
"reports.html",
year=year,
total_km=total_km,
total_co2_kg=round(total_co2 / 1000, 2),
frais_reels=frais_reels,
vehicles=vehicles,
)
```
**Step 6: Commit**
```bash
git add app/routes/ app/templates/base.html
git commit -m "feat: Flask routes stubs for dashboard, entries, reports"
```
---
## Task 8 : Templates Jinja2 + HTMX
**Files:**
- Create: `app/templates/dashboard.html`
- Create: `app/templates/entry_form.html`
- Create: `app/templates/entry_list.html`
- Create: `app/templates/reports.html`
**Step 1: Créer `app/templates/dashboard.html`**
```html
{% extends "base.html" %}
{% block title %}Tableau de bord{% endblock %}
{% block content %}
<!-- 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`**
```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`**
```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`**
```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**
```bash
python run.py
```
Ouvrir http://127.0.0.1:5000 et vérifier :
- Dashboard s'affiche sans erreur
- Formulaire de saisie fonctionne (ajouter/supprimer plages horaires)
- Liste des entrées s'affiche
- Rapports s'affichent
**Step 6: Commit**
```bash
git add app/templates/
git commit -m "feat: Jinja2 templates for dashboard, entry form, list, and reports"
```
---
## Task 9 : Configuration Gunicorn + script de démarrage
**Files:**
- Create: `gunicorn.conf.py`
- Create: `start.sh`
**Step 1: Créer `gunicorn.conf.py`**
```python
bind = "127.0.0.1:5000"
workers = 2
timeout = 30
accesslog = "-"
errorlog = "-"
```
**Step 2: Créer `start.sh`**
```bash
#!/bin/bash
set -e
export SECRET_KEY="${SECRET_KEY:-changeme-in-production}"
exec gunicorn -c gunicorn.conf.py "app:create_app()"
```
**Step 3: Rendre exécutable**
```bash
chmod +x start.sh
```
**Step 4: Tester le démarrage**
```bash
./start.sh
```
Expected: Gunicorn démarre sur 127.0.0.1:5000
**Step 5: Commit final**
```bash
git add gunicorn.conf.py start.sh
git commit -m "feat: Gunicorn config and startup script"
```
---
## Task 10 : Tests d'intégration des routes
**Files:**
- Create: `tests/test_routes.py`
**Step 1: Écrire `tests/test_routes.py`**
```python
from app.models import WorkEntry, TimeSlot
from app import db
from datetime import date, time
def test_dashboard_empty(client):
response = client.get("/")
assert response.status_code == 200
assert "Tableau de bord" in response.text
def test_entry_form_get(client):
response = client.get("/entries/new")
assert response.status_code == 200
assert "Nouvelle journée" in response.text
def test_create_entry(client, app):
response = client.post("/entries/new", data={
"date": "2025-06-02",
"day_type": "WORK",
"journey_profile_id": "voiture_seule",
"start_time": ["09:00"],
"end_time": ["17:45"],
"comment": "",
}, follow_redirects=True)
assert response.status_code == 200
with app.app_context():
entry = db.session.scalar(
__import__("sqlalchemy").select(WorkEntry).where(
WorkEntry.date == date(2025, 6, 2)
)
)
assert entry is not None
assert entry.day_type == "WORK"
assert len(entry.time_slots) == 1
def test_entry_list(client, app):
with app.app_context():
entry = WorkEntry(date=date(2025, 6, 3), day_type="TT")
db.session.add(entry)
db.session.commit()
response = client.get("/entries/")
assert response.status_code == 200
assert "TT" in response.text
def test_reports_page(client):
response = client.get("/reports/")
assert response.status_code == 200
assert "Rapports" in response.text
def test_delete_entry(client, app):
with app.app_context():
entry = WorkEntry(date=date(2025, 6, 4), day_type="RTT")
db.session.add(entry)
db.session.commit()
entry_id = entry.id
response = client.post(f"/entries/{entry_id}/delete", follow_redirects=True)
assert response.status_code == 200
with app.app_context():
import sqlalchemy as sa
deleted = db.session.scalar(
sa.select(WorkEntry).where(WorkEntry.id == entry_id)
)
assert deleted is None
```
**Step 2: Lancer tous les tests**
```bash
pytest -v
```
Expected: tous PASSED
**Step 3: Commit final**
```bash
git add tests/test_routes.py
git commit -m "test: integration tests for all routes"
```
---
## Résumé des commits
| # | Message | Contenu |
|---|---|---|
| 1 | feat: initial Flask project setup | setup, conftest |
| 2 | feat: TOML config loader | config.toml, config_loader.py |
| 3 | feat: SQLAlchemy models | models.py |
| 4 | feat: time calculation business logic | time_calc.py |
| 5 | feat: travel calculation | travel_calc.py |
| 6 | feat: leave balance calculation | leave_calc.py |
| 7 | feat: Flask routes stubs | routes/ |
| 8 | feat: Jinja2 templates | templates/ |
| 9 | feat: Gunicorn config | gunicorn.conf.py, start.sh |
| 10 | test: integration tests | test_routes.py |