UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,444 lines (1,298 loc) 94.6 kB
var LeagueResetModal = (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 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 modalStyles = ` .modal-shared-overlay { display: none; /* Hidden by default */ position: fixed; z-index: var(--le-z-index-modal-overlay, 1000); /* Ensure it's on top */ left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: var(--le-background-color-modal-overlay, rgba(0,0,0,0.4)); padding-top: 5%; padding-bottom: 5%; } .modal-shared-overlay.open { display: flex; justify-content: center; } .modal-shared-content { background-color: var(--le-background-color-panel, #fff); margin: 10% auto; /* Default to 10% from top, centered */ padding: 0; border: 1px solid var(--le-border-color-dark, #ccc); max-width: 600px; border-radius: var(--le-border-radius-large, 8px); box-shadow: var(--le-shadow-modal, 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)); display: flex; flex-direction: column; align-self: flex-start; } /* Specific styles for modal mobile-view class */ .modal-shared-content.mobile-view { width: 90%; /* Override any fixed width from shared styles */ margin: 0; /* Less margin from top on mobile */ max-width: 90%; min-width: 280px !important; } .modal-shared-header { 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); /* Modal header distinct background */ display: flex; justify-content: space-between; align-items: center; font-size: var(--le-font-size-large, 1.4em); /* Increased large font size */ border-top-left-radius: var(--le-border-radius-large, 8px); /* Match content radius */ border-top-right-radius: var(--le-border-radius-large, 8px); /* Match content radius */ } .modal-shared-header .close-button-shared { /* Specific styling for a close button if needed */ color: var(--le-text-color-secondary, #aaa); font-size: var(--le-font-size-xlarge, 1.8em); /* Increased size */ font-weight: bold; background: none; border: none; cursor: pointer; } .modal-shared-header .close-button-shared:hover, .modal-shared-header .close-button-shared:focus { color: var(--le-text-color-primary, #000); text-decoration: none; } .modal-shared-body { padding: var(--le-padding-m, 1.25em); /* Increased padding */ overflow-y: auto; /* Allow body to scroll if content is too long */ flex-grow: 1; /* Allows body to take up available space if modal has fixed height */ } .modal-shared-footer { padding: var(--le-padding-m, 1.25em); text-align: right; border-top: 1px solid var(--le-border-color-medium, #eee); background-color: var(--le-background-color-header, #f9f9f9); /* Optional: footer background */ border-bottom-left-radius: var(--le-border-radius-large, 8px); /* Match content radius */ border-bottom-right-radius: var(--le-border-radius-large, 8px); /* Match content radius */ } .modal-shared-footer .button-shared + .button-shared { /* Spacing between buttons in footer */ margin-left: var(--le-padding-s, 0.75em); /* Increased margin */ } `; const formStyles = ` .form-group-shared { margin-bottom: var(--le-padding-m, 1.25em); /* Increased margin */ } .form-label-shared { display: block; margin-bottom: var(--le-padding-xs, 0.4em); /* Increased margin */ font-weight: bold; color: var(--le-text-color-primary, #333); font-size: var(--le-font-size-label, 1em)); /* Use variable with fallback */ } .form-input-shared, .form-select-shared { width: 100%; height: auto; padding: var(--le-padding-s, 0.75em); border: 1px solid var(--le-border-color-dark, #ccc); border-radius: var(--le-border-radius-standard, 4px); box-sizing: border-box; font-size: var(--le-font-size-input, 1em)); /* Use variable with fallback */ color: var(--le-text-color-primary, #333); background-color: var(--le-background-color-panel, #fff); } /* Ensure date inputs have consistent styling across browsers */ .form-input-shared[type="date"] { max-width: 100%; } .form-input-shared:focus, .form-select-shared:focus { border-color: var(--le-border-color-accent, #2196f3); outline: none; /* Or a custom focus ring */ box-shadow: 0 0 0 2px var(--le-focus-ring-color, rgba(33, 150, 243, 0.3)); } /* Specific styling for checkbox groups if needed */ .form-checkbox-label-shared { display: flex; /* Changed to flex for better alignment */ align-items: center; font-weight: normal; /* Typically labels for checkboxes are not bold by default */ color: var(--le-text-color-primary, #333); } .form-checkbox-label-shared input[type="checkbox"] { margin-right: var(--le-padding-s, 0.75em); /* Increased margin */ /* Consider custom styling for checkboxes if desired, or rely on browser defaults */ /* For consistent appearance across browsers, custom checkbox styling can be complex */ /* For now, using default with adjusted margin */ width: auto; /* Override width: 100% from .form-input-shared if a generic class was applied */ vertical-align: middle; /* Align checkbox with text */ } /* Styling for a container of multiple checkboxes or radio buttons */ .form-options-group-shared { /* Styles for a group of checkboxes/radios, e.g., display: flex; flex-direction: column; gap: ... */ } /* Styling for individual option within a group */ .form-option-item-shared { /* Styles for each checkbox/radio item within a group */ } /* Enhanced checkbox styles for better mobile usability */ .checkbox-enhanced-shared { position: relative; display: inline-block; cursor: pointer; user-select: none; margin-right: var(--le-padding-s, 0.75em); } .checkbox-enhanced-shared input[type="checkbox"] { position: absolute; opacity: 0; cursor: pointer; height: 0; width: 0; } .checkbox-enhanced-shared .checkmark-shared { position: relative; display: inline-block; width: 1.5em; height: 1.5em; background-color: var(--le-background-color-panel, #fff); border: 2px solid var(--le-border-color-dark, #ccc); border-radius: var(--le-border-radius-small, 3px); transition: all 0.2s ease; vertical-align: middle; margin-right: var(--le-padding-xs, 0.25em); } /* Mobile-specific larger checkboxes */ @media (max-width: 768px) { .checkbox-enhanced-shared .checkmark-shared { width: 2em; height: 2em; border-width: 2px; } } /* Hover state */ .checkbox-enhanced-shared:hover input[type="checkbox"] ~ .checkmark-shared { border-color: var(--le-text-color-accent, #2196f3); background-color: var(--le-background-color-row-hover, #f9f9f9); } /* Focus state */ .checkbox-enhanced-shared input[type="checkbox"]:focus ~ .checkmark-shared { border-color: var(--le-text-color-accent, #2196f3); box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.2); } /* Checked state */ .checkbox-enhanced-shared input[type="checkbox"]:checked ~ .checkmark-shared { background-color: var(--le-text-color-accent, #2196f3); border-color: var(--le-text-color-accent, #2196f3); } /* Checkmark icon */ .checkbox-enhanced-shared .checkmark-shared:after { content: ""; position: absolute; display: none; left: 50%; top: 50%; transform: translate(-50%, -50%) rotate(45deg); width: 0.4em; height: 0.8em; border: solid var(--le-text-color-on-primary, #fff); border-width: 0 0.15em 0.15em 0; } /* Mobile-specific larger checkmark */ @media (max-width: 768px) { .checkbox-enhanced-shared .checkmark-shared:after { width: 0.5em; height: 1em; border-width: 0 0.2em 0.2em 0; } } /* Show checkmark when checked */ .checkbox-enhanced-shared input[type="checkbox"]:checked ~ .checkmark-shared:after { display: block; } /* Disabled state */ .checkbox-enhanced-shared input[type="checkbox"]:disabled ~ .checkmark-shared { background-color: var(--le-background-color-button-disabled, #eee); border-color: var(--le-border-color-medium, #ddd); cursor: not-allowed; } .checkbox-enhanced-shared input[type="checkbox"]:disabled ~ .checkmark-shared:after { border-color: var(--le-text-color-secondary, #aaa); } /* Legend/checkbox list styles for responsive layouts */ .checkbox-list-responsive-shared { display: grid; gap: var(--le-padding-s, 0.75em); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } /* Mobile-specific responsive checkbox list */ @media (max-width: 768px) { .checkbox-list-responsive-shared { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--le-padding-m, 1em); } } /* Extra small screens - force 2 columns */ @media (max-width: 480px) { .checkbox-list-responsive-shared { grid-template-columns: 1fr 1fr; gap: var(--le-padding-s, 0.75em); } } /* Legend item styling for checkbox lists */ .legend-item-shared { display: flex; align-items: center; font-size: var(--le-font-size-small, 0.9em); padding: var(--le-padding-xs, 0.25em); border-radius: var(--le-border-radius-small, 3px); transition: background-color 0.2s ease; } .legend-item-shared:hover { background-color: var(--le-background-color-row-hover, #f9f9f9); } /* Mobile-specific legend item styling */ @media (max-width: 768px) { .legend-item-shared { font-size: var(--le-font-size-medium, 1.2em); padding: var(--le-padding-s, 0.75em) var(--le-padding-xs, 0.25em); min-height: 3em; } } `; const BASE_STYLES = ` ${buttonStyles} ${modalStyles} ${formStyles} :host { display: none; position: fixed; z-index: var(--le-z-index-modal, 1001); left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: var(--le-background-color-modal-overlay, rgba(0,0,0,0.4)); align-items: center; justify-content: center; } :host([open][is-mobile="true"]) { display: flex; } :host([open]) { display: flex; } /* Header warning text styling */ .header-warning { color: var(--le-color-status-warning, #f39c12); font-weight: normal; font-size: var(--le-font-size-small, 0.8em); margin: 0 0 var(--le-padding-m, 1rem) 0; padding: var(--le-padding-s, 0.5rem); line-height: 1.3; display: block; border-radius: var(--le-border-radius-standard, 4px); background-color: rgba(243, 156, 18, 0.1); border-left: 4px solid var(--le-color-status-warning, #f39c12); box-sizing: border-box; word-break: normal; overflow-wrap: break-word; hyphens: none; } .league-info { background-color: var(--le-background-color-light, #f9f9f9); border: 1px solid var(--le-border-color-light, #e0e0e0); border-radius: var(--le-border-radius-standard, 4px); padding: var(--le-padding-m, 1rem); margin-bottom: var(--le-padding-m, 1rem); } .league-info p { margin: 0.25rem 0; } /* Horizontal form row for start date and max rinks */ .form-row { display: flex; gap: var(--le-padding-m, 1rem); margin-bottom: var(--le-padding-m, 1rem); } .footer-error { color: var(--le-color-status-error, #e74c3c); margin: 0; text-align: left; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: auto; } .form-fieldset-shared { border: 1px solid var(--le-border-color-medium, #ddd); border-radius: var(--le-border-radius-standard, 4px); padding: var(--le-padding-m, 1rem); margin: var(--le-padding-m, 1rem) 0; } .form-legend { font-weight: bold; padding: 0 var(--le-padding-s, 0.5rem); color: var(--le-text-color-primary, #333); } .interval-inputs { display: flex; align-items: center; gap: var(--le-padding-s, 0.5rem); margin-top: var(--le-padding-s, 0.5rem); margin-left: var(--le-padding-m, 1rem); } .interval-number { width: 80px; min-width: 80px; } /* Ensure consistent height for interval inputs */ .interval-inputs .form-input-shared, .interval-inputs .form-select-shared { height: auto; min-height: var(--le-form-input-min-height, 2.5rem); line-height: 1.2; vertical-align: middle; } /* Specific adjustments for select dropdown text alignment */ .interval-inputs .form-select-shared { display: flex; align-items: center; padding-top: calc(var(--le-padding-s, 0.75em) - 1px); padding-bottom: calc(var(--le-padding-s, 0.75em) - 1px); } .day-checkboxes { display: none; flex-wrap: wrap; gap: var(--le-padding-s, 0.5rem); margin-top: var(--le-padding-s, 0.5rem); margin-left: var(--le-padding-m, 1rem); } .day-checkbox-label { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; padding: 0.25rem 0.5rem; border: 1px solid var(--le-border-color-light, #e0e0e0); border-radius: var(--le-border-radius-standard, 4px); background-color: var(--le-background-color-panel, #fff); transition: background-color 0.2s ease; } .day-checkbox-label:hover { background-color: var(--le-background-color-light, #f9f9f9); } .day-checkbox-label input[type="checkbox"] { margin: 0; } .preview-section { background-color: var(--le-background-color-light, #f9f9f9); border: 1px solid var(--le-border-color-light, #e0e0e0); border-radius: var(--le-border-radius-standard, 4px); padding: var(--le-padding-m, 1rem); margin-top: var(--le-padding-m, 1rem); margin-bottom: var(--le-padding-s, 0.5rem); } .preview-section h4 { margin: 0 0 var(--le-padding-s, 0.5rem) 0; color: var(--le-text-color-primary, #333); } .preview-text { margin: 0; font-style: italic; color: var(--le-text-color-secondary, #666); } /* Mobile-specific adjustments */ @media (max-width: 480px) { /* Mobile warning text adjustments */ .header-warning { font-size: var(--le-font-size-xs, 0.75em); padding: var(--le-padding-xs, 0.25rem) 0 0 var(--le-padding-s, 0.5rem); line-height: 1.4; text-align: left; margin-bottom: var(--le-padding-s, 0.75rem); } .interval-number { width: 100%; min-width: auto; } /* Enhanced mobile height consistency for interval inputs */ .interval-inputs .form-input-shared, .interval-inputs .form-select-shared { min-height: 44px; /* Match mobile touch target */ box-sizing: border-box; padding: var(--le-padding-s, 0.75rem) var(--le-padding-m, 1rem); border-width: 2px; font-size: var(--le-font-size-medium, 1.0em); line-height: 1.2; vertical-align: middle; } /* Specific adjustments for select dropdown text alignment */ .interval-inputs .form-select-shared { padding-top: calc(var(--le-padding-s, 0.75rem) - 1px); padding-bottom: calc(var(--le-padding-s, 0.75rem) - 1px); display: flex; align-items: center; } .day-checkboxes { gap: 8px; } .day-checkbox-label { padding: 8px 12px; min-height: 44px; align-items: center; justify-content: center; } .league-info, .preview-section { padding: 12px; } .form-fieldset-shared { padding: 12px; } } `; /** * 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 (let i = 1; i <= 7; i++) { const checkDay = (currentDay + i) % 7; if (selectedDays.includes(checkDay)) { daysToAdd = i; break; } } nextDate.setDate(nextDate.getDate() + daysToAdd); } else { // Fallback to weekly nextDate.setDate(nextDate.getDate() + 7); } return nextDate; } /** * Private helper method to assign rinks to matches * @param {object} league - The league instance * @param {Array} matches - Array of match data * @returns {Array} - Array of assigned rink numbers (or null if no rinks assigned) */ function _assignRinksToMatches(league, matches) { if (!league.settings.maxRinksPerSession || league.settings.maxRinksPerSession < 1) { // No rink assignment if maxRinksPerSession is not set or invalid return matches.map(() => null); } const maxRinks = league.settings.maxRinksPerSession; const assignedRinks = []; // Create an array of available rink numbers const availableRinks = Array.from({ length: maxRinks }, (_, i) => i + 1); for (let i = 0; i < matches.length; i++) { if (availableRinks.length === 0) { // If we've run out of available rinks, reset the pool availableRinks.push(...Array.from({ length: maxRinks }, (_, i) => i + 1)); } // Randomly select a rink from available rinks const randomIndex = Math.floor(Math.random() * availableRinks.length); const selectedRink = availableRinks.splice(randomIndex, 1)[0]; assignedRinks.push(selectedRink); } return assignedRinks; } /** * Private helper method to assign dates to matches based on scheduling parameters */ function _assignMatchDates(league, allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay) { // Group matches by round for scheduling const matchesByRound = {}; for (const matchData of allMatches) { if (!matchesByRound[matchData.roundKey]) { matchesByRound[matchData.roundKey] = []; } matchesByRound[matchData.roundKey].push(matchData); } // Sort round keys to ensure consistent scheduling order const sortedRoundKeys = Object.keys(matchesByRound).sort(); let currentDate = new Date(startDate); let isFirstRound = true; for (const roundKey of sortedRoundKeys) { const roundMatches = matchesByRound[roundKey]; if (maxMatchesPerDay && roundMatches.length > maxMatchesPerDay) { // Split matches across multiple days if there's a limit let matchIndex = 0; while (matchIndex < roundMatches.length) { const matchesForThisDate = roundMatches.slice(matchIndex, matchIndex + maxMatchesPerDay); // Find the next valid date const validDate = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound); // Assign rinks for matches on this date const assignedRinks = _assignRinksToMatches(league, matchesForThisDate); // Create matches for this date for (let i = 0; i < matchesForThisDate.length; i++) { const matchData = matchesForThisDate[i]; const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: new Date(validDate), rink: assignedRinks[i] }); if (!league.addMatch(match)) { return false; // If addMatch fails, abort } } matchIndex += maxMatchesPerDay; isFirstRound = false; // Move to next date for remaining matches if (matchIndex < roundMatches.length) { currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } // Set current date for next round if (matchIndex >= roundMatches.length) { currentDate = _getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } else { // All matches in this round can fit on one day const validDate = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound); // Assign rinks for matches on this date const assignedRinks = _assignRinksToMatches(league, roundMatches); // Create matches for this date for (let i = 0; i < roundMatches.length; i++) { const matchData = roundMatches[i]; const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: new Date(validDate), rink: assignedRinks[i] }); if (!league.addMatch(match)) { return false; // If addMatch fails, abort } } isFirstRound = false; // Move to next date for next round currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } return true; } /** * Initialise fixtures for the league using a round-robin algorithm. * Each team plays once per match day. Fixtures are scheduled according to the provided scheduling parameters. * Teams will play each other `this.settings.timesTeamsPlayOther` times, with home and away fixtures balanced * as per the cycles of the round-robin generation (e.g., first cycle A vs B, second cycle B vs A). * * @param {object} league - The league instance * @param {Date} [startDate] - The start date for the first round of matches. If null, matches will have null dates. * @param {Object} [schedulingParams] - Advanced scheduling parameters * @param {string} [schedulingParams.schedulingPattern='interval'] - 'interval' or 'dayOfWeek' * @param {number} [schedulingParams.intervalNumber=1] - Number of interval units between match days * @param {string} [schedulingParams.intervalUnit='weeks'] - 'days' or 'weeks' * @param {Array<number>} [schedulingParams.selectedDays] - Array of day numbers (0=Sunday, 1=Monday, etc.) for dayOfWeek pattern * @param {number} [schedulingParams.maxMatchesPerDay] - Maximum matches per day (defaults to maxRinksPerSession if not provided) * @returns {boolean} - True if fixtures were successfully created, false if there are fewer than 2 teams or if a match fails to be added. */ function initialiseFixtures(league, startDate, schedulingParams = {}) { league.matches = []; // Clear any existing matches if (league.teams.length < 2) { return false; // Not enough teams to create fixtures } // Set default scheduling parameters const { schedulingPattern = 'interval', intervalNumber = 1, intervalUnit = 'weeks', selectedDays = [], maxMatchesPerDay = league.settings.maxRinksPerSession || null } = schedulingParams; // Prepare team list for scheduling. Add a dummy "BYE" team if the number of teams is odd. let scheduleTeams = [...league.teams]; const BYE_TEAM_MARKER = { _id: `__BYE_TEAM_INTERNAL_${Date.now()}__`, name: 'BYE' }; // Unique marker for the bye team if (scheduleTeams.length % 2 !== 0) { scheduleTeams.push(BYE_TEAM_MARKER); } const numEffectiveTeams = scheduleTeams.length; // This will now always be even // Calculate the number of rounds needed for all unique pairings once const roundsPerCycle = numEffectiveTeams - 1; // Generate all matches first, then assign dates const allMatches = []; // The round-robin algorithm fixes one team and rotates the others. // We'll fix the last team in the `scheduleTeams` list. const fixedTeam = scheduleTeams[numEffectiveTeams - 1]; // The list of teams that will be rotated. const initialRotatingTeams = scheduleTeams.slice(0, numEffectiveTeams - 1); // Loop for each full set of fixtures (e.g., once for "home" games, once for "away" games if timesTeamsPlayOther is 2) for (let cycle = 0; cycle < league.settings.timesTeamsPlayOther; cycle++) { // For each cycle, re-initialize the rotating teams to ensure the same sequence of pairings. let currentRotatingTeams = [...initialRotatingTeams]; for (let roundNum = 0; roundNum < roundsPerCycle; roundNum++) { const conceptualPairsForThisRound = []; // Stores {team1, team2} for this specific round // 1. Match involving the fixed team (e.g., scheduleTeams[n-1]) // Its opponent is the first team in the current rotated list. const opponentForFixed = currentRotatingTeams[0]; if (fixedTeam._id !== BYE_TEAM_MARKER._id && opponentForFixed._id !== BYE_TEAM_MARKER._id) { // For balanced home/away games, the fixed team should alternate home/away status in each round if (roundNum % 2 === 0) { conceptualPairsForThisRound.push({ team1: opponentForFixed, team2: fixedTeam }); // fixed team is away } else { conceptualPairsForThisRound.push({ team1: fixedTeam, team2: opponentForFixed }); // fixed team is home } } // 2. Other matches from the `currentRotatingTeams` list. // The list has `numEffectiveTeams - 1` elements (an odd number). // The first element `currentRotatingTeams[0]` is already paired with `fixedTeam`. // The remaining elements `currentRotatingTeams[1]` to `currentRotatingTeams[length-1]` are paired up. const rotatingListLength = currentRotatingTeams.length; for (let j = 1; j <= (rotatingListLength - 1) / 2; j++) { const teamA_idx = j; const teamB_idx = rotatingListLength - j; // Pairs j-th from start with j-th from end (0-indexed list) const teamA = currentRotatingTeams[teamA_idx]; const teamB = currentRotatingTeams[teamB_idx]; if (teamA._id !== BYE_TEAM_MARKER._id && teamB._id !== BYE_TEAM_MARKER._id) { conceptualPairsForThisRound.push({ team1: teamA, team2: teamB }); } } // Store matches for this round for (const pair of conceptualPairsForThisRound) { let homeTeam, awayTeam; // For even cycles (0, 2, ...), team1 is home. For odd cycles (1, 3, ...), team2 is home. // This ensures that if pair (X,Y) is generated, cycle 0 schedules X vs Y, cycle 1 schedules Y vs X. if (cycle % 2 === 0) { homeTeam = pair.team1; awayTeam = pair.team2; } else { homeTeam = pair.team2; awayTeam = pair.team1; } allMatches.push({ homeTeam, awayTeam, roundKey: `${cycle}-${roundNum}` }); } // Rotate `currentRotatingTeams` for the next round: the last element moves to the front. if (currentRotatingTeams.length > 1) { // Rotation only makes sense for 2+ teams const lastTeamInRotatingList = currentRotatingTeams.pop(); currentRotatingTeams.unshift(lastTeamInRotatingList); } } } // Now assign dates to matches based on scheduling pattern if (startDate) { _assignMatchDates(league, allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay); } else { // No start date provided - create matches without dates but with rinks if configured const assignedRinks = _assignRinksToMatches(league, allMatches); for (let i = 0; i < allMatches.length; i++) { const matchData = allMatches[i]; const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: null, rink: assignedRinks[i] }); if (!league.addMatch(match)) { return false; // If addMatch fails (e.g., duplicate _id), abort. } } } return true; } /** * Returns a set of match IDs that are in scheduling conflict. * @param {object} league - The league instance * @returns {Set<string>} */ function getConflictingMatchIds(league) { if (!league.matches) return new Set(); const today = new Date(); today.setHours(0, 0, 0, 0); const todayTimestamp = today.getTime(); const futureFixtures = league.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; }); 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 conflictingIds = new Set(); for (const dateKey in matchesByDate) { const matchesOnDay = matchesByDate[dateKey]; if (matchesOnDay.length < 2) continue; // Check for team conflicts const teamCounts = {}; matchesOnDay.forEach(match => { var _match$homeTeam, _match$awayTeam; const homeTeamId = (_match$homeTeam = match.homeTeam) === null || _match$homeTeam === void 0 ? void 0 : _match$homeTeam._id; const awayTeamId = (_match$awayTeam = match.awayTeam) === null || _match$awayTeam === void 0 ? void 0 : _match$awayTeam._id; if (homeTeamId) teamCounts[homeTeamId] = (teamCounts[homeTeamId] || 0) + 1; if (awayTeamId) teamCounts[awayTeamId] = (teamCounts[awayTeamId] || 0) + 1; }); const conflictingTeams = Object.keys(teamCounts).filter(teamId => teamCounts[teamId] > 1); // Check for rink conflicts const rinkCounts = {}; const matchesWithRinks = matchesOnDay.filter(match => match.rink != null); matchesWithRinks.forEach(match => { rinkCounts[match.rink] = (rinkCounts[match.rink] || 0) + 1; }); const conflictingRinks = Object.keys(rinkCounts).filter(rink