diff --git a/app/components/AutodraftSettings.tsx b/app/components/AutodraftSettings.tsx index 8679d43..80075c0 100644 --- a/app/components/AutodraftSettings.tsx +++ b/app/components/AutodraftSettings.tsx @@ -1,15 +1,29 @@ import { useState, useEffect } from "react"; import { Switch } from "~/components/ui/switch"; -import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group"; import { Label } from "~/components/ui/label"; +import { Button } from "~/components/ui/button"; + +type AutodraftMode = "next_pick" | "while_on"; + +// The three visible states map to isEnabled + mode combinations: +// "off" → isEnabled: false +// "next_pick" → isEnabled: true, mode: "next_pick" +// "all_picks" → isEnabled: true, mode: "while_on" +type AutodraftState = "off" | "next_pick" | "all_picks"; + +function toAutodraftState(isEnabled: boolean, mode: AutodraftMode): AutodraftState { + if (!isEnabled) return "off"; + return mode === "next_pick" ? "next_pick" : "all_picks"; +} interface AutodraftSettingsProps { seasonId: string; teamId: string; isEnabled: boolean; - mode: "next_pick" | "while_on"; + mode: AutodraftMode; + queueOnly: boolean; isMyTurn: boolean; - onUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; + onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void; } export function AutodraftSettings({ @@ -17,34 +31,37 @@ export function AutodraftSettings({ teamId, isEnabled, mode, + queueOnly, isMyTurn, onUpdate, }: AutodraftSettingsProps) { - const [localEnabled, setLocalEnabled] = useState(isEnabled); - const [localMode, setLocalMode] = useState<"next_pick" | "while_on">(mode); + const [localState, setLocalState] = useState(toAutodraftState(isEnabled, mode)); + const [localQueueOnly, setLocalQueueOnly] = useState(queueOnly); const [isUpdating, setIsUpdating] = useState(false); // Sync local state with props when they change (from socket events) useEffect(() => { - setLocalEnabled(isEnabled); - }, [isEnabled]); + setLocalState(toAutodraftState(isEnabled, mode)); + }, [isEnabled, mode]); useEffect(() => { - setLocalMode(mode); - }, [mode]); + setLocalQueueOnly(queueOnly); + }, [queueOnly]); - const handleToggle = async (checked: boolean) => { - if (isUpdating) return; // Prevent multiple simultaneous updates - - setLocalEnabled(checked); + const sendUpdate = async (newState: AutodraftState, newQueueOnly: boolean) => { + if (isUpdating) return; setIsUpdating(true); + const newEnabled = newState !== "off"; + const newMode: AutodraftMode = newState === "all_picks" ? "while_on" : "next_pick"; + try { const formData = new FormData(); formData.append("seasonId", seasonId); formData.append("teamId", teamId); - formData.append("isEnabled", checked.toString()); - formData.append("mode", localMode); + formData.append("isEnabled", newEnabled.toString()); + formData.append("mode", newMode); + formData.append("queueOnly", newQueueOnly.toString()); const response = await fetch("/api/autodraft/update", { method: "POST", @@ -52,96 +69,84 @@ export function AutodraftSettings({ }); if (response.ok) { - onUpdate(checked, localMode); + onUpdate(newEnabled, newMode, newQueueOnly); } else { // Revert on error - setLocalEnabled(!checked); + setLocalState(toAutodraftState(isEnabled, mode)); + setLocalQueueOnly(queueOnly); console.error("Failed to update autodraft settings"); } } catch (error) { // Revert on error - setLocalEnabled(!checked); + setLocalState(toAutodraftState(isEnabled, mode)); + setLocalQueueOnly(queueOnly); console.error("Error updating autodraft settings:", error); } finally { setIsUpdating(false); } }; - const handleModeChange = async (newMode: "next_pick" | "while_on") => { - if (isUpdating) return; // Prevent multiple simultaneous updates - - const previousMode = localMode; - 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(previousMode); - console.error("Failed to update autodraft mode"); - } - } catch (error) { - // Revert on error - setLocalMode(previousMode); - console.error("Error updating autodraft mode:", error); - } finally { - setIsUpdating(false); - } + const handleStateChange = (newState: AutodraftState) => { + if (isMyTurn || isUpdating) return; + setLocalState(newState); + sendUpdate(newState, localQueueOnly); }; + const handleQueueOnlyChange = async (checked: boolean) => { + if (isMyTurn || isUpdating) return; + setLocalQueueOnly(checked); + sendUpdate(localState, checked); + }; + + const isDisabled = isMyTurn || isUpdating; + return (
-
- - + + + {/* Three-state button group */} +
+ {(["off", "next_pick", "all_picks"] as AutodraftState[]).map((state) => { + const labels: Record = { + off: "Off", + next_pick: "Next Pick", + all_picks: "All Picks", + }; + const isActive = localState === state; + return ( + + ); + })}
- {localEnabled && ( - handleModeChange(value as "next_pick" | "while_on")} - disabled={isMyTurn || isUpdating} - className="space-y-2" - > -
- - -
-
- - -
-
+ {/* Queue-only toggle — only visible when autodraft is active */} + {localState !== "off" && ( +
+ + +
)} {isMyTurn && ( diff --git a/app/components/__tests__/AutodraftSettings.test.tsx b/app/components/__tests__/AutodraftSettings.test.tsx index 38fe38a..09cee7a 100644 --- a/app/components/__tests__/AutodraftSettings.test.tsx +++ b/app/components/__tests__/AutodraftSettings.test.tsx @@ -12,6 +12,7 @@ describe('AutodraftSettings Component', () => { isEnabled: false, mode: 'next_pick' as const, isMyTurn: false, + queueOnly: false, onUpdate: vi.fn(), }; @@ -24,239 +25,142 @@ describe('AutodraftSettings Component', () => { }); describe('Rendering', () => { - it('should render the autodraft switch', () => { + it('should render three autodraft state buttons', () => { render(); - - expect(screen.getByText('Autodraft')).toBeInTheDocument(); - expect(screen.getByRole('switch')).toBeInTheDocument(); + + expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next Pick' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'All Picks' })).toBeInTheDocument(); }); - it('should not show mode options when autodraft is disabled', () => { + it('should not show the queue-only toggle when autodraft is Off', () => { render(); - - expect(screen.queryByText('Next Pick')).not.toBeInTheDocument(); - expect(screen.queryByText('While On')).not.toBeInTheDocument(); + + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByText('Only autodraft from queue')).not.toBeInTheDocument(); }); - it('should show mode options when autodraft is enabled', () => { + it('should show the queue-only toggle when autodraft is active', () => { render(); - - expect(screen.getByText('Next Pick')).toBeInTheDocument(); - expect(screen.getByText('While On')).toBeInTheDocument(); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + expect(screen.getByText('Only autodraft from queue')).toBeInTheDocument(); }); - it('should show disabled message when it is user turn', () => { + it('should show disabled message when it is the user\'s turn', () => { render(); - + expect(screen.getByText(/Autodraft settings are disabled during your pick/i)).toBeInTheDocument(); }); + + it('should mark Off as active when isEnabled=false', () => { + render(); + + const offButton = screen.getByRole('button', { name: 'Off' }); + expect(offButton.className).toContain('bg-electric'); + }); + + it('should mark Next Pick as active when isEnabled=true and mode=next_pick', () => { + render(); + + const nextPickButton = screen.getByRole('button', { name: 'Next Pick' }); + expect(nextPickButton.className).toContain('bg-electric'); + }); + + it('should mark All Picks as active when isEnabled=true and mode=while_on', () => { + render(); + + const allPicksButton = screen.getByRole('button', { name: 'All Picks' }); + expect(allPicksButton.className).toContain('bg-electric'); + }); }); - describe('Switch Toggle', () => { - it('should enable autodraft when switch is toggled on', async () => { + describe('Button Group Interaction', () => { + it('should switch to Next Pick when that button is clicked', async () => { const onUpdate = vi.fn(); - render(); - - const switchElement = screen.getByRole('switch'); - fireEvent.click(switchElement); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/autodraft/update', - expect.objectContaining({ - method: 'POST', - }) + expect.objectContaining({ method: 'POST' }) ); }); await waitFor(() => { - expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick'); + expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', false); }); }); - it('should disable autodraft when switch is toggled off', async () => { + it('should switch to All Picks when that button is clicked', async () => { + const onUpdate = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'All Picks' })); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(true, 'while_on', false); + }); + }); + + it('should switch to Off when that button is clicked from an active state', async () => { const onUpdate = vi.fn(); render(); - - const switchElement = screen.getByRole('switch'); - fireEvent.click(switchElement); + + fireEvent.click(screen.getByRole('button', { name: 'Off' })); await waitFor(() => { - expect(global.fetch).toHaveBeenCalled(); - }); - - await waitFor(() => { - expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick'); + expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick', false); }); }); - it('should be disabled when it is user turn', () => { + it('should disable all buttons when it is the user\'s turn', () => { render(); - - const switchElement = screen.getByRole('switch'); - expect(switchElement).toBeDisabled(); + + expect(screen.getByRole('button', { name: 'Off' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next Pick' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'All Picks' })).toBeDisabled(); }); - it('should handle API errors gracefully', async () => { + it('should not fire API call when a button is clicked during user\'s turn', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should handle API errors by reverting state', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); (global.fetch as any).mockResolvedValueOnce({ ok: false, json: async () => ({ error: 'Server error' }), }); - render(); - - const switchElement = screen.getByRole('switch'); - fireEvent.click(switchElement); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); await waitFor(() => { expect(consoleSpy).toHaveBeenCalled(); }); - // Switch should revert to original state - expect(switchElement).not.toBeChecked(); + // Off button should still be active (reverted) + const offButton = screen.getByRole('button', { name: 'Off' }); + expect(offButton.className).toContain('bg-electric'); consoleSpy.mockRestore(); }); - }); - describe('Mode Selection', () => { - it('should select next_pick mode by default', () => { - render(); - - const nextPickRadio = screen.getByRole('radio', { name: /next pick/i }); - expect(nextPickRadio).toBeChecked(); - }); - - it('should select while_on mode when specified', () => { - render(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - 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 () => { + it('should handle network errors by reverting state', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); render(); - - const switchElement = screen.getByRole('switch'); - fireEvent.click(switchElement); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); await waitFor(() => { expect(consoleSpy).toHaveBeenCalled(); @@ -264,23 +168,114 @@ describe('AutodraftSettings Component', () => { consoleSpy.mockRestore(); }); + }); - it('should maintain state consistency on error', async () => { - (global.fetch as any).mockResolvedValueOnce({ - ok: false, - json: async () => ({ error: 'Server error' }), + describe('Queue-Only Toggle', () => { + it('should send queueOnly=true when the toggle is switched on', async () => { + const onUpdate = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', true); + }); + }); + + it('should send queueOnly=false when the toggle is switched off', async () => { + const onUpdate = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onUpdate).toHaveBeenCalledWith(true, 'next_pick', false); + }); + }); + + it('should disable the queue-only toggle when it is the user\'s turn', () => { + render(); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); + }); + + describe('API Integration', () => { + it('should post correct formData when switching to Next Pick', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/autodraft/update', + expect.objectContaining({ method: 'POST', body: expect.any(FormData) }) + ); }); - render(); - - const switchElement = screen.getByRole('switch'); - expect(switchElement).not.toBeChecked(); - - fireEvent.click(switchElement); + const formData = (global.fetch as any).mock.calls[0][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'); + expect(formData.get('queueOnly')).toBe('false'); + }); + + it('should post mode=while_on when switching to All Picks', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'All Picks' })); - // Should revert to original state on error await waitFor(() => { - expect(switchElement).not.toBeChecked(); + expect(global.fetch).toHaveBeenCalled(); + }); + + const formData = (global.fetch as any).mock.calls[0][1].body as FormData; + expect(formData.get('mode')).toBe('while_on'); + expect(formData.get('isEnabled')).toBe('true'); + }); + + it('should post isEnabled=false when switching to Off', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalled(); + }); + + const formData = (global.fetch as any).mock.calls[0][1].body as FormData; + expect(formData.get('isEnabled')).toBe('false'); + }); + + it('should disable buttons during an in-flight API call', async () => { + let resolve: any; + (global.fetch as any).mockReturnValueOnce(new Promise((r) => { resolve = r; })); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Off' })).toBeDisabled(); + }); + + resolve({ ok: true, json: async () => ({ success: true }) }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Off' })).not.toBeDisabled(); + }); + }); + + it('should only process the first click when buttons are clicked rapidly', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Next Pick' })); + fireEvent.click(screen.getByRole('button', { name: 'All Picks' })); + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx index 6a1fec3..6aef661 100644 --- a/app/components/draft/QueueSection.tsx +++ b/app/components/draft/QueueSection.tsx @@ -32,13 +32,16 @@ interface QueueSectionProps { seasonId: string; teamId: string; isMyTurn: boolean; + canPick: boolean; userAutodraft: { isEnabled: boolean; mode: "next_pick" | "while_on"; + queueOnly: boolean; }; onRemoveFromQueue: (queueId: string) => void; - onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; + onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => void; onReorder: (participantIds: string[]) => void; + onMakePick?: (participantId: string) => void; } // Sortable queue item component @@ -47,13 +50,17 @@ function SortableQueueItem({ index, participantName, sportName, + canPick, onRemove, + onDraft, }: { item: { id: string; participantId: string }; index: number; participantName: string; sportName?: string; + canPick: boolean; onRemove: () => void; + onDraft?: () => void; }) { const { attributes, @@ -74,10 +81,12 @@ function SortableQueueItem({
-
-
+
+
- {index + 1} -
-

{participantName}

+ {index + 1} +
+

{participantName}

{sportName &&

{sportName}

}
- +
+ {canPick && onDraft && ( + + )} + +
); } @@ -119,10 +140,12 @@ export function QueueSection({ seasonId, teamId, isMyTurn, + canPick, userAutodraft, onRemoveFromQueue, onAutodraftUpdate, onReorder, + onMakePick, }: QueueSectionProps) { const sensors = useSensors( useSensor(PointerSensor), @@ -149,7 +172,7 @@ export function QueueSection({ {/* Queue List */} {queue.length === 0 ? (

- Click participants to add to your queue + Click participants in Available to add to your queue

) : ( onRemoveFromQueue(item.id)} + onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined} /> ); })} @@ -188,6 +213,7 @@ export function QueueSection({ teamId={teamId} isEnabled={userAutodraft.isEnabled} mode={userAutodraft.mode} + queueOnly={userAutodraft.queueOnly} isMyTurn={isMyTurn} onUpdate={onAutodraftUpdate} /> diff --git a/app/models/__tests__/auto-pick.test.ts b/app/models/__tests__/auto-pick.test.ts new file mode 100644 index 0000000..f4229f2 --- /dev/null +++ b/app/models/__tests__/auto-pick.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { autoPickForTeam } from "../draft-utils"; + +// Mock all external model dependencies so we can control their return values +vi.mock("~/models/draft-pick", () => ({ + getDraftPicksWithSports: vi.fn(), + getTeamDraftPicksWithSports: vi.fn(), + isParticipantDrafted: vi.fn(), +})); + +vi.mock("~/models/participant", () => ({ + getParticipantsForSeasonWithSports: vi.fn(), +})); + +vi.mock("~/models/season-sport", () => ({ + getSeasonSportsSimple: vi.fn(), +})); + +vi.mock("~/models/draft-queue", () => ({ + getTeamQueue: vi.fn(), +})); + +vi.mock("~/lib/draft-eligibility", () => ({ + calculateDraftEligibility: vi.fn(), +})); + +// Prevent actual socket/db usage +vi.mock("~/server/socket"); +vi.mock("~/database/context"); + +import { + getDraftPicksWithSports, + getTeamDraftPicksWithSports, + isParticipantDrafted, +} from "~/models/draft-pick"; +import { getParticipantsForSeasonWithSports } from "~/models/participant"; +import { getSeasonSportsSimple } from "~/models/season-sport"; +import { getTeamQueue } from "~/models/draft-queue"; +import { calculateDraftEligibility } from "~/lib/draft-eligibility"; + +const SEASON_ID = "season-1"; +const TEAM_ID = "team-1"; +const DRAFT_ROUNDS = 10; +const ALL_TEAM_IDS = [TEAM_ID, "team-2"]; + +// A minimal mock db for cases that do NOT reach getTopAvailableParticipant. +// `where` resolves to [] so `await db.select()...where()` yields an array. +function makeMockDb(overrides: Record = {}) { + const mockDb: Record = { + query: { + participants: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + orderBy: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockReturnThis(), + ...overrides, + }; + return mockDb; +} + +// Build a mock db whose select chain returns a real EV participant. +// getTopAvailableParticipant makes three sequential awaitable calls: +// 1. select().from().where() → drafted picks → [] +// 2. select().from().innerJoin()×2.where() → season sports → [{sportsSeasonId, sportId}] +// 3. select().from().where().orderBy() → top participant → [{id, ...}] +function makeMockDbWithEvParticipant(participantId: string) { + return { + query: { participants: { findMany: vi.fn().mockResolvedValue([]) } }, + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + innerJoin: vi.fn().mockReturnThis(), + where: vi.fn() + .mockResolvedValueOnce([]) // call 1: drafted picks + .mockResolvedValueOnce([{ sportsSeasonId: "ss-1", sportId: "sport-1" }]) // call 2: season sports + .mockReturnThis(), // call 3: chain to orderBy + orderBy: vi.fn().mockResolvedValue([ + { id: participantId, name: "EV Player", expectedValue: "100.00" }, + ]), + delete: vi.fn().mockReturnThis(), + }; +} + +// Minimal eligibility stub — makes every sport eligible. +function stubEligibility() { + vi.mocked(calculateDraftEligibility).mockReturnValue({ + eligibleSportIds: new Set(["sport-1"]), + } as ReturnType); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]); + vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]); + vi.mocked(getSeasonSportsSimple).mockResolvedValue([]); + stubEligibility(); +}); + +describe("autoPickForTeam – queueOnly constraint (AC2 & AC3)", () => { + describe("empty queue", () => { + it("returns null when queueOnly=true (no EV fallback)", async () => { + vi.mocked(getTeamQueue).mockResolvedValue([]); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + makeMockDb() as any, + true // queueOnly + ); + + expect(result).toBeNull(); + }); + + it("returns the top EV participant when queueOnly=false (falls back to EV)", async () => { + vi.mocked(getTeamQueue).mockResolvedValue([]); + + const mockDb = makeMockDbWithEvParticipant("p-ev"); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + false // queueOnly off + ); + + // Must return the EV participant — proves the EV path ran, not the queueOnly guard. + expect(result).toBe("p-ev"); + }); + }); + + describe("all queue items already drafted", () => { + it("returns null when queueOnly=true and every queued participant is taken", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + // Both participants are already drafted + vi.mocked(isParticipantDrafted).mockResolvedValue(true); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true // queueOnly + ); + + expect(result).toBeNull(); + }); + + it("removes stale queue entries when all items are drafted", async () => { + const queue = [{ id: "q-1", participantId: "p-1", queuePosition: 1 }]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDelete = vi.fn().mockReturnThis(); + const mockWhere = vi.fn().mockResolvedValue(undefined); + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + delete: mockDelete, + where: mockWhere, + }); + + vi.mocked(isParticipantDrafted).mockResolvedValue(true); + + await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true + ); + + // Stale items should be cleaned from the queue + expect(mockDelete).toHaveBeenCalled(); + }); + }); + + describe("partial queue — first item drafted, second available", () => { + it("skips drafted item and returns the next valid participant", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + // p-1 is already drafted, p-2 is available + vi.mocked(isParticipantDrafted).mockImplementation( + async (_seasonId, participantId) => participantId === "p-1" + ); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true // queueOnly — still uses queue, just no EV fallback + ); + + expect(result).toBe("p-2"); + }); + + it("returns the first available participant when none are drafted (happy path)", async () => { + const queue = [ + { id: "q-1", participantId: "p-1", queuePosition: 1 }, + { id: "q-2", participantId: "p-2", queuePosition: 2 }, + ]; + vi.mocked(getTeamQueue).mockResolvedValue(queue as any); + + const mockDb = makeMockDb({ + query: { + participants: { + findMany: vi.fn().mockResolvedValue([ + { + id: "p-1", + name: "Player One", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + { + id: "p-2", + name: "Player Two", + sportsSeason: { sport: { id: "sport-1", name: "NFL" } }, + }, + ]), + }, + }, + }); + + vi.mocked(isParticipantDrafted).mockResolvedValue(false); + + const result = await autoPickForTeam( + SEASON_ID, + TEAM_ID, + DRAFT_ROUNDS, + ALL_TEAM_IDS, + mockDb as any, + true + ); + + expect(result).toBe("p-1"); + }); + }); +}); diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index a8c8957..520e1ad 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -82,7 +82,8 @@ export async function autoPickForTeam( teamId: string, draftRounds: number, allTeamIds: string[], - providedDb?: ReturnType + providedDb?: ReturnType, + queueOnly?: boolean ) { const db = providedDb || database(); @@ -186,6 +187,11 @@ export async function autoPickForTeam( } // Queue is empty or all queued players drafted/ineligible + if (queueOnly) { + console.log(`[AutoPick] No valid queue items and queueOnly constraint is active — will not fall back to highest EV`); + return null; + } + // Pick highest EV available from eligible sports console.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`); return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds, db); @@ -454,15 +460,40 @@ export async function executeAutoPick(params: { const allTeamIds = draftSlots.map((slot) => slot.teamId); // Use autoPickForTeam to select participant (respects eligibility and queue) + const queueOnly = autodraftSettings?.queueOnly ?? false; const participantId = await autoPickForTeam( seasonId, teamId, season.draftRounds, allTeamIds, - db + db, + queueOnly ); if (!participantId) { + // If queueOnly is set and queue is empty, disable autodraft and return success + // (timer will fire again and pick highest EV once autodraft is disabled) + if (triggeredBy === "timer" && queueOnly && autodraftSettings) { + console.log(`[AutoPick] Queue empty with queueOnly constraint — disabling autodraft for team ${teamId}`); + await db + .update(schema.autodraftSettings) + .set({ isEnabled: false, updatedAt: new Date() }) + .where(eq(schema.autodraftSettings.id, autodraftSettings.id)); + + try { + getSocketIO().to(`draft-${seasonId}`).emit("autodraft-updated", { + teamId, + isEnabled: false, + mode: autodraftSettings.mode, + queueOnly: autodraftSettings.queueOnly, + }); + } catch (error) { + console.error("[AutoPick] Socket.IO autodraft-updated error (queue-empty shutoff):", error); + } + + return { success: true }; + } + return { success: false, error: "No eligible participants available to pick", @@ -604,6 +635,7 @@ export async function executeAutoPick(params: { teamId, isEnabled: false, mode: autodraftSettings.mode, + queueOnly: autodraftSettings.queueOnly, }); } catch (error) { console.error("[AutoPick] Socket.IO autodraft-updated error:", error); diff --git a/app/routes/api/autodraft.update.ts b/app/routes/api/autodraft.update.ts index 5da956e..6bce918 100644 --- a/app/routes/api/autodraft.update.ts +++ b/app/routes/api/autodraft.update.ts @@ -18,6 +18,7 @@ export async function action(args: ActionFunctionArgs) { const teamId = formData.get("teamId") as string; const isEnabled = formData.get("isEnabled") === "true"; const mode = formData.get("mode") as "next_pick" | "while_on"; + const queueOnly = formData.get("queueOnly") === "true"; if (!seasonId || !teamId || !mode) { return Response.json({ error: "Missing required fields" }, { status: 400 }); @@ -50,6 +51,7 @@ export async function action(args: ActionFunctionArgs) { .set({ isEnabled, mode, + queueOnly, updatedAt: new Date(), }) .where(eq(schema.autodraftSettings.id, existingSettings.id)) @@ -63,6 +65,7 @@ export async function action(args: ActionFunctionArgs) { teamId, isEnabled, mode, + queueOnly, }) .returning(); } @@ -73,6 +76,7 @@ export async function action(args: ActionFunctionArgs) { teamId, isEnabled, mode, + queueOnly, }); return Response.json({ success: true, settings }); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 354a979..95b2738 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -26,18 +26,20 @@ import { useMediaQuery } from "~/hooks/useMediaQuery"; import { NotificationSettings } from "~/components/NotificationSettings"; import { toast } from "sonner"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; -import { Users, LayoutGrid, ListChecks, Settings } from "lucide-react"; +import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; import type { Route } from "./+types/$leagueId.draft.$seasonId"; type QueueItem = typeof schema.draftQueue.$inferSelect; -const MOBILE_TABS = [ - { id: "lobby" as const, label: "Lobby", Icon: Users }, +const MOBILE_TABS_BASE = [ + { id: "available" as const, label: "Available", Icon: Users }, { id: "board" as const, label: "Board", Icon: LayoutGrid }, { id: "roster" as const, label: "Roster", Icon: ListChecks }, { id: "controls" as const, label: "Controls", Icon: Settings }, ]; +const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered }; + export async function loader(args: Route.LoaderArgs) { const { params } = args; const { seasonId, leagueId } = params; @@ -247,6 +249,17 @@ export default function DraftRoom() { notificationsModeRef.current = notificationsMode; }, [notificationsMode]); + // Track current autodraft state for detecting server-initiated auto-disable + const [userAutodraft, setUserAutodraft] = useState({ + isEnabled: userAutodraftSettings?.isEnabled || false, + mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on", + queueOnly: userAutodraftSettings?.queueOnly || false, + }); + const userAutodraftRef = useRef(userAutodraft); + useEffect(() => { + userAutodraftRef.current = userAutodraft; + }, [userAutodraft]); + // Re-fetch loader data after each reconnect so stale picks/timers are refreshed useEffect(() => { if (reconnectCount > 0) { @@ -273,8 +286,8 @@ export default function DraftRoom() { return stored ? JSON.parse(stored) : false; }); const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants"); - const [mobileTab, setMobileTab] = useState<"lobby" | "board" | "roster" | "controls">( - !userTeam && isCommissioner ? "board" : "lobby" + const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "roster" | "controls">( + !userTeam && isCommissioner ? "board" : "available" ); const isMobile = useMediaQuery("(max-width: 767px)"); @@ -290,12 +303,6 @@ export default function DraftRoom() { // Track connected teams const [connectedTeams, setConnectedTeams] = useState>(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>(() => { // Initialize with timers from loader data, or use initial time if draft hasn't started const timersMap: Record = {}; @@ -483,7 +490,7 @@ export default function DraftRoom() { setIsDraftComplete(true); }; - const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on" }) => { + const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => { setAutodraftStatus((prev) => ({ ...prev, [data.teamId]: data.isEnabled, @@ -491,9 +498,16 @@ export default function DraftRoom() { // Update user's local state if it's their team if (userTeam && data.teamId === userTeam.id) { + // Detect server-side auto-disable: was enabled with queueOnly, now disabled + const prev = userAutodraftRef.current; + if (!data.isEnabled && prev.isEnabled && prev.queueOnly) { + toast.info("Autodraft disabled — your queue is empty"); + } + setUserAutodraft({ isEnabled: data.isEnabled, mode: data.mode, + queueOnly: data.queueOnly, }); } }; @@ -1128,12 +1142,14 @@ export default function DraftRoom() { seasonId: season.id, teamId: userTeam.id, isMyTurn, + canPick, userAutodraft, onRemoveFromQueue: handleRemoveFromQueue, - onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => { - setUserAutodraft({ isEnabled, mode }); + onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => { + setUserAutodraft({ isEnabled, mode, queueOnly }); }, onReorder: handleReorderQueue, + onMakePick: handleMakePick, } : null; @@ -1266,9 +1282,20 @@ export default function DraftRoom() {
{isMobile ? (
- {mobileTab === "lobby" && ( + {mobileTab === "available" && ( )} + {mobileTab === "queue" && ( +
+ {queueSectionProps ? ( + + ) : ( +

+ Join a team to manage your queue +

+ )} +
+ )} {mobileTab === "board" && ( )} @@ -1294,15 +1321,8 @@ export default function DraftRoom() { ) )} - {queueSectionProps && ( -
-

My Queue

- -
- )} -
-

Picks

+

Recent Picks

@@ -1408,32 +1428,41 @@ export default function DraftRoom() {
{/* Mobile Bottom Nav */} - + {(() => { + // Build tabs dynamically: insert Queue tab after Available only when user has a team + const mobileTabs = userTeam + ? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)] + : MOBILE_TABS_BASE; + const colCount = mobileTabs.length; + return ( + + ); + })()} {/* Force Manual Pick Dialog */} { teamId, isEnabled: true, mode: "next_pick", + queueOnly: false, createdAt: new Date(), updatedAt: new Date(), }; @@ -88,6 +89,7 @@ describe("Autodraft Settings API", () => { formData.append("teamId", teamId); formData.append("isEnabled", "true"); formData.append("mode", "next_pick"); + formData.append("queueOnly", "false"); const request = new Request("http://localhost/api/autodraft/update", { method: "POST", @@ -104,16 +106,15 @@ describe("Autodraft Settings API", () => { 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(data.settings.isEnabled).toBe(true); + expect(data.settings.mode).toBe("next_pick"); expect(mockDb.insert).toHaveBeenCalled(); expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`); expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", { teamId, isEnabled: true, mode: "next_pick", + queueOnly: false, }); }); @@ -122,32 +123,29 @@ describe("Autodraft Settings API", () => { 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", + queueOnly: false, createdAt: new Date(), updatedAt: new Date(), }; - mockDb.query.autodraftSettings.findFirst.mockResolvedValue( - existingSettings - ); + mockDb.query.autodraftSettings.findFirst.mockResolvedValue(existingSettings); - // Mock update returning updated settings const updatedSettings = { ...existingSettings, isEnabled: true, mode: "while_on", + queueOnly: false, updatedAt: new Date(), }; mockDb.returning.mockResolvedValue([updatedSettings]); @@ -157,6 +155,7 @@ describe("Autodraft Settings API", () => { formData.append("teamId", teamId); formData.append("isEnabled", "true"); formData.append("mode", "while_on"); + formData.append("queueOnly", "false"); const request = new Request("http://localhost/api/autodraft/update", { method: "POST", @@ -179,16 +178,69 @@ describe("Autodraft Settings API", () => { teamId, isEnabled: true, mode: "while_on", + queueOnly: false, + }); + }); + + it("should save queueOnly flag correctly", 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); + + const newSettings = { + id: "settings-1", + seasonId, + teamId, + isEnabled: true, + mode: "next_pick", + queueOnly: true, + 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"); + formData.append("queueOnly", "true"); + + const request = new Request("http://localhost/api/autodraft/update", { + method: "POST", + body: formData, + }); + + const response = await action({ + request, + params: {}, + context: ctx, + }); + + const data = await response.json(); + + expect(data.success).toBe(true); + expect(data.settings.queueOnly).toBe(true); + expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", { + teamId, + isEnabled: true, + mode: "next_pick", + queueOnly: true, }); }); 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, @@ -238,7 +290,7 @@ describe("Autodraft Settings API", () => { expect(data.error).toBe("Missing required fields"); }); - it("should handle both autodraft modes", async () => { + it("should handle all three autodraft states (Off, Next Pick, All Picks)", async () => { const seasonId = "season-123"; const teamId = "team-456"; const userId = "user-789"; @@ -251,77 +303,80 @@ describe("Autodraft Settings API", () => { mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null); - // Test next_pick mode - const nextPickSettings = { + // State 1: Off (isEnabled=false) + const offSettings = { id: "settings-1", seasonId, teamId, - isEnabled: true, + isEnabled: false, mode: "next_pick", + queueOnly: false, createdAt: new Date(), updatedAt: new Date(), }; - mockDb.returning.mockResolvedValueOnce([nextPickSettings]); + mockDb.returning.mockResolvedValueOnce([offSettings]); const formData1 = new FormData(); formData1.append("seasonId", seasonId); formData1.append("teamId", teamId); - formData1.append("isEnabled", "true"); + formData1.append("isEnabled", "false"); formData1.append("mode", "next_pick"); - - const request1 = new Request("http://localhost/api/autodraft/update", { - method: "POST", - body: formData1, - }); + formData1.append("queueOnly", "false"); const response1 = await action({ - request: request1, + request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData1 }), params: {}, context: ctx, }); - const data1 = await response1.json(); - expect(data1.settings.mode).toBe("next_pick"); + expect(data1.settings.isEnabled).toBe(false); - // 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]); + // State 2: Next Pick (isEnabled=true, mode=next_pick) + const nextPickSettings = { ...offSettings, id: "settings-2", isEnabled: true, mode: "next_pick" }; + mockDb.returning.mockResolvedValueOnce([nextPickSettings]); 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, - }); + formData2.append("mode", "next_pick"); + formData2.append("queueOnly", "false"); const response2 = await action({ - request: request2, + request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData2 }), params: {}, context: ctx, }); - const data2 = await response2.json(); - expect(data2.settings.mode).toBe("while_on"); + expect(data2.settings.isEnabled).toBe(true); + expect(data2.settings.mode).toBe("next_pick"); + + // State 3: All Picks (isEnabled=true, mode=while_on) + const allPicksSettings = { ...offSettings, id: "settings-3", isEnabled: true, mode: "while_on" }; + mockDb.returning.mockResolvedValueOnce([allPicksSettings]); + + const formData3 = new FormData(); + formData3.append("seasonId", seasonId); + formData3.append("teamId", teamId); + formData3.append("isEnabled", "true"); + formData3.append("mode", "while_on"); + formData3.append("queueOnly", "false"); + + const response3 = await action({ + request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData3 }), + params: {}, + context: ctx, + }); + const data3 = await response3.json(); + expect(data3.settings.isEnabled).toBe(true); + expect(data3.settings.mode).toBe("while_on"); }); - it("should emit socket event to correct room", async () => { + it("should emit socket event to correct room with queueOnly included", 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); @@ -333,30 +388,26 @@ describe("Autodraft Settings API", () => { mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null); - const newSettings = { + mockDb.returning.mockResolvedValue([{ id: "settings-1", seasonId, teamId, isEnabled: true, mode: "next_pick", + queueOnly: true, 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, - }); + formData.append("queueOnly", "true"); await action({ - request, + request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData }), params: {}, context: ctx, }); @@ -366,6 +417,7 @@ describe("Autodraft Settings API", () => { teamId, isEnabled: true, mode: "next_pick", + queueOnly: true, }); }); }); diff --git a/database/schema.ts b/database/schema.ts index cce65b0..a6cb9fb 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -207,6 +207,7 @@ export const autodraftSettings = pgTable("autodraft_settings", { .references(() => teams.id, { onDelete: "cascade" }), isEnabled: boolean("is_enabled").notNull().default(false), mode: autodraftModeEnum("mode").notNull().default("next_pick"), + queueOnly: boolean("queue_only").notNull().default(false), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); diff --git a/drizzle/0031_add_autodraft_queue_only.sql b/drizzle/0031_add_autodraft_queue_only.sql new file mode 100644 index 0000000..455612c --- /dev/null +++ b/drizzle/0031_add_autodraft_queue_only.sql @@ -0,0 +1,2 @@ +-- Add queue_only column to autodraft_settings to support the "Only autodraft from my queue" constraint +ALTER TABLE "autodraft_settings" ADD COLUMN "queue_only" boolean NOT NULL DEFAULT false; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5e54837..5264c3a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1763282000000, "tag": "0030_add_draft_picks_unique_slot", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1763283000000, + "tag": "0031_add_autodraft_queue_only", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/__tests__/timer-autodraft.test.ts b/server/__tests__/timer-autodraft.test.ts index 9777801..afd66c2 100644 --- a/server/__tests__/timer-autodraft.test.ts +++ b/server/__tests__/timer-autodraft.test.ts @@ -25,32 +25,14 @@ beforeEach(() => { 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(), - }, + 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(), @@ -73,123 +55,34 @@ describe('Timer Autodraft Integration', () => { 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.seasons.findMany.mockResolvedValue([{ + id: seasonId, status: 'draft', draftPaused: false, + currentPickNumber: 1, draftInitialTime: 120, draftIncrementTime: 30, draftRounds: 10, + }]); 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.draftTimers.findFirst.mockResolvedValue({ id: 'timer-1', seasonId, teamId, timeRemaining: 0 }); mockDb.query.autodraftSettings.findFirst.mockResolvedValue({ - id: 'settings-1', - seasonId, - teamId, - isEnabled: true, - mode: 'next_pick', + id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: false, }); - // 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', + id: 'settings-1', seasonId, teamId, isEnabled: false, mode: 'next_pick', queueOnly: false, }); - 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(); }); @@ -199,49 +92,20 @@ describe('Timer Autodraft Integration', () => { 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', queueOnly: false }; + mockDb.returning.mockResolvedValue([{ ...autodraftSettings, isEnabled: false, updatedAt: new Date() }]); - 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 + it('should keep autodraft enabled when mode is while_on (All Picks)', async () => { + const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false }; if (autodraftSettings.mode === 'while_on') { expect(autodraftSettings.isEnabled).toBe(true); } @@ -250,297 +114,228 @@ describe('Timer Autodraft Integration', () => { 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', - }, - }, + { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 }, + { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 }, ]); - - // 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) - ); + 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 + it('should skip drafted participants in queue and try next', async () => { 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', - }, + { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 }, + { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 }, ]); + 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) - ); + 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 + it('should fall back to highest EV when queue is empty and queueOnly is OFF', async () => { 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, - }, + { id: 'participant-high-ev', name: 'High EV Participant', expectedValue: 1000 }, ]); const queueItems = await mockDb.query.draftQueue.findMany(); expect(queueItems.length).toBe(0); + const queueOnly = false; const availableParticipants = await mockDb.query.participants.findMany(); - expect(availableParticipants[0].expectedValue).toBe(1000); + const selectedParticipant = queueOnly ? null : availableParticipants[0]; + expect(selectedParticipant).not.toBeNull(); + expect(selectedParticipant?.expectedValue).toBe(1000); + }); + }); + + describe('Queue-Only Constraint (AC2 & AC3)', () => { + it('should NOT fall back to highest EV when queueOnly is ON and queue is empty', async () => { + mockDb.query.draftQueue.findMany.mockResolvedValue([]); + const queueItems = await mockDb.query.draftQueue.findMany(); + expect(queueItems.length).toBe(0); + + const queueOnly = true; + const selectedParticipant = queueOnly ? null : { id: 'participant-high-ev' }; + expect(selectedParticipant).toBeNull(); + }); + + it('should disable autodraft when queueOnly is ON and queue becomes empty (AC3)', async () => { + const seasonId = 'season-123'; + const teamId = 'team-456'; + const autodraftSettings = { id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: true }; + + mockDb.returning.mockResolvedValue([{ ...autodraftSettings, isEnabled: false, updatedAt: new Date() }]); + + const participantId = null; // empty queue + queueOnly + if (!participantId && autodraftSettings.queueOnly) { + 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); + + mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', { + teamId, isEnabled: false, mode: autodraftSettings.mode, queueOnly: autodraftSettings.queueOnly, + }); + expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', { + teamId, isEnabled: false, mode: 'next_pick', queueOnly: true, + }); + } + }); + + it('should skip drafted queue items and continue to next available item', async () => { + const queue = [ + { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 }, + { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 }, + ]; + const drafted = new Set(['participant-1']); + const availableQueueItem = queue.find((item) => !drafted.has(item.participantId)); + expect(availableQueueItem?.participantId).toBe('participant-2'); + }); + + it('should auto-disable when all queue items are drafted and queueOnly is ON', async () => { + const queue = [ + { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 }, + { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 }, + ]; + const drafted = new Set(['participant-1', 'participant-2']); + const queueOnly = true; + const availableQueueItem = queue.find((item) => !drafted.has(item.participantId)); + + expect(availableQueueItem).toBeUndefined(); + const selectedParticipant = queueOnly ? null : { id: 'fallback-ev' }; + expect(selectedParticipant).toBeNull(); + }); + }); + + describe('Three-State Autodraft (AC1)', () => { + it('should map Off state to isEnabled=false', () => { + const offState = { isEnabled: false, mode: 'next_pick', queueOnly: false }; + expect(offState.isEnabled).toBe(false); + }); + + it('should map Next Pick state to isEnabled=true, mode=next_pick', () => { + const nextPickState = { isEnabled: true, mode: 'next_pick', queueOnly: false }; + expect(nextPickState.isEnabled).toBe(true); + expect(nextPickState.mode).toBe('next_pick'); + }); + + it('should map All Picks state to isEnabled=true, mode=while_on', () => { + const allPicksState = { isEnabled: true, mode: 'while_on', queueOnly: false }; + expect(allPicksState.isEnabled).toBe(true); + expect(allPicksState.mode).toBe('while_on'); + }); + + it('should transition Off → Next Pick correctly', () => { + const initial = { isEnabled: false, mode: 'next_pick' }; + const updated = { isEnabled: true, mode: 'next_pick' }; + expect(updated.isEnabled).not.toBe(initial.isEnabled); + expect(updated.mode).toBe('next_pick'); + }); + + it('should transition Next Pick → All Picks correctly', () => { + const nextPick = { isEnabled: true, mode: 'next_pick' }; + const allPicks = { ...nextPick, mode: 'while_on' }; + expect(allPicks.mode).toBe('while_on'); + expect(allPicks.isEnabled).toBe(true); + }); + + it('should auto-disable if queue is empty and queueOnly is ON (AC3)', () => { + const settings = { isEnabled: true, mode: 'next_pick', queueOnly: true }; + const queue: any[] = []; + const shouldAutoDisable = settings.queueOnly && queue.length === 0; + const finalSettings = shouldAutoDisable ? { ...settings, isEnabled: false } : settings; + expect(finalSettings.isEnabled).toBe(false); + }); + + it('should NOT auto-disable if queue is empty and queueOnly is OFF', () => { + const settings = { isEnabled: true, mode: 'next_pick', queueOnly: false }; + const queue: any[] = []; + const shouldAutoDisable = settings.queueOnly && queue.length === 0; + const finalSettings = shouldAutoDisable ? { ...settings, isEnabled: false } : settings; + expect(finalSettings.isEnabled).toBe(true); }); }); describe('Socket Event Emission', () => { - it('should emit autodraft-updated event when disabling next_pick mode', async () => { + it('should emit autodraft-updated with queueOnly 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', + teamId, isEnabled: false, mode: 'next_pick', queueOnly: false, }); - - expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`); expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', { - teamId, - isEnabled: false, - mode: 'next_pick', + teamId, isEnabled: false, mode: 'next_pick', queueOnly: false, }); }); 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, - }; - + 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); }); + + it('should emit autodraft-updated with queueOnly preserved on auto-disable (AC3)', async () => { + const seasonId = 'season-123'; + const teamId = 'team-456'; + mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', { + teamId, isEnabled: false, mode: 'next_pick', queueOnly: true, + }); + expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', { + teamId, isEnabled: false, mode: 'next_pick', queueOnly: true, + }); + }); }); describe('Back-to-Back Picks', () => { - it('should only autodraft first pick when mode is next_pick and team has consecutive picks', async () => { + it('should only autodraft first 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', queueOnly: false }; - // 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, - }; - + 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, - }; - + 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 + it('should continue autodrafting consecutive picks when mode is while_on (All Picks)', async () => { + const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false }; + if (autodraftSettings.mode === 'while_on') expect(autodraftSettings.isEnabled).toBe(true); 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 add increment time after autodraft pick', () => { + expect(0 + 30).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, + seasonId, teamId, timeRemaining: 30, currentPickNumber: 1, }); - expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', { - seasonId, - teamId, - timeRemaining: 30, - currentPickNumber: 1, + seasonId, teamId, timeRemaining: 30, currentPickNumber: 1, }); }); }); diff --git a/server/socket.ts b/server/socket.ts index 1d9b4d5..3f1e67e 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -38,6 +38,7 @@ interface ServerToClientEvents { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; + queueOnly: boolean; }) => void; "team-connected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void;