@lovebowls/leagueelements
Version:
League Elements package for LoveBowls
1,269 lines (1,105 loc) • 42.2 kB
JavaScript
// Define custom event types for the LeagueSchedule element
class LeagueScheduleEvent extends CustomEvent {
constructor(detail) {
super('league-schedule-event', {
detail,
bubbles: true,
composed: true
});
}
}
import { MOBILE_STYLES, DESKTOP_STYLES } from './LeagueSchedule-styles.js';
import { TemporalUtils } from '../../utils/temporalUtils.js';
import { exportMatchesToCSV, exportMatchesToExcel, exportMatchesToWord, exportMatchesToJSON } from '../../utils/data.js';
import { League } from '@lovebowls/leaguejs';
import '../leagueCalendar/LeagueCalendar.js';
/**
* Custom element to display a complete schedule of matches from a League
* with paging, filtering, and export functionality.
*
* @element league-schedule
* @attr {string} data - JSON stringified League object
* @attr {boolean} [is-mobile] - Whether to use mobile styles
* @attr {boolean} [can-edit] - Whether to show edit buttons for matches
* @attr {string} [filter-date] - Date filter in YYYY-MM-DD format
* @attr {string} [selected-team] - Team ID to filter matches by
*
* Emits 'league-schedule-event' with detail { type: 'matchClick', match }
* Emits 'league-schedule-event' with detail { type: 'matchEdit', match }
* Emits 'league-schedule-event' with detail { type: 'matchSave', match }
* Emits 'league-schedule-event' with detail { type: 'filterClear' }
*/
class LeagueSchedule extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.league = null;
this.matches = [];
this.teams = [];
this.currentPage = 1;
this.itemsPerPage = 25;
this.selectedMatchId = null;
this.selectedTeamId = null;
this.filterDate = null; // YYYY-MM-DD format
this.error = null;
// Generate a unique storage key for this component instance
this.storageKey = `league-schedule-filters-${this.getAttribute('data-league-id') || 'default'}`;
}
get _canEdit() {
return this.getAttribute('can-edit') === 'true';
}
get _shouldShowRinkColumn() {
// Show rink column if any match in the league has a rink assigned
return this.matches && this.matches.some(match =>
match.rink !== undefined && match.rink !== null && match.rink !== ''
);
}
static get observedAttributes() {
return ['data', 'is-mobile', 'can-edit', 'filter-date', 'selected-team'];
}
connectedCallback() {
if (this.hasAttribute('data')) {
this.loadData(this.getAttribute('data'));
}
// Restore filter state from storage after data is loaded (so storage key is correct)
this.restoreFilterState();
// Always use renderWithoutReset to preserve restored filter state
this.renderWithoutReset();
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === 'data') {
this.loadData(newValue);
} else if (name === 'is-mobile') {
this.renderWithoutReset();
} else if (name === 'can-edit') {
this.renderWithoutReset();
} else if (name === 'filter-date') {
this.filterDate = newValue || null;
this.currentPage = 1; // Reset to first page when filter changes
this.render();
} else if (name === 'selected-team') {
this.selectedTeamId = newValue || null;
this.currentPage = 1; // Reset to first page when filter changes
this.saveFilterState(); // Save the filter state when set externally
// Render first, then update calendar (calendar element will exist after render)
this.renderWithoutReset();
// Update calendar with filtered matches after render completes
setTimeout(() => {
const calendarElement = this.shadow.querySelector('#mobile-schedule-calendar, #desktop-schedule-calendar');
if (calendarElement) {
this._updateCalendarMatches(calendarElement);
}
}, 0);
}
}
/**
* Loads and parses the league data.
* @param {string|Object} data - League data as JSON string or object
*/
loadData(data) {
try {
// Parse data if it's a string
let parsedData;
if (typeof data === 'string') {
parsedData = JSON.parse(data);
} else {
parsedData = data || null;
}
// Convert to League instance if it isn't already
if (parsedData) {
if (parsedData.getMatchesRequiringAttention && parsedData.getConflictingMatchIds) {
// Already a League instance
this.league = parsedData;
} else {
// Create a League instance from the data
this.league = new League(parsedData);
}
// Update storage key with actual league ID
if (this.league._id) {
this.storageKey = `league-schedule-filters-${this.league._id}`;
}
// Extract matches and teams from league
this.matches = Array.isArray(this.league.matches) ? this.league.matches : [];
this.teams = Array.isArray(this.league.teams) ? this.league.teams : [];
// Sort matches by date first, then by rink number if present
this.matches.sort((a, b) => {
// First sort by date
const dateA = new Date(a.date);
const dateB = new Date(b.date);
const dateDiff = dateA - dateB;
// If dates are the same, sort by rink number
if (dateDiff === 0) {
const rinkA = a.rink || 0; // Treat undefined/null as 0
const rinkB = b.rink || 0; // Treat undefined/null as 0
return rinkA - rinkB;
}
return dateDiff;
});
} else {
this.league = null;
this.matches = [];
this.teams = [];
}
// Reset current page and selected match, but preserve filter state if it exists
this.currentPage = 1;
this.selectedMatchId = null;
// Preserve selectedTeamId to maintain filter state during reconnections
this.error = null;
// Navigate to page with next future match
this.navigateToNextFutureMatch();
this.render();
this.dispatchEvent(new LeagueScheduleEvent({
type: 'dataLoaded',
matches: this.matches,
teams: this.teams
}));
} catch (error) {
this.error = 'Failed to load league data';
console.error('Error loading league data:', error);
this.dispatchEvent(new LeagueScheduleEvent({
type: 'error',
message: this.error,
error
}));
this.render();
}
}
/**
* Returns the filtered list of matches based on current filter settings.
* @returns {Array} Filtered matches
*/
getFilteredMatches() {
if (!this.matches || !Array.isArray(this.matches)) {
return [];
}
let filteredMatches = [...this.matches];
// Apply date filter if set
if (this.filterDate) {
filteredMatches = filteredMatches.filter(match => {
if (!match.date) return false;
try {
const matchDate = new Date(match.date);
const matchDateString = matchDate.toISOString().split('T')[0]; // YYYY-MM-DD
return matchDateString === this.filterDate;
} catch (error) {
console.warn('Invalid match date:', match.date);
return false;
}
});
}
// Apply team filter if set
if (this.selectedTeamId) {
filteredMatches = filteredMatches.filter(match =>
match.homeTeam._id === this.selectedTeamId ||
match.awayTeam._id === this.selectedTeamId
);
}
return filteredMatches;
}
/**
* Determines winner styling classes for home and away teams
* @param {Object} match - Match object
* @returns {Object} Object with homeTeamClass and awayTeamClass properties
*/
getWinnerClasses(match) {
let homeTeamClass = '';
let awayTeamClass = '';
// Check if there's an actual result to show
const hasResult = match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number';
if (hasResult && match.result) {
const homeScore = match.result.homeScore;
const awayScore = match.result.awayScore;
if (homeScore > awayScore) {
homeTeamClass = 'winner';
} else if (awayScore > homeScore) {
awayTeamClass = 'winner';
} else {
// Draw - both teams get winner styling
homeTeamClass = 'winner';
awayTeamClass = 'winner';
}
}
return { homeTeamClass, awayTeamClass };
}
/**
* Gets comprehensive match information including attention status
* @param {Object} match - Match object
* @returns {Object} Match info with state, attention status, and reason
*/
getMatchInfo(match) {
if (!match) {
return { state: 'unknown', needsAttention: false, attentionReason: '', conflicted: false };
}
// Get attention matches and conflicting IDs from the league
let matchesRequiringAttention = [];
let conflictingIds = new Set();
if (this.league && typeof this.league.getMatchesRequiringAttention === 'function') {
try {
matchesRequiringAttention = this.league.getMatchesRequiringAttention();
} catch (error) {
console.warn('Error getting matches requiring attention:', error);
}
}
if (this.league && typeof this.league.getConflictingMatchIds === 'function') {
try {
conflictingIds = this.league.getConflictingMatchIds();
} catch (error) {
console.warn('Error getting conflicting match IDs:', error);
}
}
// Check if this match requires attention
const needsAttention = matchesRequiringAttention.some(m => m._id === match._id);
const conflicted = conflictingIds.has(match._id);
// Determine attention reason and basic state
let attentionReason = '';
let state = 'future'; // Default state
if (!match.date) {
state = 'no-date';
if (needsAttention) attentionReason = 'No date set for match';
} else {
try {
const matchDate = new Date(match.date);
const now = new Date();
// Consider matches in the past if they're before today (ignoring time)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const matchDay = new Date(matchDate.getFullYear(), matchDate.getMonth(), matchDate.getDate());
if (matchDay < today) {
// Past match
if (match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number') {
state = 'past-with-result';
} else {
state = 'past-no-result';
if (needsAttention) attentionReason = 'Match date passed, result pending';
}
} else if (matchDay.getTime() === today.getTime()) {
// Today's match
state = 'today';
} else {
// Future match
state = 'future';
if (match.result && needsAttention) {
attentionReason = 'Result entered for a future match date';
}
}
// Check for scheduling conflicts
if (conflicted) {
attentionReason = attentionReason || 'Scheduling conflict on this date';
}
} catch (error) {
console.warn('Error determining match state:', error);
state = 'error';
}
}
return {
state,
needsAttention,
attentionReason,
conflicted
};
}
/**
* Finds the next future match and navigates to the page containing it
*/
navigateToNextFutureMatch() {
const filteredMatches = this.getFilteredMatches();
if (!filteredMatches || filteredMatches.length === 0) {
this.currentPage = 1;
return;
}
// Find the first future match
const nextFutureMatchIndex = filteredMatches.findIndex(match => {
const matchInfo = this.getMatchInfo(match);
return matchInfo.state === 'future' || matchInfo.state === 'today';
});
if (nextFutureMatchIndex === -1) {
// No future matches found, stay on first page
this.currentPage = 1;
return;
}
// Calculate which page contains this match
const targetPage = Math.floor(nextFutureMatchIndex / this.itemsPerPage) + 1;
this.currentPage = Math.max(1, Math.min(targetPage, this.getTotalPages()));
}
/**
* Gets the team name from the team ID
* @param {string} teamId - Team ID
* @returns {string} Team name
*/
getTeamName(teamId) {
if (!teamId || !this.teams) return 'Unknown';
const team = this.teams.find(t => t._id === teamId);
return team ? team.name : 'Unknown';
}
/**
* Formats a match date for display
* @param {string} dateString - Date string
* @returns {string} Formatted date
*/
formatMatchDate(dateString) {
if (!dateString) return 'TBD';
try {
return TemporalUtils.formatDateString(new Date(dateString));
} catch (error) {
return 'Invalid Date';
}
}
/**
* Formats a match result for display with attention indicators
* @param {Object} match - Match object
* @returns {string} Formatted result string with attention indicators
*/
formatMatchResult(match) {
if (!match) {
return '-';
}
const matchInfo = this.getMatchInfo(match);
let resultText = '';
// Format the basic result
if (match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number') {
resultText = `${match.result.homeScore}-${match.result.awayScore}`;
} else {
resultText = '-';
}
// Add attention indicators based on the specific issue
if (matchInfo.needsAttention || matchInfo.conflicted) {
let icon = '';
if (matchInfo.conflicted) {
icon = '⚠️'; // Warning for scheduling conflicts
} else if (matchInfo.state === 'past-no-result') {
icon = '⏰'; // Clock for overdue results
} else if (matchInfo.attentionReason.includes('future match date')) {
icon = '🔮'; // Crystal ball for future results
} else if (matchInfo.state === 'no-date') {
icon = '📅'; // Calendar for missing date
} else {
icon = '❗'; // General attention needed
}
return `${resultText} ${icon}`;
}
return resultText;
}
/**
* Format the rink for display
* @param {Object} match - Match object
* @returns {string} Formatted rink string
*/
formatMatchRink(match) {
if (!match || !match.rink || match.rink === null || match.rink === undefined) {
return '';
}
return `${match.rink}`;
}
/**
* Gets the total number of pages
* @returns {number} Total pages
*/
getTotalPages() {
const filteredMatches = this.getFilteredMatches();
return Math.max(1, Math.ceil(filteredMatches.length / this.itemsPerPage));
}
/**
* Saves current filter state to sessionStorage
*/
saveFilterState() {
try {
// Update storage key if we have league data
if (this.league && this.league._id) {
this.storageKey = `league-schedule-filters-${this.league._id}`;
}
const filterState = {
selectedTeamId: this.selectedTeamId,
filterDate: this.filterDate,
currentPage: this.currentPage,
itemsPerPage: this.itemsPerPage,
timestamp: Date.now()
};
sessionStorage.setItem(this.storageKey, JSON.stringify(filterState));
} catch (error) {
console.warn('[LeagueSchedule] Failed to save filter state:', error);
}
}
/**
* Restores filter state from sessionStorage
*/
restoreFilterState() {
try {
// Update storage key if we have league data
if (this.league && this.league._id) {
this.storageKey = `league-schedule-filters-${this.league._id}`;
}
const stored = sessionStorage.getItem(this.storageKey);
if (stored) {
const filterState = JSON.parse(stored);
// Only restore if the data is recent (within last hour)
const maxAge = 60 * 60 * 1000; // 1 hour
if (Date.now() - filterState.timestamp < maxAge) {
this.selectedTeamId = filterState.selectedTeamId || null;
this.filterDate = filterState.filterDate || null;
this.currentPage = filterState.currentPage || 1;
this.itemsPerPage = filterState.itemsPerPage || 25;
} else {
sessionStorage.removeItem(this.storageKey);
}
}
} catch (error) {
console.warn('[LeagueSchedule] Failed to restore filter state:', error);
}
}
/**
* Clears saved filter state
*/
clearFilterState() {
try {
sessionStorage.removeItem(this.storageKey);
} catch (error) {
console.warn('[LeagueSchedule] Failed to clear filter state:', error);
}
}
/**
* Handles calendar date change events
* @param {Event} e - Calendar event
*/
handleCalendarDateChange = (e) => {
if (e.detail.type === 'dateChange') {
// Set the date filter based on the calendar selection
this.filterDate = e.detail.dateString || null;
this.currentPage = 1; // Reset to first page when filter changes
this.saveFilterState();
this.renderWithoutReset();
} else if (e.detail.type === 'filterClear') {
// Clear the date filter
this.filterDate = null;
this.currentPage = 1;
this.saveFilterState();
this.render();
}
}
/**
* Handles team filter change
* @param {Event} e - Change event
*/
handleTeamFilter = (e) => {
this.selectedTeamId = e.target.value;
this.currentPage = 1;
// Save filter state whenever it changes
this.saveFilterState();
// Update calendar with filtered matches
const calendarElement = this.shadow.querySelector('#mobile-schedule-calendar, #desktop-schedule-calendar');
if (calendarElement) {
this._updateCalendarMatches(calendarElement);
}
// Navigate to next future match when filter changes
this.navigateToNextFutureMatch();
this.renderWithoutReset();
}
/**
* Clears all filters
*/
clearFilters = () => {
this.selectedTeamId = null;
this.filterDate = null;
this.currentPage = 1;
// Clear saved filter state
this.clearFilterState();
// Update calendar with all matches when filters are cleared
const calendarElement = this.shadow.querySelector('#mobile-schedule-calendar, #desktop-schedule-calendar');
if (calendarElement) {
this._updateCalendarMatches(calendarElement);
}
// Remove the filter-date attribute to notify parent component
this.removeAttribute('filter-date');
// Dispatch event to notify parent that filters should be cleared
this.dispatchEvent(new LeagueScheduleEvent({
type: 'filterClear'
}));
this.render();
}
/**
* Handles page change
* @param {number} page - New page number
*/
handlePageChange = (page) => {
if (page < 1 || page > this.getTotalPages()) return;
this.currentPage = page;
this.saveFilterState();
this.renderWithoutReset();
// Scroll to top of schedule container after page change
const scheduleContainer = this.shadow.querySelector('.schedule-container');
if (scheduleContainer) {
scheduleContainer.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
/**
* Handles items per page change
* @param {Event} e - Input event
*/
handleItemsPerPageChange = (e) => {
const value = parseInt(e.target.value, 10);
// Validate input: must be integer between 10 and 1000
if (isNaN(value) || value < 10 || value > 1000) {
// Reset to current value if invalid
e.target.value = this.itemsPerPage;
return;
}
this.itemsPerPage = value;
this.currentPage = 1; // Reset to first page
this.saveFilterState();
this.renderWithoutReset();
}
/**
* Handles match selection
* @param {Object} match - Selected match
*/
handleMatchSelect = (match) => {
if (!match) return;
// Toggle selection
this.selectedMatchId = this.selectedMatchId === match._id ? null : match._id;
// Dispatch event for selection (not edit)
this.dispatchEvent(new LeagueScheduleEvent({
type: 'matchClick',
match
}));
// Re-render without resetting filters
this.renderWithoutReset();
}
/**
* Handles edit match button click
* @param {Event} e - Click event
* @param {Object} match - Match to edit
*/
handleEditMatch = (e, match) => {
e.stopPropagation(); // Prevent row click
if (!match || !this._canEdit) return;
// Set as selected
this.selectedMatchId = match._id;
// Dispatch edit event
this.dispatchEvent(new LeagueScheduleEvent({
type: 'matchEdit',
match
}));
// Re-render without resetting filters
this.renderWithoutReset();
}
/**
* Exports the data in the specified format
* @param {string} format - Export format
*/
exportData = async (format) => {
// Export ALL matches (not just current page) but respect filters
const filteredMatches = this.getFilteredMatches();
if (!filteredMatches || filteredMatches.length === 0) {
alert('No matches to export');
this.showExportMenu = false;
this.render();
return;
}
// Generate filename with current date and filter info
let filename = 'league-matches';
if (this.selectedTeamId) {
const teamName = this.getTeamName(this.selectedTeamId).replace(/[^a-zA-Z0-9]/g, '_');
filename += `_${teamName}`;
}
if (this.filterDate) {
filename += `_${this.filterDate}`;
}
filename += `_${new Date().toISOString().split('T')[0]}`;
try {
switch (format) {
case 'csv':
exportMatchesToCSV(filteredMatches, filename);
break;
case 'excel':
await exportMatchesToExcel(filteredMatches, filename);
break;
case 'word':
exportMatchesToWord(filteredMatches, filename);
break;
case 'json':
exportMatchesToJSON(filteredMatches, filename);
break;
default:
console.warn(`Unknown export format: ${format}`);
alert(`Export format "${format}" is not supported`);
return;
}
// Export completed successfully
// Dispatch event for tracking/analytics
this.dispatchEvent(new LeagueScheduleEvent({
type: 'export',
format,
matchCount: filteredMatches.length,
filename
}));
} catch (error) {
console.error('Export error:', error);
alert(`Failed to export matches: ${error.message}`);
}
this.showExportMenu = false;
this.renderWithoutReset();
}
/**
* Renders the component without resetting filter values
*/
renderWithoutReset() {
this._render(false);
}
/**
* Renders the component using lit-html
*/
render() {
this._render(true);
}
/**
* Internal render method - routes to mobile or desktop renderer
* @param {boolean} resetFilters - Whether to reset filter form values
*/
_render(resetFilters = true) {
const isMobile = this.getAttribute('is-mobile') === 'true';
if (isMobile) {
this._renderMobile(resetFilters);
} else {
this._renderDesktop(resetFilters);
}
}
/**
* Mobile-specific render method
* @param {boolean} resetFilters - Whether to reset filter form values
*/
_renderMobile(resetFilters = true) {
const { league, selectedTeamId, currentPage, itemsPerPage } = this;
// Calculate matches to display based on filters and pagination
const filteredMatches = this.getFilteredMatches();
const totalMatches = filteredMatches.length;
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, totalMatches);
const currentMatches = filteredMatches.slice(startIndex, endIndex);
const html = (strings, ...values) => {
return String.raw({ raw: strings }, ...values);
};
const editable = this._canEdit;
const content = html`
<div class="schedule-container">
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div class="controls-panel">
<div class="filter-controls">
<div class="dropdown-shared">
<select class="dropdown-select-shared" id="team-filter">
<option value="">All Teams</option>
${league?.teams?.map(team => `
<option value="${team._id}" ${team._id === selectedTeamId ? 'selected' : ''}>
${team.name}
</option>
`).join('')}
</select>
</div>
<div class="dropdown-shared">
<select id="export-select" class="dropdown-select-shared">
<option value="">Export</option>
<option value="excel">Excel</option>
<option value="word">Word</option>
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
</div>
</div>
</div>
<div class="filter-panel">
<div class="calendar-filter">
<league-calendar id="mobile-schedule-calendar" is-mobile="true"></league-calendar>
</div>
</div>
${totalMatches > 0 ? `
<div class="schedule-cards">
${currentMatches.map(match => {
const isSelected = this.selectedMatchId === match._id;
const matchInfo = this.getMatchInfo(match);
const cssClasses = [
'match-card',
matchInfo.state,
matchInfo.needsAttention ? 'needs-attention' : '',
matchInfo.conflicted ? 'conflicted' : '',
isSelected ? 'selected' : ''
].filter(Boolean).join(' ');
const titleAttr = matchInfo.attentionReason ?
` title="${matchInfo.attentionReason.replace(/"/g, '"')}"` : '';
// Check if there's an actual result to show
const hasResult = match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number';
const dateContent = `
<div class="date-rink-container">
<div class="date-section">
${editable ?
`<span class="date-link" data-match-id="${match._id}" title="Edit Match">${this.formatMatchDate(match.date)}</span>` :
this.formatMatchDate(match.date)}
</div>
${this._shouldShowRinkColumn ?
`<div class="rink-section">
${editable ?
`<span class="rink-link" data-match-id="${match._id}" title="Edit Match">${this.formatMatchRink(match)}</span>` :
this.formatMatchRink(match)}
</div>` : ''}
</div>`;
// Determine winner styling
const { homeTeamClass, awayTeamClass } = this.getWinnerClasses(match);
return `
<div
class="${cssClasses}"
data-match-id="${match._id}"${titleAttr}
>
<div class="card-row">
<span class="card-value">${dateContent}</span>
</div>
<div class="card-row teams-result-row">
<span class="home-label">H</span>
<span class="team-name-home${homeTeamClass ? ` ${homeTeamClass}` : ''}">${this.getTeamName(match.homeTeam._id)}</span>
<span class="result-score">${hasResult ? this.formatMatchResult(match) : ' vs '}</span>
<span class="team-name-away${awayTeamClass ? ` ${awayTeamClass}` : ''}">${this.getTeamName(match.awayTeam._id)}</span>
<span class="away-label">A</span>
</div>
</div>
`;
}).join('')}
</div>
<div class="paging-controls">
<div class="paging-info">
Showing ${startIndex + 1}-${endIndex} of ${totalMatches} matches
</div>
<div class="paging-buttons">
<button
class="button-shared"
id="first-page"
${currentPage === 1 ? 'disabled' : ''}
>
«
</button>
<button
class="button-shared"
id="prev-page"
${currentPage === 1 ? 'disabled' : ''}
>
‹
</button>
<button
class="button-shared"
id="next-page"
${currentPage === this.getTotalPages() ? 'disabled' : ''}
>
›
</button>
<button
class="button-shared"
id="last-page"
${currentPage === this.getTotalPages() ? 'disabled' : ''}
>
»
</button>
</div>
</div>
` : `
<div class="no-data">
No matches found.
</div>
`}
</div>
`;
this.shadow.innerHTML = `
<style>${MOBILE_STYLES}</style>
${content}
`;
this.setupEventListeners();
// Preserve team filter value if not resetting
if (!resetFilters) {
const teamFilter = this.shadow.querySelector('#team-filter');
if (teamFilter && this.selectedTeamId) {
teamFilter.value = this.selectedTeamId;
}
}
}
/**
* Desktop-specific render method
* @param {boolean} resetFilters - Whether to reset filter form values
*/
_renderDesktop(resetFilters = true) {
const { league, selectedTeamId, currentPage, itemsPerPage } = this;
// Calculate matches to display based on filters and pagination
const filteredMatches = this.getFilteredMatches();
const totalMatches = filteredMatches.length;
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, totalMatches);
const currentMatches = filteredMatches.slice(startIndex, endIndex);
const html = (strings, ...values) => {
return String.raw({ raw: strings }, ...values);
};
const editable = this._canEdit;
const content = html`
<div class="schedule-container">
${this.error ? `<div class="error">${this.error}</div>` : ''}
<div class="controls-panel">
<div class="filter-controls">
<div class="dropdown-shared">
<select class="dropdown-select-shared" id="team-filter">
<option value="">All Teams</option>
${league?.teams?.map(team => `
<option value="${team._id}" ${team._id === selectedTeamId ? 'selected' : ''}>
${team.name}
</option>
`).join('')}
</select>
</div>
<div class="dropdown-shared">
<select id="export-select" class="dropdown-select-shared">
<option value="">Export</option>
<option value="excel">Excel</option>
<option value="word">Word</option>
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
</div>
</div>
</div>
<div class="filter-panel">
<div class="calendar-filter">
<league-calendar id="desktop-schedule-calendar"></league-calendar>
</div>
</div>
${totalMatches > 0 ? `
<table class="schedule-table">
<thead>
<tr>
<th class="date-col">Date</th>
${this._shouldShowRinkColumn ? `<th class="rink-col">Rink</th>` : ''}
<th class="team-col home-col">Home</th>
<th class="result-col center-align">Result</th>
<th class="team-col">Away</th>
</tr>
</thead>
<tbody>
${currentMatches.map(match => {
const isSelected = this.selectedMatchId === match._id;
const matchInfo = this.getMatchInfo(match);
const cssClasses = [
'match-row',
matchInfo.state,
matchInfo.needsAttention ? 'needs-attention' : '',
matchInfo.conflicted ? 'conflicted' : '',
isSelected ? 'selected' : ''
].filter(Boolean).join(' ');
const titleAttr = matchInfo.attentionReason ?
` title="${matchInfo.attentionReason.replace(/"/g, '"')}"` : '';
// Check if there's an actual result to show
const hasResult = match.result &&
typeof match.result.homeScore === 'number' &&
typeof match.result.awayScore === 'number';
// For mobile, conditionally include the result row
const isMobile = this.getAttribute('is-mobile') === 'true';
const resultCell = (isMobile && !hasResult) ? '' :
`<td data-label="Result">${this.formatMatchResult(match)}</td>`;
const dateContent = editable ?
`<span class="date-link" data-match-id="${match._id}" title="Edit Match">${this.formatMatchDate(match.date)}</span>` :
this.formatMatchDate(match.date);
// Determine winner styling and combine with desktop-specific classes
const { homeTeamClass: winnerHomeClass, awayTeamClass: winnerAwayClass } = this.getWinnerClasses(match);
const homeTeamClass = `home-col${winnerHomeClass ? ` ${winnerHomeClass}` : ''}`;
const awayTeamClass = winnerAwayClass;
return `
<tr
class="${cssClasses}"
data-match-id="${match._id}"${titleAttr}
>
<td data-label="Date">${dateContent}</td>
${this._shouldShowRinkColumn ? `<td data-label="Rink">${editable ?
`<span class="rink-link" data-match-id="${match._id}" title="Edit Match">${this.formatMatchRink(match)}</span>` :
this.formatMatchRink(match)}</td>` : ''}
<td data-label="H" class="${homeTeamClass}">${this.getTeamName(match.homeTeam._id)}</td>
${resultCell.replace('data-label="Result"', 'data-label="Result" class="center-align"')}
<td data-label="A"${awayTeamClass ? ` class="${awayTeamClass}"` : ''}>${this.getTeamName(match.awayTeam._id)}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<div class="paging-controls">
<div class="paging-info">
Showing ${startIndex + 1}-${endIndex} of ${totalMatches} matches
</div>
<div class="paging-buttons">
<button
class="button-shared"
id="first-page"
${currentPage === 1 ? 'disabled' : ''}
>
«
</button>
<button
class="button-shared"
id="prev-page"
${currentPage === 1 ? 'disabled' : ''}
>
‹
</button>
<button
class="button-shared"
id="next-page"
${currentPage === this.getTotalPages() ? 'disabled' : ''}
>
›
</button>
<button
class="button-shared"
id="last-page"
${currentPage === this.getTotalPages() ? 'disabled' : ''}
>
»
</button>
</div>
</div>
` : `
<div class="no-data">
No matches found.
</div>
`}
</div>
`;
this.shadow.innerHTML = `
<style>${DESKTOP_STYLES}</style>
${content}
`;
this.setupEventListeners();
// Preserve team filter value if not resetting
if (!resetFilters) {
const teamFilter = this.shadow.querySelector('#team-filter');
if (teamFilter && this.selectedTeamId) {
teamFilter.value = this.selectedTeamId;
}
}
}
/**
* Sets up event listeners for the component
*/
setupEventListeners() {
// Team filter change
const teamFilter = this.shadow.querySelector('#team-filter');
if (teamFilter) {
teamFilter.addEventListener('change', this.handleTeamFilter);
}
// Calendar event handling
const calendarElement = this.shadow.querySelector('#mobile-schedule-calendar, #desktop-schedule-calendar');
if (calendarElement) {
// Configure calendar with appropriate matches data based on team filter
this._updateCalendarMatches(calendarElement);
// Set current filter date if exists
if (this.filterDate) {
calendarElement.setAttribute('current-filter-date', this.filterDate);
}
// Add event listener for calendar events
calendarElement.addEventListener('league-calendar-event', this.handleCalendarDateChange);
}
// Export dropdown
const exportSelect = this.shadow.querySelector('#export-select');
if (exportSelect) {
exportSelect.addEventListener('change', (e) => {
const format = e.target.value;
if (format) {
this.exportData(format);
// Reset the select to show "Export" again
e.target.value = '';
}
});
}
// Pagination buttons
const firstPageBtn = this.shadow.querySelector('#first-page');
const prevPageBtn = this.shadow.querySelector('#prev-page');
const nextPageBtn = this.shadow.querySelector('#next-page');
const lastPageBtn = this.shadow.querySelector('#last-page');
if (firstPageBtn) {
firstPageBtn.addEventListener('click', () => this.handlePageChange(1));
}
if (prevPageBtn) {
prevPageBtn.addEventListener('click', () => this.handlePageChange(this.currentPage - 1));
}
if (nextPageBtn) {
nextPageBtn.addEventListener('click', () => this.handlePageChange(this.currentPage + 1));
}
if (lastPageBtn) {
lastPageBtn.addEventListener('click', () => this.handlePageChange(this.getTotalPages()));
}
// Match card click (for mobile) and match row click (for desktop)
const matchElements = this.shadow.querySelectorAll('.match-card, .match-row');
matchElements.forEach(element => {
element.addEventListener('click', (e) => {
// Don't trigger if clicking on a date link or rink link (edit functionality)
if (e.target.classList.contains('date-link') || e.target.classList.contains('rink-link')) {
return;
}
const matchId = element.getAttribute('data-match-id');
const match = this.matches.find(m => m._id === matchId);
if (match) {
this.handleMatchSelect(match);
}
});
});
// Date link clicks for editing
const dateLinks = this.shadow.querySelectorAll('.date-link');
dateLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent row click
const matchId = link.getAttribute('data-match-id');
const match = this.matches.find(m => m._id === matchId);
if (match) {
this.handleEditMatch(e, match);
}
});
});
// Rink link clicks for editing
const rinkLinks = this.shadow.querySelectorAll('.rink-link');
rinkLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent row click
const matchId = link.getAttribute('data-match-id');
const match = this.matches.find(m => m._id === matchId);
if (match) {
this.handleEditMatch(e, match);
}
});
});
}
/**
* Updates the calendar with appropriate matches based on current team filter
* @param {HTMLElement} calendarElement - The calendar element to update
* @private
*/
_updateCalendarMatches(calendarElement) {
if (!calendarElement) return;
let matchesToShow = [];
if (this.selectedTeamId) {
// If a team is selected, show only matches for that team
matchesToShow = this.matches.filter(match =>
match.homeTeam._id === this.selectedTeamId ||
match.awayTeam._id === this.selectedTeamId
);
} else {
// If no team is selected (All Teams), show all matches
matchesToShow = this.matches;
}
// Update the calendar with the filtered matches
if (matchesToShow && matchesToShow.length > 0) {
calendarElement.setAttribute('matches', JSON.stringify(matchesToShow));
} else {
calendarElement.setAttribute('matches', JSON.stringify([]));
}
}
}
import { safeDefine } from '../../utils/elementRegistry.js';
safeDefine('league-schedule', LeagueSchedule);
export default LeagueSchedule;