UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,190 lines (1,028 loc) 124 kB
// Define custom event types class LeagueEvent extends CustomEvent { constructor(detail) { super('league-event', { detail, bubbles: true, composed: true }); } } import '../LeagueMatchesRecent/LeagueMatchesRecent.js'; import '../LeagueMatchesUpcoming/LeagueMatchesUpcoming.js'; import '../LeagueMatchesAttention/LeagueMatchesAttention.js'; import '../leagueMatch/leagueMatch.js'; import '../leagueCalendar/LeagueCalendar.js'; import '../LeagueSchedule/LeagueSchedule.js'; import { BASE_STYLES, MOBILE_STYLES, DESKTOP_STYLES, TABLE_HEADER, MOBILE_TEMPLATE, DESKTOP_TEMPLATE} from './leagueElement-styles.js'; import { Temporal, TemporalUtils } from '../../utils/temporalUtils.js'; // ADDED IMPORT import { League, Match } from '@lovebowls/leaguejs'; import { FormUtils } from '../../utils/formUtils.js'; class LeagueElement extends HTMLElement { constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); this.data = null; this.selectedResultDate = null; // Retained for LeagueMatchesRecent filtering if needed -- WILL BE REPLACED by activeCalendarFilterDate this.activeCalendarFilterDate = null; // UPDATED: Will store a string in YYYY-MM-DD format representing the selected date this.leftPanelFlexBasis = null; this.minRightPanelPixelWidth = null; this.activeView = 'table'; // Default to table view this.pointsOverTimeChartData = null; this.selectedTeamsForGraph = new Set(); this.teamColors = {}; this.activeTrendGraphType = 'pointsOverTime'; this.tableFilter = 'overall'; // Default filter state this.matchModalOpen = false; this.matchModalData = null; this.matchModalTeams = []; this.matchModalMode = 'new'; this.lovebowlsTeams = []; // Store lovebowls teams data this._handleScheduleMatchEditBound = null; // Bound function for schedule match edit events this.selectedTeamForSchedule = null; // Track selected team for schedule filtering this.shadow.host.addEventListener('league-calendar-event', this._handleCalendarDateChange.bind(this)); // ADDED event listener } get _isMobile() { return this.getAttribute('is-mobile') === 'true'; } get _canEdit() { return this.getAttribute('can-edit') === 'true'; } static get observedAttributes() { return ['data', 'selectedMatch', 'is-mobile', 'lovebowls-teams', 'can-edit']; } connectedCallback() { this.data = this.getAttribute('data'); if (this.data) { this._parseAndLoadData(this.data); } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'lovebowls-teams') { this.parseLovebowlsTeams(newValue); // If data is already loaded, re-render to apply the new team names if (this.data) { this.render(); } } else if (name === 'data') { this.data = newValue; if (newValue) { this._parseAndLoadData(newValue); } else { this.showError('data is required'); this.dispatchEvent(new LeagueEvent({data: '',error: 'data is required'})); } } else if (name === 'selectedMatch') { if (newValue) { try { const matchData = JSON.parse(newValue); const leagueData = this._table; const teamsArray = (leagueData && Array.isArray(leagueData)) ? leagueData.map(t => t.teamName) : []; this.openMatchModal(matchData, teamsArray, 'edit'); } catch (error) { console.warn('Error parsing match data for selectedMatch attribute:', error); } } } else if (name === 'is-mobile') { this.render(); } else if (name === 'can-edit') { this.render(); } } async _parseAndLoadData(data) { try { // Use the passed data parameter, not the attribute const parsed = typeof data === 'string' ? JSON.parse(data) : data; console.log('[LeagueElement] Parsed data:', parsed); this.data = new League(parsed); console.log('[LeagueElement] League instance:', this.data); // Dispatch event with League instance this.dispatchEvent(new LeagueEvent({ data: this.data })); // Just call render() - it will handle all UI updates this.render(); } catch (error) { console.error('[LeagueElement] Error parsing league data:', error); this.showError('Failed to load league data'); // Dispatch error event this.dispatchEvent(new LeagueEvent({ error: error.message })); } } showError(message) { const content = this.shadow.querySelector('.content'); if (content) { content.innerHTML = `<div class="error">${message}</div>`; } } // Helper method to replace placeholders in template _fillTemplate(template) { const currentTitle = (this.data && this.data.name ? this.data.name : 'League Table') || 'League Table'; // Conditionally render attention panel based on can-edit const attentionPanel = this._canEdit ? (this._isMobile ? `<div class="panel"> <div class="panel-header panel-header-shared">Requiring Attention</div> <league-matches-attention id="mobile-attention-matches" is-mobile="true"></league-matches-attention> </div>` : `<div class="panel"> <div class="panel-header panel-header-shared">Requiring Attention</div> <league-matches-attention id="desktop-attention-matches"></league-matches-attention> </div>` ) : ''; // Generate can-edit attribute for child components const canEditAttr = this._canEdit ? 'can-edit="true"' : 'can-edit="false"'; return template .replace(/\{\{title\}\}/g, currentTitle) .replace('{{tableRows}}', this.tableRows) .replace('{{matrixView}}', this.activeView === 'matrix' ? this.renderMatrix() : '') .replace('{{trendsViewContent}}', this.activeView === 'trends' ? this.renderTrendsViewContent() : '') .replace('{{attentionPanel}}', attentionPanel) .replace(/\{\{canEditAttr\}\}/g, canEditAttr) .replace('{{overallSelected}}', this.tableFilter === 'overall' ? 'selected' : '') .replace('{{homeSelected}}', this.tableFilter === 'home' ? 'selected' : '') .replace('{{awaySelected}}', this.tableFilter === 'away' ? 'selected' : '') .replace('{{formSelected}}', this.tableFilter === 'form' ? 'selected' : ''); } render() { // Generate table rows based on data if available let tableRows = ''; const table = this._table; console.log('[LeagueElement] _table:', table); if (table && Array.isArray(table.leagueData)) { const processedLeagueData = this._getFilteredLeagueData(); console.log('[LeagueElement] processedLeagueData:', processedLeagueData); if (Array.isArray(processedLeagueData)) { tableRows = processedLeagueData.map(team => { let positionCellClass = 'pos-cell-default'; // Apply promotion/relegation styling ONLY if mode is 'overall' // and based on league settings and the team's current rank in the displayed table. if (this.tableFilter === 'overall') { if (this.data && this.data.settings && processedLeagueData.length > 0) { const numTeams = processedLeagueData.length; const promotionSpots = parseInt(this.data.settings.promotionPositions, 10) || 0; const relegationSpots = parseInt(this.data.settings.relegationPositions, 10) || 0; // team.currentRank is 1-indexed and reflects the rank in the currently displayed table if (promotionSpots > 0 && team.currentRank <= promotionSpots) { positionCellClass = 'pos-cell-promotion'; } else if (relegationSpots > 0 && team.currentRank >= (numTeams - relegationSpots + 1)) { positionCellClass = 'pos-cell-relegation'; } } } // Generate movement indicator HTML const movementIndicator = this.renderRankMovementIndicator(team.rankMovement); // Row class for promotion/relegation is removed here return ` <tr class="team-row" data-team-id="${team.teamId}" data-team-name="${this.escapeHtml(team.teamDisplayName)}"> <td class="position-cell ${positionCellClass}">${team.currentRank !== undefined ? team.currentRank : '-'} ${movementIndicator}</td> <td>${team.teamDisplayName}</td> <td>${team.points}</td> <td title="${this.formatMatchList(team.allMatchesForTooltip, team.teamId, undefined, true)}">${team.played}</td> <td title="${this.formatMatchList(team.allMatchesForTooltip, team.teamId, 'W', false)}">${team.won}</td> <td title="${this.formatMatchList(team.allMatchesForTooltip, team.teamId, 'D', false)}">${team.drawn}</td> <td title="${this.formatMatchList(team.allMatchesForTooltip, team.teamId, 'L', false)}">${team.lost}</td> <td>${team.shotsFor}</td> <td>${team.shotsAgainst}</td> <td>${team.shotDifference}</td> <td class="form-cell">${this.renderForm(team.matches)}</td> </tr> `; }).join(''); } else { tableRows = ` <tr> <td colspan="11">Error loading filtered table data.</td> </tr>`; } // Store the title and tableRows for use in templates this.tableRows = tableRows; // Prepare data for trends view and ensure team colors are set this._preparePointsOverTimeData(); this._prepareShotsForVsAgainstData(); this._prepareFormOverTimeData(); this.ensureTeamColors(); // Render based on device type const baseTemplate = this._isMobile ? MOBILE_TEMPLATE : DESKTOP_TEMPLATE; this.shadow.innerHTML = ` <style>${this._isMobile ? MOBILE_STYLES : DESKTOP_STYLES}</style> ${this._fillTemplate(baseTemplate)} `; // Configure the recent matches components const recentMatchesElement = this.shadow.querySelector(this._isMobile ? '#mobile-recent-matches' : '#desktop-recent-matches'); if (recentMatchesElement) { recentMatchesElement.setAttribute('is-mobile', this._isMobile.toString()); recentMatchesElement.setAttribute('can-edit', this._canEdit.toString()); recentMatchesElement.setAttribute('data', JSON.stringify(this.data.matches)); // Add team mapping data for display name resolution recentMatchesElement.setAttribute('team-mapping', JSON.stringify(this._getTeamsFromLeagueData())); if (this.activeCalendarFilterDate) { // UPDATED: activeCalendarFilterDate is now already a string in YYYY-MM-DD format recentMatchesElement.setAttribute('filter-date', this.activeCalendarFilterDate); } else { recentMatchesElement.removeAttribute('filter-date'); } recentMatchesElement.removeEventListener('league-matches-recent-event', this._handleRecentMatchClick); this._handleRecentMatchClickBound = this._handleRecentMatchClick.bind(this); recentMatchesElement.addEventListener('league-matches-recent-event', this._handleRecentMatchClickBound); } // Configure the attention matches components (only if editing is enabled) if (this._canEdit) { const attentionMatchesElement = this.shadow.querySelector(this._isMobile ? '#mobile-attention-matches' : '#desktop-attention-matches'); if (attentionMatchesElement) { attentionMatchesElement.setAttribute('is-mobile', this._isMobile.toString()); // Pass the whole league data instead of just matches attentionMatchesElement.setAttribute('data', JSON.stringify(this.data)); // Add team mapping data for display name resolution attentionMatchesElement.setAttribute('team-mapping', JSON.stringify(this._getTeamsFromLeagueData())); attentionMatchesElement.removeEventListener('league-matches-attention-event', this._handleAttentionMatchClick); this._handleAttentionMatchClickBound = this._handleAttentionMatchClick.bind(this); attentionMatchesElement.addEventListener('league-matches-attention-event', this._handleAttentionMatchClickBound); } } if (!this._isMobile) { const renderedLeftPanel = this.shadow.querySelector('.left-panel'); if (renderedLeftPanel && this.leftPanelFlexBasis) { renderedLeftPanel.style.flex = this.leftPanelFlexBasis; } this.setupResizer(); } this.setupPaging(); // Paging for sub-components is handled by them dispatching events this.setupTabs(); this.setupTableFilterDropdown(); this.setupTableRowEvents(); // Set up double-click events for team rows if (this.activeView === 'trends') { // If trends tab is active by default (e.g. on reload/state persistence) this.setupTrendsViewInteractivity(); // Ensure interactivity is set up } // Configure the upcoming fixtures component const upcomingFixturesElement = this.shadow.querySelector(this._isMobile ? '#mobile-upcoming-fixtures' : '#desktop-upcoming-fixtures'); if (upcomingFixturesElement) { upcomingFixturesElement.setAttribute('is-mobile', this._isMobile.toString()); upcomingFixturesElement.setAttribute('can-edit', this._canEdit.toString()); // Add team mapping data for display name resolution upcomingFixturesElement.setAttribute('team-mapping', JSON.stringify(this._getTeamsFromLeagueData())); if (this.data && this.data.matches) { upcomingFixturesElement.setAttribute('data', JSON.stringify(this.data.matches)); // Set filter-date for upcoming fixtures if (this.activeCalendarFilterDate) { // UPDATED: activeCalendarFilterDate is now already a string in YYYY-MM-DD format upcomingFixturesElement.setAttribute('filter-date', this.activeCalendarFilterDate); } else { upcomingFixturesElement.removeAttribute('filter-date'); } } else { console.warn('[LeagueElement] render: this.data.matches is NOT available for upcomingFixturesElement.'); } // Add listener for match clicks this._handleUpcomingMatchClickBound = this._handleUpcomingMatchClick.bind(this); upcomingFixturesElement.removeEventListener('league-matches-upcoming-event', this._handleUpcomingMatchClickBound); // Remove previous if any upcomingFixturesElement.addEventListener('league-matches-upcoming-event', this._handleUpcomingMatchClickBound); // Listen for general events } // Configure the schedule component const scheduleElement = this.shadow.querySelector(this._isMobile ? '#mobile-schedule' : '#desktop-schedule'); if (scheduleElement) { scheduleElement.setAttribute('is-mobile', this._isMobile.toString()); scheduleElement.setAttribute('can-edit', this._canEdit.toString()); if (this.data) { // Pass the entire league data to the schedule component scheduleElement.setAttribute('data', JSON.stringify(this.data)); } else { console.warn('[LeagueElement] render: this.data is NOT available for scheduleElement.'); } // Pass the current filter date to the schedule component if (this.activeCalendarFilterDate) { scheduleElement.setAttribute('filter-date', this.activeCalendarFilterDate); } else { scheduleElement.removeAttribute('filter-date'); } // Pass the selected team for schedule filtering if (this.selectedTeamForSchedule) { scheduleElement.setAttribute('selected-team', this.selectedTeamForSchedule); } else { scheduleElement.removeAttribute('selected-team'); } // If the schedule view is active, set up its event listeners if (this.activeView === 'schedule') { this._setupScheduleEventListeners(); } } // ADDED: Configure the league-calendar component const calendarElement = this.shadow.querySelector(this._isMobile ? '#mobile-calendar' : '#desktop-calendar'); if (calendarElement) { calendarElement.setAttribute('is-mobile', this._isMobile.toString()); if (this.data && this.data.matches) { calendarElement.setAttribute('matches', JSON.stringify(this.data.matches)); } else { console.warn('[LeagueElement] render: this.data.matches is NOT available for league-calendar.'); } // Pass current filter date to keep calendar selection in sync if changed from parent // (e.g. if filter was set by URL param or other means and leagueElement needs to inform calendar) if (this.activeCalendarFilterDate) { // UPDATED: activeCalendarFilterDate is now already a string in YYYY-MM-DD format calendarElement.setAttribute('current-filter-date', this.activeCalendarFilterDate); } else { calendarElement.removeAttribute('current-filter-date'); } // Event listener for 'league-calendar-event' is set up in connectedCallback of leagueElement // and handles updates from the calendar. } } else { // Show error if data is missing or invalid this.shadow.innerHTML = `<div class="error">Invalid or missing league data</div>`; } // Render match modal separately to avoid full component re-renders this._renderMatchModal(); } setupPaging() { // Setup Settings icon click event const settingsIcons = this.shadow.querySelectorAll('.settings-icon'); settingsIcons.forEach(icon => { icon.onclick = () => { this.dispatchEvent(new LeagueEvent({ type: 'requestAdminView', leagueId: this.data?._id || this.data?.name })); }; }); // Upcoming Fixtures paging is now handled by LeagueMatchesUpcoming.js // Attention Matches Paging is now handled by LeagueMatchesAttention.js // Match links from sub-components will be handled by them dispatching events // if LeagueElement needs to act (e.g. open a modal from a central place) } setupResizer() { const resizer = this.shadow.querySelector('.resizer'); const leftPanel = this.shadow.querySelector('.left-panel'); const rightPanel = this.shadow.querySelector('.right-panel'); if (!resizer || !leftPanel || !rightPanel) { console.warn('Resizer or panels not found, skipping setupResizer.'); return; } // Calculate and store minimum right panel width if not already done (first-time setup) if (this.minRightPanelPixelWidth === null) { const initialRightWidth = rightPanel.getBoundingClientRect().width; const ASSUMED_INITIAL_RIGHT_PANEL_WIDTH_PX = 300; // Increased fallback this.minRightPanelPixelWidth = (initialRightWidth > 0 ? initialRightWidth : ASSUMED_INITIAL_RIGHT_PANEL_WIDTH_PX) * 1.0; // Changed multiplier to 1.0 } let x = 0; let leftWidthAtMouseDown = 0; const mouseDownHandler = (e) => { x = e.clientX; leftWidthAtMouseDown = leftPanel.getBoundingClientRect().width; document.addEventListener('mousemove', mouseMoveHandler); document.addEventListener('mouseup', mouseUpHandler); }; const mouseMoveHandler = (e) => { const dx = e.clientX - x; const hostWidth = this.shadow.host.getBoundingClientRect().width; if (hostWidth === 0) return; let finalLeftPanelPixelWidth = leftWidthAtMouseDown + dx; // 1. Apply left panel's own min/max percentage constraints const minLeftPanelBoundPx = hostWidth * 0.20; const maxLeftPanelBoundPx = hostWidth * 0.80; finalLeftPanelPixelWidth = Math.max(minLeftPanelBoundPx, finalLeftPanelPixelWidth); finalLeftPanelPixelWidth = Math.min(maxLeftPanelBoundPx, finalLeftPanelPixelWidth); // 2. Apply right panel's minimum pixel width constraint (if set) if (this.minRightPanelPixelWidth !== null && this.minRightPanelPixelWidth > 0) { const maxAllowedLeftForRightMinPx = hostWidth - this.minRightPanelPixelWidth; finalLeftPanelPixelWidth = Math.min(finalLeftPanelPixelWidth, maxAllowedLeftForRightMinPx); } // 3. Re-ensure left panel meets its own minimum after adjustments for right panel finalLeftPanelPixelWidth = Math.max(minLeftPanelBoundPx, finalLeftPanelPixelWidth); // Convert final pixel width to percentage for flex-basis const newLeftFlexPercentage = (finalLeftPanelPixelWidth / hostWidth) * 100; leftPanel.style.flex = `0 0 ${newLeftFlexPercentage}%`; }; const mouseUpHandler = () => { document.removeEventListener('mousemove', mouseMoveHandler); document.removeEventListener('mouseup', mouseUpHandler); if (leftPanel) { this.leftPanelFlexBasis = leftPanel.style.flex; } }; resizer.addEventListener('mousedown', mouseDownHandler); } /** * Renders the form icons with tooltips for recent matches. * @param {Array<Object>|string} [matches=[]] - An array of recent match objects or a form string * Each object should have 'result' (W/D/L) * and 'description' (the tooltip text). * @returns {string} HTML string for the form icons. */ renderForm(matches = []) { // Check if matches data is valid if (!Array.isArray(matches)) { // Check if the data passed might be the old form string for backward compatibility or error state if (typeof matches === 'string') { const matchesStr = String(matches); // Ensure it's a string if (matchesStr.length <= 5) { console.warn('Received string instead of matches array for form rendering. Displaying basic icons.'); // Fallback to basic rendering if it looks like a form string return matchesStr.split('').map(result => { const lowerResult = result.toLowerCase(); let symbol = '?'; if (lowerResult === 'w') symbol = '✔'; if (lowerResult === 'd') symbol = '-'; if (lowerResult === 'l') symbol = '✖'; // No title attribute in this fallback return `<span class="form-icon form-${lowerResult}">${symbol}</span>`; }).join(''); } } console.warn('Invalid matches data provided to renderForm:', matches); return ''; // Return empty string for other invalid data } // Reverse the matches array so most recent appears on the right return matches.slice().reverse().map(match => { // Ensure match object and properties exist if (!match || typeof match.result !== 'string' || typeof match.description !== 'string') { console.warn('Invalid match object within matches array:', match); // Add a title attribute indicating invalid data for the placeholder too return '<span class="form-icon" title="Invalid match data">?</span>'; } const lowerResult = match.result.toLowerCase(); let symbol = '?'; // Use match.result to determine symbol and class if (lowerResult === 'w') symbol = '✔'; if (lowerResult === 'd') symbol = '-'; if (lowerResult === 'l') symbol = '✖'; // Add the title attribute with escaped match.description for the tooltip // Symbol is removed to allow CSS to render a colored block return `<span class="form-icon form-${lowerResult}" title="${this.escapeHtml(match.description)}"></span>`; }).join(''); } /** * Basic HTML escaping function to prevent XSS issues in tooltips. * @param {string} unsafe - The string to escape. * @returns {string} The escaped string. */ escapeHtml(unsafe = '') { // Ensure input is a string const str = String(unsafe); return str .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;"); } /** * Formats a list of matches for tooltip display. * If resultType is provided, only matches of that type are included. * If not, all matches are included. * @param {Array<Object>} matches - Array of match objects * @param {string} teamId - The ID of the team to check the result for. * @param {string} [resultType] - Optional: 'W', 'D', or 'L' * @param {boolean} [displayVerb=true] - Whether to show the verb (Won/Lost/Drew) * @returns {string} Tooltip string */ formatMatchList(matches = [], teamId, resultType, displayVerb = true) { if (!Array.isArray(matches) || matches.length === 0) { if (resultType) { return `No ${resultType === 'W' ? 'wins' : resultType === 'L' ? 'losses' : 'draws'} recorded`; } return 'No matches played'; } // Filter if resultType is provided let filtered = matches; if (resultType) { filtered = matches.filter(match => { if (!match || !match.result || typeof match.result.homeScore !== 'number' || typeof match.result.awayScore !== 'number') { return false; } const homeId = match.homeTeam?._id; const awayId = match.awayTeam?._id; const { homeScore, awayScore } = match.result; let resultForTeam = ''; if (homeId === teamId) { if (homeScore > awayScore) resultForTeam = 'W'; else if (homeScore < awayScore) resultForTeam = 'L'; else resultForTeam = 'D'; } else if (awayId === teamId) { if (awayScore > homeScore) resultForTeam = 'W'; else if (awayScore < homeScore) resultForTeam = 'L'; else resultForTeam = 'D'; } return resultForTeam === resultType; }); } // Sort by date ascending - matches should already have a valid 'date' property filtered = [...filtered].sort((a, b) => new Date(a.date) - new Date(b.date)); const tooltipContent = filtered.map(match => { if (!match || !match.result || !match.date || !match.homeTeam || !match.awayTeam || typeof match.result.homeScore !== 'number' || typeof match.result.awayScore !== 'number') { return 'Invalid match data for tooltip'; } // Use display names if available, fall back to team names const homeTeamId = match.homeTeam._id; const awayTeamId = match.awayTeam._id; const homeTeamDisplay = match.homeTeamDisplayName || this.getTeamDisplayName(homeTeamId) || homeTeamId; const awayTeamDisplay = match.awayTeamDisplayName || this.getTeamDisplayName(awayTeamId) || awayTeamId; let resultVerb = ''; const { homeScore, awayScore } = match.result; if (homeTeamId === teamId) { if (homeScore > awayScore) resultVerb = 'Won'; else if (homeScore < awayScore) resultVerb = 'Lost'; else resultVerb = 'Drew'; } else if (awayTeamId === teamId) { if (awayScore > homeScore) resultVerb = 'Won'; else if (awayScore < homeScore) resultVerb = 'Lost'; else resultVerb = 'Drew'; } const dateStr = new Date(match.date).toLocaleDateString(); const matchDetails = `${homeTeamDisplay} ${homeScore}-${awayScore} ${awayTeamDisplay}`; return `${displayVerb ? resultVerb + ' ' : ''}${matchDetails} on ${dateStr}`; }).join('\n'); if (!tooltipContent) { if (resultType) { return `No ${resultType === 'W' ? 'wins' : resultType === 'L' ? 'losses' : 'draws'} recorded`; } return 'No matches played'; } return this.escapeHtml(tooltipContent); } // Add the new helper method here _getConflictingMatchKeys() { if (!this.data?.matches) return new Set(); const today = new Date(); today.setHours(0, 0, 0, 0); const todayTimestamp = today.getTime(); // 1. Filter for future matches without results const futureFixtures = this.data.matches.filter(match => { if (match.result || !match.date) return false; const matchDate = new Date(match.date); matchDate.setHours(0, 0, 0, 0); return matchDate.getTime() >= todayTimestamp; }); // 2. Group by date (using timestamp as key) const matchesByDate = futureFixtures.reduce((acc, match) => { const matchDate = new Date(match.date); matchDate.setHours(0, 0, 0, 0); const dateKey = matchDate.getTime(); if (!acc[dateKey]) { acc[dateKey] = []; } acc[dateKey].push(match); return acc; }, {}); const conflictingKeys = new Set(); // 3. Check each date for conflicts for (const dateKey in matchesByDate) { const matchesOnDay = matchesByDate[dateKey]; if (matchesOnDay.length < 2) continue; // Need at least 2 matches to have a conflict const teamCounts = {}; matchesOnDay.forEach(match => { teamCounts[match.homeTeam._id] = (teamCounts[match.homeTeam._id] || 0) + 1; teamCounts[match.awayTeam._id] = (teamCounts[match.awayTeam._id] || 0) + 1; }); // Find teams playing more than once on this day const conflictingTeams = Object.keys(teamCounts).filter(team => teamCounts[team] > 1); // If conflicts exist, add keys of all matches involving those teams on that day if (conflictingTeams.length > 0) { matchesOnDay.forEach(match => { if (conflictingTeams.includes(match.homeTeam._id) || conflictingTeams.includes(match.awayTeam._id)) { conflictingKeys.add(match._id); } }); } } return conflictingKeys; } setupTabs() { const tabs = this.shadow.querySelectorAll('.tab-button'); const tableViewDesktop = this.shadow.querySelector('#desktop-table-view'); const scheduleViewDesktop = this.shadow.querySelector('#desktop-schedule-view'); const matrixViewDesktop = this.shadow.querySelector('#desktop-matrix-view'); const trendsViewDesktop = this.shadow.querySelector('#desktop-trends-view'); const tableViewMobile = this.shadow.querySelector('#mobile-table-view'); const scheduleViewMobile = this.shadow.querySelector('#mobile-schedule-view'); const matrixViewMobile = this.shadow.querySelector('#mobile-matrix-view'); const trendsViewMobile = this.shadow.querySelector('#mobile-trends-view'); // Part 1: Reflect current state (this.activeView) onto the DOM tabs.forEach(t => { if (t.dataset.view === this.activeView) { t.classList.add('active'); } else { t.classList.remove('active'); } }); const isTableActive = this.activeView === 'table'; const isScheduleActive = this.activeView === 'schedule'; const isMatrixActive = this.activeView === 'matrix'; const isTrendsActive = this.activeView === 'trends'; if (tableViewDesktop) tableViewDesktop.style.display = isTableActive ? '' : 'none'; if (scheduleViewDesktop) scheduleViewDesktop.style.display = isScheduleActive ? '' : 'none'; if (matrixViewDesktop) matrixViewDesktop.style.display = isMatrixActive ? '' : 'none'; if (trendsViewDesktop) trendsViewDesktop.style.display = isTrendsActive ? '' : 'none'; if (tableViewMobile) tableViewMobile.style.display = isTableActive ? '' : 'none'; if (scheduleViewMobile) scheduleViewMobile.style.display = isScheduleActive ? '' : 'none'; if (matrixViewMobile) matrixViewMobile.style.display = isMatrixActive ? '' : 'none'; if (trendsViewMobile) trendsViewMobile.style.display = isTrendsActive ? '' : 'none'; // Part 2: Attach click handlers for future state changes tabs.forEach(tab => { tab.onclick = () => { if (this.activeView !== tab.dataset.view) { // Only act if view is actually changing this.activeView = tab.dataset.view; this.render(); // Change state, then re-render. Render will call setupTabs again. // After re-render, set up interactivity for the new active view // This ensures elements are in the DOM before attaching listeners. if (this.activeView === 'trends') { // Defer slightly to ensure DOM update cycle is complete from render() Promise.resolve().then(() => { this.setupTrendsViewInteractivity(); }); } else if (this.activeView === 'matrix') { // Set up matrix event listeners after DOM is ready Promise.resolve().then(() => { this.setupMatrixEventListeners(); }); } else if (this.activeView === 'schedule') { // Set up schedule event listeners after DOM is ready Promise.resolve().then(() => { this._setupScheduleEventListeners(); }); } } }; }); } _prepareMatrixData() { console.log('Current league data:', this._table); if (!this.data || !this._table || !this.data.matches) { console.warn('Cannot prepare matrix data: Required data is missing'); return null; } // Extract the leagueData array from the table object const tableData = this._table; const teams = Array.isArray(tableData) ? tableData : (tableData.leagueData || []); const matches = this.data.matches; if (!Array.isArray(teams) || teams.length === 0) { console.warn('Cannot prepare matrix data: No teams found in league data'); return null; } // Create a map of team names to their matches const teamMatches = new Map(); teams.forEach(team => { teamMatches.set(team.teamName, []); }); // Populate the matches for each team matches.forEach(match => { if (match.homeTeam && match.awayTeam) { const homeTeam = teamMatches.get(match.homeTeam); const awayTeam = teamMatches.get(match.awayTeam); if (homeTeam) homeTeam.push(match); if (awayTeam) awayTeam.push(match); } }); return { teams, teamMatches }; } _handleMatchSave(e) { if (!e.detail || !e.detail.match) { console.warn('Invalid match data received'); return; } const matchData = e.detail.match; // Update the match in this.data.matches if (this.data && this.data.matches) { this.data.matches = this.data.matches.map(m => m._id === matchData._id ? matchData : m ); } // Trigger a re-render to update the UI this.render(); } _getFilteredLeagueData() { const table = this._table; if (!this.data || !this.data.matches || !table || !Array.isArray(table.leagueData)) { return []; } const allTeamIdsInLeague = table.leagueData.map(t => t.teamId); let matchesSubset = this.data.matches.slice(0, this.data.matches.length); // For home/away filters, we don't filter the matches themselves, // but rather calculate stats differently in _calculateRanksFromMatches // The filtering logic is handled there by checking home/away context const stats = this._calculateRanksFromMatches(matchesSubset, allTeamIdsInLeague); // Apply form-based sorting if form filter is selected if (this.tableFilter === 'form') { return this._sortByForm(stats); } return stats; } /** * Sorts teams by their recent form using a weighted scoring system. * More recent matches have higher weight in the calculation. * @param {Array<Object>} stats - Array of team statistics objects * @returns {Array<Object>} Teams sorted by form score (best form first) */ _sortByForm(stats) { // Calculate form score for each team const teamsWithFormScore = stats.map(team => { const formScore = this._calculateFormScore(team.matches); return { ...team, formScore: formScore }; }); // Sort by form score (highest first), then by points as tiebreaker teamsWithFormScore.sort((a, b) => { if (Math.abs(b.formScore - a.formScore) > 0.01) { // Use small epsilon for float comparison return b.formScore - a.formScore; } // Tiebreaker: use points, then goal difference, then goals for if (b.points !== a.points) return b.points - a.points; if (b.shotDifference !== a.shotDifference) return b.shotDifference - a.shotDifference; if (b.shotsFor !== a.shotsFor) return b.shotsFor - a.shotsFor; return a.teamDisplayName.localeCompare(b.teamDisplayName); }); // Reassign ranks based on form sorting teamsWithFormScore.forEach((team, index) => { team.currentRank = index + 1; }); return teamsWithFormScore; } /** * Calculates a weighted form score based on recent match results. * Uses a standard methodology: W=3pts, D=1pt, L=0pts with heavy recency weighting. * Most recent matches have higher impact on the score. * @param {Array<Object>} formMatches - Array of recent match objects with result and description * @returns {number} Form score (0-3 range, proportionally scaled for fewer matches) */ _calculateFormScore(formMatches) { if (!Array.isArray(formMatches) || formMatches.length === 0) { return 0; } // Convert form match objects to the format expected by the generalized function // This is a bridge between the old format and the new generalized approach const weights = [1.0, 0.8, 0.6, 0.4, 0.2]; // Most recent to oldest let totalScore = 0; let totalWeight = 0; formMatches.forEach((match, index) => { if (index >= 5) return; // Only consider last 5 matches const weight = weights[index]; let matchPoints = 0; // Convert result to points if (match.result === 'W') { matchPoints = 3; } else if (match.result === 'D') { matchPoints = 1; } else if (match.result === 'L') { matchPoints = 0; } totalScore += matchPoints * weight; totalWeight += weight; }); // Scale the score to maintain a reasonable range while emphasizing recent form // The scaling ensures teams with fewer matches aren't unfairly penalized const scaledScore = totalWeight > 0 ? (totalScore / totalWeight) * 3 : 0; // Scale to 0-3 per match average return Math.round(scaledScore * 1000) / 1000; // Round to 3 decimal places for better precision } _getTeamsFromLeagueData() { if (this.data && this.data.teams) { return this.data.teams; } else if (this.data && this._table) { const tableData = this._table; const teams = Array.isArray(tableData) ? tableData : (tableData.leagueData || []); return teams.map(team => ({ _id: team.teamId, name: team.teamName })); } return []; } setupTrendsViewInteractivity() { // Graph Type Selector const graphTypeSelect = this.shadow.querySelector('#graph-type-select'); if (graphTypeSelect) { graphTypeSelect.value = this.activeTrendGraphType; // Set initial value graphTypeSelect.addEventListener('change', (event) => { this.activeTrendGraphType = event.target.value; // Re-rendering is the simplest way to handle the view change, // as it will correctly call the appropriate draw function // within renderTrendsViewContent. this.render(); }); } // Team Toggle Checkboxes (event delegation on legend container) const legendDiv = this.shadow.querySelector('.trends-graph-legend'); if (legendDiv) { legendDiv.addEventListener('change', (event) => { if (event.target.matches('.trends-team-toggle-cb')) { const teamId = event.target.value; if (event.target.checked) { this.selectedTeamsForGraph.add(teamId); } else { this.selectedTeamsForGraph.delete(teamId); } // Redraw the appropriate graph based on active type // No need to call render() here, just redraw the SVG content. if (this.activeTrendGraphType === 'shotsForVsAgainst') { this.drawShotsForVsAgainstSVG(); } else if (this.activeTrendGraphType === 'formOverTime') { this.drawFormOverTimeSVG(); } else { this.drawPointsOverTimeSVG(); } } }); } // Call this once during setup if the trends view is active, to ensure interactivity is live // However, render() / _fillTemplate handles calling drawPointsOverTimeSVG which populates legend, // so event listeners should be set up after legend is populated. // The best place to call this is after render has completed and if the trends tab is active. // We can also ensure it's called from setupTabs when trends tab becomes active. } // START - Placeholder for Trends View Methods renderTrendsViewContent() { // This will be expanded in the next steps let legendHTML = ''; // Legend is now fully populated by the specific draw...SVG function let graphAreaHTML = '<svg id="points-over-time-svg" width="100%" height="100%"></svg>'; // Height 100% to fill parent // Data availability checks for graph area const hasDataForPoints = this.pointsOverTimeChartData && this.pointsOverTimeChartData.dates && this.pointsOverTimeChartData.dates.length > 0; const hasDataForShots = this.shotsForVsAgainstData && this.shotsForVsAgainstData.teams && this.shotsForVsAgainstData.teams.length > 0; const hasDataForForm = this.formOverTimeChartData && this.formOverTimeChartData.dates && this.formOverTimeChartData.dates.length > 0; let dataUnavailable = false; if (this.activeTrendGraphType === 'pointsOverTime' && !hasDataForPoints) dataUnavailable = true; if (this.activeTrendGraphType === 'shotsForVsAgainst' && !hasDataForShots) dataUnavailable = true; if (this.activeTrendGraphType === 'formOverTime' && !hasDataForForm) dataUnavailable = true; if (dataUnavailable) { graphAreaHTML = '<p style="text-align:center; padding-top: 20px;">Graph cannot be displayed: Data unavailable or insufficient for the selected type.</p>'; } // Defer drawing the SVG until after this content is in the DOM // and if the trends tab is actually active. if (this.activeView === 'trends') { Promise.resolve().then(() => { if (this.shadow.querySelector('#points-over-time-svg')) { // Ensure element exists if (this.activeTrendGraphType === 'shotsForVsAgainst') { this._prepareShotsForVsAgainstData(); this.drawShotsForVsAgainstSVG(); } else if (this.activeTrendGraphType === 'formOverTime') { this._prepareFormOverTimeData(); this.drawFormOverTimeSVG(); } else { this.drawPointsOverTimeSVG(); } } }); } return ` <div class="trends-view-wrapper"> <div class="trends-controls dropdown-container-flex"> <label for="graph-type-select">Graph Type:</label> <div class="dropdown-shared"> <select id="graph-type-select" class="dropdown-select-shared"> <option value="pointsOverTime">Points Over Time</option> <option value="shotsForVsAgainst">Shots For vs Against</option> <option value="formOverTime">Form Over Time</option> <!-- Future graph types will be added here --> </select> </div> </div> <div class="trends-content-area"> <div class="trends-graph-legend"> ${legendHTML} <!-- Initially empty, populated by the draw...SVG functions --> </div> <div class="trends-graph-area"> ${graphAreaHTML} </div> </div> </div> `; } _preparePointsOverTimeData() { const tableData = this._table; const table = Array.isArray(tableData) ? tableData : (tableData?.leagueData || []); if (!this.data || !this.data.matches || !Array.isArray(this.data.matches) || !Array.isArray(table) || table.length === 0) { console.warn('_preparePointsOverTimeData: Essential data is missing. Clearing chart data.'); this.pointsOverTimeChartData = { dates: [], teamSeries: {}, allTeamNames: [] }; return; } const allTeamIds = table.map(team => team.teamId); if (allTeamIds.length === 0) { console.warn('_preparePointsOverTimeData: No teams found in leagueData. Clearing chart data.'); this.pointsOverTimeChartData = { dates: [], teamSeries: {}, allTeamNames: [] }; return; } const validMatches = this.data.matches.filter(match => { return match.date && match.result && typeof match.result.homeScore === 'number' && typeof match.result.awayScore === 'number' && allTeamIds.includes(match.homeTeam._id) && allTeamIds.includes(match.awayTeam._id); }); if (validMatches.length === 0) { console.warn('_preparePointsOverTimeData: No valid matches with results found for trend analysis.'); this.pointsOverTimeChartData = { dates: [], teamSeries: {}, allTeamNames: allTeamIds }; allTeamIds.forEach(teamId => { this.pointsOverTimeChartData.teamSeries[teamId] = []; }); return; } const uniqueDateTimestamps = [...new Set( validMatches.map(match => { const d = new Date(match.date); d.setHours(0, 0, 0, 0); return d.getTime(); }) )].sort((a, b) => a - b); if (uniqueDateTimestamps.length === 0) { // Should be caught by validMatches.length === 0, but as a safeguard this.pointsOverTimeChartData = { dates: [], teamSeries: {}, allTeamNames: allTeamIds }; allTeamIds.forEach(teamId => { this.pointsOverTimeChartData.teamSeries[teamId] = []; }); return; } this.pointsOverTimeChartData = { dates: uniqueDateTimestamps, teamSeries: {}, allTeamNames: allTeamIds }; allTeamIds.forEach(teamId => { this.pointsOverTimeChartData.teamSeries[teamId] = Array(uniqueDateTimestamps.length).fill(0); }); const currentTeamPoints = {}; allTeamIds.forEach(teamId => { currentTeamPoints[teamId] = 0; }); uniqueDateTimestamps.forEach((dateTimestamp, dateIndex) => { validMatches.forEach(match => { const matchDate = new Date(match.date); matchDate.setHours(0, 0, 0, 0); const matchTimestamp = matchDate.getTime(); if (matchTimestamp === dateTimestamp) { const homeTeamId = match.homeTeam._id; const awayTeamId = match.awayTeam._id; const homeScore = match.result.homeScore; const awayScore = match.result.awayScore; if (homeScore > awayScore) { currentTeamPoints[homeTeamId] += 3; } else if (awayScore > homeScore) { currentTeamPoints[awayTeamId] += 3; } else { // Draw currentTeamPoints[homeTeamId] += 1; currentTeamPoints[awayTeamId] += 1; } } }); // After processing all matches for this dateTimestamp, store the cumulative points allTeamIds.forEach(teamId => { this.pointsOverTimeChartData.teamSeries[teamId][dateIndex] = currentTeamPoints[teamId]; }); }); } _prepareShotsForVsAgainstData() { const tableData = this._table; const table = Array.isArray(tableData) ? tableData : (tableData?.leagueData || []); if (!this.data || !this.data.matches || !Array.isArray(this.data.matches) || !Array.isArray(table) || table.length === 0) { console.warn('_prepareShotsForVsAgainstData: Essential data is missing. Clearing chart data.'); this.shotsForVsAgainstData = { teams: [], averageShotsFor: 0, averageShotsAgainst: 0, maxShotsFor: 0, maxShotsAgainst: 0 }; return; } const allTeamIds = table.map(team => team.teamId); if (allTeamIds.length === 0) { console.warn('_prepareShotsForVsAgainstData: No teams found in leagueData. Clearing chart data.'); this.shotsForVsAgainstData = { teams: [], averageShotsFor: 0, averageShotsAgainst: 0, maxShotsFor: 0, maxShotsAgainst: 0 }; return; } const validMatches = this.data.matches.filter(match => { return match.date && match.result && typeof match.result.homeScore === 'number' && typeof match.result.awayScore === 'number' && allTeamIds.includes(match.homeTeam._id) && allTeamIds.includes(match.awayTeam._id); }); if (validMatches.length === 0) { console.warn('_prepareShotsForVsAgainstData: No valid matches with results found for analysis.'); this.shotsForVsAgainstData = { teams: [], averageShotsFor: 0, averageShotsAgainst: 0, maxShotsFor: 0, maxShotsAgainst: 0 }; return; } // Calculate shots for/against for each team const teamStats = {}; allTeamIds.forEach(teamId => { teamStats[teamId] = { teamId, teamName: this.getTeamDisplayName(teamId), shotsFor: 0, shotsAgainst: 0, matchesPlayed: 0 }; }); validMatches.forEach(match => { const homeTeamId = match.homeTeam._id; const awayTeamId = match.awayTeam._id; const homeScore = match.result.homeScore; const awayScore = match.result.awayScore; if (teamStats[homeTeamId]) { teamStats[homeTeamId].shotsFor += home