@lovebowls/leagueelements
Version:
League Elements package for LoveBowls
1,190 lines (1,028 loc) • 124 kB
JavaScript
// 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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* 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