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}
+11
View File
@@ -26,6 +26,7 @@ import {
IconChevronDown,
IconChevronRight,
IconClock,
IconDice5,
IconDotsVertical,
IconGripVertical,
IconPencil,
@@ -49,6 +50,7 @@ interface Props {
onDeleteColumn: (id: string) => void;
onSetWIPLimit: (id: string, limit: number) => void;
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
onPickRandom: (columnId: string) => void;
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
@@ -83,6 +85,7 @@ function KanbanColumnImpl({
onDeleteColumn,
onSetWIPLimit,
onSetMaxTimeMinutes,
onPickRandom,
onToggleDone,
onEditCard,
onDeleteCard,
@@ -411,6 +414,14 @@ function KanbanColumnImpl({
Tiempo maximo
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
</Menu.Item>
<Menu.Item
leftSection={<IconDice5 size={14} />}
data-test="column-random-pick"
disabled={cards.filter((c) => !c.locked).length === 0}
onClick={() => onPickRandom(column.id)}
>
Seleccionar Aleatorio
</Menu.Item>
<Menu.Item
leftSection={<ArchiveIcon size={14} />}
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
+1
View File
@@ -1,5 +1,6 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./styles/roulette.css";
import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
+30
View File
@@ -0,0 +1,30 @@
/* Issue 0090: ruleta de seleccion aleatoria por columna. */
@keyframes kanban-roulette-pulse {
0% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(34, 139, 230, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 139, 230, 0); }
}
@keyframes kanban-roulette-winner {
0% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.95); transform: scale(1); }
30% { box-shadow: 0 0 0 16px rgba(82, 196, 26, 0.55); transform: scale(1.03); }
60% { box-shadow: 0 0 0 22px rgba(82, 196, 26, 0); transform: scale(1.05); }
100% { box-shadow: 0 0 0 0 rgba(82, 196, 26, 0); transform: scale(1); }
}
.kanban-roulette-active {
outline: 3px solid var(--mantine-color-blue-6) !important;
outline-offset: -2px;
animation: kanban-roulette-pulse 200ms ease-out 1;
z-index: 5;
position: relative;
}
.kanban-roulette-winner {
outline: 3px solid var(--mantine-color-green-7) !important;
outline-offset: -2px;
animation: kanban-roulette-winner 1600ms ease-out 1;
z-index: 6;
position: relative;
}