docs: spec de refonte du système de votes de la roadmap

Championnat mensuel (fenêtre glissante sur created_at), recharge des
votes le lundi (Europe/Paris) sans report, pas de cap par feature, et
précondition de déploiement gatée sur le nombre de votants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-07-14 11:44:32 +02:00
parent 906c6a7f15
commit ea95431eae
@@ -0,0 +1,200 @@
# Refonte du système de votes de la roadmap
**Date :** 2026-07-14
**Statut :** proposé (en attente de validation)
## 1. Contexte et objectifs
Les abonné·es votent pour les fonctionnalités à développer en priorité, sur la
page `/compte/roadmap`. La liste des features « au vote » vient des issues GitHub
portant le label `mis au vote` ; elle s'allonge quand une idée est validée et se
raccourcit quand une feature entre en développement (label `en cours`).
Objectifs du responsable projet pour le mécanisme de vote :
1. **Favoriser les utilisateurs fréquents** (qui utilisent l'appli) plutôt que
les visiteurs ponctuels.
2. **Donner une impression d'impact réel** : pouvoir exprimer qu'une feature est
essentielle et une autre secondaire.
3. **Empêcher qu'un seul utilisateur fasse basculer le destin d'une feature** en y
concentrant tous ses votes.
4. **Rester lisible** — une bonne signalétique peut porter un système un peu
subtil, mais le principe doit s'expliquer en une phrase.
**Inconnue structurante :** le nombre de votants. Le problème est bien plus simple
à 50 votants qu'à 5. Cette inconnue est traitée par une précondition de
déploiement (§5), pas par le mécanisme lui-même.
## 2. Système actuel
- **Solde** : chaque abonné actif a un solde plafonné à `VOTES_PER_WEEK = 3`,
rechargé paresseusement de +3 par **semaine glissante de 7 jours propre à chaque
user** (`credit_pending` dans `src/subscriptions/db.py`), **sans accumulation**
(cap à 3).
- **Dépense** : voter = `spend_vote` (débit de 1 si solde > 0) + `record_vote`
(insertion d'une ligne dans `feature_votes`). **Aucune limite par feature** : les
3 votes peuvent aller sur la même.
- **Classement** : `vote_counts` = `COUNT(*)` par `issue_number`, **sur toute
l'histoire**, sans fenêtre temporelle.
Deux problèmes :
- **Comptage cumulatif à vie.** Une feature présente depuis 10 semaines a accumulé
des votes qu'une feature ajoutée cette semaine ne rattrapera jamais. Le classement
mesure autant l'ancienneté dans la liste que la popularité — ce qui mine le
« consensus fidèle » recherché. C'est le problème principal.
- **Objectifs 2 et 3 en tension.** Exprimer l'intensité = pouvoir concentrer ;
empêcher un seul de basculer = empêcher la concentration. Aucun mécanisme ne
satisfait les deux à 100 % ; il s'agit de choisir où placer le curseur.
## 3. Décisions de conception
### 3.1 Saison mensuelle (« championnat »)
Le vote fonctionne par **saisons = mois calendaires**. Le classement d'une feature
n'agrège que **ses votes du mois courant**. Au changement de mois, les compteurs
repartent de zéro et le ballot est renouvelé.
- Résout le comptage cumulatif : dans une saison, N est figé et toutes les features
ont couru la même distance ; on ne compare pas les totaux d'un mois à l'autre
(chaque mois est son propre championnat, on en tire le·s vainqueur·s).
- **Le ballot ne change qu'au 1er** : une idée validée le 3 attend le 1er suivant
(délai ≤ ~4 semaines, acceptable pour un rythme de roadmap). Convention opérateur
sur les labels GitHub (voir §4.3).
### 3.2 Recharge hebdomadaire, lundi, sans report
Le solde reste de **3 votes**, rechargé **chaque lundi à 00h00 (Europe/Paris),
sans report** (cap à 3, use-it-or-lose-it). Cadence **globale calendaire** — « chaque
lundi, tout le monde retrouve ses votes » — remplaçant le timer glissant par-user.
- C'est le rechargement **hebdomadaire** (et non un budget mensuel donné d'un coup)
qui porte l'**objectif 1** : un visiteur ponctuel du 28 ne peut pas rattraper un
habitué qui revient chaque semaine.
- 4 ou 5 lundis par mois → 12 ou 15 votes/mois selon les mois. Non problématique :
tous les votants d'un mois ont les mêmes lundis, et on ne compare pas d'un mois à
l'autre.
### 3.3 Aucun cap par feature (option A)
Un utilisateur peut concentrer tout son budget mensuel (~12 votes) sur une seule
feature.
**Justification vis-à-vis de l'objectif 3 :** à faible nombre de votants, _aucune_
valeur de cap ne règle vraiment le risque qu'un seul fasse basculer une feature —
ce qui le règle, c'est le **nombre de votants** (la part d'un individu se dilue quand
V augmente, pas quand N change). Plutôt qu'un demi-garde-fou peu lisible, on neutralise
le risque à la source via la précondition de déploiement (§5). Le cap par feature
(plafonner à ~3 votes/feature/saison) reste une **option de durcissement future** si
le whale redevient un problème à l'échelle.
### 3.4 Horloges découplées
Le passage de saison (1er) **ne touche pas le solde** ; seul le lundi le recharge.
Conséquence assumée : quand le 1er ne tombe pas un lundi, un user qui a gardé ses
votes les reporte (≤ 3) sur la nouvelle saison, et un user qui a vidé son solde attend
le lundi suivant. Cohérent avec le use-it-or-lose-it hebdo.
## 4. Conception technique
Principe directeur : **tout est dérivé du calendrier, aucun cron.** Ni la remise à
zéro de saison ni le rechargement n'exigent de tâche planifiée. **Aucune migration
de schéma** n'est nécessaire — uniquement de la logique.
### 4.1 Saison = fenêtre sur `created_at`
`feature_votes` porte déjà `created_at`. Le classement se restreint au mois courant :
- Ajouter un helper `season_start(now) -> datetime` = 1er du mois courant à 00h00
Europe/Paris, converti en UTC.
- `vote_counts()` (`src/roadmap/db.py`) ajoute `WHERE created_at >= :season_start`.
Les `created_at` sont stockés en ISO UTC (`...+00:00`) ; comparer avec
`season_start.astimezone(timezone.utc).isoformat()` (comparaison lexicographique ISO
valide car même format/offset).
Aucun effacement : au changement de mois, la fenêtre glisse et les votes du mois
précédent sortent du décompte (ils restent en base comme historique ; purge
éventuelle plus tard).
### 4.2 Recharge = lundi calendaire, paresseux
Réécrire `credit_pending` et `next_recharge_at` (`src/subscriptions/db.py`) autour du
lundi Europe/Paris au lieu de `WEEK_SECONDS` :
- `last_monday(now) -> datetime` = lundi 00h00 Europe/Paris le plus récent (UTC).
- `next_monday(now) -> datetime` = lundi 00h00 Europe/Paris suivant (UTC) — pour
l'affichage « rechargement le … ».
- `credit_pending(user_id)` :
- garde la garde `status == "active"` (les essais ne votent pas).
- première activation (`votes_last_credited_at is None`) → `balance = INITIAL_VOTES`,
cursor = `last_monday(now)`.
- sinon, si `votes_last_credited_at < last_monday(now)``balance = VOTES_PER_WEEK`
(on **fixe** à 3, pas d'ajout : ni report ni accumulation des lundis manqués),
cursor = `last_monday(now)`. Idempotent : sans effet si déjà crédité pour ce lundi.
- `next_recharge_at(user_id)``next_monday(now)` (indépendant du cursor).
- `WEEK_SECONDS` devient inutile (à retirer).
`zoneinfo.ZoneInfo("Europe/Paris")` pour les bornes locales ; cursors stockés en UTC.
### 4.3 Appartenance au ballot
Source de vérité inchangée : les labels GitHub (`fetch_roadmap_issues`). **Convention
opérateur : ne re-labelliser (`mis au vote` / `en cours`) qu'au 1er du mois**, pour que
le ballot reste figé pendant la saison.
_Fragilité connue :_ une re-labellisation en cours de mois ferait apparaître/disparaître
une feature en pleine saison (une nouveauté démarrerait à 0 face à des features déjà
votées). Acceptable en v1 (Colin est seul opérateur). **Durcissement futur possible :**
snapshoter les `issue_number` au vote en début de saison dans une table dédiée.
### 4.4 Déploiement des données existantes
Au déploiement, les lignes `feature_votes` accumulées à vie restent en base mais seules
celles du mois courant comptent → le classement « se réinitialise » de fait sur le mois
en cours. Comportement désiré, aucune action de données requise.
### 4.5 UI
- `src/roadmap/ui.py` : le libellé du solde peut évoquer le rechargement hebdo
(« rechargement le lundi ») et, si utile, la logique de saison. `_balance_item`
affiche déjà `next_recharge`.
- Signalétique à prévoir (texte de `roadmap_content`) : expliquer le championnat mensuel
et les 3 votes/semaine. Détails de rédaction hors périmètre de cette spec.
### 4.6 Ce qui ne change pas
`spend_vote`, `record_vote`, le flux du bouton « + » (`cast_vote`), la source GitHub des
issues, le schéma SQLite.
## 5. Précondition de déploiement
**Ce système n'entre pas en production tant que le nombre d'utilisateurs votant
(système actuel) n'a pas dépassé un seuil** défini par le responsable projet. En
dessous du seuil, on conserve le système actuel.
C'est le levier qui remplace le cap par feature pour l'objectif 3 : on n'ouvre le vote
concentré-libre que lorsqu'il y a assez de votants pour diluer un dumpeur. Le seuil est
un go/no-go opérateur, **pas** un paramètre de code. Pour l'évaluer : nombre de
`user_id` distincts ayant voté sur la dernière saison, ou nombre d'abonnés actifs
pouvant voter.
## 6. Tests
Cibler `tests/subscriptions/test_db.py` et `tests/roadmap/` :
- **Saison** : un vote daté du mois précédent n'est pas compté ; un vote du mois courant
l'est ; bornes autour du 1er 00h00 Europe/Paris.
- **Recharge** : franchissement d'un lundi → solde fixé à 3 (pas 3+report) ; pas de
re-crédit le même lundi (idempotence) ; première activation → `INITIAL_VOTES` ;
`next_recharge_at` = prochain lundi.
- **Fuseau** : un vote/rechargement autour de minuit tombe du bon côté en Europe/Paris
(vérifier notamment l'heure d'été).
- **Découplage** : un passage de mois ne modifie pas le solde.
## 7. Points différés
- Valeur exacte du seuil de déploiement (§5).
- Snapshot du ballot en début de saison pour figer strictement l'appartenance (§4.3).
- Rédaction de la signalétique utilisateur (§4.5).
- Purge éventuelle des vieilles lignes `feature_votes`.
- Cap par feature comme durcissement si le whale réapparaît à l'échelle.