L'app tourne désormais pour soi sans rien configurer. Sans relais
SMTP, le lien de création de compte s'affiche directement à l'écran,
construit depuis l'URL en cours — aucune config d'URL nécessaire,
même en naviguant par IP sur le LAN (le contrôle anti-open-redirect
du Host ne concerne que le chemin par email). allowed_email_domain
vide accepte tous les emails : l'adresse ne sert que d'identifiant.
Le fichier d'exemple de config était périmé : il datait d'avant les
plages, force_secure_cookies et login_rate_limit_per_min, et
laissait croire le SMTP obligatoire. Il reflète maintenant les clés
réelles, toutes optionnelles et vides par défaut.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
349 lines
18 KiB
Markdown
349 lines
18 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>/`.
|
||
|
||
**Utilisation locale sans aucune config** : `data/config.json` est optionnel (créé
|
||
avec les valeurs par défaut au premier lancement) et la config mail aussi — sans
|
||
relais SMTP, le lien de création de compte s'affiche directement à l'écran au lieu
|
||
d'être envoyé par email. Lance, ouvre [http://localhost:8000](http://localhost:8000), tape ton
|
||
email, clique sur le lien affiché, définis ton mot de passe : c'est tout.
|
||
|
||
Connexion par email + mot de passe. Si `allowed_email_domain` est renseigné, seules
|
||
les adresses de ce domaine peuvent se connecter (vide = tous les domaines acceptés).
|
||
Pour un compte qui n'a pas encore de mot de passe, se connecter avec son adresse
|
||
génère un lien (valable 24h) pour en définir un — envoyé par email si un serveur
|
||
SMTP est configuré, affiché à l'écran sinon. 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`) — toutes les clés sont
|
||
optionnelles (le fichier lui-même est créé avec les défauts au premier lancement) :
|
||
|
||
```json
|
||
{
|
||
"heures_semaine": 39.0,
|
||
"jours_par_semaine": 5,
|
||
"log_level": "INFO",
|
||
"allowed_email_domain": "",
|
||
"base_url": "",
|
||
"mail_from": "",
|
||
"smtp": {
|
||
"host": "",
|
||
"port": 587,
|
||
"user": "",
|
||
"password": "",
|
||
"use_tls": true
|
||
},
|
||
"plages": { "matin_debut": "09:00", "matin_fin": "12:00", "aprem_debut": "14:00", "aprem_fin": "17:00", "pause_dejeuner_fin": "13:30" },
|
||
"astreinte_facteurs": []
|
||
}
|
||
```
|
||
|
||
- `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` — **optionnel** : seules les adresses de ce domaine peuvent se
|
||
connecter ; vide = tous les domaines acceptés (l'email ne sert que d'identifiant).
|
||
- `base_url` — **optionnel** : schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final)
|
||
utilisés pour construire les liens envoyés par email. 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). **À renseigner en production si le mail est activé.**
|
||
- `mail_from` — **optionnel** : adresse expéditrice des emails de connexion (défaut local
|
||
`no-reply@pointeuse.local`).
|
||
- `smtp` — **optionnel** : serveur relais utilisé pour l'envoi (host, port, identifiants,
|
||
TLS). Sans `smtp.host`, aucun email n'est envoyé : le lien de création de compte
|
||
s'affiche directement à l'écran — parfait pour une instance locale perso.
|
||
- `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`
|