Files
pointeuse-optimisator/README.md
Jacquin Antoine 77ae115c4f Retour décalé après astreinte : la présence obligatoire couverte compte comme effectuée
Quand le repos de 11h décale le retour au bureau (ex. 11:00), les heures de
présence obligatoire couvertes (ex. 09:00→11:00) sont créditées à la colonne
Travaillé au lieu d'être retirées des heures dues, qui restent complètes.
Le solde est inchangé, mais le calcul est explicite : badge « +2h00 repos »
sous le total (plages couvertes au survol) et rappel dans le bloc astreinte.
Rien n'est crédité sans heures dues (week-end, férié, congé), et le crédit
ne dépasse pas le nominal du jour.

Corrige aussi la perte des interventions postées sans description (le zip
s'arrêtait sur la liste vide des descriptions).

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-09-09 21:05:50 +02:00

278 lines
14 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.

# pointeuse-optimisator
Saisie des horaires de travail avec calcul en temps réel de l'heure de sortie optimale pour finir la semaine à **exactement 0h d'heures supplémentaires**.
![Interface principale](docs/screenshot.png)
---
## Démarrage rapide
Avant le premier lancement, copie et adapte le fichier de config (voir
[Configuration](#configuration) plus bas) :
```bash
cp data/config.example.json data/config.json
```
Pour surcharger `compose.yml` localement (ports, variables d'env, volumes...) sans
modifier le fichier versionné, crée un `compose.override.yml` — il est chargé
automatiquement par `docker compose` et déjà ignoré par git.
---
## Principe
La semaine cible est **39h** (7h48 / jour). Les directives imposent des plages de présence minimum :
| Demi-journée | Plage minimale |
|---|---|
| Matin | 09:00 → 12:00 |
| Après-midi | 14:00 → 17:00 |
Ces horaires sont réglables par utilisateur depuis la page **Réglages** de l'app (voir
[Configuration](#configuration)).
Le matin n'est **jamais** touché : sa durée nominale est fixe, et si elle ne rentre pas
avant la pause déjeuner, le surplus est reporté sur l'après-midi du même jour. Quand un
écart hebdomadaire apparaît, il est **réparti uniquement sur les heures de départ du
soir** des demi-journées restantes (répartition égale, recalculée à chaque fois qu'un
départ atteint sa limite) :
- un **retard** repousse les départs — d'abord dans la marge de confort `depart_vise`,
puis au-delà si besoin (jusqu'à minuit) ;
- une **avance** avance les départs, mais jamais en dessous du minimum de présence de
l'après-midi.
Si le rythme normal suffit déjà à rentrer dans les clous, aucune préconisation n'est affichée.
---
## Ce que tu vois dans le tableau
| Colonne | Ce qu'elle dit |
|---|---|
| **Travaillé** | Heures effectivement saisies ce jour |
| **Δ jour** | Écart par rapport aux 7h48 dus (affiché dès qu'une heure est enregistrée) |
| **Δ sem.** | Solde cumulé depuis lundi — visible uniquement quand la journée est complète et sans jour manquant |
| **Fin cible** | Préconisation de sortie **M** (matin) et/ou **A** (après-midi) pour cette demi-journée — vide si le rythme normal suffit |
Les plages qui ne respectent pas les minimums sont signalées en rouge dès la saisie.
**Enregistrement automatique** : chaque heure validée (sortie du champ, Entrée ou
sélecteur) enregistre le jour ; un témoin « ✓ enregistré » s'affiche un instant
dans la colonne Jour. Plus de bouton à cliquer.
Quand la semaine affichée contient le jour courant, un **panneau Aujourd'hui**
s'invite en tête de page : heure de sortie cible en grand et barre de progression
travaillé/dû de la semaine, actualisés à chaque saisie.
Le bandeau au-dessus du tableau ajoute, quand il reste du retard à rattraper, une ligne
**Reste à faire** : le total encore dû, le nombre de demi-journées restantes, et la part
supplémentaire moyenne à ajouter sur chacune.
---
## Pages
| Page | Contenu |
|---|---|
| **Semaine courante** (`/`) | Le tableau de saisie ci-dessus, pour la semaine en cours ou une autre semaine choisie via le calendrier. |
| **Statistiques** (`/stats`) | Conformité aux plages obligatoires, ponctualité moyenne par demi-journée, distribution des horaires, graphique des soldes hebdomadaires. |
| **Journaux** (`/logs`) | Historique par utilisateur : changements de réglages, notifications ntfy envoyées, pointages de présence reçus. |
| **Réglages** (`/settings`) | Plages horaires personnelles, notifications ntfy, URLs des webhooks de présence — voir [Configuration](#configuration) et [Présence & notifications](#présence--notifications). |
| **Aide** (`/aide`) | Guide de configuration de ntfy et de l'app de présence (Automation/Tasker). |
| **Export** | Boutons **⇩ Exporter** (semaine, dans l'en-tête) et **⇩ {année}** (dans l'historique) : téléchargent un fichier `.ods`. |
---
## Comptes & connexion
Chaque personne a son propre compte, ses propres pointages, congés, réglages et jetons de
présence — tout est stocké séparément dans `data/users/<identifiant>/`.
Connexion par email + mot de passe, restreinte au domaine `allowed_email_domain` (voir
[Configuration](#configuration)). Pour un compte qui n'a pas encore de mot de passe, se
connecter avec son adresse envoie un lien (valable 24h) pour en définir un — c'est aussi
le seul moyen d'en obtenir un : il n'y a pas de "mot de passe oublié" en libre-service une
fois un mot de passe défini. La session est un cookie signé (HMAC), sans base de données
de sessions côté serveur.
---
## Lancement
```bash
docker compose up -d
```
Ouvre [http://localhost:8000](http://localhost:8000).
Les données (pointages, congés, comptes) sont stockées dans `data/` et ignorées par git.\
Seul `data/config.example.json` est versionné.
---
## Configuration
`data/config.json` (voir `data/config.example.json`) :
```json
{
"heures_semaine": 39.0,
"jours_par_semaine": 5,
"log_level": "DEBUG",
"allowed_email_domain": "exemple.fr",
"base_url": "https://pointeuse.exemple.fr",
"mail_from": "no-reply@exemple.fr",
"smtp": {
"host": "smtp.exemple.fr",
"port": 587,
"user": "no-reply@exemple.fr",
"password": "change-moi",
"use_tls": true
}
}
```
- `heures_semaine` — heures hebdomadaires visées (ex: `39.0`).
- `jours_par_semaine` — nombre de jours travaillés dans la semaine (ex: `5`). Le quota quotidien est calculé : `heures_semaine ÷ jours_par_semaine` (39h / 5 = 7h48/jour).
- `log_level` — `DEBUG`, `INFO`, `WARNING` ou `ERROR`. Contrôle la verbosité des logs (`docker logs`).
- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
- `base_url` — schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final) utilisés
pour construire les liens envoyés par email (magic link) et les URLs Tasker/Automation
affichées dans Réglages. Si vide, ces URLs sont dérivées de la requête entrante (Host header)
quand celui-ci correspond à `localhost` ou à `allowed_email_domain` ; sinon l'envoi d'email est
refusé par sécurité (anti open-redirect). **Toujours renseigner `base_url` en production.**
- `mail_from` — adresse expéditrice des emails de connexion.
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
- `force_secure_cookies` (optionnel, `false` par défaut) — force le flag `Secure` sur le
cookie de session même si la requête entrante n'est pas vue comme HTTPS par l'app
(utile derrière un reverse proxy qui termine le TLS).
Les rappels ntfy (topic, serveur, plage horaire) et les **plages horaires** (bornes de
présence obligatoire matin/après-midi, voir [Principe](#principe), et heure de reprise
par défaut de l'après-midi) se règlent **par utilisateur** depuis la page **Réglages**
de l'app, pas dans `config.json`. Modifier ses plages change, pour ce compte uniquement,
les avertissements de saisie, les stats de conformité et les préconisations de rattrapage.
---
## Présence & notifications
La page **Réglages** fournit, par utilisateur, deux URLs à jeton unique
(`/presence/{token}/arrivee` et `/presence/{token}/depart`) — sans autre authentification,
la sécurité vient de l'imprévisibilité du jeton. Une app comme **Automation** (alternative
libre à Tasker) les appelle depuis le téléphone en entrant/sortant d'une zone (wifi,
géofence...) pour signaler l'arrivée ou le départ.
Une fois configuré, l'app peut envoyer un rappel [ntfy](https://ntfy.sh) si quelqu'un est
détecté présent sans avoir pointé son arrivée, ou parti sans avoir pointé son départ,
uniquement pendant les jours/heures de travail. Guide complet sur la page **Aide**.
---
## Tests
```bash
docker compose run --rm pointeuse pytest -v
```
---
## Congés & jours fériés
Chaque ligne porte un bouton d'état unique (colonne **État**) qui résume la
situation par des pastilles colorées et ouvre un menu : **MA** (congé matin), **AM**
(congé après-midi), **J** (journée entière) et **F** (jour férié). Les trois premiers réduisent les heures
dues et recalculent les préconisations **Fin cible** pour les demi-journées
restantes. **F** marque un jour férié (fête locale non calculée, pont...) : heures
dues nulles, aucune présence obligatoire, ligne grisée avec badge « férié » — sans
consommer de congé. Un jour marqué **F** compte aussi comme férié pour les ratios
d'astreinte (×2 en journée par défaut) et pour la fenêtre d'astreinte ci-dessous.
---
## Astreinte & repos de 11h
La gestion des astreintes est une **préférence personnelle** (Réglages →
« Gestion des astreintes », active par défaut). Désactivée, tout ce qui suit
disparaît pour ce compte : bouton **AS**, saisie d'interventions, forfaits,
repos de 11h, récapitulatif et ratios — sans rien supprimer, une réactivation
retrouve les données telles quelles.
Le bouton **AS** du menu d'état marque un jour d'astreinte : la période couverte va de **18:00
ce jour-là à 18:00 le lendemain** (deux astreintes consécutives se recollent sans
trou) — sauf **samedi, dimanche et jour férié**, qui comptent de **0h00 à 0h00**
(journée entière ; un jour AS déclaré sam/dim/férié garde sa borne du lendemain
18:00 pour ne pas tronquer les interventions nocturnes). Ce jour et son lendemain, une sous-ligne dédiée apparaît sous la ligne du
jour, pleine largeur : un menu dépliant fermé par défaut (seuls l'heure de retour
et le total restent visibles). Déplié, il offre autant de lignes que nécessaire
(**+ intervention**) sur trois colonnes : **début**, **fin** et description
libre. Une fin antérieure au début (fin < début) désigne une intervention à
cheval sur minuit. Une durée forfaitaire peut être attribuée à chaque jour
d'astreinte déclaré (page **Réglages**, « Forfait par jour d'astreinte ») :
elle ne compte jamais dans les heures travaillées, elle est suivie à part dans
le récapitulatif d'astreinte.
La semaine affiche aussi **samedi et dimanche** (heures dues nulles, lignes
grisées) : on peut y déclarer des astreintes, y saisir les interventions et les
pointer si besoin — les exports `.ods` suivent.
L'app en déduit l'heure de retour au bureau : **fin du dernier événement + 11h de
repos consécutif** (exigées par période de 24h), affichée sous la liste (« retour
≥ 13:00 »). Comme pour le décompte, la durée réelle de l'intervention est arrondie
au quart d'heure supérieur (début 21:00, fin 23:37 → durée comptée 2h45) ; les
interventions hors fenêtre 18:00→18:00 ne sont pas de l'astreinte. **L'heure de
retour tombe toujours sur un quart d'heure d'horloge fixe** (:00, :15, :30 ou :45),
arrondie au quart supérieur : fin 23:30 → retour 10:30 le lendemain ; 22:39 →
23:49 (fin comptée 23:54) → retour 11:00 ; fin 02:00 → retour 13:00 le jour même.
Un repos qui se termine avant l'ouverture ne contraint rien.
À partir de cette heure de retour, la présence obligatoire couverte par le repos
est **comptée comme effectuée** :
- les heures de présence obligatoire avant l'heure de retour sont créditées au
**Travaillé** du jour, affichées sous le total par un badge explicite
(« +2h00 repos », détail des plages au survol — ex. retour 11:00 → la plage
09:00-11:00 compte comme faite) ; les heures dues restent complètes, le solde
est le même que lorsqu'on retirait ces heures des dues ;
- une présence pointée avant l'heure de retour ne compte pas dans le travaillé
(seul le crédit de présence s'y ajoute) ;
- les préconisations **Fin cible** démarrent à l'heure de retour (arrivée du matin
décalée d'autant, sans avertissement de retard) ;
- une demi-journée entièrement couverte par le repos est considérée comme remplie.
Les heures d'astreinte ne comptent **jamais** dans les heures de semaine : elles
sont décomptées à part — la **durée réelle** de chaque intervention est **arrondie
au quart d'heure supérieur** (2, 5 ou 12 min réelles → 15 min ; 22 ou 28 → 30 min),
même si l'arrondi ne tombe pas juste sur un quart d'heure de l'horloge (:00, :15,
:30, :45), et les minutes réelles communes à deux interventions qui se suivent ou
se chevauchent ne sont comptées **qu'une fois** (attribuées à la première, les
lignes d'événements somment au total du jour) — dans des compteurs pondérés selon
le jour et l'heure. Sous le bandeau de la semaine s'affiche le détail par
événement, les totaux par compteur et le total pondéré.
Par défaut : **Nuit** (20:00→08:00, tous types de jours) ×1,5 ; **Journée** (semaine
Lun-Ven, 08:00→20:00) ×1 ; **Samedi** (08:00→20:00) ×1,5 ; **Dimanche / férié**
(08:00→20:00) ×2. Les jours fériés français (fixes + Pâques, Ascension,
Pentecôte) sont calculés automatiquement, et s'ajoutent les jours marqués **F**.
Ces règles sont **éditables à la main** sur la page **Réglages** (section « Ratios
des heures d'astreinte ») : nom, types de jours, fenêtre horaire et ratio de chaque
règle, ajout et suppression de lignes. La première règle qui couvre une minute
gagne — place les fenêtres spécifiques avant les générales. Elles restent aussi
ajustables dans `data/config.json`, clé `astreinte_facteurs` : chaque règle porte
un `nom`, les `jours` concernés (`semaine`, `sam`, `dim`, `ferie`), un `debut`,
une `fin` et un `facteur`. Une minute qui ne correspond à aucune règle est rangée
dans « Autre » avec un facteur ×1.
Les événements ne s'appliquent que les jours qualifiés (astreinte ou lendemain) :
partout ailleurs ils sont ignorés. Les colonnes **Astreinte** (brut), **Astreinte
pondérée** et **Retour** (calculé) apparaissent dans les exports `.ods`.
---
## Stack
`FastAPI` · `HTMX` · `Jinja2` · fichiers JSON · `Docker`