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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<title>Kanban</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D1wc-P9j.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cph8eYBP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-S1AyDjRq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -811,6 +811,68 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [reload]);
|
}, [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) => {
|
const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
|
||||||
setBoard((prev) => {
|
setBoard((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
@@ -1009,6 +1071,7 @@ export function App() {
|
|||||||
onDeleteColumn={handleDeleteColumn}
|
onDeleteColumn={handleDeleteColumn}
|
||||||
onSetWIPLimit={handleSetWIPLimit}
|
onSetWIPLimit={handleSetWIPLimit}
|
||||||
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||||
|
onPickRandom={handlePickRandom}
|
||||||
onToggleDone={handleToggleDone}
|
onToggleDone={handleToggleDone}
|
||||||
onEditCard={openEditCard}
|
onEditCard={openEditCard}
|
||||||
onDeleteCard={handleDeleteCard}
|
onDeleteCard={handleDeleteCard}
|
||||||
@@ -1278,6 +1341,7 @@ export function App() {
|
|||||||
onDeleteColumn={handleDeleteColumn}
|
onDeleteColumn={handleDeleteColumn}
|
||||||
onSetWIPLimit={handleSetWIPLimit}
|
onSetWIPLimit={handleSetWIPLimit}
|
||||||
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
|
||||||
|
onPickRandom={handlePickRandom}
|
||||||
onToggleDone={handleToggleDone}
|
onToggleDone={handleToggleDone}
|
||||||
onEditCard={openEditCard}
|
onEditCard={openEditCard}
|
||||||
onDeleteCard={handleDeleteCard}
|
onDeleteCard={handleDeleteCard}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconClock,
|
IconClock,
|
||||||
|
IconDice5,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconGripVertical,
|
IconGripVertical,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
@@ -49,6 +50,7 @@ interface Props {
|
|||||||
onDeleteColumn: (id: string) => void;
|
onDeleteColumn: (id: string) => void;
|
||||||
onSetWIPLimit: (id: string, limit: number) => void;
|
onSetWIPLimit: (id: string, limit: number) => void;
|
||||||
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
|
onSetMaxTimeMinutes: (id: string, minutes: number) => void;
|
||||||
|
onPickRandom: (columnId: string) => void;
|
||||||
onToggleDone: (id: string, is_done: boolean) => void;
|
onToggleDone: (id: string, is_done: boolean) => void;
|
||||||
onEditCard: (card: Card) => void;
|
onEditCard: (card: Card) => void;
|
||||||
onDeleteCard: (id: string) => void;
|
onDeleteCard: (id: string) => void;
|
||||||
@@ -83,6 +85,7 @@ function KanbanColumnImpl({
|
|||||||
onDeleteColumn,
|
onDeleteColumn,
|
||||||
onSetWIPLimit,
|
onSetWIPLimit,
|
||||||
onSetMaxTimeMinutes,
|
onSetMaxTimeMinutes,
|
||||||
|
onPickRandom,
|
||||||
onToggleDone,
|
onToggleDone,
|
||||||
onEditCard,
|
onEditCard,
|
||||||
onDeleteCard,
|
onDeleteCard,
|
||||||
@@ -411,6 +414,14 @@ function KanbanColumnImpl({
|
|||||||
Tiempo maximo
|
Tiempo maximo
|
||||||
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
|
{column.max_time_minutes > 0 ? ` (${column.max_time_minutes} min)` : ""}
|
||||||
</Menu.Item>
|
</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
|
<Menu.Item
|
||||||
leftSection={<ArchiveIcon size={14} />}
|
leftSection={<ArchiveIcon size={14} />}
|
||||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
|
import "./styles/roulette.css";
|
||||||
import { MantineProvider, createTheme } from "@mantine/core";
|
import { MantineProvider, createTheme } from "@mantine/core";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { Notifications } from "@mantine/notifications";
|
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