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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user