feat: améliorations UX roadmap votes #94

- Votes cappés à VOTES_PER_WEEK (pas d'accumulation) pour valoriser
  les connexions régulières
- Solde de votes affiché dans la liste (input inéditable) à la place
  du bandeau Alert, avec date de prochain rechargement
- Compteur de votes par feature affiché comme input inéditable en
  fin de ligne (avant le bouton "+")
- Animation FLIP JS (roadmap_flip.js) : seules les lignes qui changent
  de position sont animées après un vote
- Renommage votes_credited_until → votes_last_credited_at (migration 0005)
- Constantes du module utilisées dans les assertions de tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-06-30 16:39:13 +02:00
parent f04e1b4c44
commit c4aa90b0d1
14 changed files with 286 additions and 102 deletions
+5
View File
@@ -968,6 +968,11 @@ input[type="number"] {
display: none;
}
/* Roadmap */
.roadmap-vote-list input[type="text"] {
}
/* --- Bascule desktop / mobile au point de rupture 768 px --- */
@media (max-width: 768px) {
+67
View File
@@ -0,0 +1,67 @@
/* FLIP animation for #roadmap-vote-list — only animates items that move */
(function () {
var firstPositions = {};
var debounceTimer = null;
var listObserver = null;
var observedList = null;
function getKey(el) {
var a = el.querySelector("a");
return a ? a.getAttribute("href") : el.textContent.trim().slice(0, 40);
}
function capturePositions() {
var list = document.getElementById("roadmap-vote-list");
if (!list) return;
firstPositions = {};
list.querySelectorAll(".list-group-item").forEach(function (el) {
firstPositions[getKey(el)] = el.getBoundingClientRect().top;
});
}
function applyFlip() {
var list = document.getElementById("roadmap-vote-list");
if (!list || Object.keys(firstPositions).length === 0) return;
list.querySelectorAll(".list-group-item").forEach(function (el) {
var key = getKey(el);
var first = firstPositions[key];
if (first === undefined) return;
var dy = first - el.getBoundingClientRect().top;
if (Math.abs(dy) < 2) return;
el.style.transition = "none";
el.style.transform = "translateY(" + dy + "px)";
void el.offsetWidth;
el.style.transition = "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)";
el.style.transform = "";
});
firstPositions = {};
}
function attachObserver() {
var list = document.getElementById("roadmap-vote-list");
if (!list || list === observedList) return;
if (listObserver) listObserver.disconnect();
observedList = list;
listObserver = new MutationObserver(function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(applyFlip, 50);
});
listObserver.observe(list, {
childList: true,
subtree: true,
characterData: true,
});
}
document.addEventListener(
"click",
function (e) {
if (e.target.closest('[id*="roadmap-vote"]')) capturePositions();
},
true
);
setInterval(attachObserver, 1000);
attachObserver();
})();
+11 -5
View File
@@ -25,8 +25,12 @@ _MIGRATIONS: list[tuple[str, str]] = [
"ALTER TABLE subscriptions ADD COLUMN votes_balance INTEGER NOT NULL DEFAULT 0",
),
(
"0004_add_votes_credited_until_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN votes_credited_until TEXT",
"0004_add_votes_last_credited_at_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN votes_last_credited_at TEXT",
),
(
"0005_rename_votes_credited_until_to_votes_last_credited_at",
"ALTER TABLE subscriptions RENAME COLUMN votes_credited_until TO votes_last_credited_at",
),
]
@@ -46,9 +50,11 @@ def apply_pending() -> None:
try:
conn.execute(sql)
except sqlite3.OperationalError as exc:
# SQLite ne supporte pas ALTER TABLE … ADD COLUMN IF NOT EXISTS.
# Sur une DB fraîche (schéma déjà à jour), on ignore l'erreur.
if "duplicate column name" not in str(exc):
# Sur une DB fraîche (schéma déjà à jour), certaines migrations
# sont sans effet : ADD COLUMN → "duplicate column name",
# RENAME COLUMN → "no such column". On les ignore.
err = str(exc)
if "duplicate column name" not in err and "no such column" not in err:
raise
conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
+16 -6
View File
@@ -21,27 +21,37 @@ def layout(**_):
if guard is not None:
return guard
balance = subs_db.credit_pending(current_user.id)
next_recharge = subs_db.next_recharge_at(current_user.id)
return account_shell(
"roadmap", roadmap_ui.roadmap_content(editable=True, balance=balance)
"roadmap",
roadmap_ui.roadmap_content(
editable=True, balance=balance, next_recharge=next_recharge
),
)
@callback(
Output("roadmap-vote-list", "children"),
Output("roadmap-balance", "children"),
Input({"type": "roadmap-vote", "index": ALL}, "n_clicks"),
prevent_initial_call=True,
)
def cast_vote(n_clicks):
if not current_user.is_authenticated:
return no_update, no_update
return no_update
if not ctx.triggered_id or not any(n_clicks):
return no_update, no_update
return no_update
issue_number = ctx.triggered_id["index"]
if subs_db.spend_vote(current_user.id):
roadmap_db.record_vote(current_user.id, issue_number)
balance = subs_db.credit_pending(current_user.id)
next_recharge = subs_db.next_recharge_at(current_user.id)
issues = github.fetch_roadmap_issues()
counts = roadmap_db.vote_counts()
items = roadmap_ui.vote_items(issues["au_vote"], counts, editable=True)
return items, roadmap_ui.balance_text(balance)
return roadmap_ui.vote_items(
issues["au_vote"],
counts,
editable=True,
can_vote=balance > 0,
balance=balance,
next_recharge=next_recharge,
)
+86 -28
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
import dash_bootstrap_components as dbc
@@ -9,11 +10,6 @@ from src.roadmap import github
_CHANGELOG_PATH = Path(__file__).resolve().parents[2] / "CHANGELOG.md"
def balance_text(balance: int) -> str:
mot = "vote" if balance == 1 else "votes"
return f"Il te reste {balance} {mot}."
def changelog_markdown() -> dcc.Markdown:
try:
text = _CHANGELOG_PATH.read_text(encoding="utf-8")
@@ -22,30 +18,83 @@ def changelog_markdown() -> dcc.Markdown:
return dcc.Markdown(text)
def _vote_item(issue: dict, count: int, editable: bool):
mot = "vote" if count == 1 else "votes"
children = [
html.A(issue["title"], href=issue["html_url"], target="_blank"),
html.Span(f"{count} {mot}", className="text-muted ms-1"),
def _vote_item(issue: dict, count: int, editable: bool, can_vote: bool = True):
right = [
dcc.Input(
value=str(count),
disabled=True,
type="text",
style={"width": "3rem", "textAlign": "right"},
className="ms-5 flex-shrink-0",
)
]
if editable:
children.append(
right.append(
dbc.Button(
"Voter",
"+",
id={"type": "roadmap-vote", "index": issue["number"]},
size="sm",
color="primary",
className="ms-2",
color="primary" if can_vote else "secondary",
disabled=not can_vote,
className="ms-2 flex-shrink-0",
)
)
return dbc.ListGroupItem(children)
return dbc.ListGroupItem(
[
html.A(issue["title"], href=issue["html_url"], target="_blank"),
html.Span(right, className="d-flex align-items-center"),
],
className="d-flex justify-content-between align-items-center",
)
def vote_items(au_vote: list[dict], counts: dict[int, int], editable: bool) -> list:
def _balance_item(
balance: int, next_recharge: datetime | None = None
) -> dbc.ListGroupItem:
label_parts = [html.Span("Votes restants", style={"fontWeight": "bold"})]
if next_recharge is not None:
label_parts.append(
html.Span(
f" — rechargement le {next_recharge.strftime('%d/%m/%y à %H:%M')}",
className="text-muted small",
)
)
return dbc.ListGroupItem(
[
html.Span(label_parts),
dcc.Input(
value=str(balance),
disabled=True,
type="text",
style={"width": "3rem", "textAlign": "right"},
className="ms-3 ",
),
],
className="d-flex justify-content-between align-items-center",
)
def vote_items(
au_vote: list[dict],
counts: dict[int, int],
editable: bool,
can_vote: bool = True,
balance: int | None = None,
next_recharge: datetime | None = None,
) -> list:
result = (
[_balance_item(balance, next_recharge)]
if editable and balance is not None
else []
)
ordered = sorted(au_vote, key=lambda i: counts.get(i["number"], 0), reverse=True)
if not ordered:
return [dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")]
return [_vote_item(i, counts.get(i["number"], 0), editable) for i in ordered]
return result + [
dbc.ListGroupItem("Aucune fonctionnalité au vote pour le moment.")
]
return result + [
_vote_item(i, counts.get(i["number"], 0), editable, can_vote) for i in ordered
]
def _en_cours_items(en_cours: list[dict]) -> list:
@@ -57,7 +106,9 @@ def _en_cours_items(en_cours: list[dict]) -> list:
]
def roadmap_content(editable: bool, balance: int | None = None) -> html.Div:
def roadmap_content(
editable: bool, balance: int | None = None, next_recharge: datetime | None = None
) -> html.Div:
try:
issues = github.fetch_roadmap_issues()
counts = roadmap_db.vote_counts()
@@ -66,21 +117,28 @@ def roadmap_content(editable: bool, balance: int | None = None) -> html.Div:
counts = {}
body: list = []
if editable and balance is not None:
body.append(
dbc.Alert(balance_text(balance), color="info", id="roadmap-balance")
)
body.append(html.H3("En cours", className="mt-3"))
body.append(html.H3("En cours de développement", className="mt-3"))
body.append(dbc.ListGroup(_en_cours_items(issues["en_cours"]), className="mb-4"))
can_vote = editable and balance is not None and balance > 0
body.append(html.H3("Au vote"))
body.append(
dbc.ListGroup(
vote_items(issues["au_vote"], counts, editable),
id="roadmap-vote-list",
html.Div(
dbc.ListGroup(
vote_items(
issues["au_vote"],
counts,
editable,
can_vote,
balance=balance,
next_recharge=next_recharge,
),
id="roadmap-vote-list",
),
className="mb-4",
style={"width": "fit-content"},
)
)
body.append(html.Hr())
body.append(html.H3("Changelog"))
body.append(html.H3("Historique des versions"))
body.append(changelog_markdown())
return html.Div(body)
+21 -10
View File
@@ -14,7 +14,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
current_period_end TEXT,
trial_used INTEGER NOT NULL DEFAULT 0,
votes_balance INTEGER NOT NULL DEFAULT 0,
votes_credited_until TEXT,
votes_last_credited_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -30,7 +30,8 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
INITIAL_VOTES = 2
INITIAL_VOTES = 3
VOTES_PER_WEEK = 3
WEEK_SECONDS = 7 * 24 * 3600
@@ -80,11 +81,11 @@ def freeze_votes_cursor(user_id: int) -> None:
les +2 initiaux restent gérés par credit_pending. Ne re-crédite jamais.
"""
row = get_by_user(user_id)
if row is None or row["votes_credited_until"] is None:
if row is None or row["votes_last_credited_at"] is None:
return
now = _now()
get_conn().execute(
"UPDATE subscriptions SET votes_credited_until = ?, updated_at = ? "
"UPDATE subscriptions SET votes_last_credited_at = ?, updated_at = ? "
"WHERE user_id = ?",
(now, now, user_id),
)
@@ -154,7 +155,7 @@ def has_used_trial(user_id: int) -> bool:
def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None:
get_conn().execute(
"UPDATE subscriptions SET votes_balance = ?, votes_credited_until = ?, "
"UPDATE subscriptions SET votes_balance = ?, votes_last_credited_at = ?, "
"updated_at = ? WHERE user_id = ?",
(balance, cursor_iso, _now(), user_id),
)
@@ -163,8 +164,9 @@ def _set_votes(user_id: int, balance: int, cursor_iso: str) -> None:
def credit_pending(user_id: int) -> int:
"""Crédite paresseusement les votes acquis et renvoie le solde courant.
+2 à la première activation (fin d'essai), puis +1 par semaine pleine tant
que l'abonnement est actif. Idempotent : ne crédite que des semaines pleines.
+VOTES_PER_WEEK à la première activation, puis +VOTES_PER_WEEK par semaine
pleine. Le solde est cappé à VOTES_PER_WEEK (pas d'accumulation).
Idempotent : ne crédite que des semaines pleines.
"""
row = get_by_user(user_id)
if row is None:
@@ -173,15 +175,15 @@ def credit_pending(user_id: int) -> int:
if row["status"] != "active":
return balance
now = datetime.now(timezone.utc)
cursor = row["votes_credited_until"]
cursor = row["votes_last_credited_at"]
if cursor is None:
balance += INITIAL_VOTES
balance = min(balance + INITIAL_VOTES, VOTES_PER_WEEK)
_set_votes(user_id, balance, now.isoformat())
return balance
cur = datetime.fromisoformat(cursor)
weeks = int((now - cur).total_seconds() // WEEK_SECONDS)
if weeks > 0:
balance += weeks
balance = min(balance + weeks * VOTES_PER_WEEK, VOTES_PER_WEEK)
new_cursor = cur + timedelta(seconds=weeks * WEEK_SECONDS)
_set_votes(user_id, balance, new_cursor.isoformat())
return balance
@@ -195,3 +197,12 @@ def spend_vote(user_id: int) -> bool:
(_now(), user_id),
)
return cur.rowcount > 0
def next_recharge_at(user_id: int) -> datetime | None:
"""Retourne la date du prochain rechargement de votes, ou None si non applicable."""
row = get_by_user(user_id)
if not row or not row["votes_last_credited_at"]:
return None
cursor = datetime.fromisoformat(row["votes_last_credited_at"])
return cursor + timedelta(seconds=WEEK_SECONDS)