@lovebowls/leagueelements
Version:
League Elements package for LoveBowls
1,444 lines (1,298 loc) • 94.6 kB
JavaScript
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