La dernière heure saisie qui clôt la semaine à +0h00 pile, sans
aucune demi-journée restante, déclenche une fête assumée : fond disco
en rotation de teintes, pluie d'une fourgonnée de confettis (emojis
et rectangles), trophée bondissant et titre doré défilant. Une seule
fois par semaine et par onglet, fermeture au clic ou après 8 s, et
animation réduite pour prefers-reduced-motion.
Le serveur pose un drapeau data-perfect (solde nul, plus rien à
remplir, heures dues non nulles) sur les soldes re-rendus à chaque
saisie et rafraîchissement ; le client le détecte et célèbre.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
334 lines
17 KiB
Markdown
334 lines
17 KiB
Markdown
# 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**.
|
||
|
||

|
||
|
||
---
|
||
|
||
## 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 moteur de préconisations vise un **solde à 0 en fin de semaine** : l'écart entre
|
||
heures dues et heures déjà travaillées (temps écoulé des demi-journées en cours déduit)
|
||
est réparti à parts égales sur les demi-journées restantes. Les règles, dans l'ordre :
|
||
|
||
1. **Créneaux** : chaque demi-journée non pointée (entrée + sortie) et non congéfiée
|
||
reste à planifier, y compris les jours passés — la saisie après coup est admise.
|
||
2. **Le matin n'est jamais ajusté** par l'écart hebdomadaire : arrivée à l'heure visée
|
||
(`arrivee_visee`, au plus tôt l'heure de retour après repos d'astreinte), sortie au
|
||
nominal plafonné à la pause de midi. Le nominal matin qui ne tient pas avant la
|
||
pause **bascule sur l'après-midi du même jour** (s'il est encore ouvert).
|
||
3. **Retard** — trois leviers en cascade : repousser les **départs du soir** (d'abord
|
||
dans la marge du départ visé `depart_vise`, puis jusqu'à minuit) ; sinon avancer
|
||
les **arrivées du matin** (jamais avant le début de présence matin) ; sinon avancer
|
||
les **retours de midi** (jamais avant la fin de présence matin, ni avant la sortie
|
||
du matin pointée, ni avant l'heure qu'il est).
|
||
4. **Avance** — cascade miroir : avancer les **départs du soir** (jamais sous la fin de
|
||
présence après-midi) ; sinon retarder les **arrivées du matin** (jamais après le
|
||
début de présence matin) ; sinon retarder les **retours de midi** (jamais après le
|
||
début de présence après-midi — la pause s'allonge d'autant).
|
||
5. **Journée en cours** : une demi-journée commencée (entrée pointée sans sortie) se
|
||
planifie depuis son heure d'entrée, son temps déjà écoulé est réintégré au plan ;
|
||
s'il atteint ou dépasse le nominal du créneau (**créneau saturé**, ex. sortie de
|
||
midi oubliée), la cible devient « sortir maintenant » et l'écart se reporte sur les
|
||
autres créneaux — jamais de double comptage du temps déjà fait.
|
||
6. Une entrée déjà pointée n'est jamais déplacée. Les heures `arrivee_visee`,
|
||
`pause_dejeuner_fin` et `depart_vise` sont **indicatives** : seules les plages de
|
||
présence (matin et après-midi) sont contraignantes ; le jour courant, la reprise
|
||
visée d'un après-midi non commencé ne recule jamais dans le passé — ancrée à
|
||
maintenant une fois l'heure désirée passée. Ce qui ne tient dans aucune plage
|
||
reste visible dans les compteurs « reste à faire » / avance.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| **Cible** | Horaire visé **M** (matin) et/ou **A** (après-midi) de chaque demi-journée restante : entrée → sortie proposées, recalculées en continu |
|
||
|
||
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 **et en continu** — les
|
||
cibles suivent l'avancement de la journée (recalcul toutes les 30 s, et immédiat
|
||
au retour sur l'onglet) : reprise ancrée à l'heure qu'il est une fois l'heure
|
||
désirée passée, sorties cibles qui glissent avec le reste à faire.
|
||
|
||
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.
|
||
|
||
Une **alerte de départ** (bandeau ⏰ + notification navigateur) se déclenche le jour même
|
||
quand l'heure de sortie cible est atteinte — y compris si elle a glissé plus tard dans
|
||
la journée, l'alerte suit la cible la plus fraîche.
|
||
|
||
Et quand la dernière heure saisie boucle la semaine à **+0h00 pile**, une célébration
|
||
kitsch assumée (pluie de confettis, fond disco, trophée bondissant) récompense la
|
||
performance — une seule fois par semaine et par onglet, promis.
|
||
|
||

|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
### Repos hebdomadaire de 35h (option)
|
||
|
||
La loi exige en plus, **une fois par période de 7 jours glissants**, un repos de
|
||
**35h consécutives** (24h de repos hebdomadaire + 11h de repos quotidien). Une
|
||
semaine normale le satisfait d'elle-même — le week-end offre le trou nécessaire.
|
||
Un **week-end d'astreinte fragmenté** par plusieurs interventions, lui, peut ne
|
||
plus contenir aucun trou de 35h.
|
||
|
||
La prise en compte est une **préférence personnelle** (Réglages → « Repos
|
||
hebdomadaire de 35h », désactivée par défaut). Activée :
|
||
|
||
- quand aucune période de 35h sans activité (pointages + interventions) n'existe
|
||
dans les 7 derniers jours, l'heure de retour devient **dernière activité +
|
||
35h** — à la place des 11h si elle est plus tardive ;
|
||
- **toute nouvelle perturbation avant cette échéance redémarre le décompte** à
|
||
zéro (le retour se recalcule depuis la nouvelle dernière activité) ;
|
||
- un repos qui déborde sur le(s) jour(s) suivant(s) affiche le jour visé
|
||
(« retour ≥ Mar 05:00 ») et couvre la journée de la même façon que les 11h
|
||
(présence obligatoire créditée, rien à planifier).
|
||
|
||
La fenêtre glissante puise aussi dans la semaine précédente : les jours
|
||
manquants y sont lus automatiquement ; sans eux, le début de la fenêtre compte
|
||
comme repos (jamais de faux positif).
|
||
|
||
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`
|