diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx
index 128b84c..a548575 100644
--- a/app/components/draft/QueueSection.tsx
+++ b/app/components/draft/QueueSection.tsx
@@ -1,6 +1,23 @@
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { AutodraftSettings } from "~/components/AutodraftSettings";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import type { DragEndEvent } from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
interface QueueSectionProps {
queue: Array<{
@@ -28,9 +45,78 @@ interface QueueSectionProps {
isEnabled: boolean;
mode: "next_pick" | "while_on";
};
- onClearQueue: () => void;
onRemoveFromQueue: (queueId: string) => void;
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void;
+ onReorder: (participantIds: string[]) => void;
+}
+
+// Sortable queue item component
+function SortableQueueItem({
+ item,
+ index,
+ participantName,
+ onRemove,
+}: {
+ item: { id: string; participantId: string };
+ index: number;
+ participantName: string;
+ onRemove: () => void;
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: item.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ };
+
+ return (
+
+
+
+
+
+
{index + 1}
+
+
+
+
+ );
}
export function QueueSection({
@@ -41,55 +127,64 @@ export function QueueSection({
teamId,
isMyTurn,
userAutodraft,
- onClearQueue,
onRemoveFromQueue,
onAutodraftUpdate,
+ onReorder,
}: QueueSectionProps) {
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id) {
+ const oldIndex = queue.findIndex((item) => item.id === active.id);
+ const newIndex = queue.findIndex((item) => item.id === over.id);
+
+ const reorderedQueue = arrayMove(queue, oldIndex, newIndex);
+ const participantIds = reorderedQueue.map((item) => item.participantId);
+ onReorder(participantIds);
+ }
+ };
+
return (
-
-
Queue ({queue.length})
- {queue.length > 0 && (
-
- )}
-
-
{/* Queue List */}
{queue.length === 0 ? (
Click participants to add to your queue
) : (
-
- {queue.map((item, index) => (
-
-
-
{index + 1}
-
-
- {availableParticipants.find(
+
+ item.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {queue.map((item, index) => (
+ p.id === item.participantId
- )?.name || "Unknown"}
-
-
-
-
+ )?.name || "Unknown"
+ }
+ onRemove={() => onRemoveFromQueue(item.id)}
+ />
+ ))}
- ))}
-
+
+
)}
{/* Autodraft Settings */}
diff --git a/app/models/draft-queue.ts b/app/models/draft-queue.ts
index 841b158..1055c54 100644
--- a/app/models/draft-queue.ts
+++ b/app/models/draft-queue.ts
@@ -27,6 +27,17 @@ export async function getTeamQueue(teamId: string) {
.orderBy(asc(schema.draftQueue.queuePosition));
}
+export async function isParticipantInQueue(teamId: string, participantId: string): Promise
{
+ const db = database();
+ const existing = await db.query.draftQueue.findFirst({
+ where: and(
+ eq(schema.draftQueue.teamId, teamId),
+ eq(schema.draftQueue.participantId, participantId)
+ ),
+ });
+ return !!existing;
+}
+
export async function clearTeamQueue(teamId: string) {
const db = database();
await db.delete(schema.draftQueue).where(eq(schema.draftQueue.teamId, teamId));
diff --git a/app/routes/api/__tests__/queue.add.test.ts b/app/routes/api/__tests__/queue.add.test.ts
new file mode 100644
index 0000000..c7b3d76
--- /dev/null
+++ b/app/routes/api/__tests__/queue.add.test.ts
@@ -0,0 +1,266 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { addToQueue, getTeamQueue, isParticipantInQueue } from '~/models/draft-queue';
+
+// Mock the models
+vi.mock('~/models/draft-queue', () => ({
+ addToQueue: vi.fn(),
+ getTeamQueue: vi.fn(),
+ isParticipantInQueue: vi.fn(),
+}));
+
+describe('Queue Add - Duplicate Prevention', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should successfully add a participant to an empty queue', async () => {
+ const teamId = 'team-1';
+ const participantId = 'participant-1';
+ const seasonId = 'season-1';
+
+ // Mock that participant is not in queue
+ vi.mocked(isParticipantInQueue).mockResolvedValue(false);
+
+ // Mock empty queue
+ vi.mocked(getTeamQueue).mockResolvedValue([]);
+
+ // Mock successful add
+ const mockQueueItem = {
+ id: 'queue-1',
+ seasonId,
+ teamId,
+ participantId,
+ queuePosition: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ vi.mocked(addToQueue).mockResolvedValue(mockQueueItem);
+
+ // Simulate the logic
+ const alreadyInQueue = await isParticipantInQueue(teamId, participantId);
+ expect(alreadyInQueue).toBe(false);
+
+ const currentQueue = await getTeamQueue(teamId);
+ const queuePosition = currentQueue.length + 1;
+
+ const result = await addToQueue({
+ seasonId,
+ teamId,
+ participantId,
+ queuePosition,
+ });
+
+ expect(isParticipantInQueue).toHaveBeenCalledWith(teamId, participantId);
+ expect(getTeamQueue).toHaveBeenCalledWith(teamId);
+ expect(addToQueue).toHaveBeenCalledWith({
+ seasonId,
+ teamId,
+ participantId,
+ queuePosition: 1,
+ });
+ expect(result.participantId).toBe(participantId);
+ expect(result.queuePosition).toBe(1);
+ });
+
+ it('should prevent adding duplicate participant to queue', async () => {
+ const teamId = 'team-1';
+ const participantId = 'participant-1';
+
+ // Mock that participant is already in queue
+ vi.mocked(isParticipantInQueue).mockResolvedValue(true);
+
+ const alreadyInQueue = await isParticipantInQueue(teamId, participantId);
+
+ expect(alreadyInQueue).toBe(true);
+ expect(isParticipantInQueue).toHaveBeenCalledWith(teamId, participantId);
+
+ // Should not call addToQueue if already in queue
+ expect(addToQueue).not.toHaveBeenCalled();
+ });
+
+ it('should add participant to queue when other participants exist', async () => {
+ const teamId = 'team-1';
+ const newParticipantId = 'participant-3';
+ const seasonId = 'season-1';
+
+ // Mock existing queue with 2 participants
+ const existingQueue = [
+ {
+ id: 'queue-1',
+ seasonId,
+ teamId,
+ participantId: 'participant-1',
+ queuePosition: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ {
+ id: 'queue-2',
+ seasonId,
+ teamId,
+ participantId: 'participant-2',
+ queuePosition: 2,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ];
+
+ vi.mocked(isParticipantInQueue).mockResolvedValue(false);
+ vi.mocked(getTeamQueue).mockResolvedValue(existingQueue);
+
+ const mockQueueItem = {
+ id: 'queue-3',
+ seasonId,
+ teamId,
+ participantId: newParticipantId,
+ queuePosition: 3,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ vi.mocked(addToQueue).mockResolvedValue(mockQueueItem);
+
+ // Simulate the logic
+ const alreadyInQueue = await isParticipantInQueue(teamId, newParticipantId);
+ expect(alreadyInQueue).toBe(false);
+
+ const currentQueue = await getTeamQueue(teamId);
+ const queuePosition = currentQueue.length + 1;
+
+ const result = await addToQueue({
+ seasonId,
+ teamId,
+ participantId: newParticipantId,
+ queuePosition,
+ });
+
+ expect(result.queuePosition).toBe(3);
+ expect(result.participantId).toBe(newParticipantId);
+ });
+
+ it('should prevent adding same participant multiple times in quick succession', async () => {
+ const teamId = 'team-1';
+ const participantId = 'participant-1';
+
+ // First call - not in queue
+ vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false);
+
+ const firstCheck = await isParticipantInQueue(teamId, participantId);
+ expect(firstCheck).toBe(false);
+
+ // Second call (rapid click) - now in queue
+ vi.mocked(isParticipantInQueue).mockResolvedValueOnce(true);
+
+ const secondCheck = await isParticipantInQueue(teamId, participantId);
+ expect(secondCheck).toBe(true);
+
+ // Verify the function was called twice
+ expect(isParticipantInQueue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should allow adding different participants to the same queue', async () => {
+ const teamId = 'team-1';
+ const participant1 = 'participant-1';
+ const participant2 = 'participant-2';
+ const seasonId = 'season-1';
+
+ // Add first participant
+ vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false);
+ vi.mocked(getTeamQueue).mockResolvedValueOnce([]);
+ vi.mocked(addToQueue).mockResolvedValueOnce({
+ id: 'queue-1',
+ seasonId,
+ teamId,
+ participantId: participant1,
+ queuePosition: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ let alreadyInQueue = await isParticipantInQueue(teamId, participant1);
+ expect(alreadyInQueue).toBe(false);
+
+ await addToQueue({
+ seasonId,
+ teamId,
+ participantId: participant1,
+ queuePosition: 1,
+ });
+
+ // Add second participant
+ vi.mocked(isParticipantInQueue).mockResolvedValueOnce(false);
+ vi.mocked(getTeamQueue).mockResolvedValueOnce([
+ {
+ id: 'queue-1',
+ seasonId,
+ teamId,
+ participantId: participant1,
+ queuePosition: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ ]);
+ vi.mocked(addToQueue).mockResolvedValueOnce({
+ id: 'queue-2',
+ seasonId,
+ teamId,
+ participantId: participant2,
+ queuePosition: 2,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ alreadyInQueue = await isParticipantInQueue(teamId, participant2);
+ expect(alreadyInQueue).toBe(false);
+
+ const result = await addToQueue({
+ seasonId,
+ teamId,
+ participantId: participant2,
+ queuePosition: 2,
+ });
+
+ expect(result.participantId).toBe(participant2);
+ expect(addToQueue).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle the case where participant is in another teams queue', async () => {
+ const team1Id = 'team-1';
+ const team2Id = 'team-2';
+ const participantId = 'participant-1';
+ const seasonId = 'season-1';
+
+ // Participant is in team1's queue
+ vi.mocked(isParticipantInQueue).mockImplementation(async (teamId, pId) => {
+ return teamId === team1Id && pId === participantId;
+ });
+
+ // Check team1 - should be in queue
+ const inTeam1Queue = await isParticipantInQueue(team1Id, participantId);
+ expect(inTeam1Queue).toBe(true);
+
+ // Check team2 - should not be in queue
+ const inTeam2Queue = await isParticipantInQueue(team2Id, participantId);
+ expect(inTeam2Queue).toBe(false);
+
+ // Should be able to add to team2
+ vi.mocked(getTeamQueue).mockResolvedValue([]);
+ vi.mocked(addToQueue).mockResolvedValue({
+ id: 'queue-2',
+ seasonId,
+ teamId: team2Id,
+ participantId,
+ queuePosition: 1,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+
+ const result = await addToQueue({
+ seasonId,
+ teamId: team2Id,
+ participantId,
+ queuePosition: 1,
+ });
+
+ expect(result.teamId).toBe(team2Id);
+ });
+});
diff --git a/app/routes/api/queue.add.ts b/app/routes/api/queue.add.ts
index aed87e3..1a4738b 100644
--- a/app/routes/api/queue.add.ts
+++ b/app/routes/api/queue.add.ts
@@ -2,7 +2,7 @@ import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
-import { addToQueue, getTeamQueue } from "~/models/draft-queue";
+import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue";
export async function action(args: any) {
const { request } = args;
@@ -33,6 +33,12 @@ export async function action(args: any) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
+ // Check if participant is already in queue
+ const alreadyInQueue = await isParticipantInQueue(teamId as string, participantId as string);
+ if (alreadyInQueue) {
+ return Response.json({ error: "Participant already in queue" }, { status: 400 });
+ }
+
// Get current queue to determine position
const currentQueue = await getTeamQueue(teamId as string);
const queuePosition = currentQueue.length + 1;
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
index a8ad4db..a566361 100644
--- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
@@ -17,6 +17,7 @@ import { AvailableParticipantsSection } from "~/components/draft/AvailablePartic
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
+import { toast } from "sonner";
export async function loader(args: any) {
const { params } = args;
@@ -457,19 +458,57 @@ export default function DraftRoom() {
const handleAddToQueue = async (participantId: string) => {
if (!userTeam) return;
+ // Check if participant is already in queue (client-side)
+ const alreadyInQueue = queue.some((item: any) => item.participantId === participantId);
+ if (alreadyInQueue) {
+ toast.error("This participant is already in your queue");
+ return;
+ }
+
+ // Optimistic update - create a temporary queue item
+ const tempQueueItem = {
+ id: `temp-${Date.now()}`, // Temporary ID
+ participantId,
+ queuePosition: queue.length + 1,
+ };
+
+ // Immediately update the UI
+ setQueue((prev: any) => [...prev, tempQueueItem]);
+
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
- const response = await fetch("/api/queue/add", {
- method: "POST",
- body: formData,
- });
+ try {
+ const response = await fetch("/api/queue/add", {
+ method: "POST",
+ body: formData,
+ });
- if (response.ok) {
- const data = await response.json();
- setQueue((prev: any) => [...prev, data.queueItem]);
+ if (response.ok) {
+ const data = await response.json();
+ // Replace temp item with real item from server
+ setQueue((prev: any) =>
+ prev.map((item: any) =>
+ item.id === tempQueueItem.id ? data.queueItem : item
+ )
+ );
+ } else {
+ // Revert the optimistic update on error
+ setQueue((prev: any) =>
+ prev.filter((item: any) => item.id !== tempQueueItem.id)
+ );
+
+ const error = await response.json();
+ toast.error(error.error || "Failed to add to queue");
+ }
+ } catch (error) {
+ // Revert the optimistic update on network error
+ setQueue((prev: any) =>
+ prev.filter((item: any) => item.id !== tempQueueItem.id)
+ );
+ toast.error("Network error - failed to add to queue");
}
};
@@ -491,19 +530,22 @@ export default function DraftRoom() {
}
};
- const handleClearQueue = async () => {
+ const handleReorderQueue = async (participantIds: string[]) => {
if (!userTeam) return;
const formData = new FormData();
formData.append("teamId", userTeam.id);
+ formData.append("seasonId", season.id);
+ formData.append("participantIds", JSON.stringify(participantIds));
- const response = await fetch("/api/queue/clear", {
+ const response = await fetch("/api/queue/reorder", {
method: "POST",
body: formData,
});
if (response.ok) {
- setQueue([]);
+ const data = await response.json();
+ setQueue(data.queue);
}
};
@@ -844,11 +886,11 @@ export default function DraftRoom() {
teamId={userTeam.id}
isMyTurn={isMyTurn}
userAutodraft={userAutodraft}
- onClearQueue={handleClearQueue}
onRemoveFromQueue={handleRemoveFromQueue}
onAutodraftUpdate={(isEnabled, mode) => {
setUserAutodraft({ isEnabled, mode });
}}
+ onReorder={handleReorderQueue}
/>
}
recentPicksSection={
diff --git a/package-lock.json b/package-lock.json
index 7b94a07..0360d89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,9 @@
"name": "brackt.com",
"dependencies": {
"@clerk/react-router": "^2.1.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -1065,6 +1068,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@dotenvx/dotenvx": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",
diff --git a/package.json b/package.json
index f9a3c50..d0338f0 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,9 @@
},
"dependencies": {
"@clerk/react-router": "^2.1.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",