diff --git a/app/app.py b/app/app.py index 141b3b2..92b5116 100644 --- a/app/app.py +++ b/app/app.py @@ -62,16 +62,22 @@ def create_app(): # Initialize collections if "users" not in db.list_collection_names(): db.create_collection("users") + 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.") login_manager.init_app(app) login_manager.login_view = "auth.login" diff --git a/app/auth/routes.py b/app/auth/routes.py index 469c632..21be903 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -4,7 +4,6 @@ import os from functools import wraps from urllib.parse import urljoin, urlparse -import hashlib from bson import ObjectId from flask import (Blueprint, current_app, flash, jsonify, redirect, diff --git a/app/models.py b/app/models.py index b2e44a1..2ab7486 100644 --- a/app/models.py +++ b/app/models.py @@ -82,8 +82,10 @@ def __init__(self, data): self.teleop_shift_3_fuel = data.get('teleop_shift_3_fuel', 0) self.teleop_shift_4_fuel = data.get('teleop_shift_4_fuel', 0) self.endgame_fuel = data.get('endgame_fuel', 0) + self.ferried_fuel = data.get('ferried_fuel', 0) # Climb + self.auto_climb = data.get('auto_climb', False) self.climb_level = data.get('climb_level', 0) # 0=None, 1-3 self.climb_success = data.get('climb_success', False) @@ -128,6 +130,8 @@ def to_dict(self): 'teleop_shift_3_fuel': self.teleop_shift_3_fuel, 'teleop_shift_4_fuel': self.teleop_shift_4_fuel, 'endgame_fuel': self.endgame_fuel, + 'ferried_fuel': self.ferried_fuel, + 'auto_climb': self.auto_climb, 'climb_level': self.climb_level, 'climb_success': self.climb_success, diff --git a/app/scout/TBA.py b/app/scout/TBA.py index 5787e9c..9a94388 100644 --- a/app/scout/TBA.py +++ b/app/scout/TBA.py @@ -35,7 +35,7 @@ def get_team(self, team_key): logger.error(f"Error fetching team from TBA: {e}") return None - @lru_cache(maxsize=100) + @lru_cache(maxsize=5) def get_event_matches(self, event_key): """Get matches for an event and format them by match number""" try: @@ -76,7 +76,7 @@ def get_event_matches(self, event_key): logger.error(f"Error fetching event matches from TBA: {e}") return None - @lru_cache(maxsize=100) + @lru_cache(maxsize=20) def get_current_events(self, year): """Get all events for the specified year""" try: @@ -110,7 +110,7 @@ def get_current_events(self, year): logger.error(f"Error fetching events from TBA: {e}") return None - @lru_cache(maxsize=100) + @lru_cache(maxsize=20) def get_team_status_at_event(self, team_key, event_key): """Get team status and ranking at a specific event""" try: @@ -124,7 +124,7 @@ def get_team_status_at_event(self, team_key, event_key): logger.error(f"Error fetching team status from TBA: {e}") return None - @lru_cache(maxsize=100) + @lru_cache(maxsize=20) def get_team_matches_at_event(self, team_key, event_key): """Get a team's matches at a specific event with previous and upcoming separation""" try: @@ -133,22 +133,22 @@ def get_team_matches_at_event(self, team_key, event_key): headers=self.headers, timeout=self.timeout ) - + if response.status_code != 200: return None - + matches = response.json() current_time = datetime.now().timestamp() - + previous_matches = [] upcoming_matches = [] - + for match in matches: # Get match details comp_level = match.get('comp_level', 'qm') match_number = match.get('match_number') set_number = match.get('set_number') - + # Format match name if comp_level == 'qm': match_name = f"Qualification {match_number}" @@ -158,14 +158,15 @@ def get_team_matches_at_event(self, team_key, event_key): match_name = f"Final {match_number}" else: match_name = f"{comp_level.upper()} {match_number}" - - # Determine alliance - alliance = None - for color in ['red', 'blue']: - if team_key in match['alliances'][color]['team_keys']: - alliance = color - break - + + alliance = next( + ( + color + for color in ['red', 'blue'] + if team_key in match['alliances'][color]['team_keys'] + ), + None, + ) match_info = { 'match_name': match_name, 'time': match.get('predicted_time') or match.get('time', 0) or 0, @@ -175,28 +176,28 @@ def get_team_matches_at_event(self, team_key, event_key): 'blue': match['alliances']['blue']['score'] } } - + # Sort into previous or upcoming actual_time = match.get('actual_time') if (actual_time is not None and actual_time > 0) or match_info['time'] < current_time: previous_matches.append(match_info) else: upcoming_matches.append(match_info) - + # Sort matches by time previous_matches.sort(key=lambda m: m['time']) upcoming_matches.sort(key=lambda m: m['time']) - + return { 'previous': previous_matches, 'upcoming': upcoming_matches } - + except Exception as e: logger.error(f"Error fetching team matches from TBA: {e}") return None - @lru_cache(maxsize=100) + @lru_cache(maxsize=20) def get_team_events(self, team_key, year=None): """Get all events a team is participating in for the given year""" if year is None: @@ -240,4 +241,88 @@ def get_most_recent_active_event(self, team_key): return event # If no current events, get the most recent event (events are already sorted by date, most recent first) - return events[0] \ No newline at end of file + return events[0] + + @lru_cache(maxsize=5) + def get_event_teams(self, event_key): + """ + Retrieve a list of teams attending a specific event. + + Args: + event_key (str): The unique key identifying the event. + + Returns: + list[dict] or None: A list of team objects, or None if the request fails. + """ + try: + response = requests.get( + f"{self.base_url}/event/{event_key}/teams", + headers=self.headers, + timeout=self.timeout + ) + + if response.status_code != 200: + return None + + return response.json() + except Exception as e: + logger.error(f"Error fetching event teams from TBA: {e}") + return None + + @lru_cache(maxsize=5) + def get_event_rankings(self, event_key): + """ + Retrieve the team rankings for a given event, including ranking points and match records. + + Args: + event_key (str): The unique key identifying the event (e.g., '2023miket'). + + Returns: + list[dict] or None: A list of dictionaries containing team ranking information, or None if the request fails. + Each dictionary contains: + - 'rank' (int): The team's rank at the event. + - 'team_key' (str): The team's TBA key (e.g., 'frc254'). + - 'team_number' (int): The team's number. + - 'ranking_points' (float|int): The team's ranking points. + - 'record' (dict): The team's win/loss/tie record. + - 'matches_played' (int): The number of matches played. + + Exceptions: + Logs and returns None if an exception occurs during the request or response parsing. + """ + try: + response = requests.get( + f"{self.base_url}/event/{event_key}/rankings", + headers=self.headers, + timeout=self.timeout + ) + + if response.status_code != 200: + return None + + data = response.json() + if not data: + return [] + + rankings = data.get('rankings', []) + + # Format rankings with team info and ranking points + formatted_rankings = [] + for rank in rankings: + team_key = rank.get('team_key', '') + team_number = team_key.replace('frc', '') if team_key.startswith('frc') else team_key + + formatted_rankings.append({ + 'rank': rank.get('rank'), + 'team_key': team_key, + 'team_number': int(team_number) if team_number.isdigit() else 0, + 'ranking_points': rank.get('sort_orders', [0])[0] if rank.get('sort_orders') else 0, + 'record': rank.get('record', {}), + 'matches_played': rank.get('matches_played', 0) + }) + + return formatted_rankings + + except Exception as e: + logger.error(f"Error fetching event rankings from TBA: {e}") + return None \ No newline at end of file diff --git a/app/scout/routes.py b/app/scout/routes.py index 12e3e6d..0dab57c 100644 --- a/app/scout/routes.py +++ b/app/scout/routes.py @@ -11,7 +11,7 @@ import logging from app.scout.scouting_utils import ScoutingManager -from app.utils import async_route, handle_route_errors +from app.utils import async_route, handle_route_errors, limiter from .TBA import TBAInterface @@ -231,6 +231,7 @@ def format_team_stats(stats): "matches_played": stats.get("matches_played", 0), "auto_fuel_avg": stats.get("avg_auto_fuel", 0), "teleop_fuel_avg": avg_teleop_fuel, + "ferried_fuel_avg": stats.get("avg_ferried_fuel", 0), "endgame_fuel_avg": stats.get("avg_endgame_fuel", 0), "climb_level_avg": stats.get("avg_climb_level", 0), "climb_success_rate": stats.get("climb_success_rate", 0) * 100 @@ -274,7 +275,9 @@ def compare_teams(): # Fuel Stats "avg_auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, + "avg_auto_climb": {"$avg": {"$cond": ["$auto_climb", 1, 0]}}, "avg_transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "avg_ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, "avg_teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, "avg_teleop_shift_2_fuel": {"$avg": {"$ifNull": ["$teleop_shift_2_fuel", 0]}}, "avg_teleop_shift_3_fuel": {"$avg": {"$ifNull": ["$teleop_shift_3_fuel", 0]}}, @@ -309,6 +312,7 @@ def compare_teams(): # Calculate total teleop average avg_teleop_total = ( (stats[0].get("avg_transition_fuel") or 0) + + (stats[0].get("avg_ferried_fuel") or 0) + (stats[0]["avg_teleop_shift_1_fuel"] or 0) + (stats[0]["avg_teleop_shift_2_fuel"] or 0) + (stats[0]["avg_teleop_shift_3_fuel"] or 0) + @@ -416,6 +420,7 @@ async def search_teams(): "match_number": 1, # Fuel Stats "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, @@ -424,6 +429,7 @@ async def search_teams(): "endgame_fuel": {"$ifNull": ["$endgame_fuel", 0]}, # Climb Stats + "auto_climb": 1, "climb_level": {"$ifNull": ["$climb_level", 0]}, "climb_type": 1, "climb_success": 1, @@ -533,6 +539,7 @@ def leaderboard(): # Auto Fuel "auto_fuel": {"$avg": {"$ifNull": ["$auto_fuel", 0]}}, "transition_fuel": {"$avg": {"$ifNull": ["$transition_fuel", 0]}}, + "ferried_fuel": {"$avg": {"$ifNull": ["$ferried_fuel", 0]}}, # Teleop Fuel "teleop_shift_1_fuel": {"$avg": {"$ifNull": ["$teleop_shift_1_fuel", 0]}}, @@ -544,6 +551,7 @@ def leaderboard(): "endgame_fuel": {"$avg": {"$ifNull": ["$endgame_fuel", 0]}}, # Climb Level + "auto_climb_sum": {"$sum": {"$cond": [{"$eq": ["$auto_climb", True]}, 1, 0]}}, "climb_level": {"$avg": {"$ifNull": ["$climb_level", 0]}}, # Defense Rating @@ -567,6 +575,7 @@ def leaderboard(): "auto_fuel": {"$round": ["$auto_fuel", 1]}, "transition_fuel": {"$round": ["$transition_fuel", 1]}, + "ferried_fuel": {"$round": ["$ferried_fuel", 1]}, "teleop_fuel_total": { "$add": [ "$transition_fuel", @@ -593,6 +602,12 @@ def leaderboard(): "climb_level_avg": {"$round": ["$climb_level", 1]}, + "auto_climb_pct": { + "$multiply": [ + {"$divide": ["$auto_climb_sum", "$matches_played"]}, + 100 + ] + }, "climb_l1_pct": { "$multiply": [ {"$divide": ["$climb_level_1_success", "$matches_played"]}, @@ -630,6 +645,8 @@ def leaderboard(): sort_field = { 'fuel': 'total_fuel', 'auto_fuel': 'auto_fuel', + 'ferried_fuel': 'ferried_fuel', + 'auto_climb': 'auto_climb_pct', 'climb': 'climb_success_rate', 'climb_l1': 'climb_l1_pct', 'climb_l2': 'climb_l2_pct', @@ -795,7 +812,9 @@ def matches(): # Fuel stats "auto_fuel": {"$ifNull": ["$auto_fuel", 0]}, + "auto_climb": {"$ifNull": ["$auto_climb", False]}, "transition_fuel": {"$ifNull": ["$transition_fuel", 0]}, + "ferried_fuel": {"$ifNull": ["$ferried_fuel", 0]}, "teleop_shift_1_fuel": {"$ifNull": ["$teleop_shift_1_fuel", 0]}, "teleop_shift_2_fuel": {"$ifNull": ["$teleop_shift_2_fuel", 0]}, "teleop_shift_3_fuel": {"$ifNull": ["$teleop_shift_3_fuel", 0]}, @@ -841,16 +860,20 @@ def matches(): # Prepare team data for template red_team_data = [{ "number": t["number"], + "ferried_fuel": t["ferried_fuel"], "fuel_total": t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"], "climb_level": t["climb_level"], - "climb_success": t["climb_success"] + "climb_success": t["climb_success"], + "auto_climb": t["auto_climb"] } for t in red_teams] blue_team_data = [{ "number": t["number"], + "ferried_fuel": t["ferried_fuel"], "fuel_total": t["auto_fuel"] + t["transition_fuel"] + t["teleop_shift_1_fuel"] + t["teleop_shift_2_fuel"] + t["teleop_shift_3_fuel"] + t["teleop_shift_4_fuel"] + t["endgame_fuel"], "climb_level": t["climb_level"], - "climb_success": t["climb_success"] + "climb_success": t["climb_success"], + "auto_climb": t["auto_climb"] } for t in blue_teams] matches.append({ @@ -1089,10 +1112,11 @@ def pit_scouting_delete(team_number): # @limiter.limit("30 per minute") def get_tba_events(): try: - year = datetime.now().year + year = request.args.get('year', default=datetime.now().year, type=int) tba = TBAInterface() events = tba.get_current_events(year) - current_app.logger.info(f"Successfully fetched TBA events {events} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") + event_count = len(events) if events else 0 + current_app.logger.info(f"Successfully fetched {event_count} TBA events for year {year} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") return jsonify(events) except Exception as e: current_app.logger.error(f"Error getting TBA events: {e}") @@ -1129,35 +1153,33 @@ def live_match_status(): @scouting_bp.route("/api/tba/team-status") @login_required -# @limiter.limit("30 per minute") def get_team_status(): """Get team status at an event including ranking and matches""" team_number = request.args.get('team') event_code = request.args.get('event') - + if not team_number: return jsonify({"error": "Team number is required"}), 400 - + try: # Format TBA team key team_key = f"frc{team_number}" - + # Initialize TBA interface tba = TBAInterface() - + # If event code not provided, find the most recent event if not event_code: most_recent_event = tba.get_most_recent_active_event(team_key) - if most_recent_event: - event_code = most_recent_event.get('key') - # Also return event details for the UI - event_name = most_recent_event.get('name', 'Unknown Event') - else: + if not most_recent_event: return jsonify({"error": "No events found for this team"}), 404 - + + event_code = most_recent_event.get('key') + # Also return event details for the UI + event_name = most_recent_event.get('name', 'Unknown Event') # Get team status at event (ranking) status = tba.get_team_status_at_event(team_key, event_code) - + # Get team matches at event matches = tba.get_team_matches_at_event(team_key, event_code) current_app.logger.info(f"Successfully fetched team status {status} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") @@ -1248,3 +1270,51 @@ def get_team_paths(): except Exception as e: current_app.logger.error(f"Error fetching team paths: {str(e)}", exc_info=True) return jsonify({"error": "Failed to fetch team path data."}), 500 + + +@scouting_bp.route("/scouting/mock-alliance-selection") +@login_required +@limiter.limit("30 per minute") +def mock_alliance_selection(): + """ + Render the mock alliance selection page. + + Returns: + Rendered HTML template for the mock alliance selection page. + """ + current_app.logger.info(f"Successfully loaded mock alliance selection for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") + return render_template("scouting/mock-alliance-selection.html") + + +@scouting_bp.route("/api/alliance-selection/rankings/") +@login_required +def get_alliance_rankings(event_key): + """Get team rankings for alliance selection""" + # basic validation for event_key (year + event code) + if not event_key or not event_key.isalnum(): + return jsonify({"error": "Invalid event key format"}), 400 + + try: + tba = TBAInterface() + rankings = tba.get_event_rankings(event_key) + + if not rankings: + return jsonify({"error": "Failed to fetch rankings"}), 404 + + # 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', '') + + current_app.logger.info(f"Successfully fetched alliance rankings for event {event_key}") + return jsonify(rankings) + + except Exception as e: + current_app.logger.error(f"Error fetching alliance rankings: {str(e)}", exc_info=True) + return jsonify({"error": "Failed to fetch rankings"}), 500 diff --git a/app/scout/scouting_utils.py b/app/scout/scouting_utils.py index fcd9856..e963a65 100644 --- a/app/scout/scouting_utils.py +++ b/app/scout/scouting_utils.py @@ -109,8 +109,10 @@ def add_scouting_data(self, data, scouter_id): "teleop_shift_3_fuel": int(data.get('teleop_shift_3_fuel', 0)), "teleop_shift_4_fuel": int(data.get('teleop_shift_4_fuel', 0)), "endgame_fuel": int(data.get('endgame_fuel', 0)), + "ferried_fuel": int(data.get('ferried_fuel', 0)), # Climb + "auto_climb": bool(data.get("auto_climb", False)), "climb_level": int(data.get('climb_level', 0)), "climb_type": data.get("climb_type", ""), "climb_success": bool(data.get("climb_success", False)), @@ -193,8 +195,10 @@ def get_all_scouting_data(self, user_team_number=None, user_id=None): "teleop_shift_3_fuel": 1, "teleop_shift_4_fuel": 1, "endgame_fuel": 1, + "ferried_fuel": 1, # Climb Stats + "auto_climb": 1, "climb_level": 1, "climb_type": 1, "climb_success": 1, @@ -319,6 +323,7 @@ def update_team_data(self, team_id, data, scouter_id): "teleop_shift_3_fuel": int(data.get("teleop_shift_3_fuel", 0)), "teleop_shift_4_fuel": int(data.get("teleop_shift_4_fuel", 0)), "endgame_fuel": int(data.get("endgame_fuel", 0)), + "ferried_fuel": int(data.get("ferried_fuel", 0)), # Climb Stats "climb_level": int(data.get("climb_level", 0)), diff --git a/app/static/js/compare.js b/app/static/js/compare.js index 9281cca..7935a39 100644 --- a/app/static/js/compare.js +++ b/app/static/js/compare.js @@ -96,6 +96,7 @@ function updateTeamCards(data) { // Update Teleop Period stats document.getElementById(`team${cardNum}-transition-fuel`).textContent = (stats.avg_transition_fuel || 0).toFixed(2); + document.getElementById(`team${cardNum}-ferried-fuel`).textContent = (stats.avg_ferried_fuel || 0).toFixed(2); document.getElementById(`team${cardNum}-teleop-s1`).textContent = (stats.avg_teleop_shift_1_fuel || 0).toFixed(2); document.getElementById(`team${cardNum}-teleop-s2`).textContent = (stats.avg_teleop_shift_2_fuel || 0).toFixed(2); document.getElementById(`team${cardNum}-teleop-s3`).textContent = (stats.avg_teleop_shift_3_fuel || 0).toFixed(2); @@ -239,6 +240,9 @@ function updateRawDataTable(data) { ${match.transition_fuel || 0} + + ${match.ferried_fuel || 0} + ${match.teleop_shift_1_fuel || 0} / ${match.teleop_shift_2_fuel || 0} / ${match.teleop_shift_3_fuel || 0} / ${match.teleop_shift_4_fuel || 0} diff --git a/app/static/js/mock-alliance-selection.js b/app/static/js/mock-alliance-selection.js new file mode 100644 index 0000000..863ff26 --- /dev/null +++ b/app/static/js/mock-alliance-selection.js @@ -0,0 +1,967 @@ +// Constants +const MAX_ALLIANCES = 8; +const MAX_PICKS_PER_ALLIANCE = 2; +const NOTIFICATION_TIMEOUT = 5000; +const NOTIFICATION_FADE_DURATION = 500; +const MIN_YEAR = 1992; + +// State - used to track elements and data +const state = { + alliances: [], + availableTeams: [], + selectedTeams: new Set(), + isSelectionComplete: false, + currentPick: { alliance: 0, round: 1, phase: 'captain' }, + eventKey: '', + allRankings: [], + selectedTeamForPick: null, + allEventsList: [], + draggedTeam: null, + touchStartPos: null +}; + +// Cached DOM elements +const dom = { + yearSelect: null, + eventSearchInput: null, + eventDropdownList: null, + eventDropdownArrow: null, + eventSelect: null, + searchEventsBtn: null, + loadRankingsBtn: null, + resetBtn: null, + exportBtn: null, + teamSearch: null, + allianceGrid: null, + availableTeamsList: null, + statusText: null, + statusBox: null, + startBracketBtn: null, + splitView: null, + controlButtons: null, + selectionStatus: null, + notificationArea: null +}; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + cacheDOMElements(); + initializeYearSelect(); + setupEventDropdown(); + setupEventListeners(); + loadEvents(); +}); + +// Cache all DOM elements +function cacheDOMElements() { + dom.yearSelect = document.getElementById('year-select'); + dom.eventSearchInput = document.getElementById('event-search-input'); + dom.eventDropdownList = document.getElementById('event-dropdown-list'); + dom.eventDropdownArrow = document.getElementById('event-dropdown_arrow'); + dom.eventSelect = document.getElementById('event-select'); + dom.searchEventsBtn = document.getElementById('search-events-btn'); + dom.loadRankingsBtn = document.getElementById('load-rankings-btn'); + dom.resetBtn = document.getElementById('reset-btn'); + dom.exportBtn = document.getElementById('export-btn'); + dom.teamSearch = document.getElementById('team-search'); + dom.allianceGrid = document.getElementById('alliance-grid'); + dom.availableTeamsList = document.getElementById('available-teams-list'); + dom.statusText = document.getElementById('status-text'); + dom.statusBox = dom.statusText?.parentElement?.parentElement; + dom.startBracketBtn = document.getElementById('start-bracket-btn'); + dom.splitView = document.getElementById('split-view'); + dom.controlButtons = document.getElementById('control-buttons'); + dom.selectionStatus = document.getElementById('selection-status'); + dom.notificationArea = document.getElementById('notification-area'); +} + +// Initialize year select with current year +function initializeYearSelect() { + if (dom.yearSelect) { + const currentYear = new Date().getFullYear(); + dom.yearSelect.max = currentYear; + dom.yearSelect.value = currentYear; + } +} + +// Setup all event listeners +function setupEventListeners() { + if (dom.yearSelect) { + dom.yearSelect.addEventListener('keypress', (e) => { + if (e.key === 'Enter') loadEvents(); + }); + } + + if (dom.searchEventsBtn) { + dom.searchEventsBtn.addEventListener('click', loadEvents); + } + + if (dom.loadRankingsBtn) { + dom.loadRankingsBtn.addEventListener('click', loadRankings); + } + + if (dom.resetBtn) { + dom.resetBtn.addEventListener('click', resetSelection); + } + + if (dom.exportBtn) { + dom.exportBtn.addEventListener('click', exportResults); + } + + if (dom.teamSearch) { + dom.teamSearch.addEventListener('input', filterTeams); + } + + if (dom.startBracketBtn) { + dom.startBracketBtn.addEventListener('click', handleStartBracket); + } + + if (dom.allianceGrid) { + dom.allianceGrid.addEventListener('click', handleGridClick); + dom.allianceGrid.addEventListener('dragover', handleDragOver); + dom.allianceGrid.addEventListener('dragleave', handleDragLeave); + dom.allianceGrid.addEventListener('drop', handleDrop); + } +} + +function handleGridClick(e) { + const slot = e.target.closest('.team-slot'); + if (!slot) return; + + const allianceIndex = parseInt(slot.getAttribute('data-alliance')); + const pickIndex = parseInt(slot.getAttribute('data-pick')); + + if (slot.dataset.action === 'remove') { + removeTeamFromAlliance(allianceIndex, pickIndex); + } else if (slot.classList.contains('empty')) { + handleSlotClick(allianceIndex, pickIndex); + } +} + +// Handle start bracket button click +function handleStartBracket() { + if (typeof startPlayoffBracket === 'function') { + startPlayoffBracket(); + } else { + console.error('startPlayoffBracket function not found'); + showNotification('Error: Bracket script not loaded properly.', 'error'); + } +} + +// Notification configuration +const NOTIFICATION_COLORS = { + success: { bg: 'bg-green-50', text: 'text-green-800', border: 'border-green-200', icon: 'text-green-500' }, + error: { bg: 'bg-red-50', text: 'text-red-800', border: 'border-red-200', icon: 'text-red-500' }, + warning: { bg: 'bg-yellow-50', text: 'text-yellow-800', border: 'border-yellow-200', icon: 'text-yellow-500' }, + info: { bg: 'bg-blue-50', text: 'text-blue-800', border: 'border-blue-200', icon: 'text-blue-500' } +}; + +const NOTIFICATION_ICONS = { + success: '', + error: '', + warning: '', + info: '' +}; + +function showNotification(message, type = 'info') { + if (!dom.notificationArea) return; + + const color = NOTIFICATION_COLORS[type] || NOTIFICATION_COLORS.info; + const notification = createNotificationElement(message, type, color); + dom.notificationArea.appendChild(notification); + + // Auto-dismiss + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transition = `opacity ${NOTIFICATION_FADE_DURATION}ms`; + setTimeout(() => notification.remove(), NOTIFICATION_FADE_DURATION); + }, NOTIFICATION_TIMEOUT); +} + +// Create notification DOM element +function createNotificationElement(message, type, color) { + const notification = document.createElement('div'); + notification.setAttribute('role', 'alert'); + notification.className = `flex items-center p-6 rounded-lg shadow-xl ${color.bg} ${color.text} border-2 ${color.border} mb-4 animate-fade-in`; + + const iconSvg = createSVGElement('w-6 h-6 mr-3 flex-shrink-0 ' + color.icon, NOTIFICATION_ICONS[type] || NOTIFICATION_ICONS.info); + const messageP = document.createElement('p'); + messageP.className = 'text-base font-medium flex-1'; + messageP.textContent = message; + + const dismissButton = createDismissButton(color.icon); + dismissButton.onclick = () => notification.remove(); + + notification.appendChild(iconSvg); + notification.appendChild(messageP); + notification.appendChild(dismissButton); + + return notification; +} + +// Create SVG element +function createSVGElement(className, innerHTML) { + const svg = document.createElement('svg'); + svg.className = className; + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('viewBox', '0 0 20 20'); + svg.innerHTML = innerHTML; + return svg; +} + +// Create dismiss button +function createDismissButton(iconClass) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 ${iconClass} hover:bg-opacity-20`; + + const srSpan = document.createElement('span'); + srSpan.className = 'sr-only'; + srSpan.textContent = 'Dismiss'; + button.appendChild(srSpan); + + const closeSvg = createSVGElement('w-5 h-5', ''); + button.appendChild(closeSvg); + + return button; +} + +function setupEventDropdown() { + if (!dom.eventSearchInput || !dom.eventDropdownList) return; + + // Filter list on input + dom.eventSearchInput.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase(); + + // Clear selection when user types + if (dom.eventSelect) dom.eventSelect.value = ""; + + const filtered = state.allEventsList.filter(evt => + evt.name.toLowerCase().includes(query) + ); + renderEventDropdown(filtered); + dom.eventDropdownList.classList.remove('hidden'); + }); + + // Show list on focus + dom.eventSearchInput.addEventListener('focus', () => { + if (state.allEventsList.length > 0) { + const query = dom.eventSearchInput.value.toLowerCase(); + const filtered = query + ? state.allEventsList.filter(evt => evt.name.toLowerCase().includes(query)) + : state.allEventsList; + renderEventDropdown(filtered); + dom.eventDropdownList.classList.remove('hidden'); + } + }); + + // Hide list when clicking outside + document.addEventListener('click', (e) => { + if (!dom.eventSearchInput.contains(e.target) && + !dom.eventDropdownList.contains(e.target) && + !(dom.eventDropdownArrow && dom.eventDropdownArrow.contains(e.target))) { + dom.eventDropdownList.classList.add('hidden'); + } + }); + + // Toggle list on arrow click + if (dom.eventDropdownArrow) { + dom.eventDropdownArrow.addEventListener('click', (e) => { + e.stopPropagation(); + if (dom.eventDropdownList.classList.contains('hidden')) { + if (state.allEventsList.length > 0 && !dom.eventSearchInput.value) { + renderEventDropdown(state.allEventsList); + } + dom.eventDropdownList.classList.remove('hidden'); + dom.eventSearchInput.focus(); + } else { + dom.eventDropdownList.classList.add('hidden'); + } + }); + } +} + +function renderEventDropdown(events) { + if (!dom.eventDropdownList) return; + + dom.eventDropdownList.innerHTML = ''; + + if (events.length === 0) { + const div = document.createElement('div'); + div.className = 'px-4 py-2 text-sm text-gray-500'; + div.textContent = 'No events found'; + dom.eventDropdownList.appendChild(div); + return; + } + + const fragment = document.createDocumentFragment(); + events.forEach(evt => { + const li = document.createElement('li'); + li.className = 'px-4 py-2 hover:bg-blue-100 cursor-pointer text-sm text-gray-700'; + li.textContent = evt.name; + li.addEventListener('click', () => { + if (dom.eventSelect) dom.eventSelect.value = evt.key; + if (dom.eventSearchInput) dom.eventSearchInput.value = evt.name; + dom.eventDropdownList.classList.add('hidden'); + }); + fragment.appendChild(li); + }); + + dom.eventDropdownList.appendChild(fragment); +} + +async function loadEvents() { + if (!dom.yearSelect || !dom.searchEventsBtn) return; + + const year = dom.yearSelect.value; + const currentYear = new Date().getFullYear(); + + if (!year || year < MIN_YEAR || year > currentYear + 1) { + showNotification(`Please enter a valid year between ${MIN_YEAR} and ${currentYear}.`, 'warning'); + return; + } + + setLoadingState(true); + + try { + const response = await fetch(`/api/tba/events?year=${year}&_=${Date.now()}`); + const events = await response.json(); + + if (!response.ok || events.error) { + throw new Error(events.error || 'Failed to fetch events'); + } + + state.allEventsList = Object.entries(events) + .map(([name, data]) => ({ name, key: data.key })) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (state.allEventsList.length === 0) { + if (dom.eventSearchInput) dom.eventSearchInput.placeholder = "No events found"; + showNotification(`No events found for year ${year}. Try a different year.`, 'info'); + } else { + if (dom.eventSearchInput) { + dom.eventSearchInput.disabled = false; + dom.eventSearchInput.placeholder = "Type to search events..."; + } + showNotification(`Found ${state.allEventsList.length} events for ${year}`, 'success'); + } + } catch (error) { + console.error('Error loading events:', error); + if (dom.eventSearchInput) dom.eventSearchInput.placeholder = "Failed to load events"; + showNotification('Failed to load events. Please try again.', 'error'); + } finally { + setLoadingState(false); + } +} + +// Set loading state for event search +function setLoadingState(isLoading) { + if (dom.searchEventsBtn) { + dom.searchEventsBtn.disabled = isLoading; + dom.searchEventsBtn.textContent = isLoading ? 'Searching...' : 'Search Events'; + } + + if (dom.eventSearchInput) { + dom.eventSearchInput.disabled = isLoading; + if (isLoading) { + dom.eventSearchInput.placeholder = "Loading events..."; + dom.eventSearchInput.value = ""; + } + } + + if (dom.eventSelect && isLoading) { + dom.eventSelect.value = ""; + } +} + +async function loadRankings() { + if (!dom.eventSelect || !dom.loadRankingsBtn) return; + + state.eventKey = dom.eventSelect.value; + + if (!state.eventKey) { + showNotification('Please select an event first.', 'warning'); + return; + } + + dom.loadRankingsBtn.disabled = true; + dom.loadRankingsBtn.textContent = 'Loading...'; + + try { + const response = await fetch(`/api/alliance-selection/rankings/${state.eventKey}`); + const rankings = await response.json(); + + if (rankings.error) { + showNotification(rankings.error, 'error'); + return; + } + + state.allRankings = rankings; + initializeAlliances(rankings); + + // Show UI elements + [dom.splitView, dom.controlButtons, dom.selectionStatus].forEach(el => { + if (el) el.classList.remove('hidden'); + }); + + updateStatus(); + } catch (error) { + console.error('Error loading rankings:', error); + showNotification('Failed to load rankings. Please try again.', 'error'); + } finally { + dom.loadRankingsBtn.disabled = false; + dom.loadRankingsBtn.textContent = 'Load Rankings'; + } +} + +function initializeAlliances(rankings) { + // Start fresh - only seed 1 is the first captain + state.alliances = []; + state.availableTeams = [...rankings]; + state.isSelectionComplete = false; + state.selectedTeams.clear(); + + // Alliance 1 with seed 1 as captain + const firstCaptain = rankings[0]; + state.alliances.push({ + number: 1, + captain: firstCaptain, + picks: [] + }); + + state.selectedTeams.add(firstCaptain.team_number); + state.currentPick = { alliance: 0, round: 1, phase: 'pick' }; + + renderAlliances(); + renderAvailableTeams(); +} + +// Drag and touch event handlers +function handleTeamClick(team) { + const prevSelected = state.selectedTeamForPick; + + // Toggle selection + state.selectedTeamForPick = (prevSelected && prevSelected.team_number === team.team_number) + ? null + : team; + + // Update visual state + updateTeamSelection(prevSelected, state.selectedTeamForPick); + renderAlliances(); +} + +// Update team selection visual state +function updateTeamSelection(prevTeam, newTeam) { + if (prevTeam) { + const prevCard = document.getElementById(`team-card-${prevTeam.team_number}`); + if (prevCard) { + prevCard.classList.remove('selected-for-pick'); + prevCard.querySelector('.selection-indicator')?.remove(); + } + } + + if (newTeam) { + const newCard = document.getElementById(`team-card-${newTeam.team_number}`); + if (newCard) { + newCard.classList.add('selected-for-pick'); + if (!newCard.querySelector('.selection-indicator')) { + const ind = document.createElement('div'); + ind.className = 'selection-indicator text-xs text-blue-600 font-medium mt-1'; + ind.textContent = 'SELECTED - Tap slot to place'; + newCard.appendChild(ind); + } + } + } +} + +function handleSlotClick(allianceIndex, pickIndex) { + if (!state.selectedTeamForPick) return; + + const alliance = state.alliances[allianceIndex]; + if (!alliance || alliance.picks[pickIndex]) return; + + selectTeam(state.selectedTeamForPick); + state.selectedTeamForPick = null; + renderAvailableTeams(); +} + +function handleTouchStart(e) { + const team = JSON.parse(e.currentTarget.getAttribute('data-team')); + state.touchStartPos = { + x: e.touches[0].clientX, + y: e.touches[0].clientY + }; + state.draggedTeam = team; + e.currentTarget.classList.add('dragging'); +} + +function handleTouchMove(e) { + if (!state.draggedTeam) return; + e.preventDefault(); +} + +function handleTouchEnd(e) { + if (!state.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 = state.alliances[allianceIndex]; + if (alliance && !alliance.picks[pickIndex]) { + selectTeam(state.draggedTeam); + } + } + + state.draggedTeam = null; + state.touchStartPos = null; +} + +function handleDragStart(e) { + state.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'); + state.draggedTeam = null; +} + +function handleDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const target = e.target.closest('.team-slot'); + if (target) { + target.classList.add('drag-over'); + } +} + +function handleDragLeave(e) { + const target = e.target.closest('.team-slot'); + if (target) { + target.classList.remove('drag-over'); + } +} + +function handleDrop(e) { + e.preventDefault(); + const target = e.target.closest('.team-slot'); + if (target) target.classList.remove('drag-over'); + + if (!state.draggedTeam || !target) return; + + const allianceIndex = parseInt(target.getAttribute('data-alliance')); + const pickIndex = parseInt(target.getAttribute('data-pick')); + + const alliance = state.alliances[allianceIndex]; + if (alliance && !alliance.picks[pickIndex]) { + selectTeam(state.draggedTeam); + } +} + +function renderAlliances() { + if (!dom.allianceGrid) return; + + dom.allianceGrid.innerHTML = ''; + + const allPicksComplete = areAllPicksComplete(); + + // Render all alliance slots + const fragment = document.createDocumentFragment(); + for (let i = 0; i < MAX_ALLIANCES; i++) { + const alliance = state.alliances[i]; + const isActive = !allPicksComplete && alliance && i === state.currentPick.alliance; + const card = createAllianceCard(alliance, i, isActive); + fragment.appendChild(card); + } + + dom.allianceGrid.appendChild(fragment); +} + +// Check if all picks are complete +function areAllPicksComplete() { + const totalPicks = state.alliances.reduce((sum, alliance) => sum + alliance.picks.length, 0); + return state.isSelectionComplete || + (state.alliances.length >= MAX_ALLIANCES && totalPicks >= MAX_ALLIANCES * MAX_PICKS_PER_ALLIANCE); +} + +// Create alliance card element +function createAllianceCard(alliance, index, isActive) { + 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) { + card.innerHTML = createEmptyAllianceHTML(index); + } else { + card.innerHTML = createFilledAllianceHTML(alliance, index, isActive); + } + + return card; +} + +// Create HTML for empty alliance slot +function createEmptyAllianceHTML(index) { + const picksHTML = Array(MAX_PICKS_PER_ALLIANCE) + .fill(0) + .map((_, j) => `
Pick ${j + 1}
`) + .join(''); + + return ` +
+

Alliance ${index + 1}

+
+
+ Waiting for captain... +
+ ${picksHTML} + `; +} + +// Create HTML for filled alliance slot +function createFilledAllianceHTML(alliance, index, isActive) { + const picksHTML = Array(MAX_PICKS_PER_ALLIANCE) + .fill(0) + .map((_, j) => { + const pick = alliance.picks[j]; + return pick + ? createFilledPickHTML(pick, index, j) + : createEmptyPickHTML(index, j, isActive && alliance.picks.length === j); + }) + .join(''); + + return ` +
+

Alliance ${alliance.number}

+ ${isActive ? 'PICKING' : ''} +
+
+
${alliance.captain.team_number}
+
${alliance.captain.nickname || ''}
+
Captain (Rank ${alliance.captain.rank})
+
+ ${picksHTML} + `; +} + +// Create HTML for filled pick slot +function createFilledPickHTML(pick, allianceIndex, pickIndex) { + return ` +
+
+
+
${pick.team_number}
+
${pick.nickname || ''}
+
+
+
Pick ${pickIndex + 1}
+ + + +
+
+
+ `; +} + +// Create HTML for empty pick slot +function createEmptyPickHTML(allianceIndex, pickIndex, isCurrentPick) { + return ` +
+ ${isCurrentPick ? '← Tap or drag team here' : `Pick ${pickIndex + 1}`} +
+ `; +} + +function renderAvailableTeams() { + if (!dom.availableTeamsList || !dom.teamSearch) return; + + const searchTerm = dom.teamSearch.value.toLowerCase(); + const fragment = document.createDocumentFragment(); + + state.availableTeams.forEach(team => { + // Skip selected teams + if (state.selectedTeams.has(team.team_number)) return; + + // Filter by search term + if (!matchesSearch(team, searchTerm)) return; + + const card = createTeamCard(team); + fragment.appendChild(card); + }); + + dom.availableTeamsList.innerHTML = ''; + dom.availableTeamsList.appendChild(fragment); +} + +// Check if team matches search term +function matchesSearch(team, searchTerm) { + if (!searchTerm) return true; + return team.team_number.toString().includes(searchTerm) || + (team.nickname && team.nickname.toLowerCase().includes(searchTerm)); +} + +// Create team card element +function createTeamCard(team) { + const isSelectedForPick = state.selectedTeamForPick && + state.selectedTeamForPick.team_number === team.team_number; + + const card = document.createElement('div'); + card.id = `team-card-${team.team_number}`; + card.className = `team-card bg-white border border-gray-200 rounded-lg p-3 ${ + isSelectedForPick ? 'selected-for-pick' : '' + }`; + card.draggable = true; + card.setAttribute('data-team', JSON.stringify(team)); + + // Event listeners + card.addEventListener('dragstart', handleDragStart); + card.addEventListener('dragend', handleDragEnd); + card.addEventListener('click', () => handleTeamClick(team)); + card.addEventListener('dblclick', () => { + if (!state.isSelectionComplete) selectTeam(team); + }); + card.addEventListener('touchstart', handleTouchStart, { passive: false }); + card.addEventListener('touchmove', handleTouchMove, { passive: false }); + card.addEventListener('touchend', handleTouchEnd, { passive: false }); + + card.innerHTML = ` +
${team.team_number}
+
${team.nickname || 'No name'}
+
Rank: ${team.rank}
+ ${isSelectedForPick ? '
SELECTED - Tap slot to place
' : ''} + `; + + return card; +} + +function selectTeam(team) { + const currentAlliance = state.alliances[state.currentPick.alliance]; + if (!currentAlliance) return; + + currentAlliance.picks.push(team); + state.selectedTeams.add(team.team_number); + + renderAlliances(); + renderAvailableTeams(); + advancePick(); +} + +function removeTeamFromAlliance(allianceIndex, pickIndex) { + const alliance = state.alliances[allianceIndex]; + if (!alliance || !alliance.picks[pickIndex]) return; + + const removedTeam = alliance.picks[pickIndex]; + alliance.picks.splice(pickIndex, 1); + state.selectedTeams.delete(removedTeam.team_number); + + // If removing first pick, destroy subsequent alliances + if (pickIndex === 0 && allianceIndex < state.alliances.length - 1) { + for (let i = allianceIndex + 1; i < state.alliances.length; i++) { + const a = state.alliances[i]; + if (a.captain) state.selectedTeams.delete(a.captain.team_number); + if (a.picks) a.picks.forEach(p => state.selectedTeams.delete(p.team_number)); + } + state.alliances.splice(allianceIndex + 1); + } + + recalculatePickPosition(); + renderAlliances(); + renderAvailableTeams(); + updateStatus(); +} + +function recalculatePickPosition() { + state.isSelectionComplete = false; + + // Check Round 1 + for (let i = 0; i < state.alliances.length; i++) { + if (state.alliances[i] && !state.alliances[i].picks[0]) { + state.currentPick = { alliance: i, round: 1, phase: 'pick' }; + return; + } + } + + if (state.alliances.length < MAX_ALLIANCES) return; + + // Check Round 2 (reverse order) + for (let i = state.alliances.length - 1; i >= 0; i--) { + if (state.alliances[i] && !state.alliances[i].picks[1]) { + state.currentPick = { alliance: i, round: 2, phase: 'pick' }; + return; + } + } + + state.currentPick.round = MAX_PICKS_PER_ALLIANCE + 1; +} + +function advancePick() { + if (state.alliances.length < MAX_ALLIANCES) { + handleCaptainSelection(); + } else { + handleSnakeDraft(); + } + updateStatus(); + renderAlliances(); +} + +function handleCaptainSelection() { + const nextCaptain = state.availableTeams.find(team => + !state.selectedTeams.has(team.team_number) + ); + + if (nextCaptain) { + state.alliances.push({ + number: state.alliances.length + 1, + captain: nextCaptain, + picks: [] + }); + state.selectedTeams.add(nextCaptain.team_number); + state.currentPick.alliance = state.alliances.length - 1; + state.currentPick.phase = 'pick'; + renderAvailableTeams(); + } else { + showNotification('No more teams available. Selection complete.', 'info'); + completeSelection(); + } +} + +function handleSnakeDraft() { + const available = state.availableTeams.some(t => !state.selectedTeams.has(t.team_number)); + if (!available) { + completeSelection(); + return; + } + recalculatePickPosition(); + if (state.currentPick.round > MAX_PICKS_PER_ALLIANCE) { + completeSelection(); + } +} + +function updateStatus() { + if (!dom.statusText || !dom.statusBox || !dom.startBracketBtn) return; + + const totalPicks = state.alliances.reduce((sum, a) => sum + a.picks.length, 0); + const allAlliancesFormed = state.alliances.length >= MAX_ALLIANCES; + const allPicksComplete = allAlliancesFormed && totalPicks >= MAX_ALLIANCES * MAX_PICKS_PER_ALLIANCE; + + if (state.isSelectionComplete || allPicksComplete || state.currentPick.round > MAX_PICKS_PER_ALLIANCE) { + setStatusComplete(); + } else if (state.alliances.length < MAX_ALLIANCES) { + setStatusFormingAlliances(); + } else { + setStatusPicking(); + } +} + +// Set status to complete +function setStatusComplete() { + dom.statusText.textContent = 'Alliance selection complete!'; + dom.statusBox.classList.remove('bg-blue-50', 'border-blue-500'); + dom.statusBox.classList.add('bg-green-50', 'border-green-500'); + dom.statusText.classList.remove('text-blue-800'); + dom.statusText.classList.add('text-green-800'); + dom.startBracketBtn.classList.remove('hidden'); +} + +// Set status to forming alliances +function setStatusFormingAlliances() { + const currentAlliance = state.alliances[state.currentPick.alliance]; + if (currentAlliance) { + dom.statusText.textContent = `Forming alliances (${state.alliances.length}/${MAX_ALLIANCES}) - Alliance ${currentAlliance.number} (Captain: ${currentAlliance.captain.team_number}) is picking...`; + } else { + dom.statusText.textContent = 'Processing...'; + } + dom.statusBox.classList.remove('bg-green-50', 'border-green-500'); + dom.statusBox.classList.add('bg-blue-50', 'border-blue-500'); + dom.statusText.classList.remove('text-green-800'); + dom.statusText.classList.add('text-blue-800'); + dom.startBracketBtn.classList.add('hidden'); +} + +// Set status to picking +function setStatusPicking() { + const currentAlliance = state.alliances[state.currentPick.alliance]; + if (currentAlliance) { + const pickNumber = currentAlliance.picks.length + 1; + dom.statusText.textContent = `Round ${state.currentPick.round}, Pick ${pickNumber} - Alliance ${currentAlliance.number} (Captain: ${currentAlliance.captain.team_number}) is picking...`; + } + dom.statusBox.classList.remove('bg-green-50', 'border-green-500'); + dom.statusBox.classList.add('bg-blue-50', 'border-blue-500'); + dom.statusText.classList.remove('text-green-800'); + dom.statusText.classList.add('text-blue-800'); + dom.startBracketBtn.classList.add('hidden'); +} + +function completeSelection() { + state.isSelectionComplete = true; + state.currentPick.round = 99; + updateStatus(); + renderAlliances(); +} + +function resetSelection() { + if (state.alliances.length === 0) { + showNotification('No selection to reset. Please load rankings first.', 'warning'); + return; + } + + initializeAlliances(state.allRankings); + updateStatus(); + showNotification('Alliance selection has been reset.', 'info'); +} + +function exportResults() { + if (state.alliances.length === 0) { + showNotification('No alliances to export. Please complete the selection first.', 'warning'); + return; + } + + const results = { + event: state.eventKey, + alliances: state.alliances.map(a => ({ + alliance_number: a.number, + captain: { + team_number: a.captain.team_number, + nickname: a.captain.nickname, + rank: a.captain.rank + }, + picks: a.picks.map(p => ({ + team_number: p.team_number, + nickname: p.nickname, + rank: p.rank + })) + })), + }; + + const blob = new Blob([JSON.stringify(results, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `alliance-selection-${state.eventKey}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showNotification('Alliance selection exported successfully!', 'success'); +} + +function filterTeams() { + renderAvailableTeams(); +} + +// Expose state for playoff bracket script +window.getAlliances = () => state.alliances; \ No newline at end of file diff --git a/app/static/js/playoff-bracket.js b/app/static/js/playoff-bracket.js new file mode 100644 index 0000000..0982076 --- /dev/null +++ b/app/static/js/playoff-bracket.js @@ -0,0 +1,403 @@ +// Depends on mock-alliance-selection.js state + +let bracketMatches = {}; + +function startPlayoffBracket() { + // Get alliances from main script + const alliances = window.getAlliances ? window.getAlliances() : []; + + if (alliances.length === 0) { + console.error('No alliances available'); + return; + } + + // Hide start button + const startBtn = document.getElementById('start-bracket-btn'); + if (startBtn) startBtn.classList.add('hidden'); + + const bracketView = document.getElementById('bracket-view'); + if (bracketView) { + bracketView.classList.remove('hidden'); + bracketView.scrollIntoView({ behavior: 'smooth' }); + } + + initializeBracketMatches(alliances); + renderBracket(); +} + +function initializeBracketMatches(alliancesData) { + const safeAlliances = []; + for(let i=0; i<8; i++) { + if(alliancesData[i]) { + safeAlliances.push({ + seed: i+1, + captain: alliancesData[i].captain, + picks: alliancesData[i].picks + }); + } else { + // Bye team + safeAlliances.push({ + seed: i+1, + captain: { team_number: 'BYE', nickname: '' }, + picks: [] + }); + } + } + + bracketMatches = {}; // Reset + + // Initial Upper Bracket (Round 1) - Standard seeding 1v8, 4v5, 2v7, 3v6 + createMatch('m1', safeAlliances[0], safeAlliances[7], 'Upper Round 1'); // 1 vs 8 + createMatch('m2', safeAlliances[3], safeAlliances[4], 'Upper Round 1'); // 4 vs 5 + createMatch('m3', safeAlliances[1], safeAlliances[6], 'Upper Round 1'); // 2 vs 7 + createMatch('m4', safeAlliances[2], safeAlliances[5], 'Upper Round 1'); // 3 vs 6 + + // Initialize others + ['m5', 'm6', 'm7', 'm8', 'm9', 'm10', 'm11', 'm12', 'm13', 'm14', 'm15', 'm16'].forEach(id => { + bracketMatches[id] = { + id: id, + red: null, + blue: null, + winner: null, + label: getMatchLabel(id) + }; + }); +} + +function createMatch(id, redAlliance, blueAlliance, label) { + bracketMatches[id] = { + id: id, + red: redAlliance, + blue: blueAlliance, + winner: null, + label: label + }; +} + +function getMatchLabel(id) { + const labels = { + m5: 'Lower Round 1', m6: 'Lower Round 1', + m7: 'Upper Round 2', m8: 'Upper Round 2', + m9: 'Lower Round 2', m10: 'Lower Round 2', + m11: 'Upper Semi-Final', + m12: 'Lower Round 3', + m13: 'Lower Final', + m14: 'Finals 1', + m15: 'Finals 2', + m16: 'Finals 3' + }; + return labels[id] || 'Match ' + id.replace('m', ''); +} + +function renderBracket() { + // Upper Round 1 + renderMatch('m1', 'upper-round-1'); + renderMatch('m2', 'upper-round-1'); + renderMatch('m3', 'upper-round-1'); + renderMatch('m4', 'upper-round-1'); + + // Lower Round 1 + renderMatch('m5', 'lower-round-1'); + renderMatch('m6', 'lower-round-1'); + + // Upper Round 2 + renderMatch('m7', 'upper-round-2'); + renderMatch('m8', 'upper-round-2'); + + // Lower Round 2 + renderMatch('m9', 'lower-round-2'); + renderMatch('m10', 'lower-round-2'); + + // Upper Round 3 + renderMatch('m11', 'upper-round-3'); + + // Lower Round 3 + renderMatch('m12', 'lower-round-3'); + + // Lower Final + renderMatch('m13', 'lower-round-4'); + + // Finals + const finalsStatus = getFinalsStatus(); + + renderMatch('m14', 'finals-round', finalsStatus); + renderMatch('m15', 'finals-round', finalsStatus); + renderMatch('m16', 'finals-round', finalsStatus); + + // M16 Visibility Logic + const m16El = document.getElementById('match-m16'); + if (m16El) { + if (!finalsStatus.needsTiebreaker) { + m16El.classList.add('opacity-50', 'pointer-events-none', 'grayscale'); + if (bracketMatches['m16'].winner) { + bracketMatches['m16'].winner = null; + // Force re-render of this specific element to clear selection + renderMatch('m16', 'finals-round', finalsStatus); + } + } else { + m16El.classList.remove('opacity-50', 'pointer-events-none', 'grayscale'); + } + } +} + +function getFinalsStatus() { + const m14 = bracketMatches['m14']?.winner; + const m15 = bracketMatches['m15']?.winner; + const m16 = bracketMatches['m16']?.winner; + + let champion = null; + let needsTiebreaker = false; + + if (m14 && m15) { + if (m14 === m15) { + // Sweep (2-0) + champion = m14; + } else { + // Split (1-1) + needsTiebreaker = true; + if (m16) { + champion = m16; + } + } + } + return { champion, needsTiebreaker }; +} + +function renderMatch(matchId, containerId, finalsStatus = null) { + const container = document.getElementById(containerId); + if (!container) return; + + // Check if we already rendered this match + let matchEl = document.getElementById(`match-${matchId}`); + const match = bracketMatches[matchId]; + + if (!matchEl) { + const template = document.getElementById('match-card-template'); + if (!template) return; + + matchEl = template.content.cloneNode(true).querySelector('.match-card'); + matchEl.id = `match-${matchId}`; + + // Add click handlers: User decides winner + matchEl.querySelector('.alliance-option.red').addEventListener('click', () => setMatchWinner(matchId, 'red')); + matchEl.querySelector('.alliance-option.blue').addEventListener('click', () => setMatchWinner(matchId, 'blue')); + + container.appendChild(matchEl); + } + + // Update labels + const labelEl = matchEl.querySelector('.match-label'); + if(labelEl) labelEl.textContent = match.label; + + const idEl = matchEl.querySelector('.match-id'); + if(idEl) idEl.textContent = matchId.toUpperCase(); + + const redEl = matchEl.querySelector('.alliance-option.red'); + const blueEl = matchEl.querySelector('.alliance-option.blue'); + + const isFinals = ['m14', 'm15', 'm16'].includes(matchId); + const isRedChamp = isFinals && finalsStatus?.champion === 'red'; + const isBlueChamp = isFinals && finalsStatus?.champion === 'blue'; + + updateAllianceOption(redEl, match.red, match.winner === 'red', match.winner === 'blue', isRedChamp); + updateAllianceOption(blueEl, match.blue, match.winner === 'blue', match.winner === 'red', isBlueChamp); +} + +function updateAllianceOption(el, allianceData, isWinner, isLoser, isChampion) { + const teamEl = el.querySelector('.team-number'); + const teamsListEl = el.querySelector('.alliance-teams'); + const seedEl = el.querySelector('.seed-badge'); + const winnerIcon = el.querySelector('.winner-icon'); + + el.classList.remove('bg-green-50', 'bg-gray-100', 'opacity-50', 'grayscale', 'border-green-500', 'cursor-not-allowed', 'bg-gray-50'); + el.classList.add('cursor-pointer'); // Default back to pointer + + // Reset icon & crown + if (winnerIcon) { + winnerIcon.classList.remove('opacity-100', 'scale-100'); + winnerIcon.classList.add('opacity-0', 'scale-0'); + } + + let crown = el.querySelector('.champion-crown'); + if (!crown) { + crown = document.createElement('div'); + crown.className = 'champion-crown absolute -top-2 -right-2 text-yellow-500 transform scale-0 transition-transform duration-300 pointer-events-none z-20 filter drop-shadow-md'; + crown.innerHTML = ''; + el.appendChild(crown); + } + crown.classList.remove('scale-100'); + crown.classList.add('scale-0'); + + if (allianceData) { + teamEl.textContent = 'Alliance ' + allianceData.seed; + seedEl.textContent = allianceData.seed; + + if (teamsListEl) { + const allTeams = [ + allianceData.captain.team_number, + ...allianceData.picks.map(p => p.team_number) + ]; + teamsListEl.textContent = allTeams.join(', '); + } + + if (isWinner) { + el.classList.add('bg-green-50', 'border-green-500'); + if (winnerIcon) { + winnerIcon.classList.remove('opacity-0', 'scale-0'); + winnerIcon.classList.add('opacity-100', 'scale-100'); + } + } else if (isLoser) { + el.classList.add('bg-gray-100', 'opacity-50', 'grayscale'); + } + + if (isChampion) { + crown.classList.remove('scale-0'); + crown.classList.add('scale-100'); + el.classList.add('ring-2', 'ring-yellow-400', 'ring-offset-2'); + } else { + el.classList.remove('ring-2', 'ring-yellow-400', 'ring-offset-2'); + } + + } else { + teamEl.textContent = 'TBD'; + seedEl.textContent = '?'; + el.classList.remove('cursor-pointer'); + el.classList.add('cursor-not-allowed', 'bg-gray-50'); + } +} + +function setMatchWinner(matchId, winnerColor) { + const match = bracketMatches[matchId]; + if (!match.red || !match.blue) return; + + if (matchId === 'm16') { + const finalsStatus = getFinalsStatus(); + if (!finalsStatus.needsTiebreaker) return; + } + + if (match.winner === winnerColor) { + match.winner = null; // Toggle off + } else { + match.winner = winnerColor; + } + + propagateBracket(matchId); + renderBracket(); +} + +function propagateBracket(sourceMatchId) { + const source = bracketMatches[sourceMatchId]; + const winner = source.winner ? (source.winner === 'red' ? source.red : source.blue) : null; + const loser = source.winner ? (source.winner === 'red' ? source.blue : source.red) : null; + + // Double Elimination Bracket Mapping (2023+ FRC) + const flow = { + // Round 1 Upper + 'm1': { win: { to: 'm7', role: 'red' }, lose: { to: 'm5', role: 'red' } }, + 'm2': { win: { to: 'm7', role: 'blue' }, lose: { to: 'm5', role: 'blue' } }, + 'm3': { win: { to: 'm8', role: 'red' }, lose: { to: 'm6', role: 'red' } }, + 'm4': { win: { to: 'm8', role: 'blue' }, lose: { to: 'm6', role: 'blue' } }, + + // Round 1 Lower + 'm5': { win: { to: 'm10', role: 'blue' } }, // W5 plays L8 + 'm6': { win: { to: 'm9', role: 'blue' } }, // W6 plays L7 + + // Round 2 Upper + 'm7': { win: { to: 'm11', role: 'red' }, lose: { to: 'm9', role: 'red' } }, + 'm8': { win: { to: 'm11', role: 'blue' }, lose: { to: 'm10', role: 'red' } }, + + // Round 2 Lower + 'm9': { win: { to: 'm12', role: 'red' } }, + 'm10': { win: { to: 'm12', role: 'blue' } }, + + // Round 3 Upper (Semis) + 'm11': { win: { to: ['m14', 'm15', 'm16'], role: 'red' }, lose: { to: 'm13', role: 'red' } }, + + // Round 3 Lower + 'm12': { win: { to: 'm13', role: 'blue' } }, + + // Lower Final + 'm13': { win: { to: ['m14', 'm15', 'm16'], role: 'blue' } } + }; + + const next = flow[sourceMatchId]; + if (!next) return; + + if (next.win) { + const targets = Array.isArray(next.win.to) ? next.win.to : [next.win.to]; + targets.forEach(t => updateMatchSlot(t, next.win.role, winner)); + } + if (next.lose) { + const targets = Array.isArray(next.lose.to) ? next.lose.to : [next.lose.to]; + targets.forEach(t => updateMatchSlot(t, next.lose.role, loser)); + } +} + +function updateMatchSlot(matchId, role, teamData) { + const match = bracketMatches[matchId]; + if (!match) return; + + const currentJson = JSON.stringify(match[role]); + const newJson = JSON.stringify(teamData); + + if (currentJson !== newJson) { + match[role] = teamData; + match.winner = null; // Reset result if input changes + propagateBracket(matchId); // Clear downstream + } +} + +// Allow bracket matches for export functionality +window.getBracketMatches = () => bracketMatches; + +function exportPlayoffResults() { + if (!bracketMatches || Object.keys(bracketMatches).length === 0) { + alert('No playoff matches to export. Please start the playoffs first.'); + return; + } + + // Get event key and alliances from main script + const eventKey = window.getAlliances ? (window.getAlliances()[0]?.captain ? + document.getElementById('event-select')?.value || 'unknown' : 'unknown') : 'unknown'; + + const results = { + event: eventKey, + timestamp: new Date().toISOString(), + playoff_matches: Object.entries(bracketMatches).map(([matchId, match]) => ({ + match_id: matchId, + label: match.label, + red_alliance: match.red ? { + seed: match.red.seed, + captain: match.red.captain.team_number, + picks: match.red.picks.map(p => p.team_number) + } : null, + blue_alliance: match.blue ? { + seed: match.blue.seed, + captain: match.blue.captain.team_number, + picks: match.blue.picks.map(p => p.team_number) + } : null, + winner: match.winner, + winner_alliance: match.winner && match[match.winner] ? { + seed: match[match.winner].seed, + captain: match[match.winner].captain.team_number + } : null + })) + }; + + const blob = new Blob([JSON.stringify(results, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `playoff-results-${eventKey}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // Show notification if available + if (typeof showNotification === 'function') { + showNotification('Playoff results exported successfully!', 'success'); + } else { + alert('Playoff results exported successfully!'); + } +} diff --git a/app/static/js/scout/list.js b/app/static/js/scout/list.js index 1e93df3..39c5ae3 100644 --- a/app/static/js/scout/list.js +++ b/app/static/js/scout/list.js @@ -109,6 +109,7 @@ function exportToCSV() { 'Team Number', 'Alliance', 'Auto Fuel', + 'Auto Climb', 'Transition Fuel', 'Teleop Shifts (1-4)', 'Endgame Fuel', @@ -130,12 +131,13 @@ function exportToCSV() { const match = row.querySelector('td:nth-child(3)').textContent.trim(); const autoFuel = row.querySelector('td:nth-child(4)').textContent.trim(); - const transitionFuel = row.querySelector('td:nth-child(5)').textContent.trim(); - const teleopFuel = row.querySelector('td:nth-child(6)').textContent.trim(); - const endgameFuel = row.querySelector('td:nth-child(7)').textContent.trim(); - const climb = row.querySelector('td:nth-child(8)').textContent.trim(); - const defense = row.querySelector('td:nth-child(10)').textContent.trim(); - const robotDisabled = row.querySelector('td:nth-child(11) span').textContent.trim(); + const autoClimb = row.querySelector('td:nth-child(5)').textContent.trim(); + const transitionFuel = row.querySelector('td:nth-child(6)').textContent.trim(); + const teleopFuel = row.querySelector('td:nth-child(7)').textContent.trim(); + const endgameFuel = row.querySelector('td:nth-child(8)').textContent.trim(); + const climb = row.querySelector('td:nth-child(9)').textContent.trim(); + const defense = row.querySelector('td:nth-child(11)').textContent.trim(); + const robotDisabled = row.querySelector('td:nth-child(12) span').textContent.trim(); const notes = (row.dataset.notes || '').replace(/,/g, ';').replace(/\n/g, ' '); const {scouter} = row.dataset; const {eventCode} = row.closest('.event-section').dataset; @@ -146,6 +148,7 @@ function exportToCSV() { teamNumber, alliance, autoFuel, + autoClimb, transitionFuel, teleopFuel, endgameFuel, diff --git a/app/templates/base.html b/app/templates/base.html index ec98db4..255057b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -310,8 +310,8 @@ window.si = window.si || function () { (window.siq = window.siq || []).push(arguments); }; window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); }; - - + + + +{% endblock %}