diff --git a/docs/superpowers/specs/2026-07-14-refonte-votes-roadmap-design.md b/docs/superpowers/specs/2026-07-14-refonte-votes-roadmap-design.md new file mode 100644 index 0000000..f2fb459 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-refonte-votes-roadmap-design.md @@ -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.