UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,531 lines (1,378 loc) 82.8 kB
var LeagueDashboard = (function () { 'use strict'; const baseFontSizeConstants = (fontScale = 1.0) => ` /* Base font sizes for different contexts */ --le-font-size-base-desktop: ${16 * fontScale}px; --le-font-size-base-mobile: ${18 * fontScale}px; line-height: 1.2; `; // Shared font size base variables and UI element font sizes (used internally) const getFontSizeElementVariables = (isMobile = false) => ` /* Specific sizes for common UI elements */ --le-font-size-button: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-label: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-input: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-table-header: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-font-size-table-cell: 0.7em; --le-font-size-dropdown: var(--le-font-size-base-${isMobile ? 'mobile' : 'desktop'}); --le-padding-xs: 0.25rem; --le-padding-s: 0.6rem; --le-padding-m: 1rem; `; const getMobileStyles = (fontScale = 1.0) => ` :host { ${baseFontSizeConstants(fontScale)} font-size: var(--le-font-size-base-mobile); /* Mobile-specific styling that can be added to host elements */ --le-font-size-base: 1em; --le-font-size-xs: 0.6em; --le-font-size-small: 0.8em; --le-font-size-medium: 1.0em; --le-font-size-large: 1.2em; --le-font-size-xlarge: 1.4em; --le-font-size-xxlarge: 1.6em; ${getFontSizeElementVariables(true)} .no-data padding: var(--le-padding-m, 1rem); color: var(--le-text-color-secondary, #666); background-color: transparent; border: none; } .controls-panel { gap: var(--le-padding-s, 0.5rem); } /* Mobile-specific dropdown styling */ .controls-panel .filter-controls .dropdown-shared { flex: 1; } .controls-panel .dropdown-shared { flex: 1; } .controls-panel .dropdown-shared .dropdown-select-shared { width: 100%; padding: var(--le-padding-s, 0.75rem) calc(var(--le-padding-m, 1rem) * 2.5) var(--le-padding-s, 0.75rem) var(--le-padding-m, 1rem); min-height: 44px; /* Minimum touch target size */ background-size: 1.2rem; background-position: right 1rem center; border-width: 2px; } .controls-panel .dropdown-select-shared:focus { border-width: 2px; } /* Input fields on mobile */ .form-input-shared, .form-select-shared, { min-height: 44px; /* Standard mobile touch target */ padding: var(--le-padding-xs, 0.5rem) var(--le-padding-m, 1rem); font-size: var(--le-font-size-medium, 1.0em); border-width: 2px; width: 100% !important; /* Force width consistency on mobile */ max-width: 100%; box-sizing: border-box; } /* Specific handling for date inputs on mobile */ .form-input-shared[type="date"] { -webkit-appearance: none; -moz-appearance: textfield; appearance: none; } .form-input-shared:focus, .form-select-shared:focus { border-width: 2px; } .form-label-shared { margin-bottom: var(--le-padding-s, 0.75rem); } .resizer { display: none !important; } } `; const getDesktopStyles = (fontScale = 1.0) => ` :host { ${baseFontSizeConstants(fontScale)} font-size: var(--le-font-size-base-desktop); /* Standardized font sizes for desktop - these cascade to all sub-components */ --le-font-size-base: 1em; --le-font-size-xs: 0.75em; --le-font-size-small: 0.9em; --le-font-size-medium: 1.0em; --le-font-size-large: 1.2em; --le-font-size-xlarge: 1.4em; --le-font-size-xxlarge: 1.6em; ${getFontSizeElementVariables(false)} .no-data padding: var(--le-padding-m, 1rem); color: var(--le-text-color-secondary, #666); background-color: transparent; border: none; } /* Desktop-specific dropdown styling */ .controls-panel .filter-controls .dropdown-shared { width: auto; min-width: 200px; } .controls-panel .dropdown-shared .dropdown-select-shared { min-width: 160px; width: auto; } .resizer { width: 5px; min-width: 5px; cursor: col-resize; background-color: var(--swal-background-color-header); border-left: 1px solid var(--swal-border-color-light); border-right: 1px solid var(--swal-border-color-light); z-index: 10; } .resizer:hover { background: var(--le-text-color-secondary); } } `; const panelStyles = ` .panel { margin-bottom: var(--le-padding-m, 1.25em); border: 1px solid var(--le-border-color-light, #f0f0f0); border-radius: var(--le-border-radius-standard, 4px); background-color: var(--le-background-color-panel, #fff); } .panel-header-shared { padding: var(--le-padding-s, 0.75em) var(--le-padding-m, 1.25em); /* Increased padding */ border-bottom: 1px solid var(--le-border-color-medium, #eee); font-weight: bold; color: var(--le-text-color-primary, #333); background-color: var(--le-background-color-header, #f9f9f9); display: flex; justify-content: space-between; align-items: center; font-size: var(--le-font-size-medium, 1.1em); } .panel .panel-header { display: flex; justify-content: space-between; align-items: center; background-color: var(--le-background-color-header, #f9f9f9); } .panel-content { padding: var(--le-padding-m, 1.25em); background-color: var(--le-background-color-panel, #fff); /* Common border for content area if needed border: 1px solid var(--le-border-color-light, #f0f0f0); */ } .controls-panel { /* Layout properties moved to mobile/desktop sections */ padding: 0; margin-bottom: var(--le-padding-s, 0.5rem); margin-top: var(--le-padding-s, 0.5rem); background: transparent; border: none; display: flex; justify-content: space-between; align-items: center; gap: var(--le-padding-m, 1rem); } .filter-controls { gap: var(--le-padding-s, 0.5rem); display: flex; align-items: center; flex: 1; } /* Enhanced dropdown styling for button-like appearance */ .controls-panel .dropdown-shared { position: relative; display: inline-block; } .controls-panel .dropdown-select-shared { appearance: none; -webkit-appearance: none; -moz-appearance: none; background-color: var(--le-background-color-button, #f0f0f0); border: 1px solid var(--le-border-color-medium, #ddd); border-radius: var(--le-border-radius-standard, 4px); padding: var(--le-padding-xs, 0.25rem); font-size: var(--le-font-size-dropdown, 1em)); color: var(--le-text-color-primary, #333); cursor: pointer; line-height: 1.4; min-width: 150px; transition: all 0.2s ease; font-weight: 500; } .controls-panel .dropdown-select-shared:hover { background-color: var(--le-background-color-button-hover, #e0e0e0); border-color: var(--le-border-color-dark, #ccc); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .controls-panel .dropdown-select-shared:focus { outline: none; border-color: var(--le-text-color-accent, #2196f3); box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); background-color: var(--le-background-color-panel, #fff); } .controls-panel .dropdown-select-shared:active { background-color: var(--le-background-color-button-hover, #e0e0e0); transform: translateY(1px); } `; const buttonStyles = ` .button-shared { padding: 4px 8px; border: 1px solid var(--le-border-color-medium, #ccc); background-color: var(--le-background-color-button, #f0f0f0); color: var(--le-text-color-primary, #333); /* Ensure text color contrasts with button background */ cursor: pointer; border-radius: var(--le-border-radius-standard, 4px); font-size: var(--le-font-size-button, var(--le-font-size-medium, 1.15em)); /* Use variable with fallback */ text-decoration: none; display: inline-block; text-align: center; line-height: normal; /* Ensure consistent line height */ white-space: nowrap; /* Prevent text wrapping */ vertical-align: middle; /* Align nicely if next to text/icons */ user-select: none; /* Prevent text selection on click */ transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; /* Smooth transitions */ } .button-shared:hover:not(:disabled) { background-color: var(--le-background-color-button-hover, #e0e0e0); border-color: var(--le-border-color-dark, #bbb); /* Slightly darker border on hover */ /* color: var(--le-text-color-accent-hover, inherit); Optional: change text color on hover */ } .button-shared:active:not(:disabled) { /* Optional: style for active (pressed) state */ /* background-color: var(--le-background-color-button-active, #d0d0d0); */ } .button-shared:disabled, .button-shared.disabled { /* Allow class-based disabling too */ background-color: var(--le-background-color-button-disabled, #eee); color: var(--le-text-color-secondary, #aaa); border-color: var(--le-border-color-medium, #ccc); /* Use medium border for disabled state */ cursor: not-allowed; opacity: 0.7; /* Visually indicate disabled state */ } /* Variations */ .button-shared.button-primary { background-color: var(--le-color-primary, #007bff); color: var(--le-text-color-on-primary, #fff); border-color: var(--le-color-primary, #007bff); } .button-shared.button-primary:hover:not(:disabled) { background-color: var(--le-color-primary-hover, #0056b3); border-color: var(--le-color-primary-hover, #0056b3); } .button-shared.button-secondary-light { background-color: var(--le-background-color-button-secondary-light, #f8f9fa); color: var(--le-text-color-secondary-light-text, #212529); border-color: var(--le-border-color-secondary-light, #ced4da); } .button-shared.button-secondary-light:hover:not(:disabled) { background-color: var(--le-background-color-button-secondary-light-hover, #e2e6ea); border-color: var(--le-border-color-secondary-light-hover, #dae0e5); color: var(--le-text-color-secondary-light-text-hover, #212529); } /* Example for a darker secondary button if needed elsewhere .button-shared.button-secondary { background-color: var(--le-color-secondary, #6c757d); color: var(--le-text-color-on-secondary, #fff); border-color: var(--le-color-secondary, #6c757d); } .button-shared.button-secondary:hover:not(:disabled) { background-color: var(--le-color-secondary-hover, #5a6268); border-color: var(--le-color-secondary-hover, #5a6268); } */ `; const BASE_STYLES = ` ${panelStyles} ${buttonStyles} :host { display: block; font-family: var(--le-font-family-main, 'Open Sans', Helvetica, Arial, sans-serif); box-sizing: border-box; color: var(--le-text-color-primary, #333); } .dashboard-content { padding: 0; margin: 0; } .dashboard-sections { display: flex; flex-direction: column; gap: var(--le-padding-m, 1rem); } .dashboard-section { background: var(--le-background-color-panel, #fff); border: 1px solid var(--le-border-color-light, #f0f0f0); border-radius: var(--le-border-radius-standard, 4px); padding: var(--le-padding-m, 1rem); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); transition: box-shadow 0.2s ease; } .dashboard-section:hover { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } .section-title { margin: 0 0 var(--le-padding-m, 1rem) 0; font-size: var(--le-font-size-large, 1.2em); font-weight: 600; color: var(--le-text-color-primary, #333); border-bottom: 2px solid var(--le-border-color-light, #f0f0f0); padding-bottom: var(--le-padding-s, 0.5rem); cursor: pointer; user-select: none; display: flex; align-items: center; gap: var(--le-padding-s, 0.5rem); transition: color 0.2s ease, background-color 0.2s ease; border-radius: var(--le-border-radius-small, 3px); padding: var(--le-padding-s, 0.5rem); margin: 0 0 var(--le-padding-m, 1rem) 0; } .section-title:hover { color: var(--le-text-color-accent, #2196f3); background-color: var(--le-background-color-hover, #f8f9fa); } .section-title:active { background-color: var(--le-background-color-button-hover, #e9ecef); } .collapse-icon { font-size: 0.8em; color: var(--le-text-color-secondary, #666); transition: color 0.2s ease, transform 0.2s ease; display: inline-block; width: 1em; text-align: center; } .section-title:hover .collapse-icon { color: var(--le-text-color-accent, #2196f3); } .section-content { transition: opacity 0.3s ease; } .info-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--le-padding-s, 0.75rem); margin-bottom: var(--le-padding-m, 1rem); } .info-card { background: var(--le-background-color-header, #f9f9f9); border: 1px solid var(--le-border-color-medium, #ddd); border-radius: var(--le-border-radius-small, 3px); padding: var(--le-padding-s, 0.75rem); text-align: center; transition: all 0.2s ease; min-height: 3rem; display: flex; flex-direction: column; justify-content: center; } .info-card:hover { background: var(--le-background-color-hover, #f5f5f5); transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .info-card.attention-highlight { background: var(--le-background-color-error, #fff0f0); border-color: var(--le-color-status-warning, #f39c12); } .card-label { font-size: var(--le-font-size-small, 0.9em); color: var(--le-text-color-secondary, #666); margin-bottom: var(--le-padding-xs, 0.25rem); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; } .card-value { font-size: var(--le-font-size-medium, 1.0em); font-weight: 600; color: var(--le-text-color-primary, #333); display: flex; align-items: center; justify-content: center; gap: var(--le-padding-xs, 0.25rem); } .status-indicator { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: var(--le-padding-xs, 0.25rem); } .status-setup { background-color: var(--le-color-status-info, #2196f3); } .status-upcoming { background-color: var(--le-color-status-warning, #f39c12); } .status-active { background-color: var(--le-color-status-success, #28a745); } .status-completed { background-color: var(--le-color-status-neutral, #6c757d); } .progress-section { margin: var(--le-padding-m, 1rem) 0; padding: var(--le-padding-s, 0.75rem); background: var(--le-background-color-header, #f9f9f9); border-radius: var(--le-border-radius-small, 3px); border: 1px solid var(--le-border-color-medium, #ddd); } .progress-label { font-size: var(--le-font-size-small, 0.9em); color: var(--le-text-color-secondary, #666); margin-bottom: var(--le-padding-xs, 0.25rem); font-weight: 500; text-align: center; } .progress-bar { width: 100%; height: 20px; background: var(--le-background-color-panel, #fff); border: 1px solid var(--le-border-color-medium, #ddd); border-radius: var(--le-border-radius-small, 3px); overflow: hidden; margin-bottom: var(--le-padding-xs, 0.25rem); } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--le-color-primary, #007bff) 0%, var(--le-color-primary-hover, #0056b3) 100%); transition: width 0.5s ease; position: relative; } .progress-fill::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 50%, rgba(255, 255, 255, 0.1) 100%); } .progress-percentage { text-align: center; font-size: var(--le-font-size-small, 0.9em); font-weight: 600; color: var(--le-text-color-primary, #333); } .attention-count { color: var(--le-color-status-warning, #f39c12); font-weight: 700; } .no-league { text-align: center; color: var(--le-text-color-secondary, #666); padding: var(--le-padding-m, 1rem); font-style: italic; } .error { color: var(--le-text-color-error, #dc3545); padding: var(--le-padding-s, 0.75rem); background-color: var(--le-background-color-error, #fff0f0); border: 1px solid var(--le-color-status-error, #dc3545); border-radius: var(--le-border-radius-standard, 4px); text-align: center; } /* Section-specific styling */ .league-overview { background: linear-gradient(135deg, var(--le-background-color-panel, #fff) 0%, var(--le-background-color-header, #f9f9f9) 100%); } .match-statistics { background: linear-gradient(135deg, var(--le-background-color-panel, #fff) 0%, rgba(33, 150, 243, 0.02) 100%); } .league-settings { background: linear-gradient(135deg, var(--le-background-color-panel, #fff) 0%, rgba(40, 167, 69, 0.02) 100%); } `; const MOBILE_STYLES = (fontScale = 1.0) => ` ${getMobileStyles(fontScale)} ${BASE_STYLES} .dashboard-sections { gap: var(--le-padding-s, 0.75rem); } .dashboard-section { padding: var(--le-padding-s, 0.75rem); } .section-title { font-size: var(--le-font-size-medium, 1.0em); margin-bottom: var(--le-padding-s, 0.75rem); min-height: 44px; /* Ensure touch target is large enough */ padding: var(--le-padding-s, 0.75rem); } .collapse-icon { font-size: 0.9em; /* Slightly larger on mobile for better touch targets */ } .info-cards { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--le-padding-s, 0.75rem); margin-bottom: var(--le-padding-s, 0.75rem); } .info-card { min-height: 2.5rem; padding: var(--le-padding-s, 0.75rem) var(--le-padding-xs, 0.5rem); } .card-label { font-size: var(--le-font-size-xs, 0.75em); margin-bottom: var(--le-padding-xs, 0.25rem); } .card-value { font-size: var(--le-font-size-small, 0.9em); } .progress-bar { height: 16px; } .progress-section { margin: var(--le-padding-s, 0.75rem) 0; padding: var(--le-padding-s, 0.75rem); } /* Mobile-specific responsive adjustments */ @media (max-width: 480px) { .info-cards { grid-template-columns: 1fr; } .dashboard-section { padding: var(--le-padding-s, 0.75rem) var(--le-padding-xs, 0.5rem); } } `; const DESKTOP_STYLES = (fontScale = 1.0) => ` ${getDesktopStyles(fontScale)} ${BASE_STYLES} .dashboard-sections { gap: var(--le-padding-m, 1rem); } .dashboard-section { padding: var(--le-padding-m, 1rem) var(--le-padding-m, 1.5rem); } .section-title { font-size: var(--le-font-size-large, 1.2em); margin-bottom: var(--le-padding-m, 1rem); padding: var(--le-padding-s, 0.5rem); } .info-cards { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--le-padding-m, 1rem); margin-bottom: var(--le-padding-m, 1rem); } .info-card { min-height: 3.5rem; padding: var(--le-padding-m, 1rem); } .card-label { font-size: var(--le-font-size-small, 0.9em); margin-bottom: var(--le-padding-s, 0.5rem); } .card-value { font-size: var(--le-font-size-medium, 1.0em); } .progress-bar { height: 24px; } .progress-section { margin: var(--le-padding-m, 1rem) 0; padding: var(--le-padding-m, 1rem); } /* Desktop hover effects */ .dashboard-section:hover .section-title { color: var(--le-text-color-accent, #2196f3); } .section-title:hover .collapse-icon { transform: scale(1.1); } `; const TEMPLATE = ` <div class="dashboard-content"> {{dashboardContent}} </div> `; // Define custom event types for the LeagueDashboard element class LeagueDashboardEvent extends CustomEvent { constructor(detail) { super('league-dashboard-event', { detail, bubbles: true, // Ensure event bubbles up through the DOM composed: true, // Ensure event crosses shadow DOM boundaries cancelable: true // Make the event cancelable }); } } /** * Custom element to display league dashboard with comprehensive overview. * * @element league-dashboard * @attr {string} data - JSON stringified League instance object * @attr {boolean} [is-mobile] - Whether to use mobile styles * @attr {number} [font-scale] - Font scaling factor * * Emits 'league-dashboard-event' with detail { type: 'viewTable' | 'dataLoaded' | 'error', ... } */ class LeagueDashboard extends HTMLElement { constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); this.league = null; // Panel visibility state - only Overview is expanded by default this.panelStates = { overview: true, matchStatistics: false, leagueSettings: false }; } static get observedAttributes() { return ['data', 'is-mobile', 'font-scale']; } get _isMobile() { return this.getAttribute('is-mobile') === 'true'; } get _fontScale() { return parseFloat(this.getAttribute('font-scale')) || 1.0; } connectedCallback() { this.render(); this.setupEventListeners(); } setupEventListeners() { // Add click handlers for collapsible panels this.shadow.addEventListener('click', e => { if (e.target.classList.contains('section-title') || e.target.closest('.section-title')) { const section = e.target.closest('.dashboard-section'); if (section) { this.togglePanel(section); } } }); } disconnectedCallback() { // Clean up event listeners if needed } /** * Toggles the visibility of a dashboard panel * @param {HTMLElement} sectionElement - The section element to toggle */ togglePanel(sectionElement) { const sectionClass = Array.from(sectionElement.classList).find(cls => cls === 'league-overview' || cls === 'match-statistics' || cls === 'league-settings'); if (!sectionClass) return; // Map section class to state key const stateKey = sectionClass === 'league-overview' ? 'overview' : sectionClass === 'match-statistics' ? 'matchStatistics' : sectionClass === 'league-settings' ? 'leagueSettings' : null; if (!stateKey) return; // Toggle the state this.panelStates[stateKey] = !this.panelStates[stateKey]; // Update the UI this.updatePanelVisibility(sectionElement, this.panelStates[stateKey]); } /** * Updates the visibility of a panel * @param {HTMLElement} sectionElement - The section element * @param {boolean} isVisible - Whether the panel should be visible */ updatePanelVisibility(sectionElement, isVisible) { const content = sectionElement.querySelector('.section-content'); const title = sectionElement.querySelector('.section-title'); if (content) { content.style.display = isVisible ? 'block' : 'none'; // Update margin on title based on content visibility if (title) { const marginValue = this._isMobile ? 'var(--le-padding-s, 0.75rem)' : 'var(--le-padding-m, 1rem)'; title.style.marginBottom = isVisible ? marginValue : '0'; } } if (title) { const icon = title.querySelector('.collapse-icon'); if (icon) { icon.textContent = isVisible ? '▼' : '▶'; icon.setAttribute('aria-label', isVisible ? 'Collapse section' : 'Expand section'); } } } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'data') { this.loadData(newValue); } else if (name === 'is-mobile' || name === 'font-scale') { this.render(); } } /** * Loads and parses the league data. * @param {string|Object} data - League instance or JSON string */ async loadData(data) { try { if (typeof data === 'string') { const parsedData = JSON.parse(data); // Import League class to create a proper instance const { League } = await Promise.resolve().then(function () { return index; }); this.league = new League(parsedData); } else if (data && typeof data === 'object') { // If it's already a League instance, use it directly if (data.getMatchesRequiringAttention && data.teams) { this.league = data; } else { // It's plain data, create a League instance const { League } = await Promise.resolve().then(function () { return index; }); this.league = new League(data); } } else { this.league = null; } this.render(); this.dispatchEvent(new LeagueDashboardEvent({ type: 'dataLoaded', league: this.league })); } catch (error) { const errorMessage = 'Failed to load league data for dashboard'; this.showError(errorMessage); console.error('Error loading league data for dashboard:', error); this.dispatchEvent(new LeagueDashboardEvent({ type: 'error', message: errorMessage, error })); } } /** * Shows an error message in the component. * @param {string} message */ showError(message) { const content = this.shadow.querySelector('.dashboard-content'); if (content) { content.innerHTML = `<div class="error">${message}</div>`; } } /** * Renders the dashboard content. */ render() { const isMobile = this._isMobile; const fontScale = this._fontScale; const styles = isMobile ? MOBILE_STYLES(fontScale) : DESKTOP_STYLES(fontScale); const content = this.renderDashboardContent(); this.shadow.innerHTML = ` <style>${styles}</style> ${this._fillTemplate(TEMPLATE, { dashboardContent: content })} `; } /** * Renders the main dashboard content. * @returns {string} */ renderDashboardContent() { if (!this.league) { return '<div class="no-league">No league data available</div>'; } const leagueOverview = this.renderLeagueOverview(); const matchStatistics = this.renderMatchStatistics(); const leagueSettings = this.renderLeagueSettings(); return ` <div class="dashboard-sections"> ${leagueOverview} ${matchStatistics} ${leagueSettings} </div> `; } /** * Renders the league overview section. * @returns {string} */ renderLeagueOverview() { const league = this.league; const teams = league.teams || []; const isRinkPointsLeague = league.settings?.rinkPoints?.enabled || false; const leagueType = isRinkPointsLeague ? 'Rink Points League' : 'Standard League'; // Determine season status const matches = league.matches || []; const playedMatches = matches.filter(match => match.result); const totalMatches = matches.length; let statusIndicator = ''; let statusText = ''; if (totalMatches === 0) { statusIndicator = 'status-setup'; statusText = 'Setup'; } else if (playedMatches.length === 0) { statusIndicator = 'status-upcoming'; statusText = 'Upcoming'; } else if (playedMatches.length === totalMatches) { statusIndicator = 'status-completed'; statusText = 'Completed'; } else { statusIndicator = 'status-active'; statusText = 'Active'; } const isExpanded = this.panelStates.overview; const marginValue = this._isMobile ? 'var(--le-padding-s, 0.75rem)' : 'var(--le-padding-m, 1rem)'; return ` <div class="dashboard-section league-overview"> <h3 class="section-title" style="margin-bottom: ${isExpanded ? marginValue : '0'};"> <span class="collapse-icon" aria-label="${isExpanded ? 'Collapse section' : 'Expand section'}">${isExpanded ? '▼' : '▶'}</span> Overview </h3> <div class="section-content" style="display: ${isExpanded ? 'block' : 'none'};"> <div class="info-cards"> <div class="info-card"> <div class="card-label">League Name</div> <div class="card-value">${this.escapeHtml(league.name || 'Unnamed League')}</div> </div> <div class="info-card"> <div class="card-label">Season Status</div> <div class="card-value"> <span class="status-indicator ${statusIndicator}"></span> ${statusText} </div> </div> <div class="info-card"> <div class="card-label">League Type</div> <div class="card-value">${leagueType}</div> </div> <div class="info-card"> <div class="card-label">Total Teams</div> <div class="card-value">${teams.length}</div> </div> </div> </div> </div> `; } /** * Renders the match statistics section. * @returns {string} */ renderMatchStatistics() { const league = this.league; const matches = league.matches || []; const playedMatches = matches.filter(match => match.result); const totalMatches = matches.length; const remainingMatches = totalMatches - playedMatches.length; // Calculate completion percentage const completionPercentage = totalMatches > 0 ? Math.round(playedMatches.length / totalMatches * 100) : 0; // Get attention matches const attentionMatches = league.getMatchesRequiringAttention ? league.getMatchesRequiringAttention() : []; const dateFormat = { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }; // Get last result date and next match date const lastResultDate = this.getLastResultDate(playedMatches); const nextMatchDate = this.getNextMatchDate(matches); // Get last updated timestamp const lastUpdated = new Date().toLocaleString('en-GB', dateFormat); const isExpanded = this.panelStates.matchStatistics; const marginValue = this._isMobile ? 'var(--le-padding-s, 0.75rem)' : 'var(--le-padding-m, 1rem)'; return ` <div class="dashboard-section match-statistics"> <h3 class="section-title" style="margin-bottom: ${isExpanded ? marginValue : '0'};"> <span class="collapse-icon" aria-label="${isExpanded ? 'Collapse section' : 'Expand section'}">${isExpanded ? '▼' : '▶'}</span> Match Statistics </h3> <div class="section-content" style="display: ${isExpanded ? 'block' : 'none'};"> <div class="info-cards"> <div class="info-card"> <div class="card-label">Total Scheduled</div> <div class="card-value">${totalMatches}</div> </div> <div class="info-card"> <div class="card-label">Played</div> <div class="card-value">${playedMatches.length}</div> </div> <div class="info-card"> <div class="card-label">Remaining</div> <div class="card-value">${remainingMatches}</div> </div> </div> <div class="progress-section"> <div class="progress-label">Completion Progress</div> <div class="progress-bar"> <div class="progress-fill" style="width: ${completionPercentage}%"></div> </div> <div class="progress-percentage">${completionPercentage}%</div> </div> <div class="info-cards"> <div class="info-card ${attentionMatches.length > 0 ? 'attention-highlight' : ''}"> <div class="card-label">Requiring Attention</div> <div class="card-value"> ${attentionMatches.length > 0 ? `<span class="attention-count">${attentionMatches.length}</span>` : '0'} </div> </div> <div class="info-card"> <div class="card-label">Next Match</div> <div class="card-value">${nextMatchDate}</div> </div> <div class="info-card"> <div class="card-label">Last Result</div> <div class="card-value">${lastResultDate}</div> </div> <div class="info-card"> <div class="card-label">Last Updated</div> <div class="card-value">${lastUpdated}</div> </div> </div> </div> </div> `; } /** * Renders the league settings section. * @returns {string} */ renderLeagueSettings() { const league = this.league; const settings = league.settings || {}; // Points system const pointsForWin = settings.pointsForWin || 3; const pointsForDraw = settings.pointsForDraw || 1; // Match format const timesTeamsPlayOther = settings.timesTeamsPlayOther || 1; const formatText = timesTeamsPlayOther === 1 ? 'Single Round Robin' : timesTeamsPlayOther === 2 ? 'Double Round Robin' : `${timesTeamsPlayOther}× Round Robin`; // Rink configuration const maxRinksPerSession = settings.maxRinksPerSession; const maxRinksText = maxRinksPerSession ? `${maxRinksPerSession} rinks per session` : 'Not set'; // Promotion/Relegation const hasPromotionRelegation = settings.promotionPositions || settings.relegationPositions; const promRelText = hasPromotionRelegation ? 'Enabled' : 'Disabled'; const isExpanded = this.panelStates.leagueSettings; const marginValue = this._isMobile ? 'var(--le-padding-s, 0.75rem)' : 'var(--le-padding-m, 1rem)'; return ` <div class="dashboard-section league-settings"> <h3 class="section-title" style="margin-bottom: ${isExpanded ? marginValue : '0'};"> <span class="collapse-icon" aria-label="${isExpanded ? 'Collapse section' : 'Expand section'}">${isExpanded ? '▼' : '▶'}</span> Settings Summary </h3> <div class="section-content" style="display: ${isExpanded ? 'block' : 'none'};"> <div class="info-cards"> <div class="info-card"> <div class="card-label">Points System</div> <div class="card-value">Win: ${pointsForWin}, Draw: ${pointsForDraw}</div> </div> <div class="info-card"> <div class="card-label">Match Format</div> <div class="card-value">${formatText}</div> </div> <div class="info-card"> <div class="card-label">Rink Configuration</div> <div class="card-value">${maxRinksText}</div> </div> <div class="info-card"> <div class="card-label">Promotion Relegation</div> <div class="card-value">${promRelText}</div> </div> </div> </div> </div> `; } /** * Gets the last result date from played matches. * @param {Array} playedMatches - Array of played matches * @returns {string} */ getLastResultDate(playedMatches) { if (playedMatches.length === 0) return 'None'; const lastMatch = playedMatches.filter(match => match.date).sort((a, b) => new Date(b.date) - new Date(a.date))[0]; if (!lastMatch) return 'None'; const dateFormat = { day: 'numeric', month: 'long' }; return new Date(lastMatch.date).toLocaleDateString('en-GB', dateFormat); } /** * Gets the next match date from unplayed matches. * @param {Array} matches - Array of all matches * @returns {string} */ getNextMatchDate(matches) { const today = new Date(); today.setHours(0, 0, 0, 0); const upcomingMatches = matches.filter(match => !match.result && match.date).map(match => ({ ...match, dateObj: new Date(match.date) })).filter(match => match.dateObj >= today).sort((a, b) => a.dateObj - b.dateObj); if (upcomingMatches.length === 0) return 'None scheduled'; const dateFormat = { day: 'numeric', month: 'long' }; return upcomingMatches[0].dateObj.toLocaleDateString('en-GB', dateFormat); } /** * Utility method to fill template placeholders. * @param {string} template - Template string with {{placeholders}} * @param {Object} data - Data object to fill placeholders * @returns {string} */ _fillTemplate(template, data) { return template.replace(/\{\{(\w+)\}\}/g, (match, key) => { return data[key] !== undefined ? data[key] : match; }); } /** * Escapes HTML to prevent XSS. * @param {string} unsafe - Unsafe string * @returns {string} */ escapeHtml(unsafe = '') { return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;"); } } // Define the custom element customElements.define('league-dashboard', LeagueDashboard); /** * Validation utilities for the leagueJS */ function validateLeague(data) { const errors = []; if (!data.name) { errors.push('League name is required'); } if (data.settings) { if (typeof data.settings.pointsForWin !== 'undefined') { if (typeof data.settings.pointsForWin !== 'number' || data.settings.pointsForWin < 0) { errors.push('Invalid points settings'); } } if (typeof data.settings.pointsForDraw !== 'undefined') { if (typeof data.settings.pointsForDraw !== 'number' || data.settings.pointsForDraw < 0) { errors.push('Invalid points settings'); } } if (typeof data.settings.pointsForLoss !== 'undefined') { if (typeof data.settings.pointsForLoss !== 'number' || data.settings.pointsForLoss < 0) { errors.push('Invalid points settings'); } } // Validate timesTeamsPlayOther if (typeof data.settings.timesTeamsPlayOther !== 'undefined') { if (typeof data.settings.timesTeamsPlayOther !== 'number' || data.settings.timesTeamsPlayOther < 1 || data.settings.timesTeamsPlayOther > 10) { errors.push('timesTeamsPlayOther must be an integer between 1 and 10'); } } } return { isValid: errors.length === 0, errors }; } function validateTeam(data) { const errors = []; if (!data._id || !data._id.trim()) { errors.push('Team ID is required'); } return { isValid: errors.length === 0, errors }; } function validateMatch(data) { const errors = []; if (!data.homeTeam || !data.homeTeam._id) { errors.push('Home team is required'); } if (!data.awayTeam || !data.awayTeam._id) { errors.push('Away team is required'); } if (data.date && !(data.date instanceof Date) && isNaN(new Date(data.date).getTime())) { errors.push('Invalid date format'); } // Validate result scores if result object exists if (data.result) { if (typeof data.result.homeScore !== 'number' || data.result.homeScore < 0) { errors.push('Home score must be a non-negative number'); } if (typeof data.result.awayScore !== 'number' || data.result.awayScore < 0) { errors.push('Away score must be a non-negative number'); } // Optionally, could add validation for rinkScores structure here if needed } return { isValid: errors.length === 0, errors }; } /** * Generates a GUID (Globally Unique Identifier) * @returns {string} A GUID string in the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx */ function generateGUID() { // Generate random hex digits const hex = () => Math.floor(Math.random() * 16).toString(16); // Build GUID in format: 8-4-4-4-12 return [ // 8 hex digits Array(8).fill(0).map(hex).join(''), // 4 hex digits Array(4).fill(0).map(hex).join(''), // 4 hex digits Array(4).fill(0).map(hex).join(''), // 4 hex digits Array(4).fill(0).map(hex).join(''), // 12 hex digits Array(12).fill(0).map(hex).join('')].join('-'); } /** * Team model representing a bowls team */ class Team { /** * Create a new Team * @param {Object} data - Team data * @param {string} data._id - Unique identifier for the team * @param {string} [data.name] - Name of the team (defaults to _id if not provided) * @param {Date} [data.createdAt] - Creation date (defaults to current date) * @param {Date} [data.updatedAt] - Last update date (defaults to current date) */ constructor(data) { const validationResult = validateTeam(data); if (!validationResult.isValid) { throw new Error(validationResult.errors[0]); } this._id = data._id || generateGUID(); this.name = data.name || data._id; this.createdAt = data.createdAt || new Date(); this.updatedAt = data.updatedAt || new Date(); } /** * Update team details * @param {Object} updates - Updated team details */ update(updates) { Object.assign(this.details, updates); this.updatedAt = new Date(); } /** * Convert team to JSON * @returns {Object} - JSON representation of the team */ toJSON() { return { _id: this._id, name: this.name, createdAt: this.createdAt, updatedAt: this.updatedAt }; } } /** * Match model representing a bowls match between two teams * @typedef {Object} MatchData * @property {Object} homeTeam - Home team object with _id property * @property {Object} awayTeam - Away team object with _id property * @property {string} [_id] - Unique identifier for the match * @property {Date|string|null} [date] - Match date (can be null for unscheduled matches) * @property {number} [rink] - Assigned rink number for the match * @property {Object} [result] - Optional match result data containing scores * @property {number} [result.homeScore] - Home team's score * @property {number} [result.awayScore] - Away team's score * @property {Array<Object>} [result.rinkScores] - Optional individual rink scores * @property {Date} [createdAt] - Creation date (defaults to current date) * @property {Date} [updatedAt] - Last update date (defaults to current date) */ class Match { /** * Create a new Match * @param {Object} data - Match data * @param {Object} data.homeTeam - Home team object with _id property * @param {Object} data.awayTeam - Away team object with _id property * @param {Date|string|null} [data.date] - Match date (can be null for unscheduled matches) * @param {number} [data.rink] - Assigned rink number for the match * @param {Object} [data.result] - Optional match result data containing scores * @param {number} [data.result.homeScore] - Home team's score * @param {number} [data.result.awayScore] - Away team's score * @param {Array<Object>} [data.result.rinkScores] - Optional individual rink scores * @param {Date} [data.createdAt] - Creation date (defaults to current date) * @param {Date} [data.updatedAt] - Last update date (defaults to current date) */ constructor(data) { const validationResult = validateMatch(data); if (!validationResult.isValid) { throw new Error(validationResult.errors[0]); } if (data.homeTeam._id === data.awayTeam._id) { throw new Error('Home and away teams must be different'); } this._id = data._id || generateGUID(); this.homeTeam = data.homeTeam; this.awayTeam = data.awayTeam; this.date = data.date ? new Date(data.date) : null; this.rink = data.rink || null; this.createdAt = data.createdAt || new Date(); this.updatedAt = data.updatedAt || new Date(); // Process result if scores are provided if (data.result && typeof data.result.homeScore === 'number' && typeof data.result.awayScore === 'number') { this.result = { homeScore: data.result.homeScore, awayScore: data.result.awayScore, rinkScores: data.result.rinkScores || null }; } else { this.result = null; // Ensure result is null if scores are not provided } } /** * Get the home team name * @returns {string} - The name of the home team */ get homeTeamName() { return this.homeTeam.name; } /** * Get the away team name * @returns {string} - The name of the away team */ get awayTeamName() { return this.awayTeam.name; } /** * Determines the winner of the match based on scores. * @returns {string|null} - The name of the winning team, 'draw', or null if no result is set. */ getWinner() { if (!this.result) { return null; } if (this.result.homeScore > this.result.awayScore) { return this.homeTeamName; } if (this.result.awayScore > this.result.homeScore) { return this.awayTeamName; } return 'draw'; } /** * Checks if the match resulted in a draw. * @returns {boolean|null} - True if it's a draw, false otherwise, or null if no result is set. */ isDraw() { if (!this.result) { return null; } return this.result.homeScore === this.result.awayScore; } /** * Set rink scores for an existing match result * @param {Array} rinkScores - Array of rink scores [{homeScore, awayScore}, ...] * @returns {boolean} - True if scores were set, false if no result exists */ setRinkScores(rinkScores) { if (!this.result) return false; this.result.rinkScores = rinkScores; this.updatedAt = new Date(); return true; } /** * Get rink win/draw counts * @returns {Object|null} - Object with rink win counts or null if no rink scores */ getRinkResults() { if (!this.result || !this.result.rinkScores) return null; const rinkResults = { homeWins: 0, awayWins: 0, draws: 0, total: this.result.rinkScores.length }; this.result.rinkScores.forEach(rink => { if (rink.homeScore > rink.awayScore) { rinkResults.homeWins++; } else if (rink.awayScore > rink.homeScore) { rinkResults.awayWins++; } else { rinkResults.draws++; } }); return rinkResults; } /** * Convert match to JSON * @returns {Object} - JSON representation of the match */ toJSON() { const jsonResult = this.result ? { ...this.result, winner: this.getWinner(), isDraw: this.isDraw() } : null; return { _id: this._id, homeTeam: this.homeTeam, awayTeam: this.awayTeam, date: this.date, rink: this.rink, result: jsonResult, createdAt: this.createdAt, updatedAt: this.updatedAt }; } } /** * Find the next valid date based on scheduling pattern */ function _findNextValidDate(fromDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound = false) { if (isFirstRound) { // For the first round, use the start date as-is if it's valid, otherwise find the next valid date if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) { const currentDay = fromDate.getDay(); if (selectedDays.includes(currentDay)) { return new Date(fromDate); } // Start date is not a valid day, find the next one for (let i = 1; i < 7; i++) { const checkDay = (currentDay + i) % 7; if (selectedDays.includes(checkDay)) { const nextDate = new Date(fromDate); nextDate.setDate(nextDate.getDate() + i); return nextDate; } } } return new Date(fromDate); } if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) { // Find the next occurrence of one of the selected days const currentDay = fromDate.getDay(); // Find the next selected day (starting from tomorrow) for (let i = 1; i <= 7; i++) { const checkDay = (currentDay + i) % 7; if (selectedDays.includes(checkDay)) { const nextDate = new Date(fromDate); nextDate.setDate(nextDate.getDate() + i); return nextDate; } } } // Use interval pattern (or fallback) return new Date(fromDate); } /** * Get the next scheduling date after a match date */ function _getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays) { const nextDate = new Date(currentDate); if (schedulingPattern === 'interval') { // Add interval if (intervalUnit === 'weeks') { nextDate.setDate(nextDate.getDate() + intervalNumber * 7); } else { nextDate.setDate(nextDate.getDate() + intervalNumber); } } else if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) { // Move to next occurrence of selected days const currentDay = currentDate.getDay(); let daysToAdd = 1; // Start from tomorrow // Find the next selected day for (l