Claude/redesign autodraft queue c4 kp r (#40)

* Redesign autodraft queue system with three-state control and queue-only constraint

Core Logic & Database:
- Add `queue_only` boolean column to `autodraft_settings` (migration 0031)
- Rename autodraft UI states: Off / Next Pick / All Picks (while_on mode maps to All Picks)
- `autoPickForTeam`: respects new `queueOnly` param — skips EV fallback when enabled
- `executeAutoPick`: auto-disables autodraft + emits socket event when queue empties with queueOnly ON (AC3)
- `autodraft-updated` socket event now includes `queueOnly` field

Mobile UI Overhaul:
- Rename "Lobby" tab → "Available" (AC6)
- Add new "Queue" tab to mobile bottom nav with drag-reorder, per-item Draft buttons, and autodraft controls (AC5)
- Controls tab retains commissioner tools, notifications, exit; queue controls moved to Queue tab
- Turn indicator appears on both Available and Queue tabs

Components:
- `AutodraftSettings`: replaces toggle+radio with three-state button group (Off | Next Pick | All Picks) + "Only autodraft from queue" switch (AC1, AC2)
- `QueueSection`: adds `canPick` prop + per-item Draft buttons for instant drafting when on the clock

Desktop (AC4):
- Sidebar QueueSection unchanged in position; gains same three-state controls and Draft buttons

Tests (AC7):
- `autodraft.test.ts`: updated for queueOnly field and socket event shape
- `timer-autodraft.test.ts`: new tests for queue-only constraint, auto-shutoff transitions, and all three autodraft states

https://claude.ai/code/session_01PYhJicAStoJ2u6q6dV1naB

* fix: remove erroneous ?? fallbacks in autodraft socket emissions and add autoPickForTeam tests

- Remove `?? true` default on queueOnly in queue-empty auto-disable socket emit
  (line 488) — was dead code since the column is NOT NULL, but semantically wrong
  and would have caused client-side UI desync if the type ever relaxed
- Remove `?? false` default on the next_pick auto-disable path for consistency
- Add app/models/__tests__/auto-pick.test.ts with 6 tests covering the queueOnly
  constraint: empty queue, all items drafted, partial queue skip, and EV fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add missing queueOnly prop to AutodraftSettings test fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: rewrite AutodraftSettings tests for three-state button group UI

The component was redesigned from a switch + radio buttons to Off/Next Pick/All Picks
buttons with a separate queue-only Switch toggle. Updated 17 stale tests and added 5
new tests covering the queue-only toggle and the All Picks/Off button interactions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-02-27 22:16:26 -08:00 committed by GitHub
parent 364b05cd96
commit 1ba50828f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1028 additions and 775 deletions

View file

@ -1,15 +1,29 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Switch } from "~/components/ui/switch"; import { Switch } from "~/components/ui/switch";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { Label } from "~/components/ui/label"; 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 { interface AutodraftSettingsProps {
seasonId: string; seasonId: string;
teamId: string; teamId: string;
isEnabled: boolean; isEnabled: boolean;
mode: "next_pick" | "while_on"; mode: AutodraftMode;
queueOnly: boolean;
isMyTurn: boolean; isMyTurn: boolean;
onUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; onUpdate: (isEnabled: boolean, mode: AutodraftMode, queueOnly: boolean) => void;
} }
export function AutodraftSettings({ export function AutodraftSettings({
@ -17,34 +31,37 @@ export function AutodraftSettings({
teamId, teamId,
isEnabled, isEnabled,
mode, mode,
queueOnly,
isMyTurn, isMyTurn,
onUpdate, onUpdate,
}: AutodraftSettingsProps) { }: AutodraftSettingsProps) {
const [localEnabled, setLocalEnabled] = useState(isEnabled); const [localState, setLocalState] = useState<AutodraftState>(toAutodraftState(isEnabled, mode));
const [localMode, setLocalMode] = useState<"next_pick" | "while_on">(mode); const [localQueueOnly, setLocalQueueOnly] = useState(queueOnly);
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
// Sync local state with props when they change (from socket events) // Sync local state with props when they change (from socket events)
useEffect(() => { useEffect(() => {
setLocalEnabled(isEnabled); setLocalState(toAutodraftState(isEnabled, mode));
}, [isEnabled]); }, [isEnabled, mode]);
useEffect(() => { useEffect(() => {
setLocalMode(mode); setLocalQueueOnly(queueOnly);
}, [mode]); }, [queueOnly]);
const handleToggle = async (checked: boolean) => { const sendUpdate = async (newState: AutodraftState, newQueueOnly: boolean) => {
if (isUpdating) return; // Prevent multiple simultaneous updates if (isUpdating) return;
setLocalEnabled(checked);
setIsUpdating(true); setIsUpdating(true);
const newEnabled = newState !== "off";
const newMode: AutodraftMode = newState === "all_picks" ? "while_on" : "next_pick";
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append("seasonId", seasonId); formData.append("seasonId", seasonId);
formData.append("teamId", teamId); formData.append("teamId", teamId);
formData.append("isEnabled", checked.toString()); formData.append("isEnabled", newEnabled.toString());
formData.append("mode", localMode); formData.append("mode", newMode);
formData.append("queueOnly", newQueueOnly.toString());
const response = await fetch("/api/autodraft/update", { const response = await fetch("/api/autodraft/update", {
method: "POST", method: "POST",
@ -52,96 +69,84 @@ export function AutodraftSettings({
}); });
if (response.ok) { if (response.ok) {
onUpdate(checked, localMode); onUpdate(newEnabled, newMode, newQueueOnly);
} else { } else {
// Revert on error // Revert on error
setLocalEnabled(!checked); setLocalState(toAutodraftState(isEnabled, mode));
setLocalQueueOnly(queueOnly);
console.error("Failed to update autodraft settings"); console.error("Failed to update autodraft settings");
} }
} catch (error) { } catch (error) {
// Revert on error // Revert on error
setLocalEnabled(!checked); setLocalState(toAutodraftState(isEnabled, mode));
setLocalQueueOnly(queueOnly);
console.error("Error updating autodraft settings:", error); console.error("Error updating autodraft settings:", error);
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
}; };
const handleModeChange = async (newMode: "next_pick" | "while_on") => { const handleStateChange = (newState: AutodraftState) => {
if (isUpdating) return; // Prevent multiple simultaneous updates if (isMyTurn || isUpdating) return;
setLocalState(newState);
const previousMode = localMode; sendUpdate(newState, localQueueOnly);
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 handleQueueOnlyChange = async (checked: boolean) => {
if (isMyTurn || isUpdating) return;
setLocalQueueOnly(checked);
sendUpdate(localState, checked);
};
const isDisabled = isMyTurn || isUpdating;
return ( return (
<div className="border-t pt-4 mt-4"> <div className="border-t pt-4 mt-4">
<div className="flex items-center justify-between mb-3"> <Label className="text-sm font-semibold mb-3 block">Autodraft</Label>
<Label htmlFor="autodraft-switch" className="text-sm font-semibold">
Autodraft {/* Three-state button group */}
</Label> <div className="flex rounded-lg border overflow-hidden mb-3">
<Switch {(["off", "next_pick", "all_picks"] as AutodraftState[]).map((state) => {
id="autodraft-switch" const labels: Record<AutodraftState, string> = {
checked={localEnabled} off: "Off",
onCheckedChange={handleToggle} next_pick: "Next Pick",
disabled={isMyTurn || isUpdating} all_picks: "All Picks",
/> };
const isActive = localState === state;
return (
<button
key={state}
type="button"
disabled={isDisabled}
onClick={() => handleStateChange(state)}
className={`flex-1 py-1.5 text-xs font-medium transition-colors ${
isActive
? "bg-electric text-background"
: "bg-transparent text-muted-foreground hover:bg-muted/60"
} ${isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
{labels[state]}
</button>
);
})}
</div> </div>
{localEnabled && ( {/* Queue-only toggle — only visible when autodraft is active */}
<RadioGroup {localState !== "off" && (
value={localMode} <div className="flex items-center justify-between">
onValueChange={(value) => handleModeChange(value as "next_pick" | "while_on")} <Label
disabled={isMyTurn || isUpdating} htmlFor="queue-only-switch"
className="space-y-2" className={`text-xs ${isDisabled ? "text-muted-foreground" : "cursor-pointer"}`}
> >
<div className="flex items-center space-x-2"> Only autodraft from queue
<RadioGroupItem value="next_pick" id="next_pick" disabled={isMyTurn || isUpdating} /> </Label>
<Label <Switch
htmlFor="next_pick" id="queue-only-switch"
className={`text-sm ${isMyTurn || isUpdating ? "text-muted-foreground" : "cursor-pointer"}`} checked={localQueueOnly}
> onCheckedChange={handleQueueOnlyChange}
Next Pick disabled={isDisabled}
</Label> />
</div> </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 && ( {isMyTurn && (

View file

@ -12,6 +12,7 @@ describe('AutodraftSettings Component', () => {
isEnabled: false, isEnabled: false,
mode: 'next_pick' as const, mode: 'next_pick' as const,
isMyTurn: false, isMyTurn: false,
queueOnly: false,
onUpdate: vi.fn(), onUpdate: vi.fn(),
}; };
@ -24,239 +25,142 @@ describe('AutodraftSettings Component', () => {
}); });
describe('Rendering', () => { describe('Rendering', () => {
it('should render the autodraft switch', () => { it('should render three autodraft state buttons', () => {
render(<AutodraftSettings {...defaultProps} />); render(<AutodraftSettings {...defaultProps} />);
expect(screen.getByText('Autodraft')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Off' })).toBeInTheDocument();
expect(screen.getByRole('switch')).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(<AutodraftSettings {...defaultProps} isEnabled={false} />); render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
expect(screen.queryByText('Next Pick')).not.toBeInTheDocument(); expect(screen.queryByRole('switch')).not.toBeInTheDocument();
expect(screen.queryByText('While On')).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(<AutodraftSettings {...defaultProps} isEnabled={true} />); render(<AutodraftSettings {...defaultProps} isEnabled={true} />);
expect(screen.getByText('Next Pick')).toBeInTheDocument(); expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText('While On')).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(<AutodraftSettings {...defaultProps} isMyTurn={true} />); render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
expect(screen.getByText(/Autodraft settings are disabled during your pick/i)).toBeInTheDocument(); expect(screen.getByText(/Autodraft settings are disabled during your pick/i)).toBeInTheDocument();
}); });
it('should mark Off as active when isEnabled=false', () => {
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
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(<AutodraftSettings {...defaultProps} isEnabled={true} mode="next_pick" />);
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(<AutodraftSettings {...defaultProps} isEnabled={true} mode="while_on" />);
const allPicksButton = screen.getByRole('button', { name: 'All Picks' });
expect(allPicksButton.className).toContain('bg-electric');
});
}); });
describe('Switch Toggle', () => { describe('Button Group Interaction', () => {
it('should enable autodraft when switch is toggled on', async () => { it('should switch to Next Pick when that button is clicked', async () => {
const onUpdate = vi.fn(); const onUpdate = vi.fn();
render(<AutodraftSettings {...defaultProps} onUpdate={onUpdate} />); render(<AutodraftSettings {...defaultProps} isEnabled={false} onUpdate={onUpdate} />);
const switchElement = screen.getByRole('switch'); fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
fireEvent.click(switchElement);
await waitFor(() => { await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith( expect(global.fetch).toHaveBeenCalledWith(
'/api/autodraft/update', '/api/autodraft/update',
expect.objectContaining({ expect.objectContaining({ method: 'POST' })
method: 'POST',
})
); );
}); });
await waitFor(() => { 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(<AutodraftSettings {...defaultProps} isEnabled={false} onUpdate={onUpdate} />);
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(); const onUpdate = vi.fn();
render(<AutodraftSettings {...defaultProps} isEnabled={true} onUpdate={onUpdate} />); render(<AutodraftSettings {...defaultProps} isEnabled={true} onUpdate={onUpdate} />);
const switchElement = screen.getByRole('switch'); fireEvent.click(screen.getByRole('button', { name: 'Off' }));
fireEvent.click(switchElement);
await waitFor(() => { await waitFor(() => {
expect(global.fetch).toHaveBeenCalled(); expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick', false);
});
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith(false, 'next_pick');
}); });
}); });
it('should be disabled when it is user turn', () => { it('should disable all buttons when it is the user\'s turn', () => {
render(<AutodraftSettings {...defaultProps} isMyTurn={true} />); render(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
const switchElement = screen.getByRole('switch'); expect(screen.getByRole('button', { name: 'Off' })).toBeDisabled();
expect(switchElement).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(<AutodraftSettings {...defaultProps} isMyTurn={true} />);
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(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
(global.fetch as any).mockResolvedValueOnce({ (global.fetch as any).mockResolvedValueOnce({
ok: false, ok: false,
json: async () => ({ error: 'Server error' }), json: async () => ({ error: 'Server error' }),
}); });
render(<AutodraftSettings {...defaultProps} />); render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
const switchElement = screen.getByRole('switch'); fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
fireEvent.click(switchElement);
await waitFor(() => { await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalled();
}); });
// Switch should revert to original state // Off button should still be active (reverted)
expect(switchElement).not.toBeChecked(); const offButton = screen.getByRole('button', { name: 'Off' });
expect(offButton.className).toContain('bg-electric');
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
});
describe('Mode Selection', () => { it('should handle network errors by reverting state', async () => {
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(() => {}); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
(global.fetch as any).mockRejectedValueOnce(new Error('Network error')); (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
render(<AutodraftSettings {...defaultProps} />); render(<AutodraftSettings {...defaultProps} />);
const switchElement = screen.getByRole('switch'); fireEvent.click(screen.getByRole('button', { name: 'Next Pick' }));
fireEvent.click(switchElement);
await waitFor(() => { await waitFor(() => {
expect(consoleSpy).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalled();
@ -264,23 +168,114 @@ describe('AutodraftSettings Component', () => {
consoleSpy.mockRestore(); consoleSpy.mockRestore();
}); });
});
it('should maintain state consistency on error', async () => { describe('Queue-Only Toggle', () => {
(global.fetch as any).mockResolvedValueOnce({ it('should send queueOnly=true when the toggle is switched on', async () => {
ok: false, const onUpdate = vi.fn();
json: async () => ({ error: 'Server error' }), render(<AutodraftSettings {...defaultProps} isEnabled={true} queueOnly={false} onUpdate={onUpdate} />);
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(<AutodraftSettings {...defaultProps} isEnabled={true} queueOnly={true} onUpdate={onUpdate} />);
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(<AutodraftSettings {...defaultProps} isEnabled={true} isMyTurn={true} />);
expect(screen.getByRole('switch')).toBeDisabled();
});
});
describe('API Integration', () => {
it('should post correct formData when switching to Next Pick', async () => {
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
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(<AutodraftSettings {...defaultProps} isEnabled={false} />); const formData = (global.fetch as any).mock.calls[0][1].body as FormData;
expect(formData.get('seasonId')).toBe('season-123');
const switchElement = screen.getByRole('switch'); expect(formData.get('teamId')).toBe('team-456');
expect(switchElement).not.toBeChecked(); expect(formData.get('isEnabled')).toBe('true');
expect(formData.get('mode')).toBe('next_pick');
fireEvent.click(switchElement); expect(formData.get('queueOnly')).toBe('false');
});
it('should post mode=while_on when switching to All Picks', async () => {
render(<AutodraftSettings {...defaultProps} isEnabled={false} />);
fireEvent.click(screen.getByRole('button', { name: 'All Picks' }));
// Should revert to original state on error
await waitFor(() => { 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(<AutodraftSettings {...defaultProps} isEnabled={true} />);
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(<AutodraftSettings {...defaultProps} />);
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(<AutodraftSettings {...defaultProps} />);
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);
}); });
}); });
}); });

View file

@ -32,13 +32,16 @@ interface QueueSectionProps {
seasonId: string; seasonId: string;
teamId: string; teamId: string;
isMyTurn: boolean; isMyTurn: boolean;
canPick: boolean;
userAutodraft: { userAutodraft: {
isEnabled: boolean; isEnabled: boolean;
mode: "next_pick" | "while_on"; mode: "next_pick" | "while_on";
queueOnly: boolean;
}; };
onRemoveFromQueue: (queueId: string) => void; 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; onReorder: (participantIds: string[]) => void;
onMakePick?: (participantId: string) => void;
} }
// Sortable queue item component // Sortable queue item component
@ -47,13 +50,17 @@ function SortableQueueItem({
index, index,
participantName, participantName,
sportName, sportName,
canPick,
onRemove, onRemove,
onDraft,
}: { }: {
item: { id: string; participantId: string }; item: { id: string; participantId: string };
index: number; index: number;
participantName: string; participantName: string;
sportName?: string; sportName?: string;
canPick: boolean;
onRemove: () => void; onRemove: () => void;
onDraft?: () => void;
}) { }) {
const { const {
attributes, attributes,
@ -74,10 +81,12 @@ function SortableQueueItem({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className="flex items-center justify-between p-2 bg-muted rounded-lg touch-none" className={`flex items-center justify-between p-2 rounded-lg touch-none ${
canPick ? "bg-electric/10 border border-electric/40" : "bg-muted"
}`}
> >
<div className="flex items-center gap-2 flex-1" {...attributes} {...listeners}> <div className="flex items-center gap-2 flex-1 min-w-0" {...attributes} {...listeners}>
<div className="cursor-grab active:cursor-grabbing"> <div className="cursor-grab active:cursor-grabbing flex-shrink-0">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="16" width="16"
@ -94,21 +103,33 @@ function SortableQueueItem({
<line x1="5" y1="15" x2="19" y2="15"></line> <line x1="5" y1="15" x2="19" y2="15"></line>
</svg> </svg>
</div> </div>
<Badge variant="default" className="text-xs">{index + 1}</Badge> <Badge variant="default" className="text-xs flex-shrink-0">{index + 1}</Badge>
<div> <div className="min-w-0">
<p className="font-semibold text-sm">{participantName}</p> <p className="font-semibold text-sm truncate">{participantName}</p>
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>} {sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
</div> </div>
</div> </div>
<Button <div className="flex items-center gap-1 flex-shrink-0 ml-2">
variant="ghost" {canPick && onDraft && (
size="icon" <Button
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10" variant="default"
onClick={onRemove} size="sm"
title="Remove from queue" className="h-7 text-xs bg-electric text-background hover:bg-electric/90"
> onClick={onDraft}
<span className="text-lg">×</span> >
</Button> Draft
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onRemove}
title="Remove from queue"
>
<span className="text-lg">×</span>
</Button>
</div>
</div> </div>
); );
} }
@ -119,10 +140,12 @@ export function QueueSection({
seasonId, seasonId,
teamId, teamId,
isMyTurn, isMyTurn,
canPick,
userAutodraft, userAutodraft,
onRemoveFromQueue, onRemoveFromQueue,
onAutodraftUpdate, onAutodraftUpdate,
onReorder, onReorder,
onMakePick,
}: QueueSectionProps) { }: QueueSectionProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -149,7 +172,7 @@ export function QueueSection({
{/* Queue List */} {/* Queue List */}
{queue.length === 0 ? ( {queue.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8"> <p className="text-muted-foreground text-sm text-center py-8">
Click participants to add to your queue Click participants in Available to add to your queue
</p> </p>
) : ( ) : (
<DndContext <DndContext
@ -173,7 +196,9 @@ export function QueueSection({
index={index} index={index}
participantName={participant?.name || "Unknown"} participantName={participant?.name || "Unknown"}
sportName={participant?.sport.name} sportName={participant?.sport.name}
canPick={canPick}
onRemove={() => onRemoveFromQueue(item.id)} onRemove={() => onRemoveFromQueue(item.id)}
onDraft={onMakePick ? () => onMakePick(item.participantId) : undefined}
/> />
); );
})} })}
@ -188,6 +213,7 @@ export function QueueSection({
teamId={teamId} teamId={teamId}
isEnabled={userAutodraft.isEnabled} isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode} mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn} isMyTurn={isMyTurn}
onUpdate={onAutodraftUpdate} onUpdate={onAutodraftUpdate}
/> />

View file

@ -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<string, unknown> = {}) {
const mockDb: Record<string, unknown> = {
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<typeof calculateDraftEligibility>);
}
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");
});
});
});

View file

@ -82,7 +82,8 @@ export async function autoPickForTeam(
teamId: string, teamId: string,
draftRounds: number, draftRounds: number,
allTeamIds: string[], allTeamIds: string[],
providedDb?: ReturnType<typeof database> providedDb?: ReturnType<typeof database>,
queueOnly?: boolean
) { ) {
const db = providedDb || database(); const db = providedDb || database();
@ -186,6 +187,11 @@ export async function autoPickForTeam(
} }
// Queue is empty or all queued players drafted/ineligible // 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 // Pick highest EV available from eligible sports
console.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`); console.log(`[AutoPick] No valid queue items, selecting highest EV from eligible sports`);
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds, db); return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds, db);
@ -454,15 +460,40 @@ export async function executeAutoPick(params: {
const allTeamIds = draftSlots.map((slot) => slot.teamId); const allTeamIds = draftSlots.map((slot) => slot.teamId);
// Use autoPickForTeam to select participant (respects eligibility and queue) // Use autoPickForTeam to select participant (respects eligibility and queue)
const queueOnly = autodraftSettings?.queueOnly ?? false;
const participantId = await autoPickForTeam( const participantId = await autoPickForTeam(
seasonId, seasonId,
teamId, teamId,
season.draftRounds, season.draftRounds,
allTeamIds, allTeamIds,
db db,
queueOnly
); );
if (!participantId) { 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 { return {
success: false, success: false,
error: "No eligible participants available to pick", error: "No eligible participants available to pick",
@ -604,6 +635,7 @@ export async function executeAutoPick(params: {
teamId, teamId,
isEnabled: false, isEnabled: false,
mode: autodraftSettings.mode, mode: autodraftSettings.mode,
queueOnly: autodraftSettings.queueOnly,
}); });
} catch (error) { } catch (error) {
console.error("[AutoPick] Socket.IO autodraft-updated error:", error); console.error("[AutoPick] Socket.IO autodraft-updated error:", error);

View file

@ -18,6 +18,7 @@ export async function action(args: ActionFunctionArgs) {
const teamId = formData.get("teamId") as string; const teamId = formData.get("teamId") as string;
const isEnabled = formData.get("isEnabled") === "true"; const isEnabled = formData.get("isEnabled") === "true";
const mode = formData.get("mode") as "next_pick" | "while_on"; const mode = formData.get("mode") as "next_pick" | "while_on";
const queueOnly = formData.get("queueOnly") === "true";
if (!seasonId || !teamId || !mode) { if (!seasonId || !teamId || !mode) {
return Response.json({ error: "Missing required fields" }, { status: 400 }); return Response.json({ error: "Missing required fields" }, { status: 400 });
@ -50,6 +51,7 @@ export async function action(args: ActionFunctionArgs) {
.set({ .set({
isEnabled, isEnabled,
mode, mode,
queueOnly,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(schema.autodraftSettings.id, existingSettings.id)) .where(eq(schema.autodraftSettings.id, existingSettings.id))
@ -63,6 +65,7 @@ export async function action(args: ActionFunctionArgs) {
teamId, teamId,
isEnabled, isEnabled,
mode, mode,
queueOnly,
}) })
.returning(); .returning();
} }
@ -73,6 +76,7 @@ export async function action(args: ActionFunctionArgs) {
teamId, teamId,
isEnabled, isEnabled,
mode, mode,
queueOnly,
}); });
return Response.json({ success: true, settings }); return Response.json({ success: true, settings });

View file

@ -26,18 +26,20 @@ import { useMediaQuery } from "~/hooks/useMediaQuery";
import { NotificationSettings } from "~/components/NotificationSettings"; import { NotificationSettings } from "~/components/NotificationSettings";
import { toast } from "sonner"; import { toast } from "sonner";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; 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"; import type { Route } from "./+types/$leagueId.draft.$seasonId";
type QueueItem = typeof schema.draftQueue.$inferSelect; type QueueItem = typeof schema.draftQueue.$inferSelect;
const MOBILE_TABS = [ const MOBILE_TABS_BASE = [
{ id: "lobby" as const, label: "Lobby", Icon: Users }, { id: "available" as const, label: "Available", Icon: Users },
{ id: "board" as const, label: "Board", Icon: LayoutGrid }, { id: "board" as const, label: "Board", Icon: LayoutGrid },
{ id: "roster" as const, label: "Roster", Icon: ListChecks }, { id: "roster" as const, label: "Roster", Icon: ListChecks },
{ id: "controls" as const, label: "Controls", Icon: Settings }, { 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) { export async function loader(args: Route.LoaderArgs) {
const { params } = args; const { params } = args;
const { seasonId, leagueId } = params; const { seasonId, leagueId } = params;
@ -247,6 +249,17 @@ export default function DraftRoom() {
notificationsModeRef.current = notificationsMode; notificationsModeRef.current = notificationsMode;
}, [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 // Re-fetch loader data after each reconnect so stale picks/timers are refreshed
useEffect(() => { useEffect(() => {
if (reconnectCount > 0) { if (reconnectCount > 0) {
@ -273,8 +286,8 @@ export default function DraftRoom() {
return stored ? JSON.parse(stored) : false; return stored ? JSON.parse(stored) : false;
}); });
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants"); const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants");
const [mobileTab, setMobileTab] = useState<"lobby" | "board" | "roster" | "controls">( const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "roster" | "controls">(
!userTeam && isCommissioner ? "board" : "lobby" !userTeam && isCommissioner ? "board" : "available"
); );
const isMobile = useMediaQuery("(max-width: 767px)"); const isMobile = useMediaQuery("(max-width: 767px)");
@ -290,12 +303,6 @@ export default function DraftRoom() {
// Track connected teams // Track connected teams
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set()); 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>>(() => { const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
// Initialize with timers from loader data, or use initial time if draft hasn't started // Initialize with timers from loader data, or use initial time if draft hasn't started
const timersMap: Record<string, number> = {}; const timersMap: Record<string, number> = {};
@ -483,7 +490,7 @@ export default function DraftRoom() {
setIsDraftComplete(true); 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) => ({ setAutodraftStatus((prev) => ({
...prev, ...prev,
[data.teamId]: data.isEnabled, [data.teamId]: data.isEnabled,
@ -491,9 +498,16 @@ export default function DraftRoom() {
// Update user's local state if it's their team // Update user's local state if it's their team
if (userTeam && data.teamId === userTeam.id) { 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({ setUserAutodraft({
isEnabled: data.isEnabled, isEnabled: data.isEnabled,
mode: data.mode, mode: data.mode,
queueOnly: data.queueOnly,
}); });
} }
}; };
@ -1128,12 +1142,14 @@ export default function DraftRoom() {
seasonId: season.id, seasonId: season.id,
teamId: userTeam.id, teamId: userTeam.id,
isMyTurn, isMyTurn,
canPick,
userAutodraft, userAutodraft,
onRemoveFromQueue: handleRemoveFromQueue, onRemoveFromQueue: handleRemoveFromQueue,
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => { onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => {
setUserAutodraft({ isEnabled, mode }); setUserAutodraft({ isEnabled, mode, queueOnly });
}, },
onReorder: handleReorderQueue, onReorder: handleReorderQueue,
onMakePick: handleMakePick,
} }
: null; : null;
@ -1266,9 +1282,20 @@ export default function DraftRoom() {
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{isMobile ? ( {isMobile ? (
<div className="flex h-full overflow-hidden flex-col"> <div className="flex h-full overflow-hidden flex-col">
{mobileTab === "lobby" && ( {mobileTab === "available" && (
<AvailableParticipantsSection {...availableParticipantsSectionProps} /> <AvailableParticipantsSection {...availableParticipantsSectionProps} />
)} )}
{mobileTab === "queue" && (
<div className="h-full overflow-y-auto">
{queueSectionProps ? (
<QueueSection {...queueSectionProps} />
) : (
<p className="text-muted-foreground text-sm text-center py-8 px-4">
Join a team to manage your queue
</p>
)}
</div>
)}
{mobileTab === "board" && ( {mobileTab === "board" && (
<DraftGridSection {...draftGridSectionProps} /> <DraftGridSection {...draftGridSectionProps} />
)} )}
@ -1294,15 +1321,8 @@ export default function DraftRoom() {
) )
)} )}
{queueSectionProps && (
<section>
<h2 className="font-semibold text-sm mb-2">My Queue</h2>
<QueueSection {...queueSectionProps} />
</section>
)}
<section> <section>
<h2 className="font-semibold text-sm mb-2">Picks</h2> <h2 className="font-semibold text-sm mb-2">Recent Picks</h2>
<SidebarRecentPicks picks={picks} /> <SidebarRecentPicks picks={picks} />
</section> </section>
@ -1408,32 +1428,41 @@ export default function DraftRoom() {
</div> </div>
{/* Mobile Bottom Nav */} {/* Mobile Bottom Nav */}
<nav className="md:hidden flex-shrink-0 border-t bg-card grid grid-cols-4"> {(() => {
{MOBILE_TABS.map((tab) => { // Build tabs dynamically: insert Queue tab after Available only when user has a team
const showTurnIndicator = const mobileTabs = userTeam
tab.id === "lobby" && ? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)]
isMyTurn && : MOBILE_TABS_BASE;
season.status === "draft" && const colCount = mobileTabs.length;
!isDraftComplete; return (
return ( <nav className={`md:hidden flex-shrink-0 border-t bg-card grid`} style={{ gridTemplateColumns: `repeat(${colCount}, 1fr)` }}>
<button {mobileTabs.map((tab) => {
key={tab.id} const showTurnIndicator =
onClick={() => setMobileTab(tab.id)} (tab.id === "available" || tab.id === "queue") &&
className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${ isMyTurn &&
mobileTab === tab.id ? "text-electric" : "text-muted-foreground" season.status === "draft" &&
}`} !isDraftComplete;
> return (
<div className="relative"> <button
<tab.Icon className="h-5 w-5" /> key={tab.id}
{showTurnIndicator && ( onClick={() => setMobileTab(tab.id as typeof mobileTab)}
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" /> className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${
)} mobileTab === tab.id ? "text-electric" : "text-muted-foreground"
</div> }`}
<span className="text-[10px] font-medium">{tab.label}</span> >
</button> <div className="relative">
); <tab.Icon className="h-5 w-5" />
})} {showTurnIndicator && (
</nav> <span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" />
)}
</div>
<span className="text-[10px] font-medium">{tab.label}</span>
</button>
);
})}
</nav>
);
})()}
{/* Force Manual Pick Dialog */} {/* Force Manual Pick Dialog */}
<Dialog <Dialog

View file

@ -78,6 +78,7 @@ describe("Autodraft Settings API", () => {
teamId, teamId,
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@ -88,6 +89,7 @@ describe("Autodraft Settings API", () => {
formData.append("teamId", teamId); formData.append("teamId", teamId);
formData.append("isEnabled", "true"); formData.append("isEnabled", "true");
formData.append("mode", "next_pick"); formData.append("mode", "next_pick");
formData.append("queueOnly", "false");
const request = new Request("http://localhost/api/autodraft/update", { const request = new Request("http://localhost/api/autodraft/update", {
method: "POST", method: "POST",
@ -104,16 +106,15 @@ describe("Autodraft Settings API", () => {
expect(data.success).toBe(true); expect(data.success).toBe(true);
expect(data.settings.id).toBe(newSettings.id); expect(data.settings.id).toBe(newSettings.id);
expect(data.settings.seasonId).toBe(newSettings.seasonId); expect(data.settings.isEnabled).toBe(true);
expect(data.settings.teamId).toBe(newSettings.teamId); expect(data.settings.mode).toBe("next_pick");
expect(data.settings.isEnabled).toBe(newSettings.isEnabled);
expect(data.settings.mode).toBe(newSettings.mode);
expect(mockDb.insert).toHaveBeenCalled(); expect(mockDb.insert).toHaveBeenCalled();
expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`); expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", { expect(mockSocketIO.emit).toHaveBeenCalledWith("autodraft-updated", {
teamId, teamId,
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: false,
}); });
}); });
@ -122,32 +123,29 @@ describe("Autodraft Settings API", () => {
const teamId = "team-456"; const teamId = "team-456";
const userId = "user-789"; const userId = "user-789";
// Mock team ownership verification
mockDb.query.teams.findFirst.mockResolvedValue({ mockDb.query.teams.findFirst.mockResolvedValue({
id: teamId, id: teamId,
ownerId: userId, ownerId: userId,
name: "Test Team", name: "Test Team",
}); });
// Mock existing settings
const existingSettings = { const existingSettings = {
id: "settings-1", id: "settings-1",
seasonId, seasonId,
teamId, teamId,
isEnabled: false, isEnabled: false,
mode: "next_pick", mode: "next_pick",
queueOnly: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
mockDb.query.autodraftSettings.findFirst.mockResolvedValue( mockDb.query.autodraftSettings.findFirst.mockResolvedValue(existingSettings);
existingSettings
);
// Mock update returning updated settings
const updatedSettings = { const updatedSettings = {
...existingSettings, ...existingSettings,
isEnabled: true, isEnabled: true,
mode: "while_on", mode: "while_on",
queueOnly: false,
updatedAt: new Date(), updatedAt: new Date(),
}; };
mockDb.returning.mockResolvedValue([updatedSettings]); mockDb.returning.mockResolvedValue([updatedSettings]);
@ -157,6 +155,7 @@ describe("Autodraft Settings API", () => {
formData.append("teamId", teamId); formData.append("teamId", teamId);
formData.append("isEnabled", "true"); formData.append("isEnabled", "true");
formData.append("mode", "while_on"); formData.append("mode", "while_on");
formData.append("queueOnly", "false");
const request = new Request("http://localhost/api/autodraft/update", { const request = new Request("http://localhost/api/autodraft/update", {
method: "POST", method: "POST",
@ -179,16 +178,69 @@ describe("Autodraft Settings API", () => {
teamId, teamId,
isEnabled: true, isEnabled: true,
mode: "while_on", 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 () => { it("should reject unauthorized users", async () => {
const seasonId = "season-123"; const seasonId = "season-123";
const teamId = "team-456"; const teamId = "team-456";
const _userId = "user-789";
const differentUserId = "user-999"; const differentUserId = "user-999";
// Mock team owned by different user
mockDb.query.teams.findFirst.mockResolvedValue({ mockDb.query.teams.findFirst.mockResolvedValue({
id: teamId, id: teamId,
ownerId: differentUserId, ownerId: differentUserId,
@ -238,7 +290,7 @@ describe("Autodraft Settings API", () => {
expect(data.error).toBe("Missing required fields"); 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 seasonId = "season-123";
const teamId = "team-456"; const teamId = "team-456";
const userId = "user-789"; const userId = "user-789";
@ -251,77 +303,80 @@ describe("Autodraft Settings API", () => {
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null); mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
// Test next_pick mode // State 1: Off (isEnabled=false)
const nextPickSettings = { const offSettings = {
id: "settings-1", id: "settings-1",
seasonId, seasonId,
teamId, teamId,
isEnabled: true, isEnabled: false,
mode: "next_pick", mode: "next_pick",
queueOnly: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
mockDb.returning.mockResolvedValueOnce([nextPickSettings]); mockDb.returning.mockResolvedValueOnce([offSettings]);
const formData1 = new FormData(); const formData1 = new FormData();
formData1.append("seasonId", seasonId); formData1.append("seasonId", seasonId);
formData1.append("teamId", teamId); formData1.append("teamId", teamId);
formData1.append("isEnabled", "true"); formData1.append("isEnabled", "false");
formData1.append("mode", "next_pick"); formData1.append("mode", "next_pick");
formData1.append("queueOnly", "false");
const request1 = new Request("http://localhost/api/autodraft/update", {
method: "POST",
body: formData1,
});
const response1 = await action({ const response1 = await action({
request: request1, request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData1 }),
params: {}, params: {},
context: ctx, context: ctx,
}); });
const data1 = await response1.json(); const data1 = await response1.json();
expect(data1.settings.mode).toBe("next_pick"); expect(data1.settings.isEnabled).toBe(false);
// Test while_on mode // State 2: Next Pick (isEnabled=true, mode=next_pick)
const whileOnSettings = { const nextPickSettings = { ...offSettings, id: "settings-2", isEnabled: true, mode: "next_pick" };
id: "settings-2", mockDb.returning.mockResolvedValueOnce([nextPickSettings]);
seasonId,
teamId,
isEnabled: true,
mode: "while_on",
createdAt: new Date(),
updatedAt: new Date(),
};
mockDb.returning.mockResolvedValueOnce([whileOnSettings]);
const formData2 = new FormData(); const formData2 = new FormData();
formData2.append("seasonId", seasonId); formData2.append("seasonId", seasonId);
formData2.append("teamId", teamId); formData2.append("teamId", teamId);
formData2.append("isEnabled", "true"); formData2.append("isEnabled", "true");
formData2.append("mode", "while_on"); formData2.append("mode", "next_pick");
formData2.append("queueOnly", "false");
const request2 = new Request("http://localhost/api/autodraft/update", {
method: "POST",
body: formData2,
});
const response2 = await action({ const response2 = await action({
request: request2, request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData2 }),
params: {}, params: {},
context: ctx, context: ctx,
}); });
const data2 = await response2.json(); 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 seasonId = "season-abc";
const teamId = "team-xyz"; const teamId = "team-xyz";
const userId = "user-123"; const userId = "user-123";
// Override auth mock for this test
const { getAuth } = await import("@clerk/react-router/server"); const { getAuth } = await import("@clerk/react-router/server");
vi.mocked(getAuth).mockResolvedValue({ userId } as any); vi.mocked(getAuth).mockResolvedValue({ userId } as any);
@ -333,30 +388,26 @@ describe("Autodraft Settings API", () => {
mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null); mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
const newSettings = { mockDb.returning.mockResolvedValue([{
id: "settings-1", id: "settings-1",
seasonId, seasonId,
teamId, teamId,
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; }]);
mockDb.returning.mockResolvedValue([newSettings]);
const formData = new FormData(); const formData = new FormData();
formData.append("seasonId", seasonId); formData.append("seasonId", seasonId);
formData.append("teamId", teamId); formData.append("teamId", teamId);
formData.append("isEnabled", "true"); formData.append("isEnabled", "true");
formData.append("mode", "next_pick"); formData.append("mode", "next_pick");
formData.append("queueOnly", "true");
const request = new Request("http://localhost/api/autodraft/update", {
method: "POST",
body: formData,
});
await action({ await action({
request, request: new Request("http://localhost/api/autodraft/update", { method: "POST", body: formData }),
params: {}, params: {},
context: ctx, context: ctx,
}); });
@ -366,6 +417,7 @@ describe("Autodraft Settings API", () => {
teamId, teamId,
isEnabled: true, isEnabled: true,
mode: "next_pick", mode: "next_pick",
queueOnly: true,
}); });
}); });
}); });

View file

@ -207,6 +207,7 @@ export const autodraftSettings = pgTable("autodraft_settings", {
.references(() => teams.id, { onDelete: "cascade" }), .references(() => teams.id, { onDelete: "cascade" }),
isEnabled: boolean("is_enabled").notNull().default(false), isEnabled: boolean("is_enabled").notNull().default(false),
mode: autodraftModeEnum("mode").notNull().default("next_pick"), mode: autodraftModeEnum("mode").notNull().default("next_pick"),
queueOnly: boolean("queue_only").notNull().default(false),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });

View file

@ -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;

View file

@ -218,6 +218,13 @@
"when": 1763282000000, "when": 1763282000000,
"tag": "0030_add_draft_picks_unique_slot", "tag": "0030_add_draft_picks_unique_slot",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1763283000000,
"tag": "0031_add_autodraft_queue_only",
"breakpoints": true
} }
] ]
} }

View file

@ -25,32 +25,14 @@ beforeEach(() => {
mockDb = { mockDb = {
query: { query: {
seasons: { seasons: { findMany: vi.fn(), findFirst: vi.fn() },
findMany: vi.fn(), draftSlots: { findMany: vi.fn() },
findFirst: vi.fn(), draftTimers: { findFirst: vi.fn() },
}, autodraftSettings: { findFirst: vi.fn() },
draftSlots: { draftPicks: { findMany: vi.fn(), findFirst: vi.fn() },
findMany: vi.fn(), draftQueue: { findMany: vi.fn() },
}, participants: { findMany: vi.fn() },
draftTimers: { seasonTemplateSports: { findMany: vi.fn() },
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(), update: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(), set: vi.fn().mockReturnThis(),
@ -73,123 +55,34 @@ describe('Timer Autodraft Integration', () => {
const seasonId = 'season-123'; const seasonId = 'season-123';
const teamId = 'team-456'; const teamId = 'team-456';
// Mock active draft mockDb.query.seasons.findMany.mockResolvedValue([{
mockDb.query.seasons.findMany.mockResolvedValue([ id: seasonId, status: 'draft', draftPaused: false,
{ currentPickNumber: 1, draftInitialTime: 120, draftIncrementTime: 30, draftRounds: 10,
id: seasonId, }]);
status: 'draft',
draftPaused: false,
currentPickNumber: 1,
draftInitialTime: 120,
draftIncrementTime: 30,
draftRounds: 10,
},
]);
// Mock draft slots
mockDb.query.draftSlots.findMany.mockResolvedValue([ mockDb.query.draftSlots.findMany.mockResolvedValue([
{ id: 'slot-1', teamId, draftOrder: 1 }, { id: 'slot-1', teamId, draftOrder: 1 },
{ id: 'slot-2', teamId: 'team-2', draftOrder: 2 }, { id: 'slot-2', teamId: 'team-2', draftOrder: 2 },
]); ]);
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: 'timer-1', seasonId, teamId, timeRemaining: 0 });
// 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({ mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
id: 'settings-1', id: 'settings-1', seasonId, teamId, isEnabled: true, mode: 'next_pick', queueOnly: false,
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(); expect(mockDb.query.autodraftSettings.findFirst).toBeDefined();
}); });
it('should use regular auto-pick when autodraft is disabled', async () => { it('should use regular auto-pick when autodraft is disabled', async () => {
const seasonId = 'season-123'; const seasonId = 'season-123';
const teamId = 'team-456'; const teamId = 'team-456';
// Mock autodraft settings disabled
mockDb.query.autodraftSettings.findFirst.mockResolvedValue({ mockDb.query.autodraftSettings.findFirst.mockResolvedValue({
id: 'settings-1', id: 'settings-1', seasonId, teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
seasonId,
teamId,
isEnabled: false,
mode: 'next_pick',
}); });
const settings = await mockDb.query.autodraftSettings.findFirst(); const settings = await mockDb.query.autodraftSettings.findFirst();
expect(settings.isEnabled).toBe(false); expect(settings.isEnabled).toBe(false);
}); });
it('should handle missing autodraft settings', async () => { 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); mockDb.query.autodraftSettings.findFirst.mockResolvedValue(null);
const settings = await mockDb.query.autodraftSettings.findFirst(); const settings = await mockDb.query.autodraftSettings.findFirst();
expect(settings).toBeNull(); expect(settings).toBeNull();
}); });
@ -199,49 +92,20 @@ describe('Timer Autodraft Integration', () => {
it('should disable autodraft after pick when mode is next_pick', async () => { it('should disable autodraft after pick when mode is next_pick', async () => {
const seasonId = 'season-123'; const seasonId = 'season-123';
const teamId = 'team-456'; 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') { if (autodraftSettings.isEnabled && autodraftSettings.mode === 'next_pick') {
await mockDb.update(); await mockDb.update();
await mockDb.set({ isEnabled: false, updatedAt: new Date() }); await mockDb.set({ isEnabled: false, updatedAt: new Date() });
await mockDb.where(); await mockDb.where();
const result = await mockDb.returning(); const result = await mockDb.returning();
expect(result[0].isEnabled).toBe(false); expect(result[0].isEnabled).toBe(false);
expect(mockSocketIO.to).toBeDefined();
} }
}); });
it('should keep autodraft enabled when mode is while_on', async () => { it('should keep autodraft enabled when mode is while_on (All Picks)', async () => {
const _seasonId = 'season-123'; const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false };
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') { if (autodraftSettings.mode === 'while_on') {
expect(autodraftSettings.isEnabled).toBe(true); expect(autodraftSettings.isEnabled).toBe(true);
} }
@ -250,297 +114,228 @@ describe('Timer Autodraft Integration', () => {
describe('Autodraft Pick Logic', () => { describe('Autodraft Pick Logic', () => {
it('should pick from queue first when autodraft is enabled', async () => { 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([ mockDb.query.draftQueue.findMany.mockResolvedValue([
{ { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
id: 'queue-1', { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
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([]); mockDb.query.draftPicks.findMany.mockResolvedValue([]);
const queueItems = await mockDb.query.draftQueue.findMany(); const queueItems = await mockDb.query.draftQueue.findMany();
const draftedPicks = await mockDb.query.draftPicks.findMany(); const draftedPicks = await mockDb.query.draftPicks.findMany();
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId); const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
const availableQueueItem = queueItems.find((item: any) => !draftedParticipantIds.includes(item.participantId));
// Find first non-drafted participant from queue
const availableQueueItem = queueItems.find(
(item: any) => !draftedParticipantIds.includes(item.participantId)
);
expect(availableQueueItem).toBeDefined(); expect(availableQueueItem).toBeDefined();
expect(availableQueueItem.participantId).toBe('participant-1'); expect(availableQueueItem.participantId).toBe('participant-1');
}); });
it('should skip drafted participants in queue', async () => { it('should skip drafted participants in queue and try next', async () => {
const _seasonId = 'season-123';
const _teamId = 'team-456';
// Mock queue
mockDb.query.draftQueue.findMany.mockResolvedValue([ mockDb.query.draftQueue.findMany.mockResolvedValue([
{ { id: 'queue-1', participantId: 'participant-1', queuePosition: 1 },
id: 'queue-1', { id: 'queue-2', participantId: 'participant-2', queuePosition: 2 },
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',
},
]); ]);
mockDb.query.draftPicks.findMany.mockResolvedValue([{ id: 'pick-1', participantId: 'participant-1' }]);
const queueItems = await mockDb.query.draftQueue.findMany(); const queueItems = await mockDb.query.draftQueue.findMany();
const draftedPicks = await mockDb.query.draftPicks.findMany(); const draftedPicks = await mockDb.query.draftPicks.findMany();
const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId); const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId);
const availableQueueItem = queueItems.find((item: any) => !draftedParticipantIds.includes(item.participantId));
// Should pick second participant
const availableQueueItem = queueItems.find(
(item: any) => !draftedParticipantIds.includes(item.participantId)
);
expect(availableQueueItem.participantId).toBe('participant-2'); expect(availableQueueItem.participantId).toBe('participant-2');
}); });
it('should fall back to highest EV when queue is empty or all drafted', async () => { it('should fall back to highest EV when queue is empty and queueOnly is OFF', async () => {
const _seasonId = 'season-123';
const _teamId = 'team-456';
// Mock empty queue
mockDb.query.draftQueue.findMany.mockResolvedValue([]); 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([ 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(); const queueItems = await mockDb.query.draftQueue.findMany();
expect(queueItems.length).toBe(0); expect(queueItems.length).toBe(0);
const queueOnly = false;
const availableParticipants = await mockDb.query.participants.findMany(); 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', () => { 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 seasonId = 'season-123';
const teamId = 'team-456'; const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', { mockSocketIO.to(`draft-${seasonId}`).emit('autodraft-updated', {
teamId, teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
isEnabled: false,
mode: 'next_pick',
}); });
expect(mockSocketIO.to).toHaveBeenCalledWith(`draft-${seasonId}`);
expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', { expect(mockSocketIO.emit).toHaveBeenCalledWith('autodraft-updated', {
teamId, teamId, isEnabled: false, mode: 'next_pick', queueOnly: false,
isEnabled: false,
mode: 'next_pick',
}); });
}); });
it('should emit pick-made event after autodraft pick', async () => { it('should emit pick-made event after autodraft pick', async () => {
const seasonId = 'season-123'; 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); mockSocketIO.to(`draft-${seasonId}`).emit('pick-made', pickData);
expect(mockSocketIO.emit).toHaveBeenCalledWith('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', () => { 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 seasonId = 'season-123';
const teamId = 'team-456'; 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') { 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.isEnabled).toBe(false);
expect(updatedSettings.mode).toBe('next_pick');
} }
// Simulate second pick (pick #2) - should NOT autodraft const settingsForSecondPick = { ...autodraftSettings, isEnabled: false };
const _secondPickNumber = 2;
// Autodraft should now be disabled, so second pick requires manual action
const settingsForSecondPick = {
...autodraftSettings,
isEnabled: false,
};
expect(settingsForSecondPick.isEnabled).toBe(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 () => { it('should continue autodrafting consecutive picks when mode is while_on (All Picks)', async () => {
const seasonId = 'season-123'; const autodraftSettings = { isEnabled: true, mode: 'while_on', queueOnly: false };
const teamId = 'team-456'; if (autodraftSettings.mode === 'while_on') expect(autodraftSettings.isEnabled).toBe(true);
// 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.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', () => { describe('Time Increment', () => {
it('should add increment time after autodraft pick', async () => { it('should add increment time after autodraft pick', () => {
const _seasonId = 'season-123'; expect(0 + 30).toBe(30);
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 () => { it('should emit timer-update after adding increment', async () => {
const seasonId = 'season-123'; const seasonId = 'season-123';
const teamId = 'team-456'; const teamId = 'team-456';
mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', { mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', {
seasonId, seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
teamId,
timeRemaining: 30,
currentPickNumber: 1,
}); });
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', { expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', {
seasonId, seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
teamId,
timeRemaining: 30,
currentPickNumber: 1,
}); });
}); });
}); });

View file

@ -38,6 +38,7 @@ interface ServerToClientEvents {
teamId: string; teamId: string;
isEnabled: boolean; isEnabled: boolean;
mode: "next_pick" | "while_on"; mode: "next_pick" | "while_on";
queueOnly: boolean;
}) => void; }) => void;
"team-connected": (data: { teamId: string }) => void; "team-connected": (data: { teamId: string }) => void;
"team-disconnected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void;