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:
+1181
File diff suppressed because one or more lines are too long
-1176
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-D1wc-P9j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cph8eYBP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-S1AyDjRq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user