feat: add autodraft status and team connection indicators to draft grid
This commit is contained in:
parent
86ae5014ec
commit
ff97e25438
18 changed files with 3646 additions and 117 deletions
140
app/components/AutodraftSettings.tsx
Normal file
140
app/components/AutodraftSettings.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useState } from "react";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
interface AutodraftSettingsProps {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
isEnabled: boolean;
|
||||
mode: "next_pick" | "while_on";
|
||||
isMyTurn: boolean;
|
||||
onUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void;
|
||||
}
|
||||
|
||||
export function AutodraftSettings({
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled,
|
||||
mode,
|
||||
isMyTurn,
|
||||
onUpdate,
|
||||
}: AutodraftSettingsProps) {
|
||||
const [localEnabled, setLocalEnabled] = useState(isEnabled);
|
||||
const [localMode, setLocalMode] = useState<"next_pick" | "while_on">(mode);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setLocalEnabled(checked);
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", checked.toString());
|
||||
formData.append("mode", localMode);
|
||||
|
||||
const response = await fetch("/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onUpdate(checked, localMode);
|
||||
} else {
|
||||
// Revert on error
|
||||
setLocalEnabled(!checked);
|
||||
console.error("Failed to update autodraft settings");
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setLocalEnabled(!checked);
|
||||
console.error("Error updating autodraft settings:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = async (newMode: "next_pick" | "while_on") => {
|
||||
setLocalMode(newMode);
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", localEnabled.toString());
|
||||
formData.append("mode", newMode);
|
||||
|
||||
const response = await fetch("/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onUpdate(localEnabled, newMode);
|
||||
} else {
|
||||
// Revert on error
|
||||
setLocalMode(localMode);
|
||||
console.error("Failed to update autodraft mode");
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
setLocalMode(localMode);
|
||||
console.error("Error updating autodraft mode:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label htmlFor="autodraft-switch" className="text-sm font-semibold">
|
||||
Autodraft
|
||||
</Label>
|
||||
<Switch
|
||||
id="autodraft-switch"
|
||||
checked={localEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isMyTurn || isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{localEnabled && (
|
||||
<RadioGroup
|
||||
value={localMode}
|
||||
onValueChange={(value) => handleModeChange(value as "next_pick" | "while_on")}
|
||||
disabled={isMyTurn || isUpdating}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="next_pick" id="next_pick" disabled={isMyTurn || isUpdating} />
|
||||
<Label
|
||||
htmlFor="next_pick"
|
||||
className={`text-sm ${isMyTurn || isUpdating ? "text-muted-foreground" : "cursor-pointer"}`}
|
||||
>
|
||||
Next Pick
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="while_on" id="while_on" disabled={isMyTurn || isUpdating} />
|
||||
<Label
|
||||
htmlFor="while_on"
|
||||
className={`text-sm ${isMyTurn || isUpdating ? "text-muted-foreground" : "cursor-pointer"}`}
|
||||
>
|
||||
While On
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
{isMyTurn && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Autodraft settings are disabled during your pick
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,8 @@ interface DraftGridProps {
|
|||
isCommissioner?: boolean;
|
||||
onForceAutopick?: (pickNumber: number, teamId: string) => void;
|
||||
onForceManualPick?: (pickNumber: number, teamId: string) => void;
|
||||
autodraftStatus?: Record<string, boolean>;
|
||||
connectedTeams?: Set<string>;
|
||||
}
|
||||
|
||||
export function DraftGrid({
|
||||
|
|
@ -35,6 +37,8 @@ export function DraftGrid({
|
|||
isCommissioner = false,
|
||||
onForceAutopick,
|
||||
onForceManualPick,
|
||||
autodraftStatus = {},
|
||||
connectedTeams = new Set(),
|
||||
}: DraftGridProps) {
|
||||
const totalTeams = draftSlots.length;
|
||||
|
||||
|
|
@ -46,9 +50,14 @@ export function DraftGrid({
|
|||
<div className="flex gap-2 mb-2">
|
||||
{draftSlots.map((slot) => {
|
||||
const teamTime = teamTimers?.[slot.team.id];
|
||||
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
||||
const isConnected = connectedTeams.has(slot.team.id);
|
||||
|
||||
return (
|
||||
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
||||
<div className="font-semibold text-sm truncate px-2">
|
||||
<div
|
||||
className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}
|
||||
>
|
||||
{slot.team.name}
|
||||
</div>
|
||||
{teamTimers && formatTime && (
|
||||
|
|
@ -64,6 +73,9 @@ export function DraftGrid({
|
|||
}`}
|
||||
>
|
||||
{formatTime(teamTime)}
|
||||
{isAutodraft && (
|
||||
<span className="ml-1 text-muted-foreground">(auto)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -123,7 +135,13 @@ export function DraftGrid({
|
|||
);
|
||||
|
||||
// Wrap with context menu if commissioner and current unpicked cell
|
||||
if (isCommissioner && !isPicked && isCurrent && onForceAutopick && onForceManualPick) {
|
||||
if (
|
||||
isCommissioner &&
|
||||
!isPicked &&
|
||||
isCurrent &&
|
||||
onForceAutopick &&
|
||||
onForceManualPick
|
||||
) {
|
||||
return (
|
||||
<ContextMenu key={pickNumber}>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
|
@ -131,12 +149,16 @@ export function DraftGrid({
|
|||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => onForceAutopick(pickNumber, slot.team.id)}
|
||||
onClick={() =>
|
||||
onForceAutopick(pickNumber, slot.team.id)
|
||||
}
|
||||
>
|
||||
Force Auto Pick
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onForceManualPick(pickNumber, slot.team.id)}
|
||||
onClick={() =>
|
||||
onForceManualPick(pickNumber, slot.team.id)
|
||||
}
|
||||
>
|
||||
Force Manual Pick
|
||||
</ContextMenuItem>
|
||||
|
|
|
|||
287
app/components/__tests__/AutodraftSettings.test.tsx
Normal file
287
app/components/__tests__/AutodraftSettings.test.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { AutodraftSettings } from '../AutodraftSettings';
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('AutodraftSettings Component', () => {
|
||||
const defaultProps = {
|
||||
seasonId: 'season-123',
|
||||
teamId: 'team-456',
|
||||
isEnabled: false,
|
||||
mode: 'next_pick' as const,
|
||||
isMyTurn: false,
|
||||
onUpdate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the autodraft switch', () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Autodraft')).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show mode options when autodraft is disabled', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
expect(screen.queryByText('Next Pick')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('While On')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show mode options when autodraft is enabled', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} />);
|
||||
|
||||
expect(screen.getByText('Next Pick')).toBeInTheDocument();
|
||||
expect(screen.getByText('While On')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show disabled message when it is user turn', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
|
||||
|
||||
expect(screen.getByText(/Autodraft settings are disabled during your pick/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Switch Toggle', () => {
|
||||
it('should enable autodraft when switch is toggled on', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} onUpdate={onUpdate} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/autodraft/update',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable autodraft when switch is toggled off', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} onUpdate={onUpdate} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick');
|
||||
});
|
||||
});
|
||||
|
||||
it('should be disabled when it is user turn', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Server error' }),
|
||||
});
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Switch should revert to original state
|
||||
expect(switchElement).not.toBeChecked();
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mode Selection', () => {
|
||||
it('should select next_pick mode by default', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" />);
|
||||
|
||||
const nextPickRadio = screen.getByRole('radio', { name: /next pick/i });
|
||||
expect(nextPickRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('should select while_on mode when specified', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="while_on" />);
|
||||
|
||||
const whileOnRadio = screen.getByRole('radio', { name: /while on/i });
|
||||
expect(whileOnRadio).toBeChecked();
|
||||
});
|
||||
|
||||
it('should change mode when radio button is clicked', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" onUpdate={onUpdate} />);
|
||||
|
||||
const whileOnRadio = screen.getByRole('radio', { name: /while on/i });
|
||||
fireEvent.click(whileOnRadio);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith(true, 'while_on');
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable mode selection when it is user turn', () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} isMyTurn={true} />);
|
||||
|
||||
const nextPickRadio = screen.getByRole('radio', { name: /next pick/i });
|
||||
const whileOnRadio = screen.getByRole('radio', { name: /while on/i });
|
||||
|
||||
expect(nextPickRadio).toBeDisabled();
|
||||
expect(whileOnRadio).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('API Integration', () => {
|
||||
it('should send correct data when enabling autodraft', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/autodraft/update',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const fetchCall = (global.fetch as any).mock.calls[0];
|
||||
const formData = fetchCall[1].body as FormData;
|
||||
|
||||
expect(formData.get('seasonId')).toBe('season-123');
|
||||
expect(formData.get('teamId')).toBe('team-456');
|
||||
expect(formData.get('isEnabled')).toBe('true');
|
||||
expect(formData.get('mode')).toBe('next_pick');
|
||||
});
|
||||
|
||||
it('should send correct data when changing mode', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" />);
|
||||
|
||||
const whileOnRadio = screen.getByRole('radio', { name: /while on/i });
|
||||
fireEvent.click(whileOnRadio);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const fetchCall = (global.fetch as any).mock.calls[0];
|
||||
const formData = fetchCall[1].body as FormData;
|
||||
|
||||
expect(formData.get('mode')).toBe('while_on');
|
||||
expect(formData.get('isEnabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('should show loading state during API call', async () => {
|
||||
let resolvePromise: any;
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
(global.fetch as any).mockReturnValueOnce(fetchPromise);
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
// Switch should be disabled during update
|
||||
await waitFor(() => {
|
||||
expect(switchElement).toBeDisabled();
|
||||
});
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({ ok: true, json: async () => ({ success: true }) });
|
||||
|
||||
// Switch should be enabled again
|
||||
await waitFor(() => {
|
||||
expect(switchElement).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid toggle clicks', async () => {
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
|
||||
// Click multiple times rapidly
|
||||
fireEvent.click(switchElement);
|
||||
fireEvent.click(switchElement);
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
// Should only process the first click while updating
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should maintain state consistency on error', async () => {
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Server error' }),
|
||||
});
|
||||
|
||||
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).not.toBeChecked();
|
||||
|
||||
fireEvent.click(switchElement);
|
||||
|
||||
// Should revert to original state on error
|
||||
await waitFor(() => {
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
29
app/components/ui/switch.tsx
Normal file
29
app/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "app/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
|
|
@ -8,7 +8,7 @@ interface UseDraftSocketReturn {
|
|||
off: (event: string, callback?: (...args: any[]) => void) => void;
|
||||
}
|
||||
|
||||
export function useDraftSocket(seasonId: string): UseDraftSocketReturn {
|
||||
export function useDraftSocket(seasonId: string, teamId?: string): UseDraftSocketReturn {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
|
|
@ -24,8 +24,8 @@ export function useDraftSocket(seasonId: string): UseDraftSocketReturn {
|
|||
socket.on("connect", () => {
|
||||
console.log("Connected to Socket.IO:", socket.id);
|
||||
setIsConnected(true);
|
||||
// Join the draft room
|
||||
socket.emit("join-draft", seasonId);
|
||||
// Join the draft room with optional teamId
|
||||
socket.emit("join-draft", seasonId, teamId);
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
|
|
@ -45,7 +45,7 @@ export function useDraftSocket(seasonId: string): UseDraftSocketReturn {
|
|||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [seasonId]);
|
||||
}, [seasonId, teamId]);
|
||||
|
||||
// Helper to subscribe to events
|
||||
const on = (event: string, callback: (...args: any[]) => void) => {
|
||||
|
|
|
|||
79
app/routes/api/autodraft.update.ts
Normal file
79
app/routes/api/autodraft.update.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { database } from "../../../database/context";
|
||||
import * as schema from "../../../database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
|
||||
export async function action(args: any) {
|
||||
const { request } = args;
|
||||
const auth = await getAuth(args);
|
||||
const userId = (auth as any).userId as string | null;
|
||||
|
||||
if (!userId) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const seasonId = formData.get("seasonId") as string;
|
||||
const teamId = formData.get("teamId") as string;
|
||||
const isEnabled = formData.get("isEnabled") === "true";
|
||||
const mode = formData.get("mode") as "next_pick" | "while_on";
|
||||
|
||||
if (!seasonId || !teamId || !mode) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = database();
|
||||
|
||||
// Verify user owns this team
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId),
|
||||
});
|
||||
|
||||
if (!team || team.ownerId !== userId) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Check if autodraft settings exist
|
||||
const existingSettings = await db.query.autodraftSettings.findFirst({
|
||||
where: and(
|
||||
eq(schema.autodraftSettings.seasonId, seasonId),
|
||||
eq(schema.autodraftSettings.teamId, teamId)
|
||||
),
|
||||
});
|
||||
|
||||
let settings;
|
||||
if (existingSettings) {
|
||||
// Update existing settings
|
||||
[settings] = await db
|
||||
.update(schema.autodraftSettings)
|
||||
.set({
|
||||
isEnabled,
|
||||
mode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.autodraftSettings.id, existingSettings.id))
|
||||
.returning();
|
||||
} else {
|
||||
// Create new settings
|
||||
[settings] = await db
|
||||
.insert(schema.autodraftSettings)
|
||||
.values({
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled,
|
||||
mode,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Emit socket event to notify all clients in the draft room
|
||||
const io = getSocketIO();
|
||||
io.to(`draft-${seasonId}`).emit("autodraft-updated", {
|
||||
teamId,
|
||||
isEnabled,
|
||||
mode,
|
||||
});
|
||||
|
||||
return Response.json({ success: true, settings });
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu";
|
||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||||
|
||||
export async function loader(args: any) {
|
||||
const { params } = args;
|
||||
|
|
@ -150,6 +151,21 @@ export async function loader(args: any) {
|
|||
where: eq(schema.draftTimers.seasonId, seasonId),
|
||||
});
|
||||
|
||||
// Load autodraft settings for all teams
|
||||
const autodraftSettings = await db.query.autodraftSettings.findMany({
|
||||
where: eq(schema.autodraftSettings.seasonId, seasonId),
|
||||
});
|
||||
|
||||
// Load user's autodraft settings if they have a team
|
||||
const userAutodraftSettings = userTeam
|
||||
? await db.query.autodraftSettings.findFirst({
|
||||
where: and(
|
||||
eq(schema.autodraftSettings.seasonId, seasonId),
|
||||
eq(schema.autodraftSettings.teamId, userTeam.id)
|
||||
),
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
season,
|
||||
draftSlots,
|
||||
|
|
@ -158,6 +174,8 @@ export async function loader(args: any) {
|
|||
userTeam,
|
||||
userQueue,
|
||||
timers,
|
||||
autodraftSettings,
|
||||
userAutodraftSettings,
|
||||
isCommissioner: !!isCommissioner,
|
||||
currentUserId: userId,
|
||||
};
|
||||
|
|
@ -172,9 +190,11 @@ export default function DraftRoom() {
|
|||
userTeam,
|
||||
userQueue,
|
||||
timers,
|
||||
autodraftSettings,
|
||||
userAutodraftSettings,
|
||||
isCommissioner,
|
||||
} = useLoaderData<typeof loader>();
|
||||
const { isConnected, on, off } = useDraftSocket(season.id);
|
||||
const { isConnected, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||
const [picks, setPicks] = useState(draftPicks);
|
||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -183,6 +203,25 @@ export default function DraftRoom() {
|
|||
const [queue, setQueue] = useState(userQueue);
|
||||
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
||||
const [isDraftComplete, setIsDraftComplete] = useState(season.status === "active");
|
||||
|
||||
// Track autodraft status for all teams
|
||||
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
||||
const status: Record<string, boolean> = {};
|
||||
autodraftSettings.forEach((setting: any) => {
|
||||
status[setting.teamId] = setting.isEnabled;
|
||||
});
|
||||
return status;
|
||||
});
|
||||
|
||||
// Track connected teams
|
||||
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
||||
|
||||
// Track user's autodraft settings
|
||||
const [userAutodraft, setUserAutodraft] = useState({
|
||||
isEnabled: userAutodraftSettings?.isEnabled || false,
|
||||
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
||||
});
|
||||
|
||||
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
||||
// Initialize with timers from loader data, or use initial time if draft hasn't started
|
||||
const timersMap: Record<string, number> = {};
|
||||
|
|
@ -236,11 +275,44 @@ export default function DraftRoom() {
|
|||
setIsDraftComplete(true);
|
||||
};
|
||||
|
||||
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on" }) => {
|
||||
console.log("Autodraft updated:", data);
|
||||
setAutodraftStatus((prev) => ({
|
||||
...prev,
|
||||
[data.teamId]: data.isEnabled,
|
||||
}));
|
||||
|
||||
// Update user's local state if it's their team
|
||||
if (userTeam && data.teamId === userTeam.id) {
|
||||
setUserAutodraft({
|
||||
isEnabled: data.isEnabled,
|
||||
mode: data.mode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTeamConnected = (data: { teamId: string }) => {
|
||||
console.log("Team connected:", data.teamId);
|
||||
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
||||
};
|
||||
|
||||
const handleTeamDisconnected = (data: { teamId: string }) => {
|
||||
console.log("Team disconnected:", data.teamId);
|
||||
setConnectedTeams((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(data.teamId);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
on("pick-made", handlePickMade);
|
||||
on("timer-update", handleTimerUpdate);
|
||||
on("draft-paused", handleDraftPaused);
|
||||
on("draft-resumed", handleDraftResumed);
|
||||
on("draft-completed", handleDraftCompleted);
|
||||
on("autodraft-updated", handleAutodraftUpdated);
|
||||
on("team-connected", handleTeamConnected);
|
||||
on("team-disconnected", handleTeamDisconnected);
|
||||
|
||||
return () => {
|
||||
off("pick-made", handlePickMade);
|
||||
|
|
@ -248,8 +320,11 @@ export default function DraftRoom() {
|
|||
off("draft-paused", handleDraftPaused);
|
||||
off("draft-resumed", handleDraftResumed);
|
||||
off("draft-completed", handleDraftCompleted);
|
||||
off("autodraft-updated", handleAutodraftUpdated);
|
||||
off("team-connected", handleTeamConnected);
|
||||
off("team-disconnected", handleTeamDisconnected);
|
||||
};
|
||||
}, [on, off, currentPick]);
|
||||
}, [on, off, currentPick, userTeam]);
|
||||
|
||||
// Queue handlers
|
||||
const handleAddToQueue = async (participantId: string) => {
|
||||
|
|
@ -421,8 +496,9 @@ export default function DraftRoom() {
|
|||
const currentDraftSlot = draftSlots.find(
|
||||
(slot) => slot.draftOrder === pickInRound
|
||||
);
|
||||
const isMyTurn =
|
||||
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id;
|
||||
const isMyTurn = !!(
|
||||
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
||||
);
|
||||
const canPick = isMyTurn && season.status === "draft"; // Only team owner on their turn
|
||||
|
||||
// Format timer display
|
||||
|
|
@ -609,13 +685,19 @@ export default function DraftRoom() {
|
|||
<div className="flex gap-2 mb-2">
|
||||
{draftSlots.map((slot) => {
|
||||
const teamTime = teamTimers[slot.team.id];
|
||||
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
||||
const isConnected = connectedTeams.has(slot.team.id);
|
||||
|
||||
return (
|
||||
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
||||
<div className="font-semibold text-sm truncate px-2">
|
||||
<div className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}>
|
||||
{slot.team.name}
|
||||
</div>
|
||||
<div className={`text-xs font-mono ${getTimerColor(teamTime)}`}>
|
||||
{formatTime(teamTime)}
|
||||
{isAutodraft && (
|
||||
<span className="ml-1 text-muted-foreground">(auto)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -787,6 +869,18 @@ export default function DraftRoom() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autodraft Settings */}
|
||||
<AutodraftSettings
|
||||
seasonId={season.id}
|
||||
teamId={userTeam.id}
|
||||
isEnabled={userAutodraft.isEnabled}
|
||||
mode={userAutodraft.mode}
|
||||
isMyTurn={isMyTurn}
|
||||
onUpdate={(isEnabled, mode) => {
|
||||
setUserAutodraft({ isEnabled, mode });
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
369
app/routes/leagues/__tests__/autodraft.test.ts
Normal file
369
app/routes/leagues/__tests__/autodraft.test.ts
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { action } from "../../api/autodraft.update";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../../../database/context");
|
||||
vi.mock("../../../../server/socket", () => ({
|
||||
getSocketIO: vi.fn(),
|
||||
}));
|
||||
vi.mock("@clerk/react-router/server", () => ({
|
||||
getAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Autodraft Settings API", () => {
|
||||
let mockDb: any;
|
||||
let mockSocketIO: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock Clerk auth
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId: "user-789" } as any);
|
||||
|
||||
// Mock Socket.IO
|
||||
mockSocketIO = {
|
||||
to: vi.fn().mockReturnThis(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
const socketModule = await import("../../../../server/socket");
|
||||
vi.mocked(socketModule.getSocketIO).mockReturnValue(mockSocketIO);
|
||||
|
||||
// Mock database
|
||||
mockDb = {
|
||||
query: {
|
||||
teams: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
autodraftSettings: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
const { database } = await import("../../../../database/context");
|
||||
vi.mocked(database).mockReturnValue(mockDb);
|
||||
});
|
||||
|
||||
describe("Update Autodraft Settings", () => {
|
||||
it("should create new autodraft settings when none exist", async () => {
|
||||
const seasonId = "season-123";
|
||||
const teamId = "team-456";
|
||||
const userId = "user-789";
|
||||
|
||||
// Mock team ownership verification
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
ownerId: userId,
|
||||
name: "Test Team",
|
||||
});
|
||||
|
||||
// Mock no existing settings
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
|
||||
|
||||
// Mock insert returning new settings
|
||||
const newSettings = {
|
||||
id: "settings-1",
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([newSettings]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", "true");
|
||||
formData.append("mode", "next_pick");
|
||||
|
||||
const request = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.settings.id).toBe(newSettings.id);
|
||||
expect(data.settings.seasonId).toBe(newSettings.seasonId);
|
||||
expect(data.settings.teamId).toBe(newSettings.teamId);
|
||||
expect(data.settings.isEnabled).toBe(newSettings.isEnabled);
|
||||
expect(data.settings.mode).toBe(newSettings.mode);
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
});
|
||||
});
|
||||
|
||||
it("should update existing autodraft settings", async () => {
|
||||
const seasonId = "season-123";
|
||||
const teamId = "team-456";
|
||||
const userId = "user-789";
|
||||
|
||||
// Mock team ownership verification
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
ownerId: userId,
|
||||
name: "Test Team",
|
||||
});
|
||||
|
||||
// Mock existing settings
|
||||
const existingSettings = {
|
||||
id: "settings-1",
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: "next_pick",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(
|
||||
existingSettings
|
||||
);
|
||||
|
||||
// Mock update returning updated settings
|
||||
const updatedSettings = {
|
||||
...existingSettings,
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([updatedSettings]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", "true");
|
||||
formData.append("mode", "while_on");
|
||||
|
||||
const request = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.settings.isEnabled).toBe(true);
|
||||
expect(data.settings.mode).toBe("while_on");
|
||||
expect(mockDb.update).toHaveBeenCalled();
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject unauthorized users", async () => {
|
||||
const seasonId = "season-123";
|
||||
const teamId = "team-456";
|
||||
const _userId = "user-789";
|
||||
const differentUserId = "user-999";
|
||||
|
||||
// Mock team owned by different user
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
ownerId: differentUserId,
|
||||
name: "Test Team",
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", "true");
|
||||
formData.append("mode", "next_pick");
|
||||
|
||||
const request = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("should require all fields", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", "season-123");
|
||||
// Missing teamId and mode
|
||||
|
||||
const request = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Missing required fields");
|
||||
});
|
||||
|
||||
it("should handle both autodraft modes", async () => {
|
||||
const seasonId = "season-123";
|
||||
const teamId = "team-456";
|
||||
const userId = "user-789";
|
||||
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
ownerId: userId,
|
||||
name: "Test Team",
|
||||
});
|
||||
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
|
||||
|
||||
// Test next_pick mode
|
||||
const nextPickSettings = {
|
||||
id: "settings-1",
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValueOnce([nextPickSettings]);
|
||||
|
||||
const formData1 = new FormData();
|
||||
formData1.append("seasonId", seasonId);
|
||||
formData1.append("teamId", teamId);
|
||||
formData1.append("isEnabled", "true");
|
||||
formData1.append("mode", "next_pick");
|
||||
|
||||
const request1 = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData1,
|
||||
});
|
||||
|
||||
const response1 = await action({
|
||||
request: request1,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const data1 = await response1.json();
|
||||
expect(data1.settings.mode).toBe("next_pick");
|
||||
|
||||
// Test while_on mode
|
||||
const whileOnSettings = {
|
||||
id: "settings-2",
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "while_on",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValueOnce([whileOnSettings]);
|
||||
|
||||
const formData2 = new FormData();
|
||||
formData2.append("seasonId", seasonId);
|
||||
formData2.append("teamId", teamId);
|
||||
formData2.append("isEnabled", "true");
|
||||
formData2.append("mode", "while_on");
|
||||
|
||||
const request2 = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData2,
|
||||
});
|
||||
|
||||
const response2 = await action({
|
||||
request: request2,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
const data2 = await response2.json();
|
||||
expect(data2.settings.mode).toBe("while_on");
|
||||
});
|
||||
|
||||
it("should emit socket event to correct room", async () => {
|
||||
const seasonId = "season-abc";
|
||||
const teamId = "team-xyz";
|
||||
const userId = "user-123";
|
||||
|
||||
// Override auth mock for this test
|
||||
const { getAuth } = await import("@clerk/react-router/server");
|
||||
vi.mocked(getAuth).mockResolvedValue({ userId } as any);
|
||||
|
||||
mockDb.query.teams.findFirst.mockResolvedValue({
|
||||
id: teamId,
|
||||
ownerId: userId,
|
||||
name: "Test Team",
|
||||
});
|
||||
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
|
||||
|
||||
const newSettings = {
|
||||
id: "settings-1",
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([newSettings]);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("seasonId", seasonId);
|
||||
formData.append("teamId", teamId);
|
||||
formData.append("isEnabled", "true");
|
||||
formData.append("mode", "next_pick");
|
||||
|
||||
const request = new Request("http://localhost/api/autodraft/update", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
await action({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: "next_pick",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,11 @@ export const pickedByTypeEnum = pgEnum("picked_by_type", [
|
|||
"auto",
|
||||
]);
|
||||
|
||||
export const autodraftModeEnum = pgEnum("autodraft_mode", [
|
||||
"next_pick",
|
||||
"while_on",
|
||||
]);
|
||||
|
||||
export const leagues = pgTable("leagues", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
|
|
@ -160,6 +165,20 @@ export const draftTimers = pgTable("draft_timers", {
|
|||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const autodraftSettings = pgTable("autodraft_settings", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
seasonId: uuid("season_id")
|
||||
.notNull()
|
||||
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||
teamId: uuid("team_id")
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: "cascade" }),
|
||||
isEnabled: boolean("is_enabled").notNull().default(false),
|
||||
mode: autodraftModeEnum("mode").notNull().default("next_pick"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const commissioners = pgTable("commissioners", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
leagueId: uuid("league_id")
|
||||
|
|
@ -358,3 +377,14 @@ export const draftQueueRelations = relations(draftQueue, ({ one }) => ({
|
|||
references: [participants.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const autodraftSettingsRelations = relations(autodraftSettings, ({ one }) => ({
|
||||
season: one(seasons, {
|
||||
fields: [autodraftSettings.seasonId],
|
||||
references: [seasons.id],
|
||||
}),
|
||||
team: one(teams, {
|
||||
fields: [autodraftSettings.teamId],
|
||||
references: [teams.id],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
22
drizzle/0019_acoustic_ben_grimm.sql
Normal file
22
drizzle/0019_acoustic_ben_grimm.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
CREATE TYPE "public"."autodraft_mode" AS ENUM('next_pick', 'while_on');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "autodraft_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"season_id" uuid NOT NULL,
|
||||
"team_id" uuid NOT NULL,
|
||||
"is_enabled" boolean DEFAULT false NOT NULL,
|
||||
"mode" "autodraft_mode" DEFAULT 'next_pick' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "autodraft_settings" ADD CONSTRAINT "autodraft_settings_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "autodraft_settings" ADD CONSTRAINT "autodraft_settings_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
1497
drizzle/meta/0019_snapshot.json
Normal file
1497
drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -134,6 +134,13 @@
|
|||
"when": 1760996053371,
|
||||
"tag": "0018_numerous_stardust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1761112236540,
|
||||
"tag": "0019_acoustic_ben_grimm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@react-router/express": "^7.7.1",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
@ -2755,6 +2756,35 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@react-router/express": "^7.7.1",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
|
|
|||
293
server/__tests__/socket-autodraft.test.ts
Normal file
293
server/__tests__/socket-autodraft.test.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
describe('Socket Autodraft Events', () => {
|
||||
let mockSocket: any;
|
||||
let mockIO: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockSocket = {
|
||||
id: 'socket-123',
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
mockIO = {
|
||||
to: vi.fn().mockReturnThis(),
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Team Connection Tracking', () => {
|
||||
it('should emit team-connected event when team joins draft', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Simulate joining draft with teamId
|
||||
mockSocket.join(`draft-${seasonId}`);
|
||||
mockSocket.join(`team-${teamId}`);
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-connected', { teamId });
|
||||
|
||||
expect(mockSocket.join).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockSocket.join).toHaveBeenCalledWith(`team-${teamId}`);
|
||||
expect(mockIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('team-connected', { teamId });
|
||||
});
|
||||
|
||||
it('should emit team-disconnected event when team leaves draft', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Simulate leaving draft
|
||||
mockSocket.leave(`draft-${seasonId}`);
|
||||
mockSocket.leave(`team-${teamId}`);
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-disconnected', { teamId });
|
||||
|
||||
expect(mockSocket.leave).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockSocket.leave).toHaveBeenCalledWith(`team-${teamId}`);
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('team-disconnected', { teamId });
|
||||
});
|
||||
|
||||
it('should emit team-disconnected on socket disconnect', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Simulate disconnect
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-disconnected', { teamId });
|
||||
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('team-disconnected', { teamId });
|
||||
});
|
||||
|
||||
it('should not emit connection events when joining without teamId', () => {
|
||||
const seasonId = 'season-123';
|
||||
|
||||
// Simulate joining draft without teamId (commissioner/spectator)
|
||||
mockSocket.join(`draft-${seasonId}`);
|
||||
|
||||
expect(mockSocket.join).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockIO.emit).not.toHaveBeenCalledWith('team-connected', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Autodraft Status Updates', () => {
|
||||
it('should broadcast autodraft-updated event to draft room', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(mockIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
});
|
||||
|
||||
it('should broadcast when autodraft is disabled', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
});
|
||||
|
||||
it('should broadcast mode changes', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Change from next_pick to while_on
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'while_on',
|
||||
});
|
||||
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'while_on',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include all required fields in autodraft-updated event', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
const emitCall = mockIO.emit.mock.calls[0];
|
||||
expect(emitCall[0]).toBe('autodraft-updated');
|
||||
expect(emitCall[1]).toHaveProperty('teamId');
|
||||
expect(emitCall[1]).toHaveProperty('isEnabled');
|
||||
expect(emitCall[1]).toHaveProperty('mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Ordering', () => {
|
||||
it('should emit team-connected before autodraft-updated on join', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
const emitSpy = vi.fn();
|
||||
mockIO.emit = emitSpy;
|
||||
|
||||
// Join and set autodraft
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-connected', { teamId });
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(1, 'team-connected', { teamId });
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(2, 'autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit autodraft-updated before team-disconnected on leave', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
const emitSpy = vi.fn();
|
||||
mockIO.emit = emitSpy;
|
||||
|
||||
// Disable autodraft then leave
|
||||
mockIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-disconnected', { teamId });
|
||||
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(1, 'autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
expect(emitSpy).toHaveBeenNthCalledWith(2, 'team-disconnected', { teamId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Room Isolation', () => {
|
||||
it('should only emit to specific draft room', () => {
|
||||
const seasonId1 = 'season-123';
|
||||
const seasonId2 = 'season-456';
|
||||
const teamId = 'team-789';
|
||||
|
||||
const toSpy = vi.fn().mockReturnThis();
|
||||
mockIO.to = toSpy;
|
||||
|
||||
mockIO.to(`draft-${seasonId1}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(toSpy).toHaveBeenCalledWith(`draft-${seasonId1}`);
|
||||
expect(toSpy).not.toHaveBeenCalledWith(`draft-${seasonId2}`);
|
||||
});
|
||||
|
||||
it('should broadcast to all clients in the room', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Multiple clients in same room should all receive the event
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-connected', { teamId });
|
||||
|
||||
expect(mockIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockIO.emit).toHaveBeenCalledWith('team-connected', { teamId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle missing seasonId gracefully', () => {
|
||||
const seasonId = '';
|
||||
|
||||
if (!seasonId) {
|
||||
// Should not attempt to join or emit
|
||||
expect(mockSocket.join).not.toHaveBeenCalled();
|
||||
expect(mockIO.emit).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing teamId gracefully', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = undefined;
|
||||
|
||||
mockSocket.join(`draft-${seasonId}`);
|
||||
|
||||
// Should join draft room but not emit team-connected
|
||||
expect(mockSocket.join).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
|
||||
if (!teamId) {
|
||||
expect(mockIO.emit).not.toHaveBeenCalledWith('team-connected', expect.anything());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple Connections', () => {
|
||||
it('should handle same team connecting from multiple devices', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// First connection
|
||||
mockSocket.join(`draft-${seasonId}`);
|
||||
mockSocket.join(`team-${teamId}`);
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-connected', { teamId });
|
||||
|
||||
// Second connection (same team)
|
||||
const mockSocket2 = { ...mockSocket, id: 'socket-456' };
|
||||
mockSocket2.join(`draft-${seasonId}`);
|
||||
mockSocket2.join(`team-${teamId}`);
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-connected', { teamId });
|
||||
|
||||
// Both should emit connection events
|
||||
expect(mockIO.emit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should only disconnect when all connections are closed', () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Two connections
|
||||
const socket1 = { ...mockSocket, id: 'socket-1' };
|
||||
const socket2 = { ...mockSocket, id: 'socket-2' };
|
||||
|
||||
// First disconnects
|
||||
socket1.leave(`draft-${seasonId}`);
|
||||
// Should not emit team-disconnected yet
|
||||
|
||||
// Second disconnects
|
||||
socket2.leave(`draft-${seasonId}`);
|
||||
mockIO.to(`draft-${seasonId}`).emit('team-disconnected', { teamId });
|
||||
|
||||
// In practice, this would be handled by tracking connection count
|
||||
expect(mockSocket.leave).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
547
server/__tests__/timer-autodraft.test.ts
Normal file
547
server/__tests__/timer-autodraft.test.ts
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dependencies before imports
|
||||
vi.mock('drizzle-orm/postgres-js', () => ({
|
||||
drizzle: vi.fn(() => mockDb),
|
||||
}));
|
||||
|
||||
vi.mock('postgres', () => ({
|
||||
default: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../socket', () => ({
|
||||
getSocketIO: vi.fn(() => mockSocketIO),
|
||||
}));
|
||||
|
||||
let mockDb: any;
|
||||
let mockSocketIO: any;
|
||||
|
||||
// Setup mocks
|
||||
beforeEach(() => {
|
||||
mockSocketIO = {
|
||||
to: vi.fn().mockReturnThis(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
mockDb = {
|
||||
query: {
|
||||
seasons: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
draftSlots: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
draftTimers: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
autodraftSettings: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
draftPicks: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
draftQueue: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
participants: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
seasonTemplateSports: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
update: vi.fn().mockReturnThis(),
|
||||
set: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
returning: vi.fn(),
|
||||
insert: vi.fn().mockReturnThis(),
|
||||
values: vi.fn().mockReturnThis(),
|
||||
delete: vi.fn().mockReturnThis(),
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Timer Autodraft Integration', () => {
|
||||
describe('Autodraft Settings Check', () => {
|
||||
it('should check autodraft settings when timer expires', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Mock active draft
|
||||
mockDb.query.seasons.findMany.mockResolvedValue([
|
||||
{
|
||||
id: seasonId,
|
||||
status: 'draft',
|
||||
draftPaused: false,
|
||||
currentPickNumber: 1,
|
||||
draftInitialTime: 120,
|
||||
draftIncrementTime: 30,
|
||||
draftRounds: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock draft slots
|
||||
mockDb.query.draftSlots.findMany.mockResolvedValue([
|
||||
{ id: 'slot-1', teamId, draftOrder: 1 },
|
||||
{ id: 'slot-2', teamId: 'team-2', draftOrder: 2 },
|
||||
]);
|
||||
|
||||
// Mock timer at 0
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({
|
||||
id: 'timer-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: 0,
|
||||
});
|
||||
|
||||
// Mock autodraft settings enabled
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
// Mock no existing pick
|
||||
mockDb.query.draftPicks.findFirst.mockResolvedValue(null);
|
||||
|
||||
// Mock queue
|
||||
mockDb.query.draftQueue.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'queue-1',
|
||||
participantId: 'participant-1',
|
||||
queuePosition: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock drafted picks
|
||||
mockDb.query.draftPicks.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock insert returning created pick
|
||||
mockDb.returning.mockResolvedValue([
|
||||
{
|
||||
id: 'pick-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
participantId: 'participant-1',
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
pickInRound: 1,
|
||||
pickedByUserId: '',
|
||||
pickedByType: 'auto',
|
||||
timeUsed: 120,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock select for complete pick
|
||||
mockDb.limit.mockResolvedValue([
|
||||
{
|
||||
id: 'pick-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
participantId: 'participant-1',
|
||||
pickNumber: 1,
|
||||
round: 1,
|
||||
pickInRound: 1,
|
||||
pickedByUserId: '',
|
||||
pickedByType: 'auto',
|
||||
timeUsed: 120,
|
||||
createdAt: new Date(),
|
||||
team: { id: teamId, name: 'Test Team' },
|
||||
participant: { id: 'participant-1', name: 'Test Participant' },
|
||||
sport: { id: 'sport-1', name: 'Test Sport' },
|
||||
},
|
||||
]);
|
||||
|
||||
// The actual timer logic would be tested here
|
||||
// This test verifies the mocks are set up correctly
|
||||
expect(mockDb.query.autodraftSettings.findFirst).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use regular auto-pick when autodraft is disabled', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Mock autodraft settings disabled
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
const settings = await mockDb.query.autodraftSettings.findFirst();
|
||||
expect(settings.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing autodraft settings', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
// Mock no autodraft settings
|
||||
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
|
||||
|
||||
const settings = await mockDb.query.autodraftSettings.findFirst();
|
||||
expect(settings).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Autodraft Mode Handling', () => {
|
||||
it('should disable autodraft after pick when mode is next_pick', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
const autodraftSettings = {
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
};
|
||||
|
||||
// Mock update
|
||||
mockDb.returning.mockResolvedValue([
|
||||
{
|
||||
...autodraftSettings,
|
||||
isEnabled: false,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
|
||||
// Simulate disabling after pick
|
||||
if (autodraftSettings.isEnabled && autodraftSettings.mode === 'next_pick') {
|
||||
await mockDb.update();
|
||||
await mockDb.set({ isEnabled: false, updatedAt: new Date() });
|
||||
await mockDb.where();
|
||||
const result = await mockDb.returning();
|
||||
|
||||
expect(result[0].isEnabled).toBe(false);
|
||||
expect(mockSocketIO.to).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep autodraft enabled when mode is while_on', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
const autodraftSettings = {
|
||||
id: 'settings-1',
|
||||
seasonId: _seasonId,
|
||||
teamId: _teamId,
|
||||
isEnabled: true,
|
||||
mode: 'while_on',
|
||||
};
|
||||
|
||||
// Should not disable when mode is while_on
|
||||
if (autodraftSettings.mode === 'while_on') {
|
||||
expect(autodraftSettings.isEnabled).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Autodraft Pick Logic', () => {
|
||||
it('should pick from queue first when autodraft is enabled', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
// Mock queue with available participant
|
||||
mockDb.query.draftQueue.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'queue-1',
|
||||
participantId: 'participant-1',
|
||||
queuePosition: 1,
|
||||
participant: {
|
||||
id: 'participant-1',
|
||||
name: 'Queued Participant',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'queue-2',
|
||||
participantId: 'participant-2',
|
||||
queuePosition: 2,
|
||||
participant: {
|
||||
id: 'participant-2',
|
||||
name: 'Second Queued',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock no drafted picks
|
||||
mockDb.query.draftPicks.findMany.mockResolvedValue([]);
|
||||
|
||||
const queueItems = await mockDb.query.draftQueue.findMany();
|
||||
const draftedPicks = await mockDb.query.draftPicks.findMany();
|
||||
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
|
||||
|
||||
// Find first non-drafted participant from queue
|
||||
const availableQueueItem = queueItems.find(
|
||||
(item: any) => !draftedParticipantIds.includes(item.participantId)
|
||||
);
|
||||
|
||||
expect(availableQueueItem).toBeDefined();
|
||||
expect(availableQueueItem.participantId).toBe('participant-1');
|
||||
});
|
||||
|
||||
it('should skip drafted participants in queue', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
// Mock queue
|
||||
mockDb.query.draftQueue.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'queue-1',
|
||||
participantId: 'participant-1',
|
||||
queuePosition: 1,
|
||||
},
|
||||
{
|
||||
id: 'queue-2',
|
||||
participantId: 'participant-2',
|
||||
queuePosition: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock first participant already drafted
|
||||
mockDb.query.draftPicks.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'pick-1',
|
||||
participantId: 'participant-1',
|
||||
},
|
||||
]);
|
||||
|
||||
const queueItems = await mockDb.query.draftQueue.findMany();
|
||||
const draftedPicks = await mockDb.query.draftPicks.findMany();
|
||||
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
|
||||
|
||||
// Should pick second participant
|
||||
const availableQueueItem = queueItems.find(
|
||||
(item: any) => !draftedParticipantIds.includes(item.participantId)
|
||||
);
|
||||
|
||||
expect(availableQueueItem.participantId).toBe('participant-2');
|
||||
});
|
||||
|
||||
it('should fall back to highest EV when queue is empty or all drafted', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
// Mock empty queue
|
||||
mockDb.query.draftQueue.findMany.mockResolvedValue([]);
|
||||
|
||||
// Mock season template sports
|
||||
mockDb.query.seasonTemplateSports.findMany.mockResolvedValue([
|
||||
{ sportsSeasonId: 'sports-season-1' },
|
||||
]);
|
||||
|
||||
// Mock available participants sorted by EV
|
||||
mockDb.query.participants.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'participant-high-ev',
|
||||
name: 'High EV Participant',
|
||||
expectedValue: 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
const queueItems = await mockDb.query.draftQueue.findMany();
|
||||
expect(queueItems.length).toBe(0);
|
||||
|
||||
const availableParticipants = await mockDb.query.participants.findMany();
|
||||
expect(availableParticipants[0].expectedValue).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Socket Event Emission', () => {
|
||||
it('should emit autodraft-updated event when disabling next_pick mode', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit pick-made event after autodraft pick', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
|
||||
const pickData = {
|
||||
pick: {
|
||||
id: 'pick-1',
|
||||
participantId: 'participant-1',
|
||||
pickNumber: 1,
|
||||
},
|
||||
nextPickNumber: 2,
|
||||
isDraftComplete: false,
|
||||
};
|
||||
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('pick-made', pickData);
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('pick-made', pickData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Back-to-Back Picks', () => {
|
||||
it('should only autodraft first pick when mode is next_pick and team has consecutive picks', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Mock autodraft settings with next_pick mode
|
||||
const autodraftSettings = {
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
};
|
||||
|
||||
// Simulate first pick (pick #1)
|
||||
const _firstPickNumber = 1;
|
||||
|
||||
// After first pick, autodraft should be disabled
|
||||
if (autodraftSettings.isEnabled && autodraftSettings.mode === 'next_pick') {
|
||||
// Simulate disabling autodraft after first pick
|
||||
const updatedSettings = {
|
||||
...autodraftSettings,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
expect(updatedSettings.isEnabled).toBe(false);
|
||||
expect(updatedSettings.mode).toBe('next_pick');
|
||||
}
|
||||
|
||||
// Simulate second pick (pick #2) - should NOT autodraft
|
||||
const _secondPickNumber = 2;
|
||||
|
||||
// Autodraft should now be disabled, so second pick requires manual action
|
||||
const settingsForSecondPick = {
|
||||
...autodraftSettings,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
expect(settingsForSecondPick.isEnabled).toBe(false);
|
||||
|
||||
// Verify that autodraft-updated event would be emitted after first pick
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: 'next_pick',
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue autodrafting consecutive picks when mode is while_on', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
// Mock autodraft settings with while_on mode
|
||||
const autodraftSettings = {
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'while_on',
|
||||
};
|
||||
|
||||
// Simulate first pick (pick #1)
|
||||
const _firstPickNumber = 1;
|
||||
|
||||
// After first pick, autodraft should REMAIN enabled for while_on mode
|
||||
if (autodraftSettings.mode === 'while_on') {
|
||||
expect(autodraftSettings.isEnabled).toBe(true);
|
||||
}
|
||||
|
||||
// Simulate second pick (pick #2) - SHOULD autodraft
|
||||
const _secondPickNumber = 2;
|
||||
|
||||
// Autodraft should still be enabled for second pick
|
||||
expect(autodraftSettings.isEnabled).toBe(true);
|
||||
expect(autodraftSettings.mode).toBe('while_on');
|
||||
});
|
||||
|
||||
it('should handle snake draft back-to-back picks correctly', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
const _totalTeams = 10;
|
||||
|
||||
// In a snake draft with 10 teams:
|
||||
// Pick 10 (end of round 1) and Pick 11 (start of round 2) are back-to-back for same team
|
||||
const _firstPick = 10; // Last pick of round 1
|
||||
const _secondPick = 11; // First pick of round 2 (same team in snake draft)
|
||||
|
||||
// Mock autodraft with next_pick mode
|
||||
const autodraftSettings = {
|
||||
id: 'settings-1',
|
||||
seasonId,
|
||||
teamId,
|
||||
isEnabled: true,
|
||||
mode: 'next_pick',
|
||||
};
|
||||
|
||||
// After pick 10, autodraft should be disabled
|
||||
const settingsAfterFirstPick = {
|
||||
...autodraftSettings,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
expect(settingsAfterFirstPick.isEnabled).toBe(false);
|
||||
|
||||
// Pick 11 should NOT autodraft because autodraft was disabled after pick 10
|
||||
// This ensures the team owner has a chance to make their second pick manually
|
||||
expect(settingsAfterFirstPick.mode).toBe('next_pick');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Time Increment', () => {
|
||||
it('should add increment time after autodraft pick', async () => {
|
||||
const _seasonId = 'season-123';
|
||||
const _teamId = 'team-456';
|
||||
const incrementTime = 30;
|
||||
const currentTime = 0;
|
||||
|
||||
const newTimeRemaining = currentTime + incrementTime;
|
||||
|
||||
expect(newTimeRemaining).toBe(30);
|
||||
});
|
||||
|
||||
it('should emit timer-update after adding increment', async () => {
|
||||
const seasonId = 'season-123';
|
||||
const teamId = 'team-456';
|
||||
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', {
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: 30,
|
||||
currentPickNumber: 1,
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', {
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: 30,
|
||||
currentPickNumber: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -18,10 +18,19 @@ interface ServerToClientEvents {
|
|||
timeRemaining: number;
|
||||
currentPickNumber: number;
|
||||
}) => void;
|
||||
"autodraft-updated": (data: {
|
||||
teamId: string;
|
||||
isEnabled: boolean;
|
||||
mode: "next_pick" | "while_on";
|
||||
}) => void;
|
||||
"team-connected": (data: { teamId: string }) => void;
|
||||
"team-disconnected": (data: { teamId: string }) => void;
|
||||
"draft-paused": () => void;
|
||||
"draft-resumed": () => void;
|
||||
}
|
||||
|
||||
interface ClientToServerEvents {
|
||||
"join-draft": (seasonId: string) => void;
|
||||
"join-draft": (seasonId: string, teamId?: string) => void;
|
||||
"leave-draft": (seasonId: string) => void;
|
||||
"test-event": (data: any) => void;
|
||||
}
|
||||
|
|
@ -56,19 +65,39 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
io.on("connection", (socket: Socket<ClientToServerEvents, ServerToClientEvents>) => {
|
||||
console.log("Client connected:", socket.id);
|
||||
|
||||
socket.on("join-draft", (seasonId: string) => {
|
||||
// Store team ID for this socket
|
||||
let currentTeamId: string | undefined;
|
||||
let currentSeasonId: string | undefined;
|
||||
|
||||
socket.on("join-draft", (seasonId: string, teamId?: string) => {
|
||||
if (!seasonId) {
|
||||
console.error("No seasonId provided for join-draft");
|
||||
return;
|
||||
}
|
||||
socket.join(`draft-${seasonId}`);
|
||||
currentSeasonId = seasonId;
|
||||
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
|
||||
|
||||
// If teamId provided, track connection and emit event
|
||||
if (teamId) {
|
||||
currentTeamId = teamId;
|
||||
socket.join(`team-${teamId}`);
|
||||
io!.to(`draft-${seasonId}`).emit("team-connected", { teamId });
|
||||
console.log(`Team ${teamId} connected to draft-${seasonId}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("leave-draft", (seasonId: string) => {
|
||||
if (!seasonId) return;
|
||||
socket.leave(`draft-${seasonId}`);
|
||||
console.log(`Socket ${socket.id} left draft-${seasonId}`);
|
||||
|
||||
// Emit disconnection event if team was tracked
|
||||
if (currentTeamId) {
|
||||
socket.leave(`team-${currentTeamId}`);
|
||||
io!.to(`draft-${seasonId}`).emit("team-disconnected", { teamId: currentTeamId });
|
||||
console.log(`Team ${currentTeamId} disconnected from draft-${seasonId}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("test-event", (data: any) => {
|
||||
|
|
@ -86,6 +115,12 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
|
||||
socket.on("disconnect", () => {
|
||||
console.log("Client disconnected:", socket.id);
|
||||
|
||||
// Emit disconnection event if team was tracked
|
||||
if (currentTeamId && currentSeasonId) {
|
||||
io!.to(`draft-${currentSeasonId}`).emit("team-disconnected", { teamId: currentTeamId });
|
||||
console.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -114,12 +114,30 @@ async function updateDraftTimers(): Promise<void> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// If timer is at 0 or below, trigger auto-pick immediately
|
||||
// If timer is at 0 or below, check autodraft settings and trigger auto-pick
|
||||
if (timer.timeRemaining <= 0) {
|
||||
console.log(
|
||||
`[Timer] Timer at 0 for team ${currentTeamId} in season ${season.id}, triggering auto-pick`
|
||||
`[Timer] Timer at 0 for team ${currentTeamId} in season ${season.id}, checking autodraft settings`
|
||||
);
|
||||
await triggerAutoPick(season.id, currentTeamId, currentPickNumber);
|
||||
|
||||
// Check if team has autodraft enabled
|
||||
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
||||
where: and(
|
||||
eq(schema.autodraftSettings.seasonId, season.id),
|
||||
eq(schema.autodraftSettings.teamId, currentTeamId)
|
||||
),
|
||||
});
|
||||
|
||||
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
|
||||
|
||||
if (shouldAutodraft) {
|
||||
console.log(`[Timer] Autodraft enabled for team ${currentTeamId}, triggering auto-pick`);
|
||||
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, autodraftSettings);
|
||||
} else {
|
||||
console.log(`[Timer] Autodraft disabled for team ${currentTeamId}, triggering regular auto-pick`);
|
||||
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, null);
|
||||
}
|
||||
|
||||
continue; // Skip to next draft after triggering pick
|
||||
}
|
||||
|
||||
|
|
@ -147,12 +165,21 @@ async function updateDraftTimers(): Promise<void> {
|
|||
currentPickNumber,
|
||||
});
|
||||
|
||||
// If timer just hit 0, trigger auto-pick
|
||||
// If timer just hit 0, check autodraft settings and trigger auto-pick
|
||||
if (newTimeRemaining === 0) {
|
||||
console.log(
|
||||
`[Timer] Timer expired for team ${currentTeamId} in season ${season.id}, triggering auto-pick`
|
||||
`[Timer] Timer expired for team ${currentTeamId} in season ${season.id}, checking autodraft settings`
|
||||
);
|
||||
await triggerAutoPick(season.id, currentTeamId, currentPickNumber);
|
||||
|
||||
// Check if team has autodraft enabled
|
||||
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
||||
where: and(
|
||||
eq(schema.autodraftSettings.seasonId, season.id),
|
||||
eq(schema.autodraftSettings.teamId, currentTeamId)
|
||||
),
|
||||
});
|
||||
|
||||
await triggerAutoPick(season.id, currentTeamId, currentPickNumber, autodraftSettings || null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +190,8 @@ async function updateDraftTimers(): Promise<void> {
|
|||
async function triggerAutoPick(
|
||||
seasonId: string,
|
||||
teamId: string,
|
||||
pickNumber: number
|
||||
pickNumber: number,
|
||||
autodraftSettings: any | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const io = getSocketIO();
|
||||
|
|
@ -306,6 +334,25 @@ async function triggerAutoPick(
|
|||
const isDraftComplete = allPicks.length >= totalPicks;
|
||||
const nextPickNumber = isDraftComplete ? pickNumber : pickNumber + 1;
|
||||
|
||||
// If autodraft was enabled with "next_pick" mode, disable it now
|
||||
if (autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") {
|
||||
console.log(`[Timer] Disabling autodraft for team ${teamId} after next_pick`);
|
||||
await db
|
||||
.update(schema.autodraftSettings)
|
||||
.set({
|
||||
isEnabled: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.autodraftSettings.id, autodraftSettings.id));
|
||||
|
||||
// Emit socket event to notify clients
|
||||
io.to(`draft-${seasonId}`).emit("autodraft-updated", {
|
||||
teamId,
|
||||
isEnabled: false,
|
||||
mode: autodraftSettings.mode,
|
||||
});
|
||||
}
|
||||
|
||||
// Add increment to the team that just picked
|
||||
const pickingTeamTimer = await db.query.draftTimers.findFirst({
|
||||
where: and(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue