-
-
Notifications
You must be signed in to change notification settings - Fork 1
Testing Mock Alliance Selection #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…as HTML Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a mock alliance selection feature for FRC (FIRST Robotics Competition) scouting, allowing users to simulate the alliance selection process that occurs at FRC events. The implementation includes a new interactive UI page with drag-and-drop functionality, backend API endpoints to fetch event rankings from The Blue Alliance (TBA), and navigation links from existing pages.
Key changes:
- Added comprehensive mock alliance selection page with drag-and-drop team selection, snake draft logic, and mobile touch support
- Implemented new backend API endpoint to fetch event rankings with team details
- Added navigation links to the new feature from leaderboard and team list pages
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 19 comments.
Show a summary per file
| File | Description |
|---|---|
| app/templates/scouting/mock-alliance-selection.html | New 913-line HTML template with embedded JavaScript implementing the interactive alliance selection UI, including event selection, team drag-and-drop, snake draft logic, and export functionality |
| app/scout/routes.py | Added two new routes: one for rendering the mock alliance selection page and another API endpoint for fetching event rankings with team details |
| app/scout/TBA.py | Added get_event_rankings method to fetch and format team rankings data from The Blue Alliance API |
| app/templates/scouting/list.html | Added navigation link to the new mock alliance selection feature |
| app/templates/scouting/leaderboard.html | Added navigation link to the new mock alliance selection feature |
| app/app.py | Enhanced MongoDB collection initialization with logging statements for better visibility during setup |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| info: '<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>' | ||
| }; | ||
|
|
||
| const notification = document.createElement('div'); |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dismiss button has proper screen reader support with the 'sr-only' span, but the parent notification doesn't have a role="alert" or aria-live attribute. Consider adding role="alert" to ensure screen readers announce the notification when it appears.
| const notification = document.createElement('div'); | |
| const notification = document.createElement('div'); | |
| notification.setAttribute('role', 'alert'); |
| <a href="{{ url_for('scouting.mock_alliance_selection') }}" | ||
| class="text-blue-600 hover:text-blue-800 flex items-center gap-1"> | ||
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | ||
| d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/> | ||
| </svg> | ||
| Mock Alliance Selection | ||
| </a> |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent indentation in the navigation link structure. The closing 'a' tag and SVG path should maintain consistent indentation with the rest of the code block.
| if (!year || year < 1992 || year > 2025) { | ||
| showNotification('Please enter a valid year between 1992 and 2025.', 'warning'); |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded maximum year value of 2025 will need to be updated every year. Consider using the current year dynamically (e.g., via server-side templating or JavaScript's new Date().getFullYear()) to avoid maintenance issues.
| if (!year || year < 1992 || year > 2025) { | |
| showNotification('Please enter a valid year between 1992 and 2025.', 'warning'); | |
| const currentYear = new Date().getFullYear(); | |
| if (!year || year < 1992 || year > currentYear) { | |
| showNotification(`Please enter a valid year between 1992 and ${currentYear}.`, 'warning'); |
| max="2025" | ||
| placeholder="Enter year (e.g., 2025)" | ||
| class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The hardcoded maximum year of 2025 in the HTML input element will become outdated. Consider generating this value dynamically using server-side templating or JavaScript to automatically use the current year.
| max="2025" | |
| placeholder="Enter year (e.g., 2025)" | |
| class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| max="" | |
| placeholder="Enter year (e.g., 2025)" | |
| class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
| <script> | |
| // Set the max year dynamically to the current year | |
| document.addEventListener('DOMContentLoaded', function() { | |
| var yearInput = document.getElementById('year-select'); | |
| if (yearInput) { | |
| yearInput.max = new Date().getFullYear(); | |
| } | |
| }); | |
| </script> |
app/scout/TBA.py
Outdated
| return events[0] | ||
| return events[0] | ||
|
|
||
| @lru_cache(maxsize=100) |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lru_cache decorator without considering cache invalidation may serve stale data. Event rankings change throughout an event as matches are played. Consider adding a time-based cache expiration or a mechanism to invalidate the cache when new match data becomes available.
| @scouting_bp.route("/api/alliance-selection/rankings/<event_key>") | ||
| @login_required | ||
| # @limiter.limit("30 per minute") | ||
| def get_alliance_rankings(event_key): |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's no input validation on the event_key parameter before it's used in API calls. Consider validating the format and sanitizing the input to prevent potential injection attacks or API abuse.
| logger.info("Created 'users' collection in MongoDB.") | ||
| if "teams" not in db.list_collection_names(): | ||
| db.create_collection("teams") | ||
| logger.info("Created 'teams' collection in MongoDB.") | ||
| if "team_data" not in db.list_collection_names(): | ||
| db.create_collection("team_data") | ||
| logger.info("Created 'team_data' collection in MongoDB.") | ||
| if "pit_scouting" not in db.list_collection_names(): | ||
| db.create_collection("pit_scouting") | ||
| logger.info("Created 'pit_scouting' collection in MongoDB.") | ||
| if "assignments" not in db.list_collection_names(): | ||
| db.create_collection("assignments") | ||
| logger.info("Created 'assignments' collection in MongoDB.") | ||
| if "assignment_subscriptions" not in db.list_collection_names(): | ||
| db.create_collection("assignment_subscriptions") | ||
| logger.info("Created 'assignment_subscriptions' collection in MongoDB.") |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent indentation style in the logging statements. The added logger.info calls use 4 spaces per indentation level, which should match the surrounding code style. Verify this aligns with the project's style guide.
app/scout/routes.py
Outdated
| # Fetch team details for each ranked team | ||
| for rank in rankings: | ||
| team_info = tba.get_team(rank['team_key']) | ||
| if team_info: | ||
| rank['nickname'] = team_info.get('nickname', '') | ||
| rank['city'] = team_info.get('city', '') | ||
| rank['state_prov'] = team_info.get('state_prov', '') | ||
|
|
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sequential API calls inside a loop can cause performance issues. Fetching team details for each ranked team sequentially (lines 1361-1366) could be slow for events with many teams. Consider batching these requests or using a bulk endpoint if available from the TBA API.
| # Fetch team details for each ranked team | |
| for rank in rankings: | |
| team_info = tba.get_team(rank['team_key']) | |
| if team_info: | |
| rank['nickname'] = team_info.get('nickname', '') | |
| rank['city'] = team_info.get('city', '') | |
| rank['state_prov'] = team_info.get('state_prov', '') | |
| # Fetch all team details in bulk for the event | |
| teams = tba.get_event_teams(event_key) | |
| team_info_map = {team['key']: team for team in teams} if teams else {} | |
| for rank in rankings: | |
| team_info = team_info_map.get(rank['team_key']) | |
| if team_info: | |
| rank['nickname'] = team_info.get('nickname', '') | |
| rank['city'] = team_info.get('city', '') | |
| rank['state_prov'] = team_info.get('state_prov', '') |
| for (let i = 0; i < 8; i++) { | ||
| const alliance = alliances[i]; | ||
| const isActive = !allPicksComplete && alliance && i === currentPick.alliance; | ||
| const card = document.createElement('div'); | ||
| card.className = `alliance-card bg-white border-2 rounded-lg p-4 ${isActive ? 'active border-blue-500' : 'border-gray-200'}`; | ||
|
|
||
| if (!alliance) { | ||
| // Empty alliance slot waiting for captain | ||
| card.innerHTML = ` | ||
| <div class="flex items-center justify-between mb-3"> | ||
| <h3 class="text-lg font-bold text-gray-400">Alliance ${i + 1}</h3> | ||
| </div> | ||
| <div class="team-slot empty p-3 rounded mb-3 text-center text-gray-400"> | ||
| Waiting for captain... | ||
| </div> | ||
| <div class="team-slot empty p-3 rounded mb-2 text-center text-gray-400"> | ||
| Pick 1 | ||
| </div> | ||
| <div class="team-slot empty p-3 rounded mb-2 text-center text-gray-400"> | ||
| Pick 2 | ||
| </div> | ||
| `; | ||
| } else { | ||
| let picksHtml = ''; | ||
| const maxPicks = 2; // Each alliance gets 2 picks after captain | ||
|
|
||
| for (let j = 0; j < maxPicks; j++) { | ||
| const pick = alliance.picks[j]; | ||
| if (pick) { | ||
| picksHtml += ` | ||
| <div class="team-slot filled p-3 rounded mb-2 cursor-pointer hover:bg-red-50 group" onclick="removeTeamFromAlliance(${i}, ${j})"> | ||
| <div class="flex justify-between items-center"> | ||
| <div class="flex-1"> | ||
| <div class="font-semibold">${pick.team_number}</div> | ||
| <div class="text-xs text-gray-600">${pick.nickname || ''}</div> | ||
| </div> | ||
| <div class="flex items-center gap-2"> | ||
| <div class="text-xs text-gray-500">Pick ${j + 1}</div> | ||
| <svg class="w-4 h-4 text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | ||
| </svg> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `; | ||
| } else { | ||
| picksHtml += ` | ||
| <div class="team-slot empty p-3 rounded mb-2 text-center text-gray-400" | ||
| data-alliance="${i}" | ||
| data-pick="${j}" | ||
| onclick="handleSlotClick(${i}, ${j})" | ||
| ondrop="handleDrop(event)" | ||
| ondragover="handleDragOver(event)" | ||
| ondragleave="handleDragLeave(event)"> | ||
| ${isActive && alliance.picks.length === j ? '← Tap or drag team here' : `Pick ${j + 1}`} | ||
| </div> | ||
| `; | ||
| } | ||
| } | ||
|
|
||
| card.innerHTML = ` | ||
| <div class="flex items-center justify-between mb-3"> | ||
| <h3 class="text-lg font-bold">Alliance ${alliance.number}</h3> | ||
| ${isActive ? '<span class="pick-round-indicator">PICKING</span>' : ''} | ||
| </div> | ||
| <div class="team-slot captain p-3 rounded mb-3"> | ||
| <div class="font-semibold text-blue-700">${alliance.captain.team_number}</div> | ||
| <div class="text-xs text-gray-600">${alliance.captain.nickname || ''}</div> | ||
| <div class="text-xs text-blue-600 font-medium mt-1">Captain (Rank ${alliance.captain.rank})</div> | ||
| </div> | ||
| ${picksHtml} | ||
| `; | ||
| } | ||
|
|
||
| grid.appendChild(card); | ||
| } | ||
| } | ||
|
|
||
| function renderAvailableTeams() { | ||
| const list = document.getElementById('available-teams-list'); | ||
| const searchTerm = document.getElementById('team-search').value.toLowerCase(); | ||
|
|
||
| list.innerHTML = ''; | ||
|
|
||
| availableTeams.forEach(team => { | ||
| const isDeclined = declinedTeams.has(team.team_number); | ||
| const isSelected = selectedTeams.has(team.team_number); | ||
| const isSelectedForPick = selectedTeamForPick && selectedTeamForPick.team_number === team.team_number; | ||
|
|
||
| // Hide selected teams from the list entirely | ||
| if (isSelected) return; | ||
|
|
||
| const matchesSearch = !searchTerm || | ||
| team.team_number.toString().includes(searchTerm) || | ||
| (team.nickname && team.nickname.toLowerCase().includes(searchTerm)); | ||
|
|
||
| if (!matchesSearch) return; | ||
|
|
||
| const card = document.createElement('div'); | ||
| card.className = `team-card bg-white border border-gray-200 rounded-lg p-3 ${ | ||
| isDeclined ? 'declined' : isSelectedForPick ? 'selected-for-pick' : '' | ||
| }`; | ||
| card.draggable = !isDeclined; | ||
|
|
||
| if (!isDeclined) { | ||
| card.setAttribute('data-team', JSON.stringify(team)); | ||
| card.addEventListener('dragstart', handleDragStart); | ||
| card.addEventListener('dragend', handleDragEnd); | ||
| card.addEventListener('click', () => handleTeamClick(team)); | ||
|
|
||
| // Touch events for mobile | ||
| card.addEventListener('touchstart', handleTouchStart, { passive: false }); | ||
| card.addEventListener('touchmove', handleTouchMove, { passive: false }); | ||
| card.addEventListener('touchend', handleTouchEnd, { passive: false }); | ||
| } | ||
|
|
||
| card.innerHTML = ` | ||
| <div class="font-semibold text-lg">${team.team_number}</div> | ||
| <div class="text-sm text-gray-600 truncate">${team.nickname || 'No name'}</div> | ||
| <div class="text-xs text-gray-500 mt-1">Rank: ${team.rank}</div> | ||
| ${isDeclined ? '<div class="text-xs text-red-600 font-medium mt-1">DECLINED</div>' : ''} | ||
| ${isSelectedForPick ? '<div class="text-xs text-blue-600 font-medium mt-1">SELECTED - Tap slot to place</div>' : ''} | ||
| `; | ||
|
|
||
| list.appendChild(card); | ||
| }); | ||
| } | ||
|
|
||
| let draggedTeam = null; | ||
| let touchStartPos = null; | ||
|
|
||
| function handleTeamClick(team) { | ||
| // For mobile/tablet - select team first, then click on slot | ||
| if (selectedTeamForPick && selectedTeamForPick.team_number === team.team_number) { | ||
| // Deselect if clicking the same team | ||
| selectedTeamForPick = null; | ||
| } else { | ||
| selectedTeamForPick = team; | ||
| } | ||
| renderAvailableTeams(); | ||
| renderAlliances(); | ||
| } | ||
|
|
||
| function handleSlotClick(allianceIndex, pickIndex) { | ||
| if (!selectedTeamForPick) return; | ||
|
|
||
| const alliance = alliances[allianceIndex]; | ||
| if (!alliance || alliance.picks[pickIndex]) return; // Alliance doesn't exist or slot filled | ||
|
|
||
| // Place the selected team | ||
| selectTeam(selectedTeamForPick); | ||
| selectedTeamForPick = null; | ||
| renderAvailableTeams(); | ||
| } | ||
|
|
||
| function handleTouchStart(e) { | ||
| const team = JSON.parse(e.currentTarget.getAttribute('data-team')); | ||
| touchStartPos = { | ||
| x: e.touches[0].clientX, | ||
| y: e.touches[0].clientY | ||
| }; | ||
| draggedTeam = team; | ||
| e.currentTarget.classList.add('dragging'); | ||
| } | ||
|
|
||
| function handleTouchMove(e) { | ||
| if (!draggedTeam) return; | ||
| e.preventDefault(); // Prevent scrolling while dragging | ||
| } | ||
|
|
||
| function handleTouchEnd(e) { | ||
| if (!draggedTeam) return; | ||
|
|
||
| e.currentTarget.classList.remove('dragging'); | ||
|
|
||
| const touch = e.changedTouches[0]; | ||
| const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY); | ||
|
|
||
| if (dropTarget && dropTarget.classList.contains('team-slot') && dropTarget.classList.contains('empty')) { | ||
| const allianceIndex = parseInt(dropTarget.getAttribute('data-alliance')); | ||
| const pickIndex = parseInt(dropTarget.getAttribute('data-pick')); | ||
|
|
||
| const alliance = alliances[allianceIndex]; | ||
| if (alliance && !alliance.picks[pickIndex]) { | ||
| selectTeam(draggedTeam); | ||
| } | ||
| } | ||
|
|
||
| draggedTeam = null; | ||
| touchStartPos = null; | ||
| } | ||
|
|
||
| function handleDragStart(e) { | ||
| draggedTeam = JSON.parse(e.target.getAttribute('data-team')); | ||
| e.target.classList.add('dragging'); | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| } | ||
|
|
||
| function handleDragEnd(e) { | ||
| e.target.classList.remove('dragging'); | ||
| draggedTeam = null; | ||
| } | ||
|
|
||
| function handleDragOver(e) { | ||
| e.preventDefault(); | ||
| e.dataTransfer.dropEffect = 'move'; | ||
| e.currentTarget.classList.add('drag-over'); | ||
| } | ||
|
|
||
| function handleDragLeave(e) { | ||
| e.currentTarget.classList.remove('drag-over'); | ||
| } | ||
|
|
||
| function handleDrop(e) { | ||
| e.preventDefault(); | ||
| e.currentTarget.classList.remove('drag-over'); | ||
|
|
||
| if (!draggedTeam) return; | ||
|
|
||
| const allianceIndex = parseInt(e.currentTarget.getAttribute('data-alliance')); | ||
| const pickIndex = parseInt(e.currentTarget.getAttribute('data-pick')); | ||
|
|
||
| // Check if this slot is already filled | ||
| const alliance = alliances[allianceIndex]; | ||
| if (alliance && alliance.picks[pickIndex]) { | ||
| return; // Slot already filled | ||
| } | ||
|
|
||
| // Add team to the alliance | ||
| selectTeam(draggedTeam); | ||
| } | ||
|
|
||
| function selectTeam(team) { | ||
| const currentAlliance = alliances[currentPick.alliance]; | ||
|
|
||
| // Add team to alliance directly | ||
| currentAlliance.picks.push(team); | ||
| selectedTeams.add(team.team_number); | ||
|
|
||
| renderAlliances(); | ||
| renderAvailableTeams(); | ||
| advancePick(); | ||
| } | ||
|
|
||
| function removeTeamFromAlliance(allianceIndex, pickIndex) { | ||
| const alliance = alliances[allianceIndex]; | ||
| const removedTeam = alliance.picks[pickIndex]; | ||
|
|
||
| if (!removedTeam) return; | ||
|
|
||
| // Remove from alliance | ||
| alliance.picks.splice(pickIndex, 1); | ||
|
|
||
| // Remove from selected teams | ||
| selectedTeams.delete(removedTeam.team_number); | ||
|
|
||
| // Remove from declined teams if it was there | ||
| declinedTeams.delete(removedTeam.team_number); | ||
|
|
||
| // Recalculate current pick position based on how many picks have been made | ||
| recalculatePickPosition(); | ||
|
|
||
| renderAlliances(); | ||
| renderAvailableTeams(); | ||
| updateStatus(); | ||
| } | ||
|
|
||
| function recalculatePickPosition() { | ||
| // Count total picks made across all alliances | ||
| let totalPicks = 0; | ||
| alliances.forEach(alliance => { | ||
| totalPicks += alliance.picks.length; | ||
| }); | ||
|
|
||
| // If we haven't formed 8 alliances yet | ||
| if (alliances.length < 8) { | ||
| currentPick.alliance = alliances.length - 1; | ||
| currentPick.round = 1; | ||
| currentPick.phase = 'pick'; | ||
| return; | ||
| } | ||
|
|
||
| // Otherwise, figure out which alliance should be picking based on snake draft | ||
| // Round 1: alliances 0-7 (forward) | ||
| // Round 2: alliances 7-0 (backward) | ||
|
|
||
| if (totalPicks < 8) { | ||
| // Round 1 picks | ||
| currentPick.round = 1; | ||
| currentPick.alliance = totalPicks; | ||
| } else { | ||
| // Round 2 picks | ||
| currentPick.round = 2; | ||
| const round2Picks = totalPicks - 8; | ||
| currentPick.alliance = 7 - round2Picks; | ||
| } | ||
|
|
||
| // If all picks are done, mark as complete | ||
| if (totalPicks >= 16) { | ||
| currentPick.round = 3; // Signals completion | ||
| } | ||
| } | ||
|
|
||
| function advancePick() { | ||
| // Check if we're still forming alliances (need 8 captains total) | ||
| if (alliances.length < 8) { |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Magic number 8 appears multiple times throughout the code representing the number of alliances. Consider defining this as a named constant (e.g., MAX_ALLIANCES = 8) at the top of the script to improve maintainability and make it easier to change if FRC rules ever change.
| const maxPicks = 2; // Each alliance gets 2 picks after captain | ||
|
|
||
| for (let j = 0; j < maxPicks; j++) { | ||
| const pick = alliance.picks[j]; | ||
| if (pick) { | ||
| picksHtml += ` | ||
| <div class="team-slot filled p-3 rounded mb-2 cursor-pointer hover:bg-red-50 group" onclick="removeTeamFromAlliance(${i}, ${j})"> | ||
| <div class="flex justify-between items-center"> | ||
| <div class="flex-1"> | ||
| <div class="font-semibold">${pick.team_number}</div> | ||
| <div class="text-xs text-gray-600">${pick.nickname || ''}</div> | ||
| </div> | ||
| <div class="flex items-center gap-2"> | ||
| <div class="text-xs text-gray-500">Pick ${j + 1}</div> | ||
| <svg class="w-4 h-4 text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | ||
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | ||
| </svg> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| `; | ||
| } else { | ||
| picksHtml += ` | ||
| <div class="team-slot empty p-3 rounded mb-2 text-center text-gray-400" | ||
| data-alliance="${i}" | ||
| data-pick="${j}" | ||
| onclick="handleSlotClick(${i}, ${j})" | ||
| ondrop="handleDrop(event)" | ||
| ondragover="handleDragOver(event)" | ||
| ondragleave="handleDragLeave(event)"> | ||
| ${isActive && alliance.picks.length === j ? '← Tap or drag team here' : `Pick ${j + 1}`} | ||
| </div> | ||
| `; | ||
| } | ||
| } | ||
|
|
||
| card.innerHTML = ` | ||
| <div class="flex items-center justify-between mb-3"> | ||
| <h3 class="text-lg font-bold">Alliance ${alliance.number}</h3> | ||
| ${isActive ? '<span class="pick-round-indicator">PICKING</span>' : ''} | ||
| </div> | ||
| <div class="team-slot captain p-3 rounded mb-3"> | ||
| <div class="font-semibold text-blue-700">${alliance.captain.team_number}</div> | ||
| <div class="text-xs text-gray-600">${alliance.captain.nickname || ''}</div> | ||
| <div class="text-xs text-blue-600 font-medium mt-1">Captain (Rank ${alliance.captain.rank})</div> | ||
| </div> | ||
| ${picksHtml} | ||
| `; | ||
| } | ||
|
|
||
| grid.appendChild(card); | ||
| } | ||
| } | ||
|
|
||
| function renderAvailableTeams() { | ||
| const list = document.getElementById('available-teams-list'); | ||
| const searchTerm = document.getElementById('team-search').value.toLowerCase(); | ||
|
|
||
| list.innerHTML = ''; | ||
|
|
||
| availableTeams.forEach(team => { | ||
| const isDeclined = declinedTeams.has(team.team_number); | ||
| const isSelected = selectedTeams.has(team.team_number); | ||
| const isSelectedForPick = selectedTeamForPick && selectedTeamForPick.team_number === team.team_number; | ||
|
|
||
| // Hide selected teams from the list entirely | ||
| if (isSelected) return; | ||
|
|
||
| const matchesSearch = !searchTerm || | ||
| team.team_number.toString().includes(searchTerm) || | ||
| (team.nickname && team.nickname.toLowerCase().includes(searchTerm)); | ||
|
|
||
| if (!matchesSearch) return; | ||
|
|
||
| const card = document.createElement('div'); | ||
| card.className = `team-card bg-white border border-gray-200 rounded-lg p-3 ${ | ||
| isDeclined ? 'declined' : isSelectedForPick ? 'selected-for-pick' : '' | ||
| }`; | ||
| card.draggable = !isDeclined; | ||
|
|
||
| if (!isDeclined) { | ||
| card.setAttribute('data-team', JSON.stringify(team)); | ||
| card.addEventListener('dragstart', handleDragStart); | ||
| card.addEventListener('dragend', handleDragEnd); | ||
| card.addEventListener('click', () => handleTeamClick(team)); | ||
|
|
||
| // Touch events for mobile | ||
| card.addEventListener('touchstart', handleTouchStart, { passive: false }); | ||
| card.addEventListener('touchmove', handleTouchMove, { passive: false }); | ||
| card.addEventListener('touchend', handleTouchEnd, { passive: false }); | ||
| } | ||
|
|
||
| card.innerHTML = ` | ||
| <div class="font-semibold text-lg">${team.team_number}</div> | ||
| <div class="text-sm text-gray-600 truncate">${team.nickname || 'No name'}</div> | ||
| <div class="text-xs text-gray-500 mt-1">Rank: ${team.rank}</div> | ||
| ${isDeclined ? '<div class="text-xs text-red-600 font-medium mt-1">DECLINED</div>' : ''} | ||
| ${isSelectedForPick ? '<div class="text-xs text-blue-600 font-medium mt-1">SELECTED - Tap slot to place</div>' : ''} | ||
| `; | ||
|
|
||
| list.appendChild(card); | ||
| }); | ||
| } | ||
|
|
||
| let draggedTeam = null; | ||
| let touchStartPos = null; | ||
|
|
||
| function handleTeamClick(team) { | ||
| // For mobile/tablet - select team first, then click on slot | ||
| if (selectedTeamForPick && selectedTeamForPick.team_number === team.team_number) { | ||
| // Deselect if clicking the same team | ||
| selectedTeamForPick = null; | ||
| } else { | ||
| selectedTeamForPick = team; | ||
| } | ||
| renderAvailableTeams(); | ||
| renderAlliances(); | ||
| } | ||
|
|
||
| function handleSlotClick(allianceIndex, pickIndex) { | ||
| if (!selectedTeamForPick) return; | ||
|
|
||
| const alliance = alliances[allianceIndex]; | ||
| if (!alliance || alliance.picks[pickIndex]) return; // Alliance doesn't exist or slot filled | ||
|
|
||
| // Place the selected team | ||
| selectTeam(selectedTeamForPick); | ||
| selectedTeamForPick = null; | ||
| renderAvailableTeams(); | ||
| } | ||
|
|
||
| function handleTouchStart(e) { | ||
| const team = JSON.parse(e.currentTarget.getAttribute('data-team')); | ||
| touchStartPos = { | ||
| x: e.touches[0].clientX, | ||
| y: e.touches[0].clientY | ||
| }; | ||
| draggedTeam = team; | ||
| e.currentTarget.classList.add('dragging'); | ||
| } | ||
|
|
||
| function handleTouchMove(e) { | ||
| if (!draggedTeam) return; | ||
| e.preventDefault(); // Prevent scrolling while dragging | ||
| } | ||
|
|
||
| function handleTouchEnd(e) { | ||
| if (!draggedTeam) return; | ||
|
|
||
| e.currentTarget.classList.remove('dragging'); | ||
|
|
||
| const touch = e.changedTouches[0]; | ||
| const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY); | ||
|
|
||
| if (dropTarget && dropTarget.classList.contains('team-slot') && dropTarget.classList.contains('empty')) { | ||
| const allianceIndex = parseInt(dropTarget.getAttribute('data-alliance')); | ||
| const pickIndex = parseInt(dropTarget.getAttribute('data-pick')); | ||
|
|
||
| const alliance = alliances[allianceIndex]; | ||
| if (alliance && !alliance.picks[pickIndex]) { | ||
| selectTeam(draggedTeam); | ||
| } | ||
| } | ||
|
|
||
| draggedTeam = null; | ||
| touchStartPos = null; | ||
| } | ||
|
|
||
| function handleDragStart(e) { | ||
| draggedTeam = JSON.parse(e.target.getAttribute('data-team')); | ||
| e.target.classList.add('dragging'); | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| } | ||
|
|
||
| function handleDragEnd(e) { | ||
| e.target.classList.remove('dragging'); | ||
| draggedTeam = null; | ||
| } | ||
|
|
||
| function handleDragOver(e) { | ||
| e.preventDefault(); | ||
| e.dataTransfer.dropEffect = 'move'; | ||
| e.currentTarget.classList.add('drag-over'); | ||
| } | ||
|
|
||
| function handleDragLeave(e) { | ||
| e.currentTarget.classList.remove('drag-over'); | ||
| } | ||
|
|
||
| function handleDrop(e) { | ||
| e.preventDefault(); | ||
| e.currentTarget.classList.remove('drag-over'); | ||
|
|
||
| if (!draggedTeam) return; | ||
|
|
||
| const allianceIndex = parseInt(e.currentTarget.getAttribute('data-alliance')); | ||
| const pickIndex = parseInt(e.currentTarget.getAttribute('data-pick')); | ||
|
|
||
| // Check if this slot is already filled | ||
| const alliance = alliances[allianceIndex]; | ||
| if (alliance && alliance.picks[pickIndex]) { | ||
| return; // Slot already filled | ||
| } | ||
|
|
||
| // Add team to the alliance | ||
| selectTeam(draggedTeam); | ||
| } | ||
|
|
||
| function selectTeam(team) { | ||
| const currentAlliance = alliances[currentPick.alliance]; | ||
|
|
||
| // Add team to alliance directly | ||
| currentAlliance.picks.push(team); | ||
| selectedTeams.add(team.team_number); | ||
|
|
||
| renderAlliances(); | ||
| renderAvailableTeams(); | ||
| advancePick(); | ||
| } | ||
|
|
||
| function removeTeamFromAlliance(allianceIndex, pickIndex) { | ||
| const alliance = alliances[allianceIndex]; | ||
| const removedTeam = alliance.picks[pickIndex]; | ||
|
|
||
| if (!removedTeam) return; | ||
|
|
||
| // Remove from alliance | ||
| alliance.picks.splice(pickIndex, 1); | ||
|
|
||
| // Remove from selected teams | ||
| selectedTeams.delete(removedTeam.team_number); | ||
|
|
||
| // Remove from declined teams if it was there | ||
| declinedTeams.delete(removedTeam.team_number); | ||
|
|
||
| // Recalculate current pick position based on how many picks have been made | ||
| recalculatePickPosition(); | ||
|
|
||
| renderAlliances(); | ||
| renderAvailableTeams(); | ||
| updateStatus(); | ||
| } | ||
|
|
||
| function recalculatePickPosition() { | ||
| // Count total picks made across all alliances | ||
| let totalPicks = 0; | ||
| alliances.forEach(alliance => { | ||
| totalPicks += alliance.picks.length; | ||
| }); | ||
|
|
||
| // If we haven't formed 8 alliances yet | ||
| if (alliances.length < 8) { | ||
| currentPick.alliance = alliances.length - 1; | ||
| currentPick.round = 1; | ||
| currentPick.phase = 'pick'; | ||
| return; | ||
| } | ||
|
|
||
| // Otherwise, figure out which alliance should be picking based on snake draft | ||
| // Round 1: alliances 0-7 (forward) | ||
| // Round 2: alliances 7-0 (backward) | ||
|
|
||
| if (totalPicks < 8) { | ||
| // Round 1 picks | ||
| currentPick.round = 1; | ||
| currentPick.alliance = totalPicks; | ||
| } else { | ||
| // Round 2 picks | ||
| currentPick.round = 2; | ||
| const round2Picks = totalPicks - 8; | ||
| currentPick.alliance = 7 - round2Picks; | ||
| } | ||
|
|
||
| // If all picks are done, mark as complete | ||
| if (totalPicks >= 16) { | ||
| currentPick.round = 3; // Signals completion | ||
| } | ||
| } | ||
|
|
||
| function advancePick() { | ||
| // Check if we're still forming alliances (need 8 captains total) | ||
| if (alliances.length < 8) { | ||
| // Find next captain - highest ranked team that hasn't been selected or declined | ||
| const nextCaptain = availableTeams.find(team => | ||
| !selectedTeams.has(team.team_number) && | ||
| !declinedTeams.has(team.team_number) | ||
| ); | ||
|
|
||
| if (nextCaptain) { | ||
| // Create new alliance with this captain | ||
| alliances.push({ | ||
| number: alliances.length + 1, | ||
| captain: nextCaptain, | ||
| picks: [] | ||
| }); | ||
| selectedTeams.add(nextCaptain.team_number); | ||
| currentPick.alliance = alliances.length - 1; | ||
| currentPick.phase = 'pick'; | ||
|
|
||
| // Re-render to remove captain from available teams | ||
| renderAvailableTeams(); | ||
| } else { | ||
| showNotification('Not enough teams available to form 8 alliances. Some teams may have declined.', 'error'); | ||
| completeSelection(); | ||
| return; | ||
| } | ||
| } else { | ||
| // All 8 alliances formed, now do the snake draft picks | ||
| const maxPicks = 2; // Each alliance gets 2 picks after captain |
Copilot
AI
Dec 11, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Magic number 2 appears in multiple locations representing the maximum number of picks per alliance. Consider defining this as a named constant (e.g., MAX_PICKS_PER_ALLIANCE = 2) for better maintainability.
Summary
Resolves #25
Checklist