feat(kanban): Seleccionar Aleatorio en columna con ruleta (issue 0090)

Adds a column-level "Seleccionar Aleatorio" context menu entry that picks
a card at random from the column with a roulette-style highlight
animation that decelerates to the winner.

Selection respects existing filters (uses cardsByColumn which is the
post-filter view) and always excludes locked cards. Menu entry is
disabled when nothing pickable is left.

Implementation:
- KanbanColumn: new Menu.Item with IconDice5; data-test selector for e2e.
- onPickRandom prop wired from KanbanColumn -> App.
- handlePickRandom in App.tsx: cryptographically random winner via
  crypto.getRandomValues, 2 full laps + offset, cubic decay 50ms -> 220ms,
  follows the active card with scrollIntoView.
- src/styles/roulette.css: .kanban-roulette-active (blue pulse, single
  step) and .kanban-roulette-winner (green pulse + scale, ~1.6s).
  Imported globally from main.tsx.

Manual verification only (visual timing + needs real cards). Backend
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:27:39 +02:00
parent 9f4fd85db3
commit c93ac46c37
8 changed files with 1290 additions and 1179 deletions
+64
View File
@@ -811,6 +811,68 @@ export function App() {
}
}, [reload]);
// Issue 0090: ruleta de seleccion aleatoria por columna.
// Recorre las cards visibles (post-filtro) no bloqueadas con highlight
// acelerado-decelerado y termina con flash verde sobre la ganadora.
const handlePickRandom = useCallback((columnId: string) => {
const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
if (cards.length === 0) {
notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
return;
}
if (cards.length === 1) {
const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("kanban-roulette-winner");
setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
// Decide ganadora con seguridad criptografica.
const winnerIdx = (() => {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return buf[0] % cards.length;
})();
// Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
const baseLaps = 2;
const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);
// Decay temporal: empieza rapido (50ms), termina lento (220ms).
const startMs = 50;
const endMs = 220;
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
let step = 0;
const tick = () => {
const idx = step % cards.length;
const prevIdx = (idx - 1 + cards.length) % cards.length;
const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
if (prevEl) prevEl.classList.remove("kanban-roulette-active");
if (currEl) {
currEl.classList.add("kanban-roulette-active");
currEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
step++;
if (step > totalSteps) {
if (currEl) {
currEl.classList.remove("kanban-roulette-active");
currEl.classList.add("kanban-roulette-winner");
setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
}
return;
}
const t = totalSteps > 0 ? step / totalSteps : 1;
const delay = startMs + (endMs - startMs) * easeOut(t);
setTimeout(tick, delay);
};
tick();
}, [cardsByColumn]);
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -1009,6 +1071,7 @@ export function App() {
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
@@ -1278,6 +1341,7 @@ export function App() {
onDeleteColumn={handleDeleteColumn}
onSetWIPLimit={handleSetWIPLimit}
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
onPickRandom={handlePickRandom}
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}